Files
Module-Rust/server/engagement/triggers.js
wtclaude bfe9b5b18d
All checks were successful
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in 46s
PR Checks / server-tests (pull_request) Successful in 7m23s
feat(rust): Dangerous Treasures — feed, map, Treasure Hunter, Start a Dangerous Treasures event (PLAN_REDESIGNS §14.3.4)
Stage 5 of the §14.3 integration plan, on protocol 14 (docs
rust-link/PROTOCOL.md §20.8).

- dt.started and dt.won are public kinds, dt.ended a staff kind. The new
  dangerousTreasures.js reads each row per viewer: grid and invaderCount for
  everyone, the winner's name for the presence audience (D376), the position,
  the step's key, the winner's Steam id and the invaders for staff.
- Feed lines in the World events filter: "A Dangerous Treasures event has
  started · K12", "Alice won the treasure · K12 · 3 players fought for it".
- Triggers rust.dt.started (with byEvent) and rust.dt.won, public, cooldown
  subject the server, names only.
- eventDangerousTreasures.js: rust.dt.start, "Start a Dangerous Treasures
  event" (risk change, reversible none, budget rust.dt.events), server and
  difficulty. Refused at verify where the server said DT is not loaded or
  console.plugins is false (Rust-Plugins#34). dt-not-ready answers "Dangerous
  Treasures is not ready yet" with retryAfterMs 600000 (D365, D371);
  dt-no-spot retries on core's delay; DT's own refusals are permanent.
- Treasure Hunter (D375): treasuresWon -> treasures_won, the title category
  treasureswon, and a sortable Treasures column shown where the server
  reports DT or a row counts one.
- The map draws a running chest on the world layer; the server settings'
  plugin line names DT and whether a step can start one, from the
  integrations read's new `console`.
- coreApi ^1.15.0, ci/core-ref.json pinned to website main at #215's merge.
  ci/bundle.json ships the two new files; swagger fragment and engagement
  manifest regenerated; README lists DT.

Server 627/627 (16 new in test/dangerousTreasures.test.js) and every check;
client 92/92, build and externals. Booted against the pinned core: 45
triggers, 13 actions, 11 budgets, 21 option sources; 89 routes unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 06:45:30 -05:00

677 lines
32 KiB
JavaScript

// ── What can happen, as core's engagement engine is told it ───────────────
//
// The payload contracts behind every notification this module can cause
// (MODULE_API.md §2.4, `registerEventTriggers`; PLAN.md §25). A trigger says
// what an event IS, what a template may interpolate, and — the part that is a
// security boundary — the widest audience a rule on it may EVER be given.
//
// ── The ceiling is containment, not size ──────────────────────────────────
//
// `owner` is not a small `staff`, and `staff` does not permit `owner`. For the
// raid alert "one person" is the person whose base it was; for a ban it is
// nobody outside the staff room. Each ceiling below is chosen against that
// lattice and not against a ladder, and core refuses a rule that widens one.
//
// ── What no variable here carries, on purpose ─────────────────────────────
//
// • An IP address. The login and ban frames carry one; the triggers do not,
// so no template an operator writes can put an address in a mail. The
// admin feed still shows it, to staff, where it is useful.
// • The raider (D66). The raid alert says what was destroyed, where and
// when. Who did it is gameplay intelligence the game does not hand the
// victim, and a variable that is not declared cannot be interpolated.
// • A Steam id other than the subject's own.
//
// ── Why `subjectKey` is what it is ────────────────────────────────────────
//
// Core's cooldown is per (rule, user, subject, channel). So the subject is the
// thing a recipient should hear about once per cooldown: a BUILDING for a raid
// (however many walls fall), a SERVER for a broadcast (however often it
// bounces), a CLAN for a membership change. A subject that changed every firing
// — a boot id, a timestamp — would make every cooldown a no-op.
//
// ── `version` ──────────────────────────────────────────────────────────────
//
// The prop-schema version a template records it was authored against. Bump one
// on a rename or a type change, never for a label.
const ID = 'rust'
/** Site-relative paths, built the way the client registers them. */
const PATHS = {
servers: `/${ID}`,
account: `/player/${ID}`,
}
// A server id is VARCHAR(64) of the operator's choosing, and a clan's
// `externalId` is `<serverId>:<clanId>:<createdMs>`. Core validates a `url`
// variable against a character class with no `:` in it, so every id that goes
// into a path is percent-encoded — without it the clan link would be dropped at
// emit in production, silently, for every clan there is.
const serverPath = (serverId) => `/${ID}/servers/${encodeURIComponent(serverId)}`
const leaderboardPath = (serverId) => `${serverPath(serverId)}?tab=leaderboard`
const clanPath = (externalId) => `/${ID}/clans/${encodeURIComponent(externalId)}`
const V1 = 1
// ── Shared variables ───────────────────────────────────────────────────────
// **Every trigger carries its own headline.** Most rules here point at core's
// generic `notify.event` / `inapp.event`, and core's structural projection fills
// `title` and `intro` from the trigger's LABEL and DESCRIPTION only when the
// payload does not define them — "the payload wins, the projection fills gaps"
// (ENGAGEMENT.md §4.6.1). Without these two, the phase-10 walk rendered a
// multi-server site's notice as "A server came online. A server's game
// started…" — true, and useless, because it never said which. The emitter
// writes the sentence (`emit.js` `headline`); an operator's own template can
// still ignore it and interpolate the parts.
const HEADLINE = [
{ 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.' },
]
const SERVER = [
{ 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.' },
]
const CLAN = [
{ name: 'clanKey', type: 'string', required: true, example: 'main:12:1790142840000',
description: 'The clan\'s stable identity. The cooldown subject; not meant for display.' },
{ name: 'clan', type: 'string', required: true, example: 'The Rust Belt',
description: 'The clan\'s name.' },
{ name: 'clanUrl', type: 'url', required: false, example: '/rust/clans/main%3A12%3A1790142840000',
description: 'Site-relative path to the clan\'s page.' },
]
// ── The raid alert ─────────────────────────────────────────────────────────
const RAID = {
id: 'rust.base.destroyed',
label: 'Your base was raided',
description:
'Part of a base you are authorised on was destroyed by another player: a wall, a door, ' +
'an external wall or gate, or the tool cupboard.',
kind: 'event',
// One per base per cooldown, however many walls fall. The building is the
// tool cupboard's id — the game's own answer to "which base is this".
subjectKey: 'building',
// One emit per authorised, linked person, each with `ownerUserId` set (D59).
// `owner` is the ceiling AND the default: there is nobody else this may reach.
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'building', type: 'string', required: true, example: '8113',
description: 'The base, as the id of its tool cupboard. The cooldown subject.' },
{ name: 'structure', type: 'string', required: true, example: 'door',
description: 'What was destroyed: "building block", "door", "external wall" or "tool cupboard".' },
{ name: 'grid', type: 'string', required: false, example: 'H7',
description: 'The map grid square. Absent when the server could not work one out.' },
// A FRAGMENT, for use inside a sentence. An unset optional interpolates to
// the empty string, so "your door in {{grid}} was destroyed" reads "your
// door in was destroyed" when the grid is unknown; this carries its own
// leading space and vanishes cleanly instead.
{ name: 'atGrid', type: 'string', required: false, example: ' in H7',
description: 'Sentence fragment: " in H7" with its own leading space, or nothing when the grid is unknown.' },
{ name: 'ownerOnline', type: 'boolean', required: true, example: false,
description: 'Whether YOU were online when it happened. The seeded rule alerts only when this is false.' },
],
}
// ── Server lifecycle ───────────────────────────────────────────────────────
//
// `everyone` because a server being up is what a server page already says to
// anyone. The DEFAULT is `subscribers` — the people who asked — and an operator
// widens deliberately.
const BROADCASTS = [
{
id: 'rust.wipe.started',
label: 'A server wiped',
description: 'A server started a new wipe: a fresh map, and everything built on the old one gone.',
kind: 'event',
subjectKey: 'serverId',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'wipeId', type: 'string', required: true, example: '1790142840-3000-1234',
description: 'The new wipe\'s identity.' },
],
},
{
id: 'rust.server.online',
label: 'A server came online',
description: 'A server\'s game started, or came back after being unreachable.',
kind: 'event',
subjectKey: 'serverId',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE],
},
{
id: 'rust.server.offline',
label: 'A server went offline',
description: 'A server\'s game stopped, crashed, or stopped talking to the website.',
kind: 'event',
subjectKey: 'serverId',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE],
},
{
id: 'rust.leaderboard.topped',
label: 'A new kills leader',
description: 'Somebody new leads the current wipe\'s kills on a server.',
kind: 'event',
subjectKey: 'serverId',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'leader', type: 'string', required: true, example: 'Marisol',
description: 'The new leader\'s in-game name.' },
{ name: 'kills', type: 'int', required: true, example: 42,
description: 'Their kills this wipe.' },
{ name: 'leaderboardUrl', type: 'url', required: false, example: '/rust/servers/main?tab=leaderboard',
description: 'Site-relative path to the server\'s leaderboard.' },
],
},
]
// ── The player's own account ───────────────────────────────────────────────
const ACCOUNT = {
id: 'rust.player.linked',
label: 'A Steam account was linked',
description: 'A Steam account was linked to your website account with an in-game code.',
kind: 'event',
subjectKey: 'steamId',
// PLAN.md §10 said `self`; core has no such ceiling (§25.1). `owner` with the
// linking user as `ownerUserId` is the value that exists and means the same.
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000001',
description: 'The Steam account that was linked. Also the cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Marisol',
description: 'The in-game name the game reported when it was linked.' },
{ name: 'accountUrl', type: 'url', required: false, example: '/player/rust',
description: 'Site-relative path to your Rust account page.' },
],
}
// ── 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
// travel on the envelope as `recipientUserIds`, because "the clan this was
// about" is a different answer every firing and cannot be a saved audience.
//
// No `rust.clan.member.added`: core already fires `team.member.joined` for our
// clans through the Team sync, and a second trigger would notify twice (D64).
const CLANS = [
{
id: 'rust.clan.member.left',
label: 'Someone left your clan',
description: 'A member left a clan you are in.',
kind: 'event',
subjectKey: 'clanKey',
audience: 'members',
ceiling: 'members',
version: V1,
variables: [
...CLAN,
...SERVER,
...HEADLINE,
{ name: 'member', type: 'string', required: false, example: 'Darrow',
description: 'Who left.' },
],
},
{
id: 'rust.clan.member.kicked',
label: 'Someone was removed from your clan',
description: 'A member was removed from a clan you are in — or you were.',
kind: 'event',
subjectKey: 'clanKey',
audience: 'members',
ceiling: 'members',
version: V1,
variables: [
...CLAN,
...SERVER,
...HEADLINE,
{ name: 'member', type: 'string', required: false, example: 'Darrow',
description: 'Who was removed.' },
{ name: 'by', type: 'string', required: false, example: 'Marisol',
description: 'Who removed them.' },
],
},
{
id: 'rust.clan.disbanded',
label: 'Your clan was disbanded',
description: 'A clan you were in was disbanded.',
kind: 'event',
subjectKey: 'clanKey',
audience: 'members',
ceiling: 'members',
version: V1,
variables: [
...CLAN,
...SERVER,
...HEADLINE,
{ name: 'by', type: 'string', required: false, example: 'Marisol',
description: 'Who disbanded it.' },
],
},
]
// ── Moderation — staff, and never wider ────────────────────────────────────
const MODERATION = [
{
id: 'rust.player.reported',
label: 'A player was reported',
description: 'A player filed an in-game report against another.',
kind: 'event',
subjectKey: 'steamId',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
description: 'The reported player\'s Steam id. The cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Darrow',
description: 'The reported player\'s name.' },
{ name: 'reporter', type: 'string', required: false, example: 'Marisol',
description: 'Who filed the report.' },
{ name: 'reportType', type: 'string', required: false, example: 'cheat',
description: 'The category the reporter chose.' },
{ name: 'topic', type: 'string', required: false, example: 'Aimbot at the dome',
description: 'The report\'s subject line.' },
{ name: 'message', type: 'string', required: false, example: 'Headshots through two walls.',
description: 'The report\'s text.' },
],
},
{
id: 'rust.player.banned',
label: 'A player was banned',
description: 'A player was banned on a server.',
kind: 'event',
subjectKey: 'steamId',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
description: 'The banned player\'s Steam id. The cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Darrow',
description: 'The banned player\'s name.' },
{ name: 'reason', type: 'string', required: false, example: 'Cheating',
description: 'The reason given.' },
],
},
{
id: 'rust.player.unbanned',
label: 'A player was unbanned',
description: 'A ban on a server was lifted.',
kind: 'event',
subjectKey: 'steamId',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
description: 'The player\'s Steam id. The cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Darrow',
description: 'The player\'s name.' },
],
},
{
id: 'rust.login.denied',
label: 'A login was not approved',
description:
'Somebody tried to join a server and was not let in within a minute: a ban, a failed ' +
'authentication, or a player who gave up while connecting.',
kind: 'event',
subjectKey: 'steamId',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
description: 'The Steam id that tried to connect. The cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Darrow',
description: 'The name it connected with.' },
{ name: 'attemptedAt', type: 'datetime', required: true, example: '2026-09-23T03:10:00Z',
description: 'When the attempt was made.' },
],
},
]
// ── RunicNPC (docs/runicnpc/PLAN.md stage 4) ───────────────────────────────
//
// What an event's phase waits on: "8 guards died" (waves) and "the boss fell
// below 50%". Core counts a gate's firings from phase entry and does not know
// which run an NPC belongs to, so each firing says its profile, whether an event
// placed it and which run did; a gate's `where` names what it waits for. A
// profile only events place is the plain way to count only an event's NPCs.
//
// `staff`, both halves: a death names who killed it. A boss announced to
// players is stage 6's, with its own trigger.
const NPC_VARS = [
{ name: 'profile', type: 'string', required: true, example: 'guard',
description: 'The RunicNPC profile the NPC was made from. The cooldown subject, and what a phase gate usually names.' },
{ name: 'npc', type: 'string', required: false, example: 'Gate Guard',
description: 'The NPC\'s own name, as the victim\'s death screen shows it.' },
{ name: 'byEvent', type: 'boolean', required: true, example: true,
description: 'Whether an event placed it (rather than an admin\'s placement or another plugin).' },
{ name: 'runId', type: 'string', required: false, example: '42',
description: 'The event run that placed it, when one did.' },
{ name: 'placement', type: 'string', required: false, example: 'guard-3',
description: 'The placement it came from, when it came from one.' },
]
const NPCS = [
{
id: 'rust.npc.died',
label: 'A RunicNPC NPC died',
description: 'One of RunicNPC\'s NPCs was killed. A phase can wait for a number of them: "8 guards died".',
kind: 'event',
subjectKey: 'profile',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
...SERVER,
...HEADLINE,
...NPC_VARS,
{ name: 'killer', type: 'string', required: false, example: 'Marisol',
description: 'The player who landed the killing blow. Absent when no player did.' },
{ name: 'killerSteamId', type: 'string', required: false, example: '76561198000000002',
description: 'Their Steam id.' },
{ name: 'contributors', type: 'int', required: true, example: 3,
description: 'How many players took health from it, the killer included.' },
],
},
{
id: 'rust.npc.health',
label: 'A RunicNPC NPC fell to a health threshold',
description: 'One of RunicNPC\'s NPCs fell to a fraction of its health its profile names. A phase can wait for "the boss below 50%".',
kind: 'event',
subjectKey: 'profile',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
...SERVER,
...HEADLINE,
...NPC_VARS,
{ name: 'percent', type: 'int', required: true, example: 50,
description: 'The threshold it fell to, as a percentage of its health: 50 for half.' },
],
},
]
// ── A RunicNPC boss (docs/runicnpc/PLAN.md stage 6, D277) ───────────────────
//
// Public, unlike the two above: a boss rising, changing phase and falling is
// server news, as a wipe is. `everyone` is the ceiling and `subscribers` the
// default, as for the broadcasts. The game also says these lines in chat; these
// are for the app, the in-app inbox and email. Discord hears a boss only through
// an event's own announce step (D277), so core does not change.
//
// No Steam id is declared, the killer's included: a public notice names the
// killer by their in-game name only.
const BOSS_VARS = [
{ name: 'profile', type: 'string', required: true, example: 'juggernaut',
description: 'The RunicNPC profile the boss was made from.' },
{ name: 'boss', type: 'string', required: true, example: 'Juggernaut',
description: 'The boss\'s own name, as players see it.' },
{ name: 'byEvent', type: 'boolean', required: true, example: true,
description: 'Whether an event placed it.' },
]
const BOSSES = [
{
id: 'rust.boss.spawned',
label: 'A boss appeared',
description: 'A RunicNPC boss spawned on a server: an admin\'s placement, an event, or another plugin.',
kind: 'event',
// One per kind of boss per cooldown, however often it respawns.
subjectKey: 'profile',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE, ...BOSS_VARS],
},
{
id: 'rust.boss.phase',
label: 'A boss changed phase',
description: 'A RunicNPC boss fell to one of its phases\' health thresholds and changed: stronger, faster, or calling for help.',
kind: 'event',
// A profile's phase: phase 2 is not swallowed by phase 1's cooldown.
subjectKey: 'phaseKey',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [
...SERVER,
...HEADLINE,
...BOSS_VARS,
{ name: 'phaseKey', type: 'string', required: true, example: 'juggernaut:2',
description: 'The profile and the phase. The cooldown subject; not meant for display.' },
{ name: 'phase', type: 'int', required: true, example: 2,
description: 'Which phase it entered, counting from 1.' },
{ name: 'percent', type: 'int', required: true, example: 25,
description: 'The health it fell to, as a percentage.' },
],
},
{
id: 'rust.boss.killed',
label: 'A boss was killed',
description: 'Players killed a RunicNPC boss on a server.',
kind: 'event',
subjectKey: 'profile',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [
...SERVER,
...HEADLINE,
...BOSS_VARS,
{ name: 'killer', type: 'string', required: false, example: 'Marisol',
description: 'The player who landed the killing blow, by in-game name. Absent when no player did.' },
{ name: 'damagers', type: 'int', required: true, example: 5,
description: 'How many players hurt it, the killer included.' },
],
},
]
// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1, D355) ─────
//
// One trigger per `world.event` type, `rust.world.<type>`. Public news, as a
// boss is: `everyone` is the ceiling and `subscribers` the default. A phase
// gate reads the same catalogue, so "advance when Bradley is destroyed" is
// `{ on: 'rust.world.bradley.destroyed' }` with no further code.
//
// The cooldown subject is the server and the type, and the crate types add
// where the crate is — one line per oil rig per cooldown, not one per hack on
// the map. `player` is the in-game name only, as a boss's killer is; no Steam
// id is declared.
const WORLD_SUBJECT = { name: 'subject', type: 'string', required: true, example: 'main:heli.downed',
description: 'The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display.' }
const WORLD_GRID = { name: 'grid', type: 'string', required: false, example: 'G7',
description: 'The map grid square. Absent when the server has no map geometry yet, or the event is off the map.' }
const WORLD_PLAYER = { name: 'player', type: 'string', required: false, example: 'Marisol',
description: 'The player it names, by in-game name. Absent when nobody is named.' }
const WORLD_CREDITED = [
{ name: 'player', type: 'string', required: false, example: 'Marisol and Darrow',
description: 'Who is credited, by in-game name and by the server\'s credit rule: "Alice", "Alice and Bob", "Alice, Bob and 2 others". Absent when nobody is.' },
{ name: 'credited', type: 'int', required: true, example: 2,
description: 'How many players the credit rule names.' },
]
const WORLD_WHERE = (example, list) => ({ name: 'where', type: 'string', required: true, example,
description: `Where it is: ${list}.` })
const CRATE_WHERE = WORLD_WHERE('oilrig_large', 'oilrig_large, oilrig_small, cargo, ghostship or chinook (a crate a Chinook dropped)')
const WORLD_HARBOUR = { name: 'harbour', type: 'string', required: false, example: 'Harbor',
description: 'The harbour, by the game\'s own name for it.' }
const WORLD_MONUMENT = { name: 'monument', type: 'string', required: false, example: 'Water Treatment Plant',
description: 'The monument it dropped at, by the game\'s own name for it.' }
const WORLD_COUNT = { name: 'count', type: 'int', required: true, example: 12,
description: 'How many: road Bradleys come several at once.' }
/** Each world event's label, description and the variables it carries beyond the server's. */
const WORLD_EVENTS = [
['heli.arrived', 'A patrol helicopter arrived', 'A patrol helicopter flew onto the map.', [WORLD_GRID]],
['heli.left', 'The patrol helicopter left', 'A patrol helicopter left the map without being downed.', [WORLD_GRID]],
['heli.downed', 'A patrol helicopter was downed', 'Players brought down a patrol helicopter.', [WORLD_GRID, ...WORLD_CREDITED]],
['bradley.spawned', 'Bradley is out', 'Launch Site\'s Bradley spawned, or road Bradleys took to the roads.', [WORLD_WHERE('road', 'launch_site or road'), WORLD_COUNT]],
['bradley.destroyed', 'A Bradley was destroyed', 'Players destroyed a Bradley APC: Launch Site\'s, a road one, or another.', [WORLD_WHERE('launch_site', 'launch_site, road or other'), WORLD_GRID, ...WORLD_CREDITED]],
['cargo.arrived', 'The cargo ship arrived', 'The cargo ship sailed onto the map.', [WORLD_GRID]],
['cargo.docking', 'The cargo ship is docking', 'The cargo ship turned in to dock at a harbour.', [WORLD_GRID, WORLD_HARBOUR]],
['cargo.docked', 'The cargo ship docked', 'The cargo ship docked at a harbour, with fresh crates.', [WORLD_GRID, WORLD_HARBOUR]],
['cargo.undocked', 'The cargo ship cast off', 'The cargo ship left the harbour it was docked at.', [WORLD_GRID, WORLD_HARBOUR]],
['cargo.left', 'The cargo ship is leaving', 'The cargo ship turned for the edge of the map.', [WORLD_GRID]],
['chinook.arrived', 'A Chinook arrived', 'A Chinook flew onto the map with a locked crate.', [WORLD_GRID]],
['chinook.dropped', 'A Chinook dropped its crate', 'A Chinook dropped a locked crate at a monument.', [WORLD_GRID, WORLD_MONUMENT]],
['chinook.downed', 'A Chinook was shot down', 'Players shot down a Chinook.', [WORLD_GRID, ...WORLD_CREDITED]],
['airdrop.called', 'An airdrop is on its way', 'A cargo plane is bringing an airdrop: called by a supply signal, or by the schedule.', [WORLD_GRID, WORLD_PLAYER]],
['airdrop.landed', 'An airdrop landed', 'An airdrop reached the ground.', [WORLD_GRID, WORLD_PLAYER]],
['airdrop.looted', 'An airdrop was looted', 'Somebody opened an airdrop first.', [WORLD_GRID, WORLD_PLAYER]],
['crate.hack.started', 'A locked crate is being hacked', 'Somebody started hacking a locked crate. On an oil rig this also calls its scientists.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
['crate.unlocked', 'A locked crate unlocked', 'A hacked locked crate finished unlocking.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
['crate.looted', 'A locked crate was looted', 'Somebody opened an unlocked locked crate first.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
['vendor.arrived', 'The Travelling Vendor arrived', 'The Travelling Vendor drove onto the map.', [WORLD_GRID]],
['vendor.left', 'The Travelling Vendor left', 'The Travelling Vendor left the map.', [WORLD_GRID]],
['satellite.crashed', 'A satellite crashed', 'A satellite came down, and its crates are burning.', [WORLD_GRID]],
['deepsea.opened', 'The Deep Sea opened', 'The Deep Sea is open: its ghost ships and islands can be reached.', []],
['deepsea.closed', 'The Deep Sea closed', 'The Deep Sea closed.', []],
]
const WORLD = WORLD_EVENTS.map(([event, label, description, vars]) => ({
id: `rust.world.${event}`,
label,
description,
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE, WORLD_SUBJECT, ...vars],
}))
// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4, §14.3.9) ──────
//
// A treasure event opening and being won are triggers, as a world event is
// (D355): public news, `everyone` the ceiling and `subscribers` the default.
// The cooldown subject is the server, so a busy hour of treasures is one line
// per cooldown. `player` is the winner's in-game name only; no Steam id is
// declared. Its ending is staff's on the feed and raises nothing.
const DT_SUBJECT = { name: 'subject', type: 'string', required: true, example: 'main:dt.won',
description: 'The server and the event. The cooldown subject; not meant for display.' }
const DT_BY_EVENT = { name: 'byEvent', type: 'boolean', required: true, example: false,
description: 'Whether one of this site\'s events started it (a Start a Dangerous Treasures event step).' }
const DANGEROUS_TREASURES = [
{
id: 'rust.dt.started',
label: 'A treasure event started',
description: 'Dangerous Treasures opened a guarded, burning treasure chest on a server: on its own schedule, by an admin, or by an event.',
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE, DT_SUBJECT, WORLD_GRID, DT_BY_EVENT],
},
{
id: 'rust.dt.won',
label: 'A treasure was won',
description: 'A player emptied a Dangerous Treasures chest on a server.',
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [
...SERVER,
...HEADLINE,
DT_SUBJECT,
WORLD_GRID,
{ name: 'player', type: 'string', required: false, example: 'Marisol',
description: 'Who emptied the chest, by in-game name.' },
{ name: 'invaders', type: 'int', required: true, example: 3,
description: 'How many players fought for it, the winner included.' },
],
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES, ...WORLD, ...DANGEROUS_TREASURES])
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))
module.exports = { TRIGGERS, TRIGGER_IDS, PATHS, serverPath, leaderboardPath, clanPath }