feat(rust): the rewards — tally, kit reward, chat and the news leg (phase 13b, protocol 10)
Four event verbs and the announce leg, per PLAN.md §29: - rust.participation.open / .collect: the plugin counts who takes part (seconds, kills or both, in a zone this run opened or the whole server) and collect files them as the run's participants, keyed by Steam id. - rust.kit.entitle: the five recipient modes (D101), rows in the new rust_perm_run_grants (D84) unioned into the permission push, one extra use of the kit per reward as site-held credits on perm.sync (D103), and the rust.kit.entitled notice deferred from phase 10 (D64). - rust.announce: one server or every server (D105). - rust.chat announce leg, speaking only on servers whose new news switch is on (D104) - a card on Admin -> Rust visibility (D106). Budgets rust.grants and rust.announcements; the kit source and four fixed-choice sources (core has no enum param type). rust_perm_run_grants carries core's idempotency key so a revert of a lost answer can find its rows. Protocol 10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
"db",
|
||||
"engagement",
|
||||
"eventLeases.js",
|
||||
"eventRewards.js",
|
||||
"eventWorld.js",
|
||||
"index.js",
|
||||
"ingest.js",
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
// lists each server's clan board: a server whose clans cannot be read, one at
|
||||
// the game's 100-clan ceiling (D55), and one running the uMod Clans plugin,
|
||||
// whose clans are a separate system and never Teams (D47).
|
||||
//
|
||||
// Phase 13b adds a third (D104, D106): whether a published news post is also
|
||||
// said in each server's in-game chat. Off by default, because core sends every
|
||||
// post to every registered leg — without a switch, the day this module updated,
|
||||
// every post would start appearing in every server's chat. It lives here because
|
||||
// this is the one page that lists every server with a setting of its own.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
@@ -87,6 +93,7 @@ export default function Visibility() {
|
||||
const [fleet, setFleet] = useState('staff')
|
||||
const [clanRoster, setClanRoster] = useState('members')
|
||||
const [servers, setServers] = useState({})
|
||||
const [news, setNews] = useState({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
@@ -98,6 +105,7 @@ export default function Visibility() {
|
||||
setFleet(state.presence.fleet)
|
||||
setClanRoster((state.clans && state.clans.roster) || 'members')
|
||||
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
|
||||
setNews(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, Boolean(s.on)])))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -114,7 +122,9 @@ export default function Visibility() {
|
||||
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
|
||||
const clans = data.clans || { audiences: [], roster: 'members', servers: [] }
|
||||
const dirtyClans = clanRoster !== clans.roster
|
||||
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans
|
||||
const newsRows = (data.news && data.news.servers) || []
|
||||
const dirtyNews = newsRows.filter((s) => Boolean(news[s.id]) !== Boolean(s.on))
|
||||
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans || dirtyNews.length > 0
|
||||
|
||||
const effective = (id) => servers[id] || fleet
|
||||
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
|
||||
@@ -131,6 +141,7 @@ export default function Visibility() {
|
||||
if (dirtyServers.length) {
|
||||
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
|
||||
}
|
||||
if (dirtyNews.length) body.news = Object.fromEntries(dirtyNews.map((s) => [s.id, Boolean(news[s.id])]))
|
||||
load(await api.adminVisibility.save(body))
|
||||
setSaved(true)
|
||||
setReloads((n) => n + 1)
|
||||
@@ -225,6 +236,43 @@ export default function Visibility() {
|
||||
<ClanBoards servers={clans.servers || []} />
|
||||
</Card>
|
||||
|
||||
<Card title="News in game chat" subtitle="a published news post, said in each server’s chat">
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
When a news post is published, its title is said in the chat of every server switched on
|
||||
here. A server that is down when a post is published is skipped rather than told late.
|
||||
</p>
|
||||
{newsRows.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
|
||||
)}
|
||||
{newsRows.map((s) => (
|
||||
<label
|
||||
key={s.id}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
fontSize: '0.86rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(news[s.id])}
|
||||
onChange={(e) => setNews((prev) => ({ ...prev, [s.id]: e.target.checked }))}
|
||||
aria-label={`Say news in ${s.name}’s chat`}
|
||||
/>
|
||||
<span style={{ minWidth: 180, color: 'var(--head)' }}>
|
||||
{s.name}
|
||||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
|
||||
</span>
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>{news[s.id] ? 'says news' : 'off'}</span>
|
||||
</label>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button type="submit" className="btn" disabled={busy || !dirty}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
|
||||
@@ -316,6 +316,81 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rust.kit.entitled",
|
||||
"label": "An event rewarded you a kit",
|
||||
"description": "An event on a Rust server rewarded you: a kit is waiting in the in-game Kits menu, with one extra use.",
|
||||
"kind": "event",
|
||||
"subjectKey": "rewardKey",
|
||||
"audience": "owner",
|
||||
"ceiling": "owner",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Main is back online",
|
||||
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
|
||||
},
|
||||
{
|
||||
"name": "intro",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Main is back up and taking players.",
|
||||
"description": "One sentence of detail. Core generic bodies use it as the body."
|
||||
},
|
||||
{
|
||||
"name": "rewardKey",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "41:7",
|
||||
"description": "The run and step that awarded it. The cooldown subject; not meant for display."
|
||||
},
|
||||
{
|
||||
"name": "kit",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "vip-starter",
|
||||
"description": "The kit name, as the server Kits plugin has it."
|
||||
},
|
||||
{
|
||||
"name": "serverId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "main",
|
||||
"description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Runic Gateway | Main",
|
||||
"description": "The server's display name."
|
||||
},
|
||||
{
|
||||
"name": "serverUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/rust/servers/main",
|
||||
"description": "Site-relative path to the server's page."
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "top",
|
||||
"description": "How the recipients were chosen: everyone, top, minScore, random or topPercent."
|
||||
},
|
||||
{
|
||||
"name": "accountUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/player/rust",
|
||||
"description": "Site-relative path to your Rust account page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rust.leaderboard.topped",
|
||||
"label": "A new kills leader",
|
||||
@@ -1059,6 +1134,28 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "rewards-v1",
|
||||
"rules": [
|
||||
{
|
||||
"trigger_id": "rust.kit.entitled",
|
||||
"audience": "owner",
|
||||
"channels": [
|
||||
"email",
|
||||
"inapp"
|
||||
],
|
||||
"template_keys": {
|
||||
"email": "notify.event",
|
||||
"inapp": "inapp.event",
|
||||
"digest": "notify.digest"
|
||||
},
|
||||
"conditions": null,
|
||||
"cooldown_seconds": 0,
|
||||
"delay_seconds": 0,
|
||||
"cancel_on": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "clans-v1",
|
||||
"rules": [
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
-- it knows this module registered, because it is the side that knows which
|
||||
-- registrant owned what.
|
||||
|
||||
-- Phase 13b.
|
||||
DROP TABLE IF EXISTS rust_perm_run_grants;
|
||||
|
||||
-- Phase 7b.
|
||||
DROP TABLE IF EXISTS rust_clan_boards;
|
||||
DROP TABLE IF EXISTS rust_clan_members;
|
||||
|
||||
@@ -792,3 +792,51 @@ CREATE TABLE IF NOT EXISTS rust_clan_boards (
|
||||
CONSTRAINT fk_rust_clan_boards_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ── What an event granted (phase 13b, protocol 10) ────────────────────────
|
||||
--
|
||||
-- `rust.kit.entitle`'s ledger: one row per run, step, website user and
|
||||
-- permission (PLAN.md §29, D84). It is a table of its own, and not rows in
|
||||
-- `rust_perm_grants`, because that table is UNIQUE on (user, permission,
|
||||
-- scope): an admin grant of the same kit would collide with an event's, and a
|
||||
-- revert deleting "the" row would take the admin's grant with it. The push reads
|
||||
-- the UNION of the two, so a permission held both ways survives either being
|
||||
-- withdrawn.
|
||||
--
|
||||
-- The grant reaches every account the user has linked (D28), like any other.
|
||||
-- The CREDIT does not: `steam_id` is the account that took part, and one win is
|
||||
-- one extra use of the kit on that account (D103). `permission` is empty for a
|
||||
-- kit anybody may redeem — the credit is then the whole reward.
|
||||
--
|
||||
-- `idem_key` is core's idempotency key for the step. It is what a revert has
|
||||
-- when core lost the answer and holds no resource: without it the rows a lost
|
||||
-- answer wrote would be a grant nothing could ever withdraw.
|
||||
--
|
||||
-- `server_id` is the kit's server and the only one the grant reaches (D102).
|
||||
-- There is no foreign key to `rust_servers`: a server deleted mid-event must not
|
||||
-- delete the ledger a revert needs to find.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_run_grants (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id VARCHAR(64) NOT NULL,
|
||||
step_id VARCHAR(64) NOT NULL,
|
||||
idem_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
user_id INT NOT NULL,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
steam_id VARCHAR(32) NOT NULL,
|
||||
permission VARCHAR(128) NOT NULL DEFAULT '',
|
||||
kit VARCHAR(128) NOT NULL,
|
||||
credit TINYINT(1) NOT NULL DEFAULT 1,
|
||||
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rust_perm_run_grant (run_id, step_id, user_id, permission),
|
||||
KEY idx_rust_perm_run_grant_server (server_id),
|
||||
KEY idx_rust_perm_run_grant_key (run_id, idem_key),
|
||||
CONSTRAINT fk_rust_perm_run_grants_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 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
|
||||
-- appearing in every server's chat.
|
||||
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS announce_news TINYINT(1) NOT NULL DEFAULT 0;
|
||||
|
||||
@@ -129,6 +129,11 @@ const HEADLINES = Object.freeze({
|
||||
intro: `The Steam account ${d.player || d.steamId} was linked with an in-game code. `
|
||||
+ 'If that was not you, unlink it from your Rust account page.',
|
||||
}),
|
||||
'rust.kit.entitled': (d) => ({
|
||||
title: `You earned ${d.kit} on ${d.server}`,
|
||||
intro: `An event on ${d.server} rewarded you: the ${d.kit} kit is waiting in the Kits menu, `
|
||||
+ 'with one extra use. Redeem it in game.',
|
||||
}),
|
||||
'rust.clan.member.left': (d) => ({
|
||||
title: `${d.member || 'A member'} left ${d.clan}`,
|
||||
intro: `${d.member || 'A member'} left ${d.clan} on ${d.server}.`,
|
||||
@@ -546,12 +551,38 @@ function linked({ userId, steamId, name }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `rust.kit.entitled` for each user one reward step granted (phase 13b). Never
|
||||
* throws: a notification that could not be raised must not fail the grant it
|
||||
* is about. Returns how many were raised.
|
||||
*/
|
||||
function entitled({ userIds, kit, server, mode, runId, stepId }) {
|
||||
let raised = 0
|
||||
try {
|
||||
const rewardKey = `${runId}:${stepId}`
|
||||
for (const userId of new Set(userIds || [])) {
|
||||
const uid = Number(userId)
|
||||
if (!Number.isInteger(uid) || uid < 1) continue
|
||||
const ok = fire(T['rust.kit.entitled'], {
|
||||
data: { rewardKey, kit: String(kit), ...serverVars(server), ...(mode ? { mode: String(mode) } : {}), accountUrl: PATHS.account },
|
||||
ownerUserId: uid,
|
||||
dedupeKey: dedupeKey('entitled', runId, stepId, uid),
|
||||
})
|
||||
if (ok) raised++
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('could not raise the reward notification', { error: err.message })
|
||||
}
|
||||
return raised
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
onEvent,
|
||||
serverObserved,
|
||||
checkLeader,
|
||||
sweepLoginDenied,
|
||||
linked,
|
||||
entitled,
|
||||
reset,
|
||||
dedupeKey,
|
||||
headline,
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
// ── One rule group per family ─────────────────────────────────────────────
|
||||
//
|
||||
// A group is seeded ONCE (per deployment, per key), so a rule appended to a
|
||||
// group in a later version reaches fresh installs only. Seven families, seven
|
||||
// group in a later version reaches fresh installs only. Eight families, eight
|
||||
// keys: a future raid rule takes `raid-v2` without disturbing anybody's clan
|
||||
// rules. Every rule is disabled — core ignores `enabled` rather than trusting it
|
||||
// — so installing this module mails nobody until an operator decides it should.
|
||||
@@ -234,6 +234,25 @@ const RULE_GROUPS = Object.freeze([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Its own group, not a rule appended to `account-v1`: a group is seeded once,
|
||||
// so an appended rule would reach fresh installs only (R7).
|
||||
key: 'rewards-v1',
|
||||
note: 'module-rust: an event rewarded you a kit (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.kit.entitled',
|
||||
name: 'Kit reward earned',
|
||||
audience: 'owner',
|
||||
// Email as well: a reward granted at 03:00 is news the person reads the
|
||||
// next morning, before they are next in game or on the site.
|
||||
channels: ['email', 'inapp'],
|
||||
template_keys: GENERIC,
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'clans-v1',
|
||||
note: 'module-rust: clan departures and disbands (disabled)',
|
||||
|
||||
@@ -218,6 +218,39 @@ const ACCOUNT = {
|
||||
],
|
||||
}
|
||||
|
||||
// ── A reward (phase 13b) ───────────────────────────────────────────────────
|
||||
//
|
||||
// Deferred from phase 10 (D64) to the phase that grants something. Emitted once
|
||||
// per recipient USER when an event's `rust.kit.entitle` writes their rows, with
|
||||
// that user as `ownerUserId` — so, like the link notice, `owner` is both the
|
||||
// ceiling and the only audience there is: a reward is nobody else's news.
|
||||
//
|
||||
// The subject is the run and the step, so one award is one notification however
|
||||
// often a retried step writes the same rows.
|
||||
|
||||
const REWARD = {
|
||||
id: 'rust.kit.entitled',
|
||||
label: 'An event rewarded you a kit',
|
||||
description: 'An event on a Rust server rewarded you: a kit is waiting in the in-game Kits menu, with one extra use.',
|
||||
kind: 'event',
|
||||
subjectKey: 'rewardKey',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
...HEADLINE,
|
||||
{ name: 'rewardKey', type: 'string', required: true, example: '41:7',
|
||||
description: 'The run and step that awarded it. The cooldown subject; not meant for display.' },
|
||||
{ name: 'kit', type: 'string', required: true, example: 'vip-starter',
|
||||
description: 'The kit name, as the server Kits plugin has it.' },
|
||||
...SERVER,
|
||||
{ name: 'mode', type: 'string', required: false, example: 'top',
|
||||
description: 'How the recipients were chosen: everyone, top, minScore, random or topPercent.' },
|
||||
{ name: 'accountUrl', type: 'url', required: false, example: '/player/rust',
|
||||
description: 'Site-relative path to your Rust account page.' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Clans ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `members` ceiling — clan membership is the clan's business (D49). Recipients
|
||||
@@ -374,7 +407,7 @@ const MODERATION = [
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, ...CLANS, ...MODERATION])
|
||||
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION])
|
||||
|
||||
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))
|
||||
|
||||
|
||||
854
server/eventRewards.js
Normal file
854
server/eventRewards.js
Normal file
@@ -0,0 +1,854 @@
|
||||
// ── What an event GIVES on a Rust server (PLAN.md §29, protocol 10) ───────
|
||||
//
|
||||
// 13a made things in the world. This file records who was there, gives them
|
||||
// something they can redeem, and can tell the server. Four verbs:
|
||||
//
|
||||
// rust.participation.open the game starts counting who takes part (D81)
|
||||
// rust.participation.collect core files the count as the run's participants
|
||||
// rust.kit.entitle the right to redeem a kit, and one more use of
|
||||
// it (R16, D103), for the people a mode picks
|
||||
// rust.announce one line in a server's chat, or every server's
|
||||
//
|
||||
// …and the announce leg, `rust.chat`, which says a published news post in the
|
||||
// chat of every server whose switch is on (D104).
|
||||
//
|
||||
// ── Who decides what ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The GAME counts: presence, kills, the score (D81, D99). The SITE picks the
|
||||
// recipients and holds the reward: the tally is read, a mode chosen per event
|
||||
// picks from it (D101), and a row per recipient goes into
|
||||
// `rust_perm_run_grants`, which the permission mirror pushes like any other
|
||||
// grant (D84). So a reward granted at 03:00 to somebody offline is waiting when
|
||||
// they next log in, and a wipe cannot take it away: the site re-pushes it.
|
||||
//
|
||||
// ── An action is never handed the participants ──────────────────────────────
|
||||
//
|
||||
// Core records participants from `collect`'s answer, but does not give them to
|
||||
// a later step. So `kit.entitle` reads the tally from the plugin itself, as
|
||||
// `uo.item.grant` reads it from the shard, and does not depend on a collect step
|
||||
// having run.
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const core = require('./core')
|
||||
const client = require('./sidecarClient')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const permDb = require('./model/permissions/permissions.db')
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const emit = require('./engagement/emit')
|
||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
|
||||
const log = core.logger('rewards')
|
||||
|
||||
// Mirrors of the plugin's bounds (§29.5). The plugin's are authoritative, and
|
||||
// an operator may set them lower; these price a step and refuse a bad one on
|
||||
// the authoring form rather than at four in the morning.
|
||||
const MAX_RECIPIENTS = 100
|
||||
const MAX_CHAT = 256
|
||||
const TALLY_MAX_MINUTES = 7 * 24 * 60
|
||||
const MAX_KILL_WEIGHT = 1000
|
||||
const DEFAULT_KILL_WEIGHT = 5
|
||||
|
||||
const SCORES = ['seconds', 'kills', 'both']
|
||||
const KILLS_OF = ['players', 'npcs', 'both']
|
||||
const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent']
|
||||
|
||||
/** The fleet, in `rust.announce`'s `server` param (D105). */
|
||||
const EVERY_SERVER = '*'
|
||||
|
||||
/** The plugin's refusals a second attempt would repeat. */
|
||||
const PERMANENT = new Set([
|
||||
'events-disabled',
|
||||
'malformed',
|
||||
'out-of-range',
|
||||
'already-open',
|
||||
'no-zone',
|
||||
'ambiguous-zone',
|
||||
'too-many',
|
||||
'too-long',
|
||||
'kits-missing',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.grants',
|
||||
label: 'Kit rewards',
|
||||
unit: 'rewards',
|
||||
description:
|
||||
'Kits an event rewards: one per recipient, each the right to redeem the kit and one more use of it. A mode that is not a count is priced at the most it could grant.',
|
||||
},
|
||||
{
|
||||
id: 'rust.announcements',
|
||||
label: 'Chat announcements',
|
||||
unit: 'lines',
|
||||
description: 'Lines an event says in a server\'s chat: one per server reached.',
|
||||
},
|
||||
]
|
||||
|
||||
/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
|
||||
function transportFailure(server, result, what) {
|
||||
const permanent = result.status === 'not-configured' || result.status === 'no-token'
|
||||
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) }
|
||||
}
|
||||
|
||||
/** A plugin refusal carried in a 200, classified by its reason. */
|
||||
function refusal(server, data, what) {
|
||||
return {
|
||||
ok: false,
|
||||
...(PERMANENT.has(data && data.reason) ? { retry: false } : {}),
|
||||
error: pluginError(data, `${server.name || server.id} refused the ${what}`),
|
||||
}
|
||||
}
|
||||
|
||||
/** One word from a fixed set, or null. Compared without case: an author types these. */
|
||||
function oneOf(raw, allowed) {
|
||||
const text = String(raw === undefined || raw === null ? '' : raw).trim().toLowerCase()
|
||||
return allowed.find((a) => a.toLowerCase() === text) || null
|
||||
}
|
||||
|
||||
/** `<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') 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 }
|
||||
}
|
||||
|
||||
// ── The verbs ────────────────────────────────────────────────────────────────
|
||||
|
||||
const participationOpen = {
|
||||
id: 'rust.participation.open',
|
||||
label: 'Start counting participants',
|
||||
description:
|
||||
'The game counts who takes part from here on: time present, kills, or both — in a zone this run opened, or on the whole server. It stops after its minutes; teardown forgets it.',
|
||||
// It watches rather than changes anything, but it is ledgered, like
|
||||
// `uo.participation.open`: the game holds a tally for the run, and teardown
|
||||
// gives it back.
|
||||
risk: 'inspect',
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'Which server counts.' },
|
||||
{ name: 'zone', type: 'string', required: false, example: 'Airfield brawl', source: 'rust.options.runZones',
|
||||
description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts (D100).' },
|
||||
{ name: 'score', type: 'string', required: true, example: 'both', source: 'rust.options.scoreModes',
|
||||
description: 'What earns a place: seconds present, kills, or both.' },
|
||||
{ name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsOf',
|
||||
description: 'Whose deaths count as a kill: players, NPCs (animals included), or both. The last hit gets it. Needed unless the score is seconds.' },
|
||||
{ name: 'killWeight', type: 'float', required: false, example: DEFAULT_KILL_WEIGHT,
|
||||
description: `For a score of both: how many minutes one kill is worth. Left blank, ${DEFAULT_KILL_WEIGHT}.` },
|
||||
{ name: 'minutes', type: 'int', required: false, example: 60,
|
||||
description: `How long it counts, up to ${TALLY_MAX_MINUTES} (seven days). Left blank, seven days. The game forgets a tally seven days after it opened, however long it counted.` },
|
||||
],
|
||||
cost: () => ({}),
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const score = oneOf(params.score, SCORES)
|
||||
if (!score) return { ok: false, retry: false, error: `a tally scores seconds, kills or both, not "${params.score}"` }
|
||||
|
||||
const killsOf = score === 'seconds' ? null : oneOf(params.killsOf, KILLS_OF)
|
||||
if (score !== 'seconds' && !killsOf) {
|
||||
return { ok: false, retry: false, error: 'a tally that counts kills says whose: players, npcs or both' }
|
||||
}
|
||||
|
||||
let killWeight
|
||||
if (score === 'both') {
|
||||
const raw = params.killWeight
|
||||
killWeight = raw === undefined || raw === null || raw === '' ? DEFAULT_KILL_WEIGHT : Number(raw)
|
||||
if (!Number.isFinite(killWeight) || killWeight < 0 || killWeight > MAX_KILL_WEIGHT) {
|
||||
return { ok: false, retry: false, error: `a kill is worth 0 to ${MAX_KILL_WEIGHT} minutes, not "${raw}"` }
|
||||
}
|
||||
}
|
||||
|
||||
let minutes
|
||||
if (params.minutes !== undefined && params.minutes !== null && params.minutes !== '') {
|
||||
minutes = Number(params.minutes)
|
||||
if (!Number.isInteger(minutes) || minutes < 1 || minutes > TALLY_MAX_MINUTES) {
|
||||
return { ok: false, retry: false, error: `a tally counts for 1 to ${TALLY_MAX_MINUTES} minutes, not "${params.minutes}"` }
|
||||
}
|
||||
}
|
||||
|
||||
const zone = String(params.zone || '').trim()
|
||||
const found = await serverFor(String(params.server || '').trim())
|
||||
if (!found.ok) return found
|
||||
|
||||
// Whether the zone exists is not asked in a dry run: it is opened by an
|
||||
// earlier step of the same run, so before the run it never does.
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await client.tallyOpen(found.server, {
|
||||
runId: String(runId),
|
||||
key: idempotencyKey,
|
||||
score,
|
||||
...(killsOf ? { killsOf } : {}),
|
||||
...(killWeight === undefined ? {} : { killWeight }),
|
||||
...(minutes === undefined ? {} : { holdMs: minutes * 60000 }),
|
||||
...(zone ? { zone } : {}),
|
||||
})
|
||||
if (!result.ok) return transportFailure(found.server, result, 'tally')
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'tally.ok') return refusal(found.server, data, 'tally')
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
resources: [
|
||||
{
|
||||
kind: 'tally',
|
||||
ref: tallyRef(found.server.id, runId),
|
||||
payload: { serverId: found.server.id, score, ...(zone ? { zone } : {}) },
|
||||
},
|
||||
],
|
||||
detail: {
|
||||
server: found.server.name || found.server.id,
|
||||
counting: zone ? `in the zone "${zone}"` : 'on the whole server',
|
||||
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; the tally was already open' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Forget the tally on every server the ledger names — or, when core lost the
|
||||
* answer and holds none, on every server, since `runId` is all a tally is
|
||||
* keyed by. A tally already gone is a success.
|
||||
*/
|
||||
async revert({ runId, resources }) {
|
||||
const targets = resources && resources.length
|
||||
? [...new Set(resources.map((r) => (r.payload && r.payload.serverId) || refParts(r.ref).serverId))]
|
||||
: (await servers.listForPolling()).map((s) => s.id)
|
||||
|
||||
const failed = []
|
||||
const errors = []
|
||||
for (const serverId of targets) {
|
||||
const found = await serverFor(serverId)
|
||||
if (!found.ok) {
|
||||
// A server deleted or switched off cannot be asked, and its tally ends on
|
||||
// its own seven days after it opened; the ledger row is not held for it.
|
||||
continue
|
||||
}
|
||||
const result = await client.tallyClose(found.server, { runId: String(runId) })
|
||||
const refused = result.ok && (!result.data || result.data.kind !== 'tally.ok')
|
||||
if (!result.ok || refused) {
|
||||
failed.push(...(resources || []).filter((r) => refParts(r.ref).serverId === serverId).map((r) => r.ref))
|
||||
errors.push(result.ok ? pluginError(result.data, `${found.server.name || found.server.id} refused to close the tally`) : transportError(found.server, result, 'tally'))
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.length) return { ok: true }
|
||||
if (!resources || !resources.length || failed.length === resources.length) return { ok: false, error: errors.join('; ') }
|
||||
return { ok: true, failed }
|
||||
},
|
||||
|
||||
/** A tally is in force while its server still holds it. A server that cannot be asked has said nothing. */
|
||||
async reconcile({ runId, resources }) {
|
||||
const inForce = []
|
||||
for (const r of resources || []) {
|
||||
const found = await serverFor(refParts(r.ref).serverId)
|
||||
if (!found.ok) {
|
||||
inForce.push(r.ref)
|
||||
continue
|
||||
}
|
||||
const result = await client.tallySnapshot(found.server, runId)
|
||||
const gone = result.ok && result.data && result.data.kind !== 'tally.snapshot' && result.data.reason === 'no-tally'
|
||||
if (!gone) inForce.push(r.ref)
|
||||
}
|
||||
return { ok: true, inForce }
|
||||
},
|
||||
}
|
||||
|
||||
const participationCollect = {
|
||||
id: 'rust.participation.collect',
|
||||
label: 'Record participants',
|
||||
description:
|
||||
'Files everybody the tally counted as this run\'s participants, with their score, time and kills. The tally keeps counting if its minutes are not up.',
|
||||
risk: 'inspect',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'The server whose tally to read.' },
|
||||
],
|
||||
cost: () => ({}),
|
||||
|
||||
async perform({ runId, params, verify }) {
|
||||
const found = await serverFor(String(params.server || '').trim())
|
||||
if (!found.ok) return found
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const tally = await readTally(found.server, runId)
|
||||
if (!tally.ok) return tally
|
||||
|
||||
const people = peopleOf(tally.data)
|
||||
const users = await usersFor(people.map((p) => p.steamId))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
// The member vocabulary is the Steam id, as the team provider's is.
|
||||
participants: people.map((p) => ({
|
||||
memberKey: p.steamId,
|
||||
...(users.has(p.steamId) ? { userId: users.get(p.steamId) } : {}),
|
||||
score: p.score,
|
||||
...(p.joinedAt > 0 ? { joinedAt: new Date(p.joinedAt).toISOString() } : {}),
|
||||
meta: { name: p.name, seconds: p.seconds, kills: p.kills },
|
||||
})),
|
||||
detail: {
|
||||
server: found.server.name || found.server.id,
|
||||
participants: people.length,
|
||||
linked: users.size,
|
||||
...(Number(tally.data.overflow) > 0 ? { overflow: Number(tally.data.overflow) } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const kitEntitle = {
|
||||
id: 'rust.kit.entitle',
|
||||
label: 'Reward a kit',
|
||||
description:
|
||||
'Gives the people a mode picks from this run\'s tally the right to redeem a kit on its server, and one more use of it. Waits for them if they are offline. Teardown withdraws what is not yet redeemed.',
|
||||
risk: 'change',
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'kit', type: 'string', required: true, example: 'main/vip-starter', source: 'rust.options.kits',
|
||||
description: 'The kit, as server/kit. The reward reaches only that server (D102).' },
|
||||
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientModes',
|
||||
description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, or the top X per cent.' },
|
||||
{ name: 'count', type: 'float', required: false, example: 3,
|
||||
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone.' },
|
||||
],
|
||||
|
||||
// Priced before the tally is read, so at the most it could grant: the count
|
||||
// for a count, and the server's recipient bound for every other mode. An
|
||||
// author who wants a tight cap picks a count.
|
||||
cost: (p) => {
|
||||
const mode = oneOf(p.recipients, MODES)
|
||||
const n = Math.round(Number(p.count) || 0)
|
||||
return { 'rust.grants': mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS }
|
||||
},
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const parsed = splitKit(params.kit)
|
||||
if (!parsed) return { ok: false, retry: false, error: `"${params.kit}" is not a kit — pick one from the list, as server/kit` }
|
||||
|
||||
const mode = oneOf(params.recipients, MODES)
|
||||
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
|
||||
|
||||
const count = checkCount(mode, params.count)
|
||||
if (!count.ok) return { ok: false, retry: false, error: count.error }
|
||||
|
||||
const found = await serverFor(parsed.serverId)
|
||||
if (!found.ok) return found
|
||||
const server = found.server
|
||||
|
||||
const kit = await readKit(server, parsed.kit)
|
||||
if (!kit.ok) return kit
|
||||
|
||||
if ((mode === 'top' || mode === 'random') && count.value > kit.maxRecipients) {
|
||||
return { ok: false, retry: false, error: `${server.name || server.id} rewards at most ${kit.maxRecipients} people in one step, not ${count.value}` }
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const ref = entitlementRef(server.id, runId, stepId)
|
||||
const resource = { kind: 'entitlement', ref, payload: { serverId: server.id, kit: kit.kit } }
|
||||
|
||||
// A repeated key finds its rows already written and changes nothing — the
|
||||
// rows ARE the grant, and a set written twice is the same set.
|
||||
const existing = await permDb.listRunGrantsForStep(runId, stepId)
|
||||
if (existing.length) {
|
||||
return {
|
||||
ok: true,
|
||||
resources: [resource],
|
||||
detail: { repeat: true, granted: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was granted' },
|
||||
}
|
||||
}
|
||||
|
||||
const tally = await readTally(server, runId)
|
||||
if (!tally.ok) return tally
|
||||
|
||||
const picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
|
||||
const bound = Number(tally.data.maxRecipients) || kit.maxRecipients
|
||||
if (picked.length > bound) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${picked.length} people qualify, and ${server.name || server.id} rewards at most ${bound} in one step — pick a count-based mode or a higher bar`,
|
||||
}
|
||||
}
|
||||
|
||||
const users = await usersFor(picked.map((p) => p.steamId))
|
||||
const rows = []
|
||||
const byUser = new Set()
|
||||
const missed = []
|
||||
|
||||
for (const p of picked) {
|
||||
const userId = users.get(p.steamId)
|
||||
if (!userId) {
|
||||
missed.push(p.name)
|
||||
continue
|
||||
}
|
||||
// One reward per website user: two linked accounts that both took part are
|
||||
// one person, and one win is one use (D103). The higher score, being
|
||||
// earlier in the list, is the account that gets the credit.
|
||||
if (byUser.has(userId)) continue
|
||||
byUser.add(userId)
|
||||
rows.push({
|
||||
runId,
|
||||
stepId,
|
||||
idemKey: idempotencyKey,
|
||||
userId,
|
||||
serverId: server.id,
|
||||
steamId: p.steamId,
|
||||
permission: kit.permission,
|
||||
kit: kit.kit,
|
||||
credit: kit.max > 0,
|
||||
})
|
||||
}
|
||||
|
||||
await permDb.insertRunGrants(rows)
|
||||
await permDb.markDirty(server.id)
|
||||
|
||||
emit.entitled({ userIds: [...byUser], kit: kit.kit, server, mode, runId, stepId })
|
||||
|
||||
log.info('kit rewarded', { server: server.id, run: runId, step: stepId, kit: kit.kit, mode, granted: rows.length, missed: missed.length })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
resources: [resource],
|
||||
detail: {
|
||||
kit: kit.kit,
|
||||
server: server.name || server.id,
|
||||
mode,
|
||||
...(count.value === null ? {} : { count: count.value }),
|
||||
granted: rows.length,
|
||||
...(missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Withdraw a step's rows and push. The permission goes and each unredeemed
|
||||
* credit is put back by the plugin; a redemption already made stands (R16).
|
||||
* A row already gone is a success.
|
||||
*/
|
||||
async revert({ runId, resources, idempotencyKey }) {
|
||||
const touched = new Set()
|
||||
|
||||
if (!resources || !resources.length) {
|
||||
for (const serverId of await permDb.deleteRunGrantsForKey(runId, idempotencyKey)) touched.add(serverId)
|
||||
} else {
|
||||
for (const r of resources) {
|
||||
const { runId: refRun, stepId } = refParts(r.ref)
|
||||
if (!stepId) continue
|
||||
for (const serverId of await permDb.deleteRunGrantsForStep(refRun || runId, stepId)) touched.add(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const serverId of touched) await permDb.markDirty(serverId)
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
/** The site holds the entitlement and re-pushes it, so a restart or a wipe cannot take it away. */
|
||||
async reconcile({ resources }) {
|
||||
return { ok: true, inForce: (resources || []).map((r) => r.ref) }
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Say one line on one server, and classify the answer. `repeat` is a success:
|
||||
* the plugin remembered the key, and the line was already said.
|
||||
*/
|
||||
async function sayOn(server, body) {
|
||||
const result = await client.chat(server, body)
|
||||
if (!result.ok) return { state: 'down', result }
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'chat.ok') return { state: 'refused', data }
|
||||
return { state: data.said === false ? 'repeat' : 'said', data }
|
||||
}
|
||||
|
||||
const announce = {
|
||||
id: 'rust.announce',
|
||||
label: 'Say it in game chat',
|
||||
description: 'One line in a Rust server\'s chat, or in every server\'s. A line said cannot be taken back.',
|
||||
risk: 'notify',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.chatServers',
|
||||
description: 'Which server, or * for every server (D105).' },
|
||||
{ name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!',
|
||||
description: `The line, up to ${MAX_CHAT} characters.` },
|
||||
],
|
||||
|
||||
// One per server reached. `*` is priced at the enabled servers when core asks,
|
||||
// which is synchronous — so at the count this module last saw.
|
||||
cost: (p) => ({ 'rust.announcements': String(p.server || '').trim() === EVERY_SERVER ? Math.max(1, servers.lastEnabledCount()) : 1 }),
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const message = String(params.message || '').replace(/\s+/g, ' ').trim()
|
||||
if (!message) return { ok: false, retry: false, error: 'a chat line needs a message' }
|
||||
if (message.length > MAX_CHAT) {
|
||||
return { ok: false, retry: false, error: `a chat line is at most ${MAX_CHAT} characters, and this one is ${message.length}` }
|
||||
}
|
||||
|
||||
const target = String(params.server || '').trim()
|
||||
let list
|
||||
if (target === EVERY_SERVER) {
|
||||
list = await servers.listForPolling()
|
||||
if (!list.length) return { ok: false, retry: false, error: 'there are no enabled Rust servers to say it on' }
|
||||
} else {
|
||||
const found = await serverFor(target)
|
||||
if (!found.ok) return found
|
||||
list = [found.server]
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const body = { key: idempotencyKey || `run:${runId}`, message, event: true }
|
||||
const outcomes = await Promise.all(list.map(async (server) => ({ server, ...(await sayOn(server, body)) })))
|
||||
const name = (o) => o.server.name || o.server.id
|
||||
|
||||
// One server named: its answer is the step's.
|
||||
if (target !== EVERY_SERVER) {
|
||||
const o = outcomes[0]
|
||||
if (o.state === 'down') return transportFailure(o.server, o.result, 'chat')
|
||||
if (o.state === 'refused') return refusal(o.server, o.data, 'chat line')
|
||||
return { ok: true, detail: { said: [name(o)], ...(o.state === 'repeat' ? { repeat: true } : {}) } }
|
||||
}
|
||||
|
||||
// Every server: a success for each that took the line, and the rest named
|
||||
// (D104's reason — a line said an hour late in a restarted server is noise).
|
||||
const said = outcomes.filter((o) => o.state === 'said' || o.state === 'repeat').map(name)
|
||||
const down = outcomes.filter((o) => o.state === 'down').map(name)
|
||||
const refused = outcomes.filter((o) => o.state === 'refused').map((o) => `${name(o)}: ${pluginError(o.data, 'refused')}`)
|
||||
|
||||
if (!said.length && refused.length) return { ok: false, retry: false, error: refused.join('; ') }
|
||||
if (!said.length) return { ok: false, error: `no server could be reached: ${down.join(', ')}` }
|
||||
|
||||
return { ok: true, detail: { said, ...(down.length ? { down } : {}), ...(refused.length ? { refused } : {}) } }
|
||||
},
|
||||
}
|
||||
|
||||
// ── The announce leg (D104) ──────────────────────────────────────────────────
|
||||
|
||||
/** A news post as one chat line: its title, or failing that its excerpt, flattened and bounded. */
|
||||
function chatLine(post) {
|
||||
const text = String((post && (post.title || post.excerpt)) || '').replace(/\s+/g, ' ').trim()
|
||||
return text.length > MAX_CHAT ? `${text.slice(0, MAX_CHAT - 1)}…` : text
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin's memory of recent keys, keyed off the post: its id when core's
|
||||
* news path gives one, else what it says — `core.announce` hands a leg a post
|
||||
* with no id. Either way a retried leg never says the same line twice.
|
||||
*/
|
||||
function chatKey(post, line) {
|
||||
if (post && post.id !== undefined && post.id !== null) return `news:${post.id}`
|
||||
return `news:${crypto.createHash('sha1').update(line).digest('hex')}`
|
||||
}
|
||||
|
||||
const LEG = {
|
||||
leg: 'rust.chat',
|
||||
label: 'Rust in-game chat',
|
||||
|
||||
/**
|
||||
* Say a post in the chat of every server whose switch is on. Never throws, as
|
||||
* every leg client must not. The answer is one outcome per switched-on
|
||||
* server, for `classify`.
|
||||
*/
|
||||
async dispatch(post) {
|
||||
try {
|
||||
const line = chatLine(post)
|
||||
if (!line) return { ok: false, empty: true, outcomes: [] }
|
||||
|
||||
const list = (await servers.listForPolling()).filter((s) => s.announceNews)
|
||||
const key = chatKey(post, line)
|
||||
const outcomes = await Promise.all(
|
||||
list.map(async (server) => ({ server: server.name || server.id, ...(await sayOn(server, { key, message: line })) })),
|
||||
)
|
||||
return { ok: true, outcomes }
|
||||
} catch (err) {
|
||||
log.warn('news chat leg failed', { error: err.message })
|
||||
return { ok: false, error: err.message, outcomes: [] }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* `done` when every switched-on server that is up took the line — or when no
|
||||
* server is switched on, since there is nothing to deliver. `retry` only when
|
||||
* every switched-on server is down. A server that refused is named; one that
|
||||
* was down is skipped, never queued (D104).
|
||||
*/
|
||||
classify(result) {
|
||||
if (!result || (!result.ok && !result.empty && !result.outcomes)) return { outcome: 'retry', error: (result && result.error) || 'no answer' }
|
||||
if (result.empty) return { outcome: 'terminal', error: 'the post has no title or excerpt to say' }
|
||||
if (!result.ok) return { outcome: 'retry', error: result.error || 'the leg failed' }
|
||||
|
||||
const outcomes = result.outcomes || []
|
||||
if (!outcomes.length) return { outcome: 'done' }
|
||||
|
||||
const took = outcomes.filter((o) => o.state === 'said' || o.state === 'repeat')
|
||||
const down = outcomes.filter((o) => o.state === 'down').map((o) => o.server)
|
||||
const refused = outcomes.filter((o) => o.state === 'refused').map((o) => `${o.server}: ${pluginError(o.data, 'refused')}`)
|
||||
|
||||
if (down.length === outcomes.length) return { outcome: 'retry', error: `every server is down: ${down.join(', ')}` }
|
||||
if (!took.length) return { outcome: 'terminal', error: refused.join('; ') }
|
||||
|
||||
const notes = [...(down.length ? [`skipped (down): ${down.join(', ')}`] : []), ...refused]
|
||||
return notes.length ? { outcome: 'done', error: notes.join('; ') } : { outcome: 'done' }
|
||||
},
|
||||
}
|
||||
|
||||
// ── Option sources ───────────────────────────────────────────────────────────
|
||||
|
||||
/** A fixed choice as a dropdown — core has no enum type, so a source is how a field offers words. */
|
||||
const fixed = (id, label, description, rows) => ({ id, label, description, async resolve() { return rows } })
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
// Live from each server's Kits, so the form offers only kits that exist. The
|
||||
// label says what a reward of each one gives (R16, D103).
|
||||
id: 'rust.options.kits',
|
||||
label: 'Kits',
|
||||
description: "Each server's Kits, flagged by what a reward of one gives.",
|
||||
searchable: true,
|
||||
async resolve({ q } = {}) {
|
||||
const term = String(q || '').trim().toLowerCase()
|
||||
const answers = await perServer((server) => client.kits(server))
|
||||
const rows = []
|
||||
for (const { server, result } of answers) {
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'kits.list') continue
|
||||
for (const k of data.kits || []) {
|
||||
if (!k || !k.name) continue
|
||||
const value = `${server.id}/${k.name}`
|
||||
if (term && !value.toLowerCase().includes(term)) continue
|
||||
const reward = kitReward(k)
|
||||
const flags = [
|
||||
reward.permission ? null : 'open to everyone',
|
||||
reward.max > 0 ? `${reward.max} use${reward.max === 1 ? '' : 's'}` : null,
|
||||
reward.rewardsNothing ? 'rewards nothing' : null,
|
||||
].filter(Boolean)
|
||||
rows.push({ value, label: flags.length ? `${k.name} · ${flags.join(' · ')}` : String(k.name), group: server.name || server.id })
|
||||
}
|
||||
}
|
||||
return bounded(rows, 'rust.options.kits')
|
||||
},
|
||||
},
|
||||
// Free text: the zones a run will open do not exist when it is authored, and
|
||||
// the name is checked when the step runs (D100). Declared so the field is
|
||||
// documented rather than a bare box, and answers nothing.
|
||||
fixed('rust.options.runZones', 'Zones this run opens', 'The name an earlier "Open a zone" step of the same run gave its zone. Type it; it is checked when the step runs.', []),
|
||||
fixed('rust.options.scoreModes', 'Score', 'What earns a place in a tally.', [
|
||||
{ value: 'seconds', label: 'Seconds present' },
|
||||
{ value: 'kills', label: 'Kills' },
|
||||
{ value: 'both', label: 'Both — minutes plus a weight per kill' },
|
||||
]),
|
||||
fixed('rust.options.killsOf', 'Kills of', 'Whose deaths count as a kill.', [
|
||||
{ value: 'players', label: 'Players' },
|
||||
{ value: 'npcs', label: 'NPCs, animals included' },
|
||||
{ value: 'both', label: 'Players and NPCs' },
|
||||
]),
|
||||
fixed('rust.options.recipientModes', 'Recipients', 'Who a reward goes to (D101).', [
|
||||
{ value: 'everyone', label: 'Everyone who scored' },
|
||||
{ value: 'top', label: 'The top N (ties in)' },
|
||||
{ value: 'minScore', label: 'A score of at least X' },
|
||||
{ value: 'random', label: 'N drawn at random' },
|
||||
{ value: 'topPercent', label: 'The top X per cent (ties in)' },
|
||||
]),
|
||||
{
|
||||
id: 'rust.options.chatServers',
|
||||
label: 'Chat servers',
|
||||
description: 'Every enabled server, or * for all of them.',
|
||||
async resolve() {
|
||||
const list = await servers.listForPolling()
|
||||
return [{ value: EVERY_SERVER, label: 'Every server' }, ...list.map((s) => ({ value: s.id, label: s.name || s.id }))]
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const ACTIONS = [participationOpen, participationCollect, kitEntitle, announce]
|
||||
|
||||
module.exports = {
|
||||
MAX_RECIPIENTS,
|
||||
MAX_CHAT,
|
||||
EVERY_SERVER,
|
||||
BUDGETS,
|
||||
ACTIONS,
|
||||
LEG,
|
||||
OPTION_SOURCES,
|
||||
pickRecipients,
|
||||
checkCount,
|
||||
splitKit,
|
||||
kitReward,
|
||||
chatLine,
|
||||
chatKey,
|
||||
refParts,
|
||||
}
|
||||
@@ -58,6 +58,7 @@ module.exports = function register(ctx, api) {
|
||||
const seeds = require('./engagement/seeds')
|
||||
const eventLeases = require('./eventLeases')
|
||||
const eventWorld = require('./eventWorld')
|
||||
const eventRewards = require('./eventRewards')
|
||||
const boot = require('./boot')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -129,8 +130,9 @@ module.exports = function register(ctx, api) {
|
||||
// refresh. Registration is a claim, not a call: nothing here touches the
|
||||
// database, and the seeds are written by core after the schema is up.
|
||||
//
|
||||
// **Not registered, and that is D62:** no announce leg and no post hook. Both
|
||||
// need something in game to deliver to, and phase 10 reaches no game.
|
||||
// The announce leg arrived with phase 13b (D62, D104), when there was a chat
|
||||
// verb to deliver through; it is registered below with the rewards. There is
|
||||
// still no post hook: nothing in game mirrors a post as state.
|
||||
api.registerEventTriggers(TRIGGERS)
|
||||
api.registerNotificationStreams(STREAMS)
|
||||
api.registerAudiences(AUDIENCES)
|
||||
@@ -160,19 +162,32 @@ module.exports = function register(ctx, api) {
|
||||
// The world verbs (PLAN.md §28, protocol 9): what an event MAKES and gives
|
||||
// back — a zone, crates, NPCs — and the budgets that price them, each declared
|
||||
// beside the verb that spends it (D79, D89). A lease spends none of them.
|
||||
api.registerEventBudgets(eventWorld.BUDGETS)
|
||||
api.registerEventActions(eventWorld.ACTIONS)
|
||||
//
|
||||
// The rewards (PLAN.md §29, protocol 10) join them: the tally, the kit reward
|
||||
// and the chat line, with the two budgets they spend. Registered in the same
|
||||
// calls' neighbours, not merged into eventWorld's arrays, so each file keeps
|
||||
// its own statement of what it declares.
|
||||
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS])
|
||||
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS])
|
||||
|
||||
// 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.
|
||||
api.registerAnnounceLeg(eventRewards.LEG)
|
||||
|
||||
// ONE call for every option source: core takes a batch once, as this module's
|
||||
// complete statement, and refuses a second.
|
||||
api.registerEventOptionSources([...eventLeases.OPTION_SOURCES, ...eventWorld.OPTION_SOURCES])
|
||||
api.registerEventOptionSources([
|
||||
...eventLeases.OPTION_SOURCES,
|
||||
...eventWorld.OPTION_SOURCES,
|
||||
...eventRewards.OPTION_SOURCES,
|
||||
])
|
||||
|
||||
// Everything else this module will register — the rewards and the announce
|
||||
// leg (13b), the slash commands — is deliberately absent. Each arrives
|
||||
// with the phase that has something real to put in it. A registration
|
||||
// with nothing behind it is worse than a missing one: a declared trigger
|
||||
// nothing emits and a declared slot nothing fills are both surfaces an operator
|
||||
// can configure and then wait on.
|
||||
// Everything else this module will register — the slash commands — is
|
||||
// deliberately absent, and arrives with the phase that has something real to
|
||||
// put in it. A registration with nothing behind it is worse than a missing
|
||||
// one: a declared trigger nothing emits and a declared slot nothing fills are
|
||||
// both surfaces an operator can configure and then wait on.
|
||||
|
||||
log.info('registered', {
|
||||
version: require('../module.json').version,
|
||||
@@ -183,8 +198,9 @@ module.exports = function register(ctx, api) {
|
||||
streams: STREAMS.length,
|
||||
audiences: AUDIENCES.length,
|
||||
leases: eventLeases.LEASES.length,
|
||||
actions: eventWorld.ACTIONS.length,
|
||||
budgets: eventWorld.BUDGETS.length,
|
||||
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length,
|
||||
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length,
|
||||
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length,
|
||||
announceLeg: eventRewards.LEG.leg,
|
||||
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const GROUPS = 'rust_perm_groups'
|
||||
const GROUP_PERMISSIONS = 'rust_perm_group_permissions'
|
||||
const GROUP_MEMBERS = 'rust_perm_group_members'
|
||||
const GRANTS = 'rust_perm_grants'
|
||||
const RUN_GRANTS = 'rust_perm_run_grants'
|
||||
const PUSHED = 'rust_perm_pushed'
|
||||
const DRIFT = 'rust_perm_drift'
|
||||
const REVOCATIONS = 'rust_perm_revocations'
|
||||
@@ -190,6 +191,76 @@ async function deleteGrant(id) {
|
||||
return Number(result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
// ---- what events granted (phase 13b) ----
|
||||
//
|
||||
// `rust_perm_run_grants` is authored by `rust.kit.entitle`, never by a person,
|
||||
// and it is read beside `rust_perm_grants` rather than merged into it (D84): the
|
||||
// push unions the two, and a revert deletes exactly one step's rows.
|
||||
|
||||
/** Every event grant, for the push. Small: one row per recipient per reward step still standing. */
|
||||
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
|
||||
FROM ${RUN_GRANTS}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** One step's rows. A repeated key finds them here and writes nothing new. */
|
||||
async function listRunGrantsForStep(runId, stepId) {
|
||||
return core.query(
|
||||
`SELECT user_id AS userId, server_id AS serverId, steam_id AS steamId, permission, kit, credit
|
||||
FROM ${RUN_GRANTS}
|
||||
WHERE run_id = ? AND step_id = ?`,
|
||||
[String(runId), String(stepId)],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One step's recipients, in one statement. `INSERT IGNORE` against the unique
|
||||
* key, so a retry that races the first attempt writes each row once.
|
||||
*/
|
||||
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)}`,
|
||||
rows.flatMap((r) => [
|
||||
String(r.runId),
|
||||
String(r.stepId),
|
||||
String(r.idemKey || ''),
|
||||
r.userId,
|
||||
r.serverId,
|
||||
r.steamId,
|
||||
r.permission || '',
|
||||
r.kit,
|
||||
r.credit ? 1 : 0,
|
||||
]),
|
||||
)
|
||||
|
||||
return Number(result.affectedRows || 0)
|
||||
}
|
||||
|
||||
/** Withdraw one step's rows. Returns the servers they were on; none is a success. */
|
||||
async function deleteRunGrantsForStep(runId, stepId) {
|
||||
return deleteRunGrantsWhere('run_id = ? AND step_id = ?', [String(runId), String(stepId)])
|
||||
}
|
||||
|
||||
/** The same, found by core's idempotency key — the revert of an answer core lost. */
|
||||
async function deleteRunGrantsForKey(runId, idemKey) {
|
||||
if (!idemKey) return []
|
||||
return deleteRunGrantsWhere('run_id = ? AND idem_key = ?', [String(runId), String(idemKey)])
|
||||
}
|
||||
|
||||
async function deleteRunGrantsWhere(where, params) {
|
||||
const found = await core.query(`SELECT DISTINCT server_id AS serverId FROM ${RUN_GRANTS} WHERE ${where}`, params)
|
||||
if (!found.length) return []
|
||||
|
||||
await core.query(`DELETE FROM ${RUN_GRANTS} WHERE ${where}`, params)
|
||||
return found.map((row) => row.serverId)
|
||||
}
|
||||
|
||||
/**
|
||||
* One website account by name, for the authoring form.
|
||||
*
|
||||
@@ -463,6 +534,7 @@ async function listCatalogue() {
|
||||
module.exports = {
|
||||
GROUPS,
|
||||
GRANTS,
|
||||
RUN_GRANTS,
|
||||
PUSHED,
|
||||
DRIFT,
|
||||
listGroups,
|
||||
@@ -478,6 +550,11 @@ module.exports = {
|
||||
getGrant,
|
||||
insertGrant,
|
||||
deleteGrant,
|
||||
listRunGrants,
|
||||
listRunGrantsForStep,
|
||||
insertRunGrants,
|
||||
deleteRunGrantsForStep,
|
||||
deleteRunGrantsForKey,
|
||||
findUserByUsername,
|
||||
listLinks,
|
||||
listGroupsForUser,
|
||||
|
||||
@@ -278,12 +278,13 @@ async function forPlayer(userId, steamIds, serverRows) {
|
||||
* server is six times the queries for the same rows.
|
||||
*/
|
||||
async function readAuthored() {
|
||||
const [groups, groupPermissions, members, grants, links] = await Promise.all([
|
||||
const [groups, groupPermissions, members, grants, links, runGrants] = await Promise.all([
|
||||
db.listGroups(),
|
||||
db.listGroupPermissions(),
|
||||
db.listGroupMembers(),
|
||||
db.listGrants(),
|
||||
db.listLinks(),
|
||||
db.listRunGrants(),
|
||||
])
|
||||
|
||||
const steamIdsByUser = new Map()
|
||||
@@ -293,7 +294,7 @@ async function readAuthored() {
|
||||
steamIdsByUser.get(link.userId).push(link.steamId)
|
||||
}
|
||||
|
||||
return { groups, groupPermissions, members, grants, steamIdsByUser }
|
||||
return { groups, groupPermissions, members, grants, runGrants, steamIdsByUser }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,6 +314,7 @@ async function readAuthored() {
|
||||
*/
|
||||
function buildDesired(serverId, authored) {
|
||||
const { groups, groupPermissions, members, grants, steamIdsByUser } = authored
|
||||
const runGrants = authored.runGrants || []
|
||||
|
||||
const scopedGroups = groups.filter((group) => inScope(group.scope, serverId))
|
||||
const groupNames = new Set(scopedGroups.map((group) => group.name))
|
||||
@@ -378,6 +380,54 @@ function buildDesired(serverId, authored) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── What events granted (phase 13b, D84) ──────────────────────────────
|
||||
//
|
||||
// Unioned with the admin grants above through the same `seenGrant`, so a
|
||||
// permission held both ways is ONE row in the game — and withdrawing either
|
||||
// leaves the other standing, because the next build still finds it.
|
||||
//
|
||||
// An event grant reaches only the kit's server (D102), and like any grant it
|
||||
// reaches every account the user has linked (D28).
|
||||
//
|
||||
// The CREDIT is different: one win is one extra use, on the account that took
|
||||
// part, and only while that account is still linked to the user who won it.
|
||||
const credits = new Map()
|
||||
|
||||
for (const row of runGrants) {
|
||||
if (row.serverId !== serverId) continue
|
||||
|
||||
const linked = steamIdsByUser.get(row.userId) || []
|
||||
const permission = normaliseName(row.permission)
|
||||
|
||||
if (permission) {
|
||||
managed.add(permission)
|
||||
|
||||
for (const steamId of linked) {
|
||||
const key = `${steamId}:${permission}`
|
||||
if (seenGrant.has(key)) continue
|
||||
seenGrant.add(key)
|
||||
|
||||
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
|
||||
permissionsBySteamId.get(steamId).push(permission)
|
||||
rows.push({ kind: 'grant', subject: steamId, object: permission })
|
||||
}
|
||||
}
|
||||
|
||||
if (Number(row.credit) && linked.includes(row.steamId)) {
|
||||
// A Steam id is digits, so the first bar is always the split; a kit name
|
||||
// may contain one.
|
||||
const key = `${row.steamId}|${row.kit}`
|
||||
credits.set(key, (credits.get(key) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const creditRows = [...credits.entries()]
|
||||
.map(([key, count]) => {
|
||||
const bar = key.indexOf('|')
|
||||
return { steamId: key.slice(0, bar), kit: key.slice(bar + 1), count }
|
||||
})
|
||||
.sort((a, b) => (a.steamId + a.kit).localeCompare(b.steamId + b.kit))
|
||||
|
||||
const payload = {
|
||||
groups: scopedGroups.map((group) => ({
|
||||
name: group.name,
|
||||
@@ -391,9 +441,21 @@ function buildDesired(serverId, authored) {
|
||||
permissions,
|
||||
})),
|
||||
managed: [...managed].sort(),
|
||||
// Always sent, even empty: to the plugin an absent field means "this site
|
||||
// says nothing about credits", and an empty one means "nobody has any" —
|
||||
// which is what a revert of the last reward must be able to say (D103).
|
||||
credits: creditRows,
|
||||
}
|
||||
|
||||
return { payload, rows, hash: hashRows(rows) }
|
||||
// Credits are in the digest, so a new reward or a revert pushes, but they are
|
||||
// NOT in `rows`: those are the pushed ledger's, and a use of a kit is not
|
||||
// something in the permission store to retire.
|
||||
const hashed = [
|
||||
...rows,
|
||||
...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
|
||||
]
|
||||
|
||||
return { payload, rows, hash: hashRows(hashed) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,8 @@ const STATE = 'rust_server_state'
|
||||
async function listServers({ enabledOnly = false } = {}) {
|
||||
return core.query(
|
||||
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
|
||||
protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt
|
||||
protocol, enabled, sort_order AS sortOrder, announce_news AS announceNews,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM ${SERVERS}
|
||||
${enabledOnly ? 'WHERE enabled = 1' : ''}
|
||||
ORDER BY sort_order ASC, id ASC`,
|
||||
|
||||
@@ -48,12 +48,33 @@ function withToken(row) {
|
||||
}
|
||||
}
|
||||
|
||||
return { id: row.id, name: row.name, baseUrl: row.sidecarBaseUrl, token, protocol: row.protocol }
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
baseUrl: row.sidecarBaseUrl,
|
||||
token,
|
||||
protocol: row.protocol,
|
||||
// D104: whether a published news post is said in this server's chat.
|
||||
announceNews: Boolean(Number(row.announceNews)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many servers were enabled when this process last looked. An action's
|
||||
* `cost()` is synchronous and cannot ask the database, so `rust.announce` to
|
||||
* every server is priced at this — refreshed by every poll, which runs
|
||||
* continuously. One until the first poll, which is the least a fleet can be.
|
||||
*/
|
||||
let enabledCount = 1
|
||||
|
||||
function lastEnabledCount() {
|
||||
return enabledCount
|
||||
}
|
||||
|
||||
/** Every enabled server, with tokens, for the poller. */
|
||||
async function listForPolling() {
|
||||
const rows = await db.listServers({ enabledOnly: true })
|
||||
enabledCount = rows.length
|
||||
return rows.map(withToken)
|
||||
}
|
||||
|
||||
@@ -165,6 +186,7 @@ module.exports = {
|
||||
STALE_AFTER_MS,
|
||||
withToken,
|
||||
listForPolling,
|
||||
lastEnabledCount,
|
||||
listPublic,
|
||||
getPublic,
|
||||
listForAdmin,
|
||||
|
||||
@@ -34,7 +34,7 @@ async function getServerPresence(serverId) {
|
||||
/** Every configured server with its override, in the operator's own order. */
|
||||
async function listServerPresence() {
|
||||
return core.query(
|
||||
`SELECT id, name, enabled, presence_audience AS presence
|
||||
`SELECT id, name, enabled, presence_audience AS presence, announce_news AS announceNews
|
||||
FROM ${SERVERS}
|
||||
ORDER BY sort_order ASC, id ASC`,
|
||||
)
|
||||
@@ -52,4 +52,12 @@ async function setServerPresence(serverId, value) {
|
||||
await core.query(`UPDATE ${SERVERS} SET presence_audience = ? WHERE id = ?`, [value, serverId])
|
||||
}
|
||||
|
||||
module.exports = { getSetting, setSetting, getServerPresence, listServerPresence, setServerPresence }
|
||||
/**
|
||||
* Turns one server's news switch on or off (D104). Existence is the model's
|
||||
* question, asked with a read first, for the reason given above.
|
||||
*/
|
||||
async function setServerNews(serverId, on) {
|
||||
await core.query(`UPDATE ${SERVERS} SET announce_news = ? WHERE id = ?`, [on ? 1 : 0, serverId])
|
||||
}
|
||||
|
||||
module.exports = { getSetting, setSetting, getServerPresence, listServerPresence, setServerPresence, setServerNews }
|
||||
|
||||
@@ -184,6 +184,12 @@ async function describe() {
|
||||
}
|
||||
}),
|
||||
},
|
||||
// D104/D106: whether a published news post is said in each server's chat.
|
||||
// On this page because it is the one that lists every server with a setting
|
||||
// of its own, and it answers the same kind of question — what a server shows.
|
||||
news: {
|
||||
servers: servers.map((s) => ({ id: s.id, name: s.name, enabled: Boolean(s.enabled), on: Boolean(Number(s.announceNews)) })),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +203,7 @@ async function describe() {
|
||||
* Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
|
||||
* sentence the page can show.
|
||||
*/
|
||||
async function update({ fleet, servers, clanRoster } = {}, actor = null) {
|
||||
async function update({ fleet, servers, clanRoster, news } = {}, actor = null) {
|
||||
if (fleet !== undefined && !isAudience(fleet)) {
|
||||
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
|
||||
}
|
||||
@@ -221,6 +227,17 @@ async function update({ fleet, servers, clanRoster } = {}, actor = null) {
|
||||
}
|
||||
}
|
||||
|
||||
const newsChanges = Object.entries(news || {})
|
||||
for (const [id, value] of newsChanges) {
|
||||
if (typeof value !== 'boolean') {
|
||||
return { ok: false, status: 400, message: `News in game chat is on or off for server ${id}, not "${value}".` }
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if ((await db.getServerPresence(id)) === undefined) {
|
||||
return { ok: false, status: 404, message: `There is no server called ${id}.` }
|
||||
}
|
||||
}
|
||||
|
||||
const userId = actor && actor.id != null ? actor.id : null
|
||||
|
||||
if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
|
||||
@@ -229,6 +246,10 @@ async function update({ fleet, servers, clanRoster } = {}, actor = null) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.setServerPresence(id, value)
|
||||
}
|
||||
for (const [id, on] of newsChanges) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.setServerNews(id, on)
|
||||
}
|
||||
|
||||
// What was written, for the controller's audit row. Recorded there rather than
|
||||
// here because the activity log takes the REQUEST (who, from where), and a
|
||||
@@ -239,6 +260,7 @@ async function update({ fleet, servers, clanRoster } = {}, actor = null) {
|
||||
...(fleet !== undefined ? { fleet } : {}),
|
||||
...(clanRoster !== undefined ? { clanRoster } : {}),
|
||||
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
|
||||
...(newsChanges.length ? { news: Object.fromEntries(newsChanges) } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ async function syncOne(server, { authored, sync, state, force }) {
|
||||
groups: desired.payload.groups,
|
||||
grants: desired.payload.grants,
|
||||
managed: desired.payload.managed,
|
||||
credits: desired.payload.credits,
|
||||
retire,
|
||||
})
|
||||
|
||||
@@ -333,6 +334,9 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
|
||||
foreign: (report.foreign || []).length,
|
||||
pending: (report.pending || []).length,
|
||||
notLanded: (report.notLanded || []).length,
|
||||
...(report.creditsApplied !== undefined
|
||||
? { creditsApplied: report.creditsApplied, creditsWithdrawn: report.creditsWithdrawn }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ async function read(req, res) {
|
||||
|
||||
async function update(req, res) {
|
||||
try {
|
||||
const { fleet, servers, clanRoster } = req.body || {}
|
||||
const result = await visibility.update({ fleet, servers, clanRoster }, req.user)
|
||||
const { fleet, servers, clanRoster, news } = req.body || {}
|
||||
const result = await visibility.update({ fleet, servers, clanRoster, news }, req.user)
|
||||
if (!result.ok) {
|
||||
res.status(result.status || 400).json({ message: result.message })
|
||||
return
|
||||
|
||||
@@ -36,8 +36,8 @@ visibilityRouter.get(
|
||||
visibilityRouter.put(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Change who may see who is online, or who may see a clan roster'
|
||||
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, or any of them together. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.'
|
||||
// #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat'
|
||||
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
|
||||
@@ -46,6 +46,7 @@ visibilityRouter.put(
|
||||
body('fleet').optional().isIn(AUDIENCES).withMessage(`fleet must be one of ${AUDIENCES.join(', ')}`),
|
||||
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
|
||||
body('clanRoster').optional().isIn(CLAN_AUDIENCES).withMessage(`clanRoster must be one of ${CLAN_AUDIENCES.join(', ')}`),
|
||||
body('news').optional().isObject().withMessage('news maps a server id to true or false'),
|
||||
validate,
|
||||
visibility.update,
|
||||
)
|
||||
|
||||
@@ -63,7 +63,9 @@ const TIMEOUT_MS = 12000
|
||||
* a value on a server and give it back (PLAN.md §27); **9** adds the world
|
||||
* verbs — `/world/monuments`, `/world/owned`, `/world/zone`, `/world/place` and
|
||||
* `/world/revert` — what an event places in the world and gives back (PLAN.md
|
||||
* §28). The bump lands here in the same change as the emitters,
|
||||
* §28); **10** adds the rewards — `/tally/open`, `/tally/snapshot`,
|
||||
* `/tally/close`, `/kits` and `/chat`, and a `credits` field on the permission
|
||||
* sync (PLAN.md §29). 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
|
||||
@@ -73,7 +75,7 @@ const TIMEOUT_MS = 12000
|
||||
* deployment into a `409` naming both numbers instead of a parse failure three
|
||||
* layers further in.
|
||||
*/
|
||||
const PROTOCOL_VERSION = 9
|
||||
const PROTOCOL_VERSION = 10
|
||||
|
||||
/** What a caller gets back. Shaped once so every call site reads the same. */
|
||||
function reply(ok, status, data = null) {
|
||||
@@ -362,6 +364,28 @@ const worldPlace = (server, body) => request(server, '/world/place', { method: '
|
||||
*/
|
||||
const worldRevert = (server, body) => request(server, '/world/revert', { method: 'POST', body })
|
||||
|
||||
/**
|
||||
* Start counting who takes part in a run (protocol 10). `data.kind` is
|
||||
* `tally.ok` or `tally.error`; a repeated key is answered `repeat: true`.
|
||||
*/
|
||||
const tallyOpen = (server, body) => request(server, '/tally/open', { method: 'POST', body })
|
||||
|
||||
/** Who has taken part in a run so far, scored by the plugin. `tally.error` `no-tally` when it holds none. */
|
||||
const tallySnapshot = (server, runId) =>
|
||||
request(server, `/tally/snapshot?runId=${encodeURIComponent(String(runId))}`)
|
||||
|
||||
/** Stop counting and forget. `closed: false` means it was already gone, which is a success. */
|
||||
const tallyClose = (server, body) => request(server, '/tally/close', { method: 'POST', body })
|
||||
|
||||
/** 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')
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const chat = (server, body) => request(server, '/chat', { method: 'POST', body })
|
||||
|
||||
module.exports = {
|
||||
TIMEOUT_MS,
|
||||
LEASE_TIMEOUT_MS,
|
||||
@@ -388,5 +412,10 @@ module.exports = {
|
||||
worldZone,
|
||||
worldPlace,
|
||||
worldRevert,
|
||||
tallyOpen,
|
||||
tallySnapshot,
|
||||
tallyClose,
|
||||
kits,
|
||||
chat,
|
||||
joinUrl,
|
||||
}
|
||||
|
||||
@@ -560,6 +560,24 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
news: {
|
||||
type: 'object',
|
||||
description: 'Whether a published news post is also said in each server’s in-game chat. Off by default (D104).',
|
||||
properties: {
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'main' },
|
||||
name: { type: 'string', example: 'Main · Vanilla' },
|
||||
enabled: { type: 'boolean', example: true },
|
||||
on: { type: 'boolean', example: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
RustClanAudience: {
|
||||
@@ -652,6 +670,12 @@ module.exports = {
|
||||
example: { main: 'public', pvp: null },
|
||||
},
|
||||
clanRoster: { $ref: '#/components/schemas/RustClanAudience' },
|
||||
news: {
|
||||
type: 'object',
|
||||
description: 'A server id to whether a published news post is said in its in-game chat.',
|
||||
additionalProperties: { type: 'boolean' },
|
||||
example: { main: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
RustSidecarProbe: {
|
||||
|
||||
@@ -140,9 +140,10 @@ test('the ceilings are the ones §25.2 decided', () => {
|
||||
for (const id of ['rust.clan.member.left', 'rust.clan.member.kicked', 'rust.clan.disbanded']) {
|
||||
assert.strictEqual(by[id], 'members', id)
|
||||
}
|
||||
// D64: core's team.member.joined already covers it, and kits wait for phase 13.
|
||||
// D64: core's team.member.joined already covers it. The kit reward waited for
|
||||
// the phase that grants one (13b), and a reward is nobody else's news.
|
||||
assert.strictEqual(by['rust.clan.member.added'], undefined)
|
||||
assert.strictEqual(by['rust.kit.entitled'], undefined)
|
||||
assert.strictEqual(by['rust.kit.entitled'], 'owner')
|
||||
})
|
||||
|
||||
test('a clan path survives core\'s url check, colons and all', () => {
|
||||
|
||||
@@ -143,34 +143,58 @@ test('nothing is registered that has nothing behind it yet', () => {
|
||||
// leases and option sources, and kept budgets here on purpose (D79): a lease
|
||||
// spends none, and a dimension nothing spends is a dial that does nothing.
|
||||
// Phase 13a deleted the budgets and actions lines, and registered each budget
|
||||
// beside the verb that spends it (below). The announce leg is 13b's.
|
||||
assert.deepStrictEqual(api.record.legs, [])
|
||||
// beside the verb that spends it (below). Phase 13b registered the announce
|
||||
// leg (D104) — the post hook is still absent: nothing in game mirrors a post.
|
||||
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['rust.chat'])
|
||||
assert.strictEqual(api.record.hooks.post, undefined)
|
||||
})
|
||||
|
||||
test('the world verbs are registered, and every budget has a verb that spends it (phase 13a)', () => {
|
||||
test('the event verbs are registered, and every budget has a verb that spends it (phases 13a, 13b)', () => {
|
||||
const { api } = register()
|
||||
|
||||
const actions = api.record.eventActions
|
||||
const budgets = api.record.eventBudgets
|
||||
assert.deepStrictEqual(actions.map((a) => a.id).sort(), ['rust.crate.place', 'rust.npc.place', 'rust.zone.open'])
|
||||
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), ['rust.npcs', 'rust.prefabs', 'rust.zone.minutes'])
|
||||
assert.deepStrictEqual(actions.map((a) => a.id).sort(), [
|
||||
'rust.announce',
|
||||
'rust.crate.place',
|
||||
'rust.kit.entitle',
|
||||
'rust.npc.place',
|
||||
'rust.participation.collect',
|
||||
'rust.participation.open',
|
||||
'rust.zone.open',
|
||||
])
|
||||
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
|
||||
'rust.announcements',
|
||||
'rust.grants',
|
||||
'rust.npcs',
|
||||
'rust.prefabs',
|
||||
'rust.zone.minutes',
|
||||
])
|
||||
|
||||
// D79/D89/D97: every dimension has a verb that spends it, and each verb spends
|
||||
// exactly ONE — priced from its own declared examples, which is how core
|
||||
// decides which cap boxes the switchboard shows. A verb whose dimension moved
|
||||
// with its params would hide the other dial from every operator.
|
||||
// D79/D89/D97: every dimension has a verb that spends it, and a verb that
|
||||
// spends anything spends exactly ONE — priced from its own declared examples,
|
||||
// which is how core decides which cap boxes the switchboard shows. A verb
|
||||
// whose dimension moved with its params would hide the other dial from every
|
||||
// operator. The two tally verbs spend nothing: counting people costs no loot.
|
||||
const spent = new Set()
|
||||
for (const a of actions) {
|
||||
const example = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
|
||||
const dims = Object.keys(a.cost(example)).filter((id) => a.cost(example)[id] > 0)
|
||||
if (a.id.startsWith('rust.participation.')) {
|
||||
assert.strictEqual(dims.length, 0, `${a.id} prices ${dims.join(', ')}`)
|
||||
continue
|
||||
}
|
||||
assert.strictEqual(dims.length, 1, `${a.id} prices ${dims.join(', ')}`)
|
||||
spent.add(dims[0])
|
||||
}
|
||||
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
|
||||
|
||||
// What a teardown can give back is declared honestly: a line said and a
|
||||
// collect filed are not undone.
|
||||
const once = new Set(['rust.announce', 'rust.participation.collect'])
|
||||
for (const a of actions) {
|
||||
assert.strictEqual(a.reversible, 'ledger')
|
||||
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
|
||||
if (once.has(a.id)) continue
|
||||
assert.strictEqual(typeof a.revert, 'function')
|
||||
assert.strictEqual(typeof a.reconcile, 'function')
|
||||
// Every param source is one this module registers.
|
||||
|
||||
@@ -320,3 +320,61 @@ test('names are lowered, because the store lowers them', () => {
|
||||
// push one name, find another, and report its own grant as drift for ever.
|
||||
assert.deepEqual(payload.grants[0].permissions, ['kits.gold'])
|
||||
})
|
||||
|
||||
// ── What events granted (phase 13b, D84, D102, D103) ─────────────────────────
|
||||
|
||||
test('an event grant is unioned with the admin grants, reaches only its server, and credits the account that played', () => {
|
||||
withCore()
|
||||
const model = require('../model/permissions/permissions.model')
|
||||
|
||||
const set = {
|
||||
...authored(),
|
||||
runGrants: [
|
||||
// User 1 won on main with their second account; the grant reaches both
|
||||
// accounts (D28), the credit only the one that took part (D103).
|
||||
{ runId: '41', stepId: '7', userId: 1, serverId: 'main', steamId: '7656099', permission: 'Kits.Event', kit: 'event', credit: 1 },
|
||||
// The same permission an admin already grants user 1: one row in the game.
|
||||
{ runId: '41', stepId: '8', userId: 1, serverId: 'main', steamId: '7656001', permission: 'kits.gold', kit: 'gold', credit: 1 },
|
||||
// A kit anybody may redeem: a credit and no permission at all.
|
||||
{ runId: '41', stepId: '9', userId: 2, serverId: 'main', steamId: '7656002', permission: '', kit: 'starter', credit: 1 },
|
||||
// A win on another server reaches nothing here (D102).
|
||||
{ runId: '42', stepId: '1', userId: 2, serverId: 'creative', steamId: '7656002', permission: 'kits.creative', kit: 'c', credit: 1 },
|
||||
// An account unlinked since the win earns its credit nowhere.
|
||||
{ runId: '43', stepId: '1', userId: 2, serverId: 'main', steamId: '7656777', permission: '', kit: 'starter', credit: 1 },
|
||||
],
|
||||
}
|
||||
|
||||
const { payload, rows, hash } = model.buildDesired('main', set)
|
||||
const grantsFor = (steamId) => (payload.grants.find((g) => g.steamId === steamId) || { permissions: [] }).permissions.sort()
|
||||
|
||||
assert.deepEqual(grantsFor('7656001'), ['kits.event', 'kits.gold'])
|
||||
assert.deepEqual(grantsFor('7656099'), ['kits.event', 'kits.gold'])
|
||||
assert.ok(!payload.managed.includes('kits.creative'))
|
||||
assert.strictEqual(rows.filter((r) => r.kind === 'grant' && r.object === 'kits.gold').length, 2)
|
||||
|
||||
assert.deepEqual(payload.credits, [
|
||||
{ steamId: '7656001', kit: 'gold', count: 1 },
|
||||
{ steamId: '7656002', kit: 'starter', count: 1 },
|
||||
{ steamId: '7656099', kit: 'event', count: 1 },
|
||||
])
|
||||
// Credits push but never enter the pushed ledger: a use of a kit is not
|
||||
// something in the permission store to retire.
|
||||
assert.ok(!rows.some((r) => r.kind === 'credit'))
|
||||
|
||||
// A revert of the last reward still moves the digest, so it is pushed.
|
||||
const withdrawn = model.buildDesired('main', { ...set, runGrants: set.runGrants.filter((r) => r.stepId !== '9') })
|
||||
assert.notStrictEqual(withdrawn.hash, hash)
|
||||
})
|
||||
|
||||
test('the permission an admin grants survives an event revert of the same one (D84)', () => {
|
||||
withCore()
|
||||
const model = require('../model/permissions/permissions.model')
|
||||
|
||||
const event = { runId: '41', stepId: '8', userId: 1, serverId: 'main', steamId: '7656001', permission: 'kits.gold', kit: 'gold', credit: 0 }
|
||||
const before = model.buildDesired('main', { ...authored(), runGrants: [event] })
|
||||
const after = model.buildDesired('main', { ...authored(), runGrants: [] })
|
||||
|
||||
// Nothing to retire: the admin grant still desires every row the event did.
|
||||
assert.deepEqual(model.retirements(before.rows, after.rows), [])
|
||||
assert.deepEqual(after.payload.credits, [])
|
||||
})
|
||||
|
||||
419
server/test/rewards.test.js
Normal file
419
server/test/rewards.test.js
Normal file
@@ -0,0 +1,419 @@
|
||||
// ── The rewards (PLAN.md §29, protocol 10) ────────────────────────────────
|
||||
//
|
||||
// Who took part, what they may redeem, and a line in chat. Every test here is
|
||||
// one of the ways the contract's half can look right and be wrong:
|
||||
//
|
||||
// each recipient mode picks what D101 says, ties in, and a retried draw is the same draw
|
||||
// a reward is priced before the tally is read, so at the most it could grant
|
||||
// a kit that rewards nothing is refused, and a mode's count is checked
|
||||
// one person with two accounts is one reward, and an unlinked winner is named
|
||||
// a repeated key writes nothing new; a revert deletes the step's rows and pushes
|
||||
// a lost answer is reverted by its key
|
||||
// an entitlement is always in force — the site holds it
|
||||
// a tally's teardown closes it; "cannot ask" is not "gone"
|
||||
// a line to every server succeeds for those that took it and names the rest
|
||||
// the news leg speaks only where the switch is on, and retries only when all are down
|
||||
|
||||
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 action = (id) => rewards.ACTIONS.find((a) => a.id === id)
|
||||
const source = (id) => rewards.OPTION_SOURCES.find((s) => s.id === id)
|
||||
|
||||
const ROWS = {
|
||||
main: { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 },
|
||||
alt: { id: 'alt', name: 'Alt', sidecarBaseUrl: 'http://alt:1', sidecarTokenEnc: null, enabled: 1 },
|
||||
}
|
||||
|
||||
const ok = (data) => ({ ok: true, status: 'ok', data })
|
||||
const person = (steamId, score, extra = {}) => ({ steamId, name: `p${steamId}`, seconds: 60, kills: 0, score, joinedAt: Number(steamId), ...extra })
|
||||
|
||||
const KIT = { name: 'vip', permission: 'kits.vip', max: 1, cooldown: 0 }
|
||||
|
||||
/** Replace the module's collaborators for one test, and put them back after. */
|
||||
function stub(t, { kits, snapshot, tallyOpen, tallyClose, chat, polling, links, existing } = {}) {
|
||||
const calls = { grants: [], dirty: [], deleted: [], emitted: [], chat: [], open: [], close: [] }
|
||||
const saved = {
|
||||
getServer: serversDb.getServer,
|
||||
listForPolling: servers.listForPolling,
|
||||
kits: client.kits,
|
||||
tallySnapshot: client.tallySnapshot,
|
||||
tallyOpen: client.tallyOpen,
|
||||
tallyClose: client.tallyClose,
|
||||
chat: client.chat,
|
||||
insertRunGrants: permDb.insertRunGrants,
|
||||
listRunGrantsForStep: permDb.listRunGrantsForStep,
|
||||
deleteRunGrantsForStep: permDb.deleteRunGrantsForStep,
|
||||
deleteRunGrantsForKey: permDb.deleteRunGrantsForKey,
|
||||
markDirty: permDb.markDirty,
|
||||
userIdsForSteamIds: linksDb.userIdsForSteamIds,
|
||||
entitled: emit.entitled,
|
||||
}
|
||||
|
||||
serversDb.getServer = async (id) => ROWS[id] || null
|
||||
servers.listForPolling = async () =>
|
||||
(polling || [{ id: 'main' }]).map((s) => ({ name: ROWS[s.id] ? ROWS[s.id].name : s.id, baseUrl: `http://${s.id}:1`, token: 't', ...s }))
|
||||
client.kits = async (server) => (kits ? kits(server) : ok({ kind: 'kits.list', kits: [KIT], maxRecipients: 100 }))
|
||||
client.tallySnapshot = async (server, runId) =>
|
||||
snapshot ? snapshot(server, runId) : ok({ kind: 'tally.snapshot', runId, people: [], maxRecipients: 100 })
|
||||
client.tallyOpen = async (server, body) => {
|
||||
calls.open.push({ server: server.id, body })
|
||||
return tallyOpen ? tallyOpen(server, body) : ok({ kind: 'tally.ok', runId: body.runId })
|
||||
}
|
||||
client.tallyClose = async (server, body) => {
|
||||
calls.close.push({ server: server.id, body })
|
||||
return tallyClose ? tallyClose(server, body) : ok({ kind: 'tally.ok', closed: true })
|
||||
}
|
||||
client.chat = async (server, body) => {
|
||||
calls.chat.push({ server: server.id, body })
|
||||
return chat ? chat(server, body) : ok({ kind: 'chat.ok', said: true })
|
||||
}
|
||||
permDb.insertRunGrants = async (rows) => {
|
||||
calls.grants.push(...rows)
|
||||
return rows.length
|
||||
}
|
||||
permDb.listRunGrantsForStep = async () => existing || []
|
||||
permDb.deleteRunGrantsForStep = async (runId, stepId) => {
|
||||
calls.deleted.push({ runId, stepId })
|
||||
return ['main']
|
||||
}
|
||||
permDb.deleteRunGrantsForKey = async (runId, key) => {
|
||||
calls.deleted.push({ runId, key })
|
||||
return key ? ['main'] : []
|
||||
}
|
||||
permDb.markDirty = async (scope) => calls.dirty.push(scope)
|
||||
linksDb.userIdsForSteamIds = async (ids) =>
|
||||
ids.filter((id) => links && links[id]).map((id) => ({ steamId: id, userId: links[id] }))
|
||||
emit.entitled = (args) => {
|
||||
calls.emitted.push(args)
|
||||
return (args.userIds || []).length
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
serversDb.getServer = saved.getServer
|
||||
servers.listForPolling = saved.listForPolling
|
||||
Object.assign(client, {
|
||||
kits: saved.kits,
|
||||
tallySnapshot: saved.tallySnapshot,
|
||||
tallyOpen: saved.tallyOpen,
|
||||
tallyClose: saved.tallyClose,
|
||||
chat: saved.chat,
|
||||
})
|
||||
Object.assign(permDb, {
|
||||
insertRunGrants: saved.insertRunGrants,
|
||||
listRunGrantsForStep: saved.listRunGrantsForStep,
|
||||
deleteRunGrantsForStep: saved.deleteRunGrantsForStep,
|
||||
deleteRunGrantsForKey: saved.deleteRunGrantsForKey,
|
||||
markDirty: saved.markDirty,
|
||||
})
|
||||
linksDb.userIdsForSteamIds = saved.userIdsForSteamIds
|
||||
emit.entitled = saved.entitled
|
||||
})
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
// ── Picking ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const TALLY = [person('1', 50), person('2', 40), person('3', 40), person('4', 10), person('5', 0)]
|
||||
const ids = (list) => list.map((p) => p.steamId)
|
||||
|
||||
test('every verb outlives the client, which outlives the sidecar', () => {
|
||||
assert.ok(10000 < client.TIMEOUT_MS)
|
||||
for (const a of rewards.ACTIONS) assert.ok(client.TIMEOUT_MS < a.budgetMs, `${a.id} budgetMs`)
|
||||
})
|
||||
|
||||
test('everyone is everyone who scored; a score of zero earns nothing (D101)', () => {
|
||||
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'everyone', null, 'k')), ['1', '2', '3', '4'])
|
||||
})
|
||||
|
||||
test('top N keeps everybody tied with the last one in', () => {
|
||||
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'top', 1, 'k')), ['1'])
|
||||
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'top', 2, 'k')), ['1', '2', '3'])
|
||||
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'top', 99, 'k')), ['1', '2', '3', '4'])
|
||||
})
|
||||
|
||||
test('top per cent rounds up and keeps ties — 10% of 11 is 2 (§29.2)', () => {
|
||||
const eleven = Array.from({ length: 11 }, (_, i) => person(String(i + 1), 100 - i))
|
||||
assert.deepStrictEqual(ids(rewards.pickRecipients(eleven, 'topPercent', 10, 'k')), ['1', '2'])
|
||||
// 25% of the four who scored is one, and the tie at 40 does not arise — but
|
||||
// 50% is two, which lands on a tie and takes both.
|
||||
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'topPercent', 25, 'k')), ['1'])
|
||||
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'topPercent', 50, 'k')), ['1', '2', '3'])
|
||||
})
|
||||
|
||||
test('a minimum score is at least X, and zero means everyone who took part', () => {
|
||||
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'minScore', 40, 'k')), ['1', '2', '3'])
|
||||
assert.strictEqual(rewards.pickRecipients(TALLY, 'minScore', 0, 'k').length, 5)
|
||||
})
|
||||
|
||||
test('a retried draw draws the same winners, and another key draws differently', () => {
|
||||
const many = Array.from({ length: 40 }, (_, i) => person(String(1000 + i), 0))
|
||||
const first = ids(rewards.pickRecipients(many, 'random', 5, 'key-a'))
|
||||
assert.strictEqual(first.length, 5)
|
||||
assert.deepStrictEqual(ids(rewards.pickRecipients([...many].reverse(), 'random', 5, 'key-a')), first)
|
||||
assert.notDeepStrictEqual(ids(rewards.pickRecipients(many, 'random', 5, 'key-b')), first)
|
||||
})
|
||||
|
||||
test('a count is checked against its mode', () => {
|
||||
assert.strictEqual(rewards.checkCount('everyone', undefined).ok, true)
|
||||
assert.strictEqual(rewards.checkCount('top', 0).ok, false)
|
||||
assert.strictEqual(rewards.checkCount('top', 101).ok, false)
|
||||
assert.strictEqual(rewards.checkCount('random', 2.5).ok, false)
|
||||
assert.strictEqual(rewards.checkCount('topPercent', 0).ok, false)
|
||||
assert.strictEqual(rewards.checkCount('topPercent', 100).ok, true)
|
||||
assert.strictEqual(rewards.checkCount('minScore', -1).ok, false)
|
||||
assert.strictEqual(rewards.checkCount('minScore', 12.5).ok, true)
|
||||
})
|
||||
|
||||
test('a reward is priced at the most it could grant, before the tally is read (§29.2)', () => {
|
||||
const cost = action('rust.kit.entitle').cost
|
||||
assert.deepStrictEqual(cost({ recipients: 'top', count: 3 }), { 'rust.grants': 3 })
|
||||
assert.deepStrictEqual(cost({ recipients: 'random', count: 7 }), { 'rust.grants': 7 })
|
||||
for (const mode of ['everyone', 'minScore', 'topPercent']) {
|
||||
assert.deepStrictEqual(cost({ recipients: mode, count: 10 }), { 'rust.grants': rewards.MAX_RECIPIENTS }, mode)
|
||||
}
|
||||
})
|
||||
|
||||
test('the kit source says what a reward of each kit gives, and flags one that gives nothing', async (t) => {
|
||||
stub(t, {
|
||||
kits: () => ok({
|
||||
kind: 'kits.list',
|
||||
kits: [
|
||||
{ name: 'vip', permission: 'kits.vip', max: 0 },
|
||||
{ name: 'starter', permission: '', max: 3 },
|
||||
{ name: 'free', permission: '', max: 0 },
|
||||
],
|
||||
}),
|
||||
})
|
||||
const rows = await source('rust.options.kits').resolve({})
|
||||
assert.deepStrictEqual(rows.map((r) => r.value), ['main/vip', 'main/starter', 'main/free'])
|
||||
assert.strictEqual(rows[0].label, 'vip')
|
||||
assert.match(rows[1].label, /open to everyone · 3 uses/)
|
||||
assert.match(rows[2].label, /rewards nothing/)
|
||||
})
|
||||
|
||||
// ── rust.kit.entitle ─────────────────────────────────────────────────────────
|
||||
|
||||
const ENTITLE = { runId: 41, stepId: 7, idempotencyKey: 'key-41-7', params: { kit: 'main/vip', recipients: 'top', count: 2 } }
|
||||
|
||||
test('the reward writes one row per linked winner, credits the account that played, and pushes', async (t) => {
|
||||
const calls = stub(t, {
|
||||
snapshot: () => ok({ kind: 'tally.snapshot', people: TALLY, maxRecipients: 100 }),
|
||||
links: { 1: 10, 2: 20 },
|
||||
})
|
||||
const res = await action('rust.kit.entitle').perform(ENTITLE)
|
||||
|
||||
assert.strictEqual(res.ok, true)
|
||||
assert.deepStrictEqual(res.resources, [{ kind: 'entitlement', ref: 'main:41:7', payload: { serverId: 'main', kit: 'vip' } }])
|
||||
assert.deepStrictEqual(calls.grants.map((r) => [r.userId, r.steamId, r.permission, r.credit, r.idemKey]), [
|
||||
[10, '1', 'kits.vip', true, 'key-41-7'],
|
||||
[20, '2', 'kits.vip', true, 'key-41-7'],
|
||||
])
|
||||
assert.deepStrictEqual(calls.dirty, ['main'])
|
||||
// Top 2 is three people with the tie; the third linked nothing and is named.
|
||||
assert.strictEqual(res.detail.granted, 2)
|
||||
assert.deepStrictEqual(res.detail.missed, ['p3'])
|
||||
assert.deepStrictEqual(calls.emitted[0].userIds, [10, 20])
|
||||
})
|
||||
|
||||
test('two accounts one person holds are one reward, on the higher-scoring account', async (t) => {
|
||||
const calls = stub(t, {
|
||||
snapshot: () => ok({ kind: 'tally.snapshot', people: [person('1', 5), person('2', 9)] }),
|
||||
links: { 1: 10, 2: 10 },
|
||||
})
|
||||
await action('rust.kit.entitle').perform({ ...ENTITLE, params: { kit: 'main/vip', recipients: 'everyone' } })
|
||||
assert.deepStrictEqual(calls.grants.map((r) => r.steamId), ['2'])
|
||||
})
|
||||
|
||||
test('a kit with no use limit is a permission and no credit; a kit that rewards nothing is refused', async (t) => {
|
||||
let calls = stub(t, {
|
||||
kits: () => ok({ kind: 'kits.list', kits: [{ name: 'vip', permission: 'kits.vip', max: 0 }] }),
|
||||
snapshot: () => ok({ kind: 'tally.snapshot', people: [person('1', 5)] }),
|
||||
links: { 1: 10 },
|
||||
})
|
||||
await action('rust.kit.entitle').perform(ENTITLE)
|
||||
assert.strictEqual(calls.grants[0].credit, false)
|
||||
|
||||
calls = stub(t, { kits: () => ok({ kind: 'kits.list', kits: [{ name: 'vip', permission: '', max: 0 }] }) })
|
||||
const res = await action('rust.kit.entitle').perform(ENTITLE)
|
||||
assert.strictEqual(res.ok, false)
|
||||
assert.strictEqual(res.retry, false)
|
||||
assert.match(res.error, /gives nobody anything/)
|
||||
})
|
||||
|
||||
test('a dry run checks the kit and the mode and reads no tally', async (t) => {
|
||||
let read = false
|
||||
const calls = stub(t, { snapshot: () => { read = true; return ok({ kind: 'tally.snapshot', people: [] }) } })
|
||||
const res = await action('rust.kit.entitle').perform({ ...ENTITLE, verify: true })
|
||||
assert.deepStrictEqual(res, { ok: true })
|
||||
assert.strictEqual(read, false)
|
||||
assert.strictEqual(calls.grants.length, 0)
|
||||
|
||||
const bad = await action('rust.kit.entitle').perform({ ...ENTITLE, verify: true, params: { kit: 'vip', recipients: 'top', count: 2 } })
|
||||
assert.strictEqual(bad.retry, false)
|
||||
assert.match(bad.error, /server\/kit/)
|
||||
})
|
||||
|
||||
test('a repeated key finds its rows and writes nothing new', async (t) => {
|
||||
const calls = stub(t, { existing: [{ userId: 10 }, { userId: 20 }] })
|
||||
const res = await action('rust.kit.entitle').perform(ENTITLE)
|
||||
assert.strictEqual(res.ok, true)
|
||||
assert.strictEqual(res.detail.repeat, true)
|
||||
assert.strictEqual(calls.grants.length, 0)
|
||||
assert.strictEqual(calls.emitted.length, 0)
|
||||
})
|
||||
|
||||
test('more qualifying than the server rewards is refused for good, never trimmed (§29.5)', async (t) => {
|
||||
stub(t, {
|
||||
snapshot: () => ok({ kind: 'tally.snapshot', people: TALLY, maxRecipients: 3 }),
|
||||
links: { 1: 1, 2: 2, 3: 3, 4: 4 },
|
||||
})
|
||||
const res = await action('rust.kit.entitle').perform({ ...ENTITLE, params: { kit: 'main/vip', recipients: 'everyone' } })
|
||||
assert.strictEqual(res.ok, false)
|
||||
assert.strictEqual(res.retry, false)
|
||||
assert.match(res.error, /4 people qualify/)
|
||||
})
|
||||
|
||||
test('no tally on the server is refused for good, and names the verb that opens one', async (t) => {
|
||||
stub(t, { snapshot: () => ok({ kind: 'tally.error', reason: 'no-tally' }) })
|
||||
const res = await action('rust.kit.entitle').perform(ENTITLE)
|
||||
assert.strictEqual(res.retry, false)
|
||||
assert.match(res.error, /rust\.participation\.open/)
|
||||
})
|
||||
|
||||
test('a revert deletes the step\'s rows and pushes; a lost answer is found by its key', async (t) => {
|
||||
const calls = stub(t)
|
||||
const a = action('rust.kit.entitle')
|
||||
assert.deepStrictEqual(await a.revert({ runId: 41, resources: [{ kind: 'entitlement', ref: 'main:41:7' }] }), { ok: true })
|
||||
assert.deepStrictEqual(calls.deleted[0], { runId: '41', stepId: '7' })
|
||||
assert.deepStrictEqual(calls.dirty, ['main'])
|
||||
|
||||
assert.deepStrictEqual(await a.revert({ runId: 41, resources: [], idempotencyKey: 'key-41-7' }), { ok: true })
|
||||
assert.deepStrictEqual(calls.deleted[1], { runId: 41, key: 'key-41-7' })
|
||||
})
|
||||
|
||||
test('an entitlement is always in force: the site holds it, and a wipe cannot take it', async () => {
|
||||
const res = await action('rust.kit.entitle').reconcile({ runId: 41, resources: [{ ref: 'main:41:7' }] })
|
||||
assert.deepStrictEqual(res, { ok: true, inForce: ['main:41:7'] })
|
||||
})
|
||||
|
||||
// ── The tally ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a tally crosses with its key, its score and its zone; kills need a whose', async (t) => {
|
||||
const calls = stub(t)
|
||||
const open = action('rust.participation.open')
|
||||
const res = await open.perform({
|
||||
runId: 41,
|
||||
idempotencyKey: 'k',
|
||||
params: { server: 'main', zone: 'Arena', score: 'Both', killsOf: 'npcs', minutes: 30 },
|
||||
})
|
||||
assert.strictEqual(res.ok, true)
|
||||
assert.deepStrictEqual(calls.open[0].body, { runId: '41', key: 'k', score: 'both', killsOf: 'npcs', killWeight: 5, holdMs: 1800000, zone: 'Arena' })
|
||||
assert.strictEqual(res.resources[0].ref, 'main:41')
|
||||
|
||||
const missing = await open.perform({ runId: 41, params: { server: 'main', score: 'kills' } })
|
||||
assert.strictEqual(missing.retry, false)
|
||||
})
|
||||
|
||||
test('the plugin\'s permanent refusals stay refused; the switch is named', async (t) => {
|
||||
stub(t, { tallyOpen: () => ok({ kind: 'tally.error', reason: 'events-disabled', message: 'events are switched off' }) })
|
||||
const res = await action('rust.participation.open').perform({ runId: 1, params: { server: 'main', score: 'seconds' } })
|
||||
assert.strictEqual(res.retry, false)
|
||||
assert.match(res.error, /switched off/)
|
||||
})
|
||||
|
||||
test('collect files each person by Steam id, with the website user where linked', async (t) => {
|
||||
stub(t, {
|
||||
snapshot: () => ok({ kind: 'tally.snapshot', people: [person('1', 3.5, { kills: 1 }), person('2', 1)] }),
|
||||
links: { 1: 10 },
|
||||
})
|
||||
const res = await action('rust.participation.collect').perform({ runId: 41, params: { server: 'main' } })
|
||||
assert.strictEqual(res.participants.length, 2)
|
||||
assert.deepStrictEqual(res.participants[0], {
|
||||
memberKey: '1',
|
||||
userId: 10,
|
||||
score: 3.5,
|
||||
joinedAt: new Date(1).toISOString(),
|
||||
meta: { name: 'p1', seconds: 60, kills: 1 },
|
||||
})
|
||||
assert.strictEqual(res.participants[1].userId, undefined)
|
||||
})
|
||||
|
||||
test('teardown closes the tally; a lost answer closes it on every server; "cannot ask" keeps it in force', async (t) => {
|
||||
const calls = stub(t, { polling: [{ id: 'main' }, { id: 'alt' }] })
|
||||
const open = action('rust.participation.open')
|
||||
assert.deepStrictEqual(await open.revert({ runId: 41, resources: [{ ref: 'main:41', payload: { serverId: 'main' } }] }), { ok: true })
|
||||
assert.deepStrictEqual(calls.close.map((c) => c.server), ['main'])
|
||||
|
||||
await open.revert({ runId: 41, resources: [] })
|
||||
assert.deepStrictEqual(calls.close.map((c) => c.server), ['main', 'main', 'alt'])
|
||||
|
||||
stub(t, { snapshot: () => ({ ok: false, status: 'http-503' }) })
|
||||
assert.deepStrictEqual(await open.reconcile({ runId: 41, resources: [{ ref: 'main:41' }] }), { ok: true, inForce: ['main:41'] })
|
||||
stub(t, { snapshot: () => ok({ kind: 'tally.error', reason: 'no-tally' }) })
|
||||
assert.deepStrictEqual(await open.reconcile({ runId: 41, resources: [{ ref: 'main:41' }] }), { ok: true, inForce: [] })
|
||||
})
|
||||
|
||||
// ── Chat ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('one server: its answer is the step\'s, and a line carries the key and says it is an event\'s', async (t) => {
|
||||
const calls = stub(t, { chat: () => ({ ok: false, status: 'http-503' }) })
|
||||
const res = await action('rust.announce').perform({ runId: 1, idempotencyKey: 'k1', params: { server: 'main', message: ' Go\n now ' } })
|
||||
assert.strictEqual(res.ok, false)
|
||||
assert.notStrictEqual(res.retry, false)
|
||||
assert.deepStrictEqual(calls.chat[0].body, { key: 'k1', message: 'Go now', event: true })
|
||||
})
|
||||
|
||||
test('every server: a success for those that took it, the rest named (D104, D105)', async (t) => {
|
||||
stub(t, {
|
||||
polling: [{ id: 'main' }, { id: 'alt' }],
|
||||
chat: (server) => (server.id === 'alt' ? { ok: false, status: 'http-503' } : ok({ kind: 'chat.ok', said: true })),
|
||||
})
|
||||
const res = await action('rust.announce').perform({ runId: 1, idempotencyKey: 'k', params: { server: '*', message: 'hi' } })
|
||||
assert.strictEqual(res.ok, true)
|
||||
assert.deepStrictEqual(res.detail, { said: ['Main'], down: ['Alt'] })
|
||||
})
|
||||
|
||||
test('a line too long is refused on the form', async (t) => {
|
||||
stub(t)
|
||||
const res = await action('rust.announce').perform({ runId: 1, params: { server: 'main', message: 'x'.repeat(rewards.MAX_CHAT + 1) }, verify: true })
|
||||
assert.strictEqual(res.retry, false)
|
||||
})
|
||||
|
||||
test('the news leg speaks only where the switch is on, keyed by the post', async (t) => {
|
||||
const calls = stub(t, { polling: [{ id: 'main', announceNews: true }, { id: 'alt', announceNews: false }] })
|
||||
const result = await rewards.LEG.dispatch({ id: 9, title: 'Wipe tonight', excerpt: 'Long text' })
|
||||
assert.deepStrictEqual(calls.chat, [{ server: 'main', body: { key: 'news:9', message: 'Wipe tonight' } }])
|
||||
assert.deepStrictEqual(rewards.LEG.classify(result), { outcome: 'done' })
|
||||
})
|
||||
|
||||
test('the leg: nobody switched on is done; all down is retry; some down is done and named', () => {
|
||||
const { classify } = rewards.LEG
|
||||
assert.deepStrictEqual(classify({ ok: true, outcomes: [] }), { outcome: 'done' })
|
||||
assert.strictEqual(classify({ ok: true, outcomes: [{ server: 'A', state: 'down' }] }).outcome, 'retry')
|
||||
const some = classify({ ok: true, outcomes: [{ server: 'A', state: 'down' }, { server: 'B', state: 'said' }] })
|
||||
assert.strictEqual(some.outcome, 'done')
|
||||
assert.match(some.error, /skipped \(down\): A/)
|
||||
assert.strictEqual(classify({ ok: false, empty: true, outcomes: [] }).outcome, 'terminal')
|
||||
})
|
||||
|
||||
test('a post with no id is keyed by what it says; a long title is bounded to one line', () => {
|
||||
const line = rewards.chatLine({ title: 'x'.repeat(400) })
|
||||
assert.strictEqual(line.length, rewards.MAX_CHAT)
|
||||
assert.match(rewards.chatKey({ title: 'Hi' }, 'Hi'), /^news:[0-9a-f]{40}$/)
|
||||
assert.strictEqual(rewards.chatLine({ title: null, excerpt: 'Body\ntext' }), 'Body text')
|
||||
})
|
||||
@@ -185,6 +185,31 @@ test('the clan roster audience defaults to members, and a bad one writes nothing
|
||||
}
|
||||
})
|
||||
|
||||
test('news in game chat is off by default, on or off per server, and validated whole (D104, D106)', async () => {
|
||||
const { model, written, restore } = setup({ overrides: { main: null } })
|
||||
const db = require('../model/visibility/visibility.db')
|
||||
const news = []
|
||||
db.setServerNews = async (id, on) => news.push({ id, on })
|
||||
try {
|
||||
// A row that never had the column set reads as off.
|
||||
assert.deepEqual((await model.describe()).news.servers, [{ id: 'main', name: 'MAIN', enabled: true, on: false }])
|
||||
|
||||
const notBoolean = await model.update({ news: { main: 'yes' } })
|
||||
assert.equal(notBoolean.status, 400)
|
||||
const unknown = await model.update({ fleet: 'public', news: { main: true, nope: true } })
|
||||
assert.equal(unknown.status, 404)
|
||||
assert.deepEqual(news, [])
|
||||
assert.deepEqual(written.settings, [], 'the fleet change beside it was not written either')
|
||||
|
||||
const ok = await model.update({ news: { main: true } }, { id: 3 })
|
||||
assert.equal(ok.ok, true)
|
||||
assert.deepEqual(news, [{ id: 'main', on: true }])
|
||||
assert.deepEqual(ok.changed.news, { main: true })
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
// ── The public routes ─────────────────────────────────────────────────────
|
||||
|
||||
/** A response double recording what a handler answered. */
|
||||
|
||||
@@ -710,8 +710,8 @@
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Change who may see who is online, or who may see a clan roster",
|
||||
"description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, or any of them together. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.",
|
||||
"summary": "Change who may see who is online, who may see a clan roster, or which servers say news in chat",
|
||||
"description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Saved; answers the new state",
|
||||
@@ -4513,6 +4513,99 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"news": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Whether a published news post is also said in each server’s in-game chat. Off by default (D104)."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"servers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "main"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Main · Vanilla"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"on": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5152,6 +5245,37 @@
|
||||
},
|
||||
"clanRoster": {
|
||||
"$ref": "#/components/schemas/RustClanAudience"
|
||||
},
|
||||
"news": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "A server id to whether a published news post is said in its in-game chat."
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"main": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user