diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 59b19eb..52e9258 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -130,6 +130,9 @@ jobs: - name: Check the OpenAPI fragment is current (MODULE_API.md §2.8) run: npm run check:swagger --prefix server + - name: Check the engagement freeze is current (PLAN.md §25) + run: npm run check:engagement --prefix server + client-build: runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/ci/bundle.json b/ci/bundle.json index b0b374d..ce75ece 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -32,6 +32,7 @@ "configEdit.js", "core.js", "db", + "engagement", "index.js", "ingest.js", "model", @@ -41,6 +42,7 @@ "sidecarClient.js" ], "root": [ + "engagement-triggers.json", "swagger-fragment.json", "LICENSE.md", "README.md" diff --git a/engagement-triggers.json b/engagement-triggers.json new file mode 100644 index 0000000..859e228 --- /dev/null +++ b/engagement-triggers.json @@ -0,0 +1,1203 @@ +{ + "_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.", + "coreApi": "^1.10.0", + "triggers": [ + { + "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", + "subjectKey": "building", + "audience": "owner", + "ceiling": "owner", + "version": 1, + "variables": [ + { + "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": "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": "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." + }, + { + "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." + } + ] + }, + { + "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": 1, + "variables": [ + { + "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." + }, + { + "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": "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": "by", + "type": "string", + "required": false, + "example": "Marisol", + "description": "Who disbanded it." + } + ] + }, + { + "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": 1, + "variables": [ + { + "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." + }, + { + "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": "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": "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.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": 1, + "variables": [ + { + "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." + }, + { + "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": "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": "member", + "type": "string", + "required": false, + "example": "Darrow", + "description": "Who left." + } + ] + }, + { + "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": 1, + "variables": [ + { + "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": "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": "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." + } + ] + }, + { + "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": 1, + "variables": [ + { + "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": "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": "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." + } + ] + }, + { + "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": 1, + "variables": [ + { + "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": "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": "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.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", + "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": "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." + } + ] + }, + { + "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": 1, + "variables": [ + { + "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": "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": "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.unbanned", + "label": "A player was unbanned", + "description": "A ban on a server was lifted.", + "kind": "event", + "subjectKey": "steamId", + "audience": "staff", + "ceiling": "staff", + "version": 1, + "variables": [ + { + "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": "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": "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.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": 1, + "variables": [ + { + "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": "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." + } + ] + }, + { + "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": 1, + "variables": [ + { + "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": "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." + } + ] + }, + { + "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": 1, + "variables": [ + { + "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": "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": "wipeId", + "type": "string", + "required": true, + "example": "1790142840-3000-1234", + "description": "The new wipe's identity." + } + ] + } + ], + "streams": [ + { + "id": "rust.base.destroyed", + "label": "Your base was raided", + "personal": true, + "requiresLinkedAccount": true + }, + { + "id": "rust.server.offline", + "label": "A server went offline", + "personal": false, + "requiresLinkedAccount": false + }, + { + "id": "rust.server.online", + "label": "A server came online", + "personal": false, + "requiresLinkedAccount": false + }, + { + "id": "rust.wipe.started", + "label": "A server wiped", + "personal": false, + "requiresLinkedAccount": false + } + ], + "audiences": [ + { + "id": "rust.clan.members", + "label": "Members of a clan", + "params": [ + { + "id": "clan", + "type": "string", + "required": true + } + ], + "ceiling": "members" + }, + { + "id": "rust.server.players", + "label": "Everyone who has played on a server", + "params": [ + { + "id": "serverId", + "type": "string", + "required": true + } + ], + "ceiling": "authenticated" + }, + { + "id": "rust.wipe.participants", + "label": "Everyone playing a server's current wipe", + "params": [ + { + "id": "serverId", + "type": "string", + "required": true + } + ], + "ceiling": "authenticated" + } + ], + "ruleGroups": [ + { + "key": "raid-v1", + "rules": [ + { + "trigger_id": "rust.base.destroyed", + "audience": "owner", + "channels": [ + "email", + "inapp", + "push" + ], + "template_keys": { + "email": "rust.base.destroyed", + "inapp": "rust.base.destroyed-inapp", + "digest": "notify.digest" + }, + "conditions": { + "variable": "ownerOnline", + "cmp": "eq", + "value": false + }, + "cooldown_seconds": 1800, + "delay_seconds": 0, + "cancel_on": [] + } + ] + }, + { + "key": "wipe-v1", + "rules": [ + { + "trigger_id": "rust.wipe.started", + "audience": "subscribers", + "channels": [ + "email", + "inapp", + "push" + ], + "template_keys": { + "email": "rust.wipe.started", + "inapp": "rust.wipe.started-inapp", + "digest": "notify.digest" + }, + "conditions": null, + "cooldown_seconds": 21600, + "delay_seconds": 0, + "cancel_on": [] + } + ] + }, + { + "key": "server-v1", + "rules": [ + { + "trigger_id": "rust.server.online", + "audience": "subscribers", + "channels": [ + "inapp", + "push" + ], + "template_keys": { + "inapp": "inapp.event" + }, + "conditions": null, + "cooldown_seconds": 3600, + "delay_seconds": 0, + "cancel_on": [] + }, + { + "trigger_id": "rust.server.offline", + "audience": "subscribers", + "channels": [ + "inapp", + "push" + ], + "template_keys": { + "inapp": "inapp.event" + }, + "conditions": null, + "cooldown_seconds": 3600, + "delay_seconds": 300, + "cancel_on": [ + "rust.server.online" + ] + } + ] + }, + { + "key": "leaderboard-v1", + "rules": [ + { + "trigger_id": "rust.leaderboard.topped", + "audience": "subscribers", + "channels": [ + "inapp" + ], + "template_keys": { + "inapp": "inapp.event" + }, + "conditions": null, + "cooldown_seconds": 3600, + "delay_seconds": 0, + "cancel_on": [] + } + ] + }, + { + "key": "account-v1", + "rules": [ + { + "trigger_id": "rust.player.linked", + "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": [ + { + "trigger_id": "rust.clan.member.left", + "audience": "members", + "channels": [ + "inapp" + ], + "template_keys": { + "inapp": "inapp.event" + }, + "conditions": null, + "cooldown_seconds": 0, + "delay_seconds": 0, + "cancel_on": [] + }, + { + "trigger_id": "rust.clan.member.kicked", + "audience": "members", + "channels": [ + "inapp" + ], + "template_keys": { + "inapp": "inapp.event" + }, + "conditions": null, + "cooldown_seconds": 0, + "delay_seconds": 0, + "cancel_on": [] + }, + { + "trigger_id": "rust.clan.disbanded", + "audience": "members", + "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": "moderation-v1", + "rules": [ + { + "trigger_id": "rust.player.reported", + "audience": "staff", + "channels": [ + "email", + "inapp" + ], + "template_keys": { + "email": "notify.event", + "inapp": "inapp.event", + "digest": "notify.digest" + }, + "conditions": null, + "cooldown_seconds": 3600, + "delay_seconds": 0, + "cancel_on": [] + }, + { + "trigger_id": "rust.player.banned", + "audience": "staff", + "channels": [ + "inapp" + ], + "template_keys": { + "inapp": "inapp.event" + }, + "conditions": null, + "cooldown_seconds": 0, + "delay_seconds": 0, + "cancel_on": [] + }, + { + "trigger_id": "rust.player.unbanned", + "audience": "staff", + "channels": [ + "inapp" + ], + "template_keys": { + "inapp": "inapp.event" + }, + "conditions": null, + "cooldown_seconds": 0, + "delay_seconds": 0, + "cancel_on": [] + }, + { + "trigger_id": "rust.login.denied", + "audience": "staff", + "channels": [ + "inapp" + ], + "template_keys": { + "inapp": "inapp.event" + }, + "conditions": null, + "cooldown_seconds": 3600, + "delay_seconds": 0, + "cancel_on": [] + } + ] + } + ], + "templates": [ + { + "key": "rust.base.destroyed", + "channel": "email", + "triggerId": "rust.base.destroyed", + "seedVersion": 1 + }, + { + "key": "rust.base.destroyed-inapp", + "channel": "inapp", + "triggerId": "rust.base.destroyed", + "seedVersion": 1 + }, + { + "key": "rust.wipe.started", + "channel": "email", + "triggerId": "rust.wipe.started", + "seedVersion": 1 + }, + { + "key": "rust.wipe.started-inapp", + "channel": "inapp", + "triggerId": "rust.wipe.started", + "seedVersion": 1 + } + ] +} diff --git a/server/boot.js b/server/boot.js index b4eb58d..e711dd5 100644 --- a/server/boot.js +++ b/server/boot.js @@ -21,10 +21,11 @@ // letting that fail the boot would make installing the module before installing // the bridge impossible. // -// ── Three timers, and they answer three different questions ─────────────── +// ── Four timers, and they answer four different questions ───────────────── // // refresh (30s) what is each server, and who is on it — the BOARDS // ingest (5s) what has happened since we last looked — the CURSOR +// sweep (1m) which login attempts were never let in (PLAN.md §25) // prune (1h) forgetting the detail we promised not to keep for ever // // The boards poll and the ingest are deliberately separate rather than one loop @@ -42,6 +43,7 @@ const core = require('./core') const db = require('./model/servers/servers.db') +const engagement = require('./engagement/emit') const eventsDb = require('./model/events/events.db') const ingest = require('./ingest') const permSync = require('./permSync') @@ -53,10 +55,12 @@ const log = core.logger('boot') let refreshTimer = null let ingestTimer = null let pruneTimer = null +let sweepTimer = null const REFRESH_MS = 30 * 1000 const INGEST_MS = 5 * 1000 const PRUNE_MS = 60 * 60 * 1000 +const SWEEP_MS = 60 * 1000 /** * How long this module keeps raw events. @@ -94,7 +98,15 @@ async function refreshOne(server) { // One call for both boards. `/server` would answer the same question about // the server itself, but presence would then be a second round trip to the // same process for a fact it already had in hand. - const board = await sidecar.boards(server) + // + // **And one for `/health`, because the boards cannot say whether the game is + // there NOW** (D68, PLAN.md §25.1). The sidecar keeps its last `server.hello` + // after the plugin disconnects — that is what lets a page render a server + // that is off — so a game that hung, or whose bridge was unloaded, while the + // sidecar stayed up read as online here from phase 4 until phase 10. Only + // `/health`'s `plugin_connected` answers the question, and the two are asked + // together so they describe the same moment. + const [board, health] = await Promise.all([sidecar.boards(server), sidecar.health(server)]) // Three outcomes, and collapsing any two of them loses something an operator // needs: @@ -113,6 +125,7 @@ async function refreshOne(server) { // server said, which is exactly what the pages exist to render while it is // off. await db.markUnreachable(server.id, false) + engagement.serverObserved(server, false) return } @@ -125,20 +138,34 @@ async function refreshOne(server) { // reach is worse than an empty one, because it looks current. await db.markUnreachable(server.id, true) await ingest.applyBoards(server.id, {}) + engagement.serverObserved(server, false) return } - await ingest.applyBoards(server.id, boards) + // Unknown is not connected. A `/health` that did not answer while `/boards` + // did is odd enough to be worth a line, and reporting the server up on the + // strength of a board the game may have left behind hours ago is the defect + // this call exists to remove. + const connected = Boolean(health.ok && health.data && health.data.plugin_connected === true) + if (!health.ok) log.warn('the sidecar answered /boards but not /health', { server: server.id }) + + // The presence board is the plugin's last word too. While the game is not + // connected it names people as online who may have left hours ago, which is + // both wrong and — under §23's rule — a claim about named people nobody made. + await ingest.applyBoards( + server.id, + connected ? boards : { ...boards, 'players.online': { players: [] } }, + ) await db.putState({ serverId: server.id, reachable: true, - // A stored `server.hello` means the game connected; whether it is connected - // NOW is a different question, and `/health` is what answers it. The board - // alone cannot say, which is why `online` is not simply `true` here — it is - // decided by freshness in the model, from `updated_at`. - online: true, - players: Number(frame.players) || 0, + // The plugin is connected NOW (D68). A stored `server.hello` only says it + // connected once; the model still applies its own freshness on top. + online: connected, + // A board the game left behind is a description, not a sighting. + seen: connected, + players: connected ? Number(frame.players) || 0 : 0, maxPlayers: Number(frame.maxPlayers) || 0, hostname: frame.hostname || null, level: frame.level || null, @@ -150,6 +177,9 @@ async function refreshOne(server) { protocol: frame.protocol === undefined ? null : Number(frame.protocol), raw: frame, }) + + // After the write, so a transition announced is one a page already shows. + engagement.serverObserved(server, connected) } catch (err) { // A failure here is one server's, and it must not reach `Promise.allSettled` // as a rejection that hides which one. Log with the id and carry on. @@ -189,6 +219,26 @@ async function prune() { } } +/** + * Login attempts that were never approved (D64, PLAN.md §25). + * + * On its own minute timer rather than the prune's hour: an attempt waits a + * minute for its approval, and a staff alert an hour late is not an alert. A + * query over stored rows, so it needs nothing kept in memory and a restart loses + * nothing; the dedupe key makes a second pass over the same attempt a no-op. + */ +async function sweep() { + let rows + try { + rows = await servers.listForPolling() + } catch (err) { + log.warn('could not read the server list', { error: err.message }) + return + } + const sent = await engagement.sweepLoginDenied(rows) + if (sent > 0) log.info('unapproved logins reported', { attempts: sent }) +} + async function onBoot() { await refresh() // The permission mirror owns its own loop and its own cadence (see @@ -199,10 +249,11 @@ async function onBoot() { refreshTimer = setInterval(refresh, REFRESH_MS) ingestTimer = setInterval(ingestAll, INGEST_MS) pruneTimer = setInterval(prune, PRUNE_MS) + sweepTimer = setInterval(sweep, SWEEP_MS) // Node keeps the process alive for a pending timer. Core's own intervals are // unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a // thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL. - for (const timer of [refreshTimer, ingestTimer, pruneTimer]) { + for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) { if (timer && typeof timer.unref === 'function') timer.unref() } @@ -220,13 +271,14 @@ async function onBoot() { async function onShutdown() { permSync.stop() - for (const timer of [refreshTimer, ingestTimer, pruneTimer]) { + for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) { if (timer) clearInterval(timer) } refreshTimer = null ingestTimer = null pruneTimer = null + sweepTimer = null log.info('shut down') } diff --git a/server/engagement/audiences.js b/server/engagement/audiences.js new file mode 100644 index 0000000..fa46f51 --- /dev/null +++ b/server/engagement/audiences.js @@ -0,0 +1,107 @@ +// ── Named sets of people, over this module's own data ───────────────────── +// +// `registerAudiences` (MODULE_API.md §2.4; PLAN.md §25.2). An operator points a +// rule or a segment at one of these; core calls `resolve` when a rule fires. +// +// Three properties, each the contract rather than a style: +// +// • **A resolver returns website user ids and nothing else.** Never an +// address, a channel or a Steam id: core maps ids to people after the +// preferences, the suppression list and the verification gate, and a module +// that could hand it anything else would have a way to send mail. +// • **One that fails answers NOBODY** — never everybody, never its last good +// answer. A throw here is caught and returned as `[]`, and core treats a +// throw the same way; both are here so the property does not rest on +// either side alone. +// • **Params are constants**, fixed when an operator saves the rule. "The clan +// this event was about" is therefore not expressible as an audience — a +// clan trigger carries its own recipients instead (`emit.js`). + +const core = require('../core') + +const log = core.logger('audiences') + +const LINKS = 'rust_account_links' + +/** Wraps a resolver so a failure is an empty set, logged, and never a throw. */ +function safe(id, fn) { + return async (params) => { + try { + const rows = await fn(params || {}) + return rows.map((r) => Number(r.userId)).filter((n) => Number.isInteger(n) && n > 0) + } catch (err) { + log.warn('an audience could not be resolved; it answers nobody', { audience: id, error: err.message }) + return [] + } + } +} + +const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null) + +const AUDIENCES = Object.freeze([ + { + id: 'rust.clan.members', + label: 'Members of a clan', + params: [{ id: 'clan', type: 'string', required: true }], + ceiling: 'members', + // A clan's LINKED members, as the store holds them now. A clan that has + // been disbanded has no roster, so a rule saved against it resolves to + // nobody — which is the truth, and not the same as the audience being gone. + resolve: safe('rust.clan.members', async ({ clan }) => { + const key = text(clan) + if (!key) return [] + return core.query( + `SELECT DISTINCT l.user_id AS userId + FROM rust_clan_members m + JOIN rust_clans c ON c.external_id = m.external_id AND c.gone_at IS NULL + JOIN ${LINKS} l ON l.steam_id = m.steam_id + WHERE m.external_id = ?`, + [key], + ) + }), + }, + { + id: 'rust.server.players', + label: 'Everyone who has played on a server', + params: [{ id: 'serverId', type: 'string', required: true }], + ceiling: 'authenticated', + // Linked accounts with a stats row on this server in ANY wipe. The stats + // table is the record of having played, and it outlives both wipes and the + // raw event history (R12). + resolve: safe('rust.server.players', async ({ serverId }) => { + const id = text(serverId) + if (!id) return [] + return core.query( + `SELECT DISTINCT l.user_id AS userId + FROM rust_player_wipe_stats s + JOIN ${LINKS} l ON l.steam_id = s.steam_id + WHERE s.server_id = ?`, + [id], + ) + }), + }, + { + id: 'rust.wipe.participants', + label: 'Everyone playing a server\'s current wipe', + params: [{ id: 'serverId', type: 'string', required: true }], + ceiling: 'authenticated', + // The same, narrowed to the wipe the server is on NOW. Resolved at send + // time, so a rule saved last month reaches this month's players — which is + // what "current" has to mean for a parameter fixed when the rule was saved. + // A server with no known wipe resolves to nobody rather than to every wipe. + resolve: safe('rust.wipe.participants', async ({ serverId }) => { + const id = text(serverId) + if (!id) return [] + return core.query( + `SELECT DISTINCT l.user_id AS userId + FROM rust_server_state st + JOIN rust_player_wipe_stats s ON s.server_id = st.server_id AND s.wipe_id = st.wipe_id + JOIN ${LINKS} l ON l.steam_id = s.steam_id + WHERE st.server_id = ? AND st.wipe_id IS NOT NULL`, + [id], + ) + }), + }, +]) + +module.exports = { AUDIENCES } diff --git a/server/engagement/emit.js b/server/engagement/emit.js new file mode 100644 index 0000000..9021220 --- /dev/null +++ b/server/engagement/emit.js @@ -0,0 +1,563 @@ +// ── What happened, told to core's engagement engine ─────────────────────── +// +// The fan-out behind `triggers.js` (PLAN.md §25). Ingest calls `onEvent` for +// every frame it stores; the refresh calls `serverObserved` for every poll; +// ingest calls `checkLeader` after a batch; the prune timer calls +// `sweepLoginDenied`; the link route calls `linked`. +// +// **Nothing here decides who is told.** It says what happened and, for a +// personal or clan event, who it is ABOUT. Core applies the rule, the ceiling, +// the preference, the suppression list and the verification gate. A module +// cannot send mail (MODULE_API.md §2.7), and this file is not the back door. +// +// **Nothing here throws into its caller.** Every entry point catches, because +// its callers are the ingest cursor and the refresh loop — a malformed frame or +// a core-side contract problem must cost one notification and never the feed. +// +// ── Three rules the whole file follows ──────────────────────────────────── +// +// 1. **Emit on the transition, never on the poll** (R7). A server that is +// still up is not news. Transitions are tracked in memory, and a FIRST +// sighting is never one — so a website restart announces nothing. +// +// 2. **A replayed event notifies only while it is still news** (D63). After an +// outage the cursor replays hours of frames. A broadcast older than 15 +// minutes tells nobody; a personal or staff event is kept for 24 hours, +// because "your base was raided at 03:10" is still true and still wanted. +// +// 3. **Every emit carries a dedupe key made from the EVENT, not the store.** +// Core's outbox is unique on (rule, user, channel, key), so the same frame +// replayed after a crash is a no-op. The key is built from what the event +// says — its server, time and subject — rather than from the sidecar's row +// id, because a sidecar whose database is replaced starts its ids again +// and would otherwise have every new alert swallowed as a repeat of an old +// one. + +const crypto = require('crypto') + +const core = require('../core') + +const clans = require('../model/clans/clans.model') +const clansDb = require('../model/clans/clans.db') +const eventsDb = require('../model/events/events.db') +const linksDb = require('../model/links/links.db') +const serversDb = require('../model/servers/servers.db') +const { TRIGGER_IDS: T, PATHS, serverPath, leaderboardPath, clanPath } = require('./triggers') + +const log = core.logger('engagement') + +/** How old a broadcast may be and still be news (D63). */ +const BROADCAST_MAX_AGE_MS = 15 * 60 * 1000 + +/** How old a personal or staff event may be and still be worth telling (D63). */ +const PERSONAL_MAX_AGE_MS = 24 * 60 * 60 * 1000 + +/** + * How long a login attempt waits for its approval before it counts as denied + * (D64, PLAN.md §16.5). The game raises no rejection hook, so a denial is the + * ABSENCE of an approval — which is only knowable after a wait. + */ +const LOGIN_APPROVAL_WINDOW_MS = 60 * 1000 + +/** How far BEFORE an attempt an approval may be stamped and still answer it — clock grain, not policy. */ +const LOGIN_APPROVAL_SLACK_MS = 5 * 1000 + +const STRUCTURE_LABELS = Object.freeze({ + block: 'building block', + door: 'door', + wall: 'external wall', + cupboard: 'tool cupboard', +}) + +// ── Small helpers ────────────────────────────────────────────────────────── + +const str = (value) => (value === undefined || value === null || value === '' ? undefined : String(value)) + +/** `rust::` — readable prefix, bounded length. */ +function dedupeKey(what, ...parts) { + const digest = crypto.createHash('sha1').update(parts.map((p) => String(p ?? '')).join('\u0000')).digest('hex') + return `rust:${what}:${digest}` +} + +function frameTime(item, frame) { + const t = Number(frame && frame.t) || Number(item && item.t) + return Number.isFinite(t) && t > 0 ? t : Date.now() +} + +/** D63, as a question: is an event from `t` still worth telling, for this family? */ +function stillNews(t, maxAgeMs, now = Date.now()) { + return now - t <= maxAgeMs +} + +function serverVars(server) { + const serverId = String(server.id) + return { serverId, server: server.name || serverId, serverUrl: serverPath(serverId) } +} + +/** + * The headline every trigger carries (`triggers.js` HEADLINE). + * + * Core's generic bodies fall back to a trigger's LABEL and DESCRIPTION when the + * payload has no `title`/`intro`, and on a multi-server site that fallback says + * "A server came online" without ever saying which. So the sentence is written + * here, from the payload, and core renders it. Plain register, no conditionals: + * a missing part falls back to a neutral word rather than leaving a hole. + */ +const HEADLINES = Object.freeze({ + 'rust.base.destroyed': (d) => ({ + title: `Your base on ${d.server} is being raided`, + intro: `A ${d.structure} was destroyed${d.atGrid || ''} on ${d.server}.`, + }), + 'rust.wipe.started': (d) => ({ + title: `${d.server} has wiped`, + intro: `A new wipe has started on ${d.server}: a fresh map, and a fresh start for everyone.`, + }), + 'rust.server.online': (d) => ({ + title: `${d.server} is online`, + intro: `${d.server} is back up and talking to the website.`, + }), + 'rust.server.offline': (d) => ({ + title: `${d.server} is offline`, + intro: `${d.server} stopped, or stopped talking to the website.`, + }), + 'rust.leaderboard.topped': (d) => ({ + title: `${d.leader} leads ${d.server}`, + intro: `${d.leader} now leads this wipe's kills on ${d.server}, with ${d.kills}.`, + }), + 'rust.player.linked': (d) => ({ + title: 'A Steam account was linked to your account', + 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.clan.member.left': (d) => ({ + title: `${d.member || 'A member'} left ${d.clan}`, + intro: `${d.member || 'A member'} left ${d.clan} on ${d.server}.`, + }), + 'rust.clan.member.kicked': (d) => ({ + title: `${d.member || 'A member'} was removed from ${d.clan}`, + intro: `${d.by || 'A clan leader'} removed ${d.member || 'a member'} from ${d.clan} on ${d.server}.`, + }), + 'rust.clan.disbanded': (d) => ({ + title: `${d.clan} was disbanded`, + intro: `${d.by || 'A clan leader'} disbanded ${d.clan} on ${d.server}.`, + }), + 'rust.player.reported': (d) => ({ + title: `${d.player || d.steamId} was reported on ${d.server}`, + intro: `${d.reporter || 'A player'} reported ${d.player || d.steamId}` + + `${d.reportType ? ` (${d.reportType})` : ''}${d.topic ? `: ${d.topic}` : '.'}`, + }), + 'rust.player.banned': (d) => ({ + title: `${d.player || d.steamId} was banned on ${d.server}`, + intro: d.reason ? `Reason given: ${d.reason}` : 'No reason was given.', + }), + 'rust.player.unbanned': (d) => ({ + title: `${d.player || d.steamId} was unbanned on ${d.server}`, + intro: `The ban on ${d.player || d.steamId} (${d.steamId}) was lifted.`, + }), + 'rust.login.denied': (d) => ({ + title: `A login to ${d.server} was not approved`, + intro: `${d.player || 'Someone'} (${d.steamId}) tried to join ${d.server} and was not let in within a minute.`, + }), +}) + +function headline(triggerId, data) { + const make = HEADLINES[triggerId] + return make ? make(data || {}) : {} +} + +/** + * Hands one event to core. Never throws. + * + * Core throws on a contract mismatch outside production, which is how a + * declaration and an emitter drifting apart is meant to be found. It is logged + * at `error` here rather than re-thrown, because the caller is the ingest + * cursor — and an `error` line is what a rig walk reads. + */ +function fire(triggerId, envelope) { + try { + const data = envelope.data || {} + core.emit(triggerId, { ...envelope, data: { ...headline(triggerId, data), ...data } }) + return true + } catch (err) { + log.error('core refused an emit', { trigger: triggerId, error: err.message }) + return false + } +} + +/** Steam id -> website user id, for the ids that are linked. Unlinked ones are simply absent. */ +async function usersFor(steamIds) { + const ids = [...new Set((steamIds || []).map(String).filter(Boolean))] + if (!ids.length) return new Map() + const rows = await linksDb.userIdsForSteamIds(ids) + return new Map(rows.map((r) => [String(r.steamId), Number(r.userId)])) +} + +// ── Per-kind handlers ────────────────────────────────────────────────────── + +/** + * The raid alert (D59-D61, D66, D67). + * + * One emit per authorised, LINKED person, each with `ownerUserId` — so the + * `owner` ceiling holds per emit and "nobody else" is structural rather than a + * filter somebody could forget. Two Steam accounts held by one website user are + * one person: they get one alert, online if either account is. + */ +async function onRaid(server, item, frame) { + const t = frameTime(item, frame) + if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0 + + // D67: no cupboard, nobody to tell. Absent — not empty — is also what a + // protocol-6 plugin sends, so a half-upgraded deployment alerts nobody rather + // than guessing an owner from the placer. + if (!frame.buildingId || !Array.isArray(frame.authorized)) return 0 + + const authorized = frame.authorized.filter((a) => a && a.steamId) + const attacker = str(frame.attackerId) + + // An authorised attacker is demolishing their own base, or a teammate's. + if (attacker && authorized.some((a) => String(a.steamId) === attacker)) return 0 + + const users = await usersFor(authorized.map((a) => a.steamId)) + if (!users.size) return 0 + + const byUser = new Map() + for (const a of authorized) { + const userId = users.get(String(a.steamId)) + if (!userId) continue + byUser.set(userId, byUser.get(userId) === true || a.online === true) + } + + const base = { + ...serverVars(server), + building: String(frame.buildingId), + structure: STRUCTURE_LABELS[frame.structure] || 'structure', + grid: str(frame.grid), + atGrid: str(frame.grid) ? ` in ${frame.grid}` : undefined, + } + const key = dedupeKey('raid', server.id, frame.buildingId, t, frame.prefab) + + let sent = 0 + for (const [userId, online] of byUser) { + if (fire(T['rust.base.destroyed'], { + data: { ...base, ownerOnline: online }, + ownerUserId: userId, + dedupeKey: key, + occurredAt: t, + })) sent += 1 + } + return sent +} + +async function onWipe(server, item, frame) { + const t = frameTime(item, frame) + if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0 + const wipeId = str(frame.wipeId) + if (!wipeId) return 0 + + return fire(T['rust.wipe.started'], { + data: { ...serverVars(server), wipeId }, + dedupeKey: dedupeKey('wipe', server.id, wipeId), + occurredAt: t, + }) ? 1 : 0 +} + +/** + * Clan departures and disbands. Recipients travel on the envelope, because + * "the clan this was about" is a different set every firing. + * + * Nobody is told about what they did themselves: the leaver is not told they + * left, the one who kicked is not told they kicked, the one who disbanded is not + * told they disbanded. The one KICKED is told — it happened to them. + */ +async function onClan(server, item, frame) { + const t = frameTime(item, frame) + if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0 + + const externalId = await clans.resolveExternalId(server.id, frame) + if (!externalId) return 0 + + const kind = frame.kind + const subject = str(frame.steamId) + let steamIds + let actor + + if (kind === 'clan.disbanded') { + // From the frame (protocol 7): by the time this runs the next board may + // already have removed the roster the store would answer with. + steamIds = Array.isArray(frame.members) ? frame.members.map(String) : null + if (!steamIds) steamIds = (await clansDb.listMembers(externalId)).map((m) => String(m.steamId)) + actor = subject + } else { + steamIds = (await clansDb.listMembers(externalId)).map((m) => String(m.steamId)) + if (kind === 'clan.member.kicked') { + if (subject) steamIds.push(subject) + actor = str(frame.bySteamId) + } else { + actor = subject + } + } + + const users = await usersFor(steamIds.filter((id) => id !== actor)) + const recipientUserIds = [...new Set(users.values())] + if (!recipientUserIds.length) return 0 + + const data = { + ...serverVars(server), + clanKey: externalId, + clan: str(frame.clanName) || 'your clan', + clanUrl: clanPath(externalId), + } + if (kind === 'clan.member.left') data.member = str(frame.name) + if (kind === 'clan.member.kicked') { + data.member = str(frame.name) + data.by = str(frame.byName) + } + if (kind === 'clan.disbanded') data.by = str(frame.name) + + const triggerId = kind === 'clan.disbanded' ? T['rust.clan.disbanded'] : T[`rust.${kind}`] + + return fire(triggerId, { + data, + recipientUserIds, + dedupeKey: dedupeKey(kind, server.id, externalId, subject, t), + occurredAt: t, + }) ? 1 : 0 +} + +async function onReported(server, item, frame) { + const t = frameTime(item, frame) + if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0 + const steamId = str(frame.targetId) + if (!steamId) return 0 + + return fire(T['rust.player.reported'], { + data: { + ...serverVars(server), + steamId, + player: str(frame.targetName), + reporter: str(frame.reporterName), + reportType: str(frame.reportType), + topic: str(frame.subject), + message: str(frame.message), + }, + dedupeKey: dedupeKey('reported', server.id, steamId, frame.reporterId, t), + occurredAt: t, + }) ? 1 : 0 +} + +async function onBan(server, item, frame) { + const t = frameTime(item, frame) + if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0 + const steamId = str(frame.steamId) + if (!steamId) return 0 + + const banned = frame.kind === 'player.banned' + const data = { ...serverVars(server), steamId, player: str(frame.name) } + // The address the frame carries is deliberately NOT copied: no trigger + // declares one, so no template can ever put it in a mail. + if (banned) data.reason = str(frame.reason) + + return fire(banned ? T['rust.player.banned'] : T['rust.player.unbanned'], { + data, + dedupeKey: dedupeKey(frame.kind, server.id, steamId, t), + occurredAt: t, + }) ? 1 : 0 +} + +const HANDLERS = Object.freeze({ + 'entity.destroyed': onRaid, + 'server.wipe': onWipe, + 'clan.member.left': onClan, + 'clan.member.kicked': onClan, + 'clan.disbanded': onClan, + 'player.reported': onReported, + 'player.banned': onBan, + 'player.unbanned': onBan, +}) + +/** + * One stored frame. Called by ingest BEFORE the frame is applied, because + * applying a disband deletes the roster a clan notification is sent to. + * + * @returns {Promise} emits handed to core, for the log and the tests + */ +async function onEvent(server, item) { + const frame = (item && item.frame) || {} + const kind = (item && item.kind) || frame.kind + const handler = HANDLERS[kind] + if (!handler || !server) return 0 + + try { + return await handler(server, item, { ...frame, kind }) + } catch (err) { + log.warn('could not raise a notification', { server: server.id, kind, error: err.message }) + return 0 + } +} + +// ── Transitions tracked in memory ────────────────────────────────────────── +// +// Deliberately NOT persisted. The question each one answers is "has THIS +// process seen a previous value", and a value restored from the database would +// make the first poll after a restart a transition against state the game may +// have left hours ago. + +const tracker = { online: new Map(), leader: new Map() } + +/** Forget every tracked value. For the tests. */ +function reset() { + tracker.online.clear() + tracker.leader.clear() +} + +/** + * One poll's verdict on one server (D68): is its game connected now? + * + * Synchronous and fire-and-forget — the refresh must not wait on core. + */ +function serverObserved(server, connected) { + try { + if (!server) return 0 + const id = String(server.id) + const now = Boolean(connected) + const before = tracker.online.get(id) + tracker.online.set(id, now) + + // First sight is never a transition: a restart announces nothing. + if (before === undefined || before === now) return 0 + + return fire(now ? T['rust.server.online'] : T['rust.server.offline'], { + data: serverVars(server), + // A transition observed by a poll is observed NOW, so it needs no age check; + // the key is per minute so that one real flap is one event even if two + // polls land either side of a restart of this process. + dedupeKey: dedupeKey(now ? 'online' : 'offline', id, Math.floor(Date.now() / 60000)), + }) ? 1 : 0 + } catch (err) { + log.warn('could not raise a server transition', { server: server && server.id, error: err.message }) + return 0 + } +} + +/** + * After a batch: has somebody new taken the lead in this wipe's kills? (D64) + * + * Only a STRICT lead counts. The leaderboard breaks a tie on who was seen last, + * so two players level on kills trade the top row every time either one moves — + * and reading the top row alone would announce a new leader each time. + */ +async function checkLeader(server) { + try { + const state = await serversDb.getState(server.id) + const wipeId = state && state.wipeId + if (!wipeId) return 0 + + const rows = await eventsDb.leaderboard({ serverId: server.id, wipeId, sort: 'kills', limit: 2 }) + const top = rows[0] + const kills = top ? Number(top.kills) || 0 : 0 + const id = String(server.id) + const before = tracker.leader.get(id) + + if (!top || kills <= 0) { + tracker.leader.set(id, { wipeId, steamId: null }) + return 0 + } + + const tied = rows[1] && Number(rows[1].kills) === kills + const steamId = String(top.steamId) + + // First sight, or a new wipe: remember, announce nothing. + if (!before || before.wipeId !== wipeId) { + tracker.leader.set(id, { wipeId, steamId: tied ? null : steamId }) + return 0 + } + + if (tied || before.steamId === steamId) return 0 + + tracker.leader.set(id, { wipeId, steamId }) + + return fire(T['rust.leaderboard.topped'], { + data: { + ...serverVars(server), + leader: str(top.name) || 'A player', + kills, + leaderboardUrl: leaderboardPath(id), + }, + dedupeKey: dedupeKey('leader', id, wipeId, steamId, kills), + }) ? 1 : 0 + } catch (err) { + log.warn('could not check the leaderboard', { server: server && server.id, error: err.message }) + return 0 + } +} + +/** + * Login attempts that were never approved (D64). + * + * A query over what is stored rather than a timer per attempt, so a restart + * loses nothing and running it twice is a no-op (the key is the attempt's own + * server, Steam id and time). Bounded by D63's personal age: an attempt a day + * old is not worth a staff mail. + */ +async function sweepLoginDenied(servers, now = Date.now()) { + let sent = 0 + for (const server of servers || []) { + try { + const rows = await eventsDb.unapprovedLogins({ + serverId: server.id, + from: now - PERSONAL_MAX_AGE_MS, + to: now - LOGIN_APPROVAL_WINDOW_MS, + windowMs: LOGIN_APPROVAL_WINDOW_MS, + slackMs: LOGIN_APPROVAL_SLACK_MS, + }) + for (const row of rows) { + const t = Number(row.t) + if (fire(T['rust.login.denied'], { + data: { + ...serverVars(server), + steamId: String(row.steamId), + player: str(row.name), + attemptedAt: new Date(t), + }, + dedupeKey: dedupeKey('login-denied', server.id, row.steamId, t), + occurredAt: t, + })) sent += 1 + } + } catch (err) { + log.warn('could not sweep login attempts', { server: server && server.id, error: err.message }) + } + } + return sent +} + +/** A Steam account was just linked (R1). Called by the link route, once, on a NEW link. */ +function linked({ userId, steamId, name }) { + try { + const uid = Number(userId) + if (!Number.isInteger(uid) || uid < 1 || !steamId) return 0 + return fire(T['rust.player.linked'], { + data: { steamId: String(steamId), player: str(name), accountUrl: PATHS.account }, + ownerUserId: uid, + dedupeKey: dedupeKey('linked', steamId, uid), + }) ? 1 : 0 + } catch (err) { + log.warn('could not raise the link notification', { error: err.message }) + return 0 + } +} + +module.exports = { + onEvent, + serverObserved, + checkLeader, + sweepLoginDenied, + linked, + reset, + dedupeKey, + headline, + stillNews, + BROADCAST_MAX_AGE_MS, + PERSONAL_MAX_AGE_MS, + LOGIN_APPROVAL_WINDOW_MS, + STRUCTURE_LABELS, +} diff --git a/server/engagement/seeds.js b/server/engagement/seeds.js new file mode 100644 index 0000000..490b1c0 --- /dev/null +++ b/server/engagement/seeds.js @@ -0,0 +1,315 @@ +// ── What the notifications read like, and the rules that use them ───────── +// +// `registerEngagementSeeds` (MODULE_API.md §2.4 and §1.1 under 1.9.0; PLAN.md +// §25.2). Data only: nothing here names a recipient, and nothing here turns a +// rule on. +// +// ── Two bespoke bodies, and why only two ────────────────────────────────── +// +// A body earns its place when the message has something to say that core's +// structural projection cannot. The raid alert does — it is the one message +// here somebody acts on at 3am, and it must say WHERE and WHAT in the first +// line. The wipe does — it is the one broadcast a whole community waits for. +// Everything else is "this happened, here is the link", which is exactly what +// core's `notify.event` / `inapp.event` already say, so it points at those and +// authors nothing (§4.6.1 property 1). +// +// The register is plain, not in-universe. Rust has no court or herald to write +// in the voice of, and a raid alert dressed as fiction is a raid alert read a +// second later than it should be. +// +// ── Three rules for editing a body ──────────────────────────────────────── +// +// 1. **No conditionals, and never an optional inside a clause.** An unset +// optional interpolates to the EMPTY STRING. `atGrid` is a fragment that +// carries its own leading space for exactly that reason; `grid` on its own +// belongs on a line of its own or nowhere. +// 2. **No brand.** `siteName` and friends are supplied by the renderer, so one +// image mails as whichever site it is running as. +// 3. **Bump `seedVersion` when a body changes, never for a comment.** It is how +// a better default reaches deployments whose operators did not edit it. +// +// ── 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 +// 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. + +// ── Block helpers ────────────────────────────────────────────────────────── + +const text = (id, body, opts = {}) => ({ + id, + type: 'email.text', + props: opts.muted ? { text: body, muted: true } : { text: body }, +}) +const heading = (id, body, level = 'h1') => ({ id, type: 'email.heading', props: { level, text: body } }) +const button = (id, label, url, textLead) => ({ + id, + type: 'email.button', + props: textLead ? { label, url, textLead } : { label, url }, +}) +const divider = (id) => ({ id, type: 'email.divider', props: {} }) + +// Every email ends with the unsubscribe pair; `unsubscribeUrl` is core's +// per-delivery variable, not something a trigger declares. +const unsubscribe = () => [ + divider('rule'), + button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these messages, use this link:'), +] + +const email = (key, name, triggerId, subject, blocks) => ({ + key, + name, + channel: 'email', + triggerId, + triggerVersion: 1, + seedVersion: 1, + subject, + blocks: [...blocks, ...unsubscribe()], +}) + +/** In-app: heading = the row's title, button = its one action, the rest = its body. */ +const inapp = (key, name, triggerId, title, body, action, url) => ({ + key, + name, + channel: 'inapp', + triggerId, + triggerVersion: 1, + seedVersion: 1, + subject: null, + blocks: [heading('h', title, 'h3'), text('intro', body), button('cta', action, url)], +}) + +const TEMPLATES = Object.freeze([ + email( + 'rust.base.destroyed', + 'Rust — your base was raided', + 'rust.base.destroyed', + 'Your base on {{server}} is being raided', + [ + heading('h', 'Your base is being raided'), + text('p1', 'A {{structure}} of a base you are authorised on was destroyed{{atGrid}} on {{server}}.'), + text('p2', + 'You are getting this because you are on the base\'s tool cupboard. Further damage to the ' + + 'same base will not send another alert for a while.', { muted: true }), + button('cta', 'Open the server page', '{{serverUrl}}'), + ], + ), + inapp( + 'rust.base.destroyed-inapp', + 'Rust — your base was raided (in-app)', + 'rust.base.destroyed', + 'Your base is being raided', + 'A {{structure}} was destroyed{{atGrid}} on {{server}}.', + 'Open the server', + '{{serverUrl}}', + ), + email( + 'rust.wipe.started', + 'Rust — a server wiped', + 'rust.wipe.started', + '{{server}} has wiped', + [ + heading('h', '{{server}} has wiped'), + text('p1', 'A new wipe has started on {{server}}: a fresh map, and a fresh start for everyone.'), + button('cta', 'Open the server page', '{{serverUrl}}'), + ], + ), + inapp( + 'rust.wipe.started-inapp', + 'Rust — a server wiped (in-app)', + 'rust.wipe.started', + '{{server}} has wiped', + 'A new wipe has started: a fresh map, and a fresh start for everyone.', + 'Open the server', + '{{serverUrl}}', + ), +]) + +// ── The rules — every one of them off ────────────────────────────────────── + +/** Core's generic bodies (§4.6.1 property 1). */ +const GENERIC = { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' } + +/** This module's bodies for a trigger, and core's digest. */ +const bodies = (key) => ({ email: key, inapp: `${key}-inapp`, digest: 'notify.digest' }) + +const RULE_GROUPS = Object.freeze([ + { + key: 'raid-v1', + note: 'module-rust: the raid alert (disabled)', + rules: [ + { + trigger_id: 'rust.base.destroyed', + name: 'Raid alert — offline owners', + audience: 'owner', + // Push is allowed because this trigger is also a stream (D65); the + // tickle carries no content, and the app pulls the inbox row. + channels: ['email', 'inapp', 'push'], + template_keys: bodies('rust.base.destroyed'), + // Per BUILDING (the subjectKey): a raid is dozens of walls and one alert. + cooldown_seconds: 1800, + max_sends_per_hour: 500, + // D61: "offline raid alert" is this condition, not code. An operator who + // wants online raids too deletes it. + conditions: { variable: 'ownerOnline', cmp: 'eq', value: false }, + }, + ], + }, + { + key: 'wipe-v1', + note: 'module-rust: wipe announcements (disabled)', + rules: [ + { + trigger_id: 'rust.wipe.started', + name: 'Server wiped', + audience: 'subscribers', + channels: ['email', 'inapp', 'push'], + template_keys: bodies('rust.wipe.started'), + cooldown_seconds: 6 * 3600, + max_sends_per_hour: 2000, + }, + ], + }, + { + key: 'server-v1', + note: 'module-rust: server up and down (disabled)', + rules: [ + { + trigger_id: 'rust.server.online', + name: 'Server came online', + audience: 'subscribers', + channels: ['inapp', 'push'], + template_keys: { inapp: GENERIC.inapp }, + cooldown_seconds: 3600, + max_sends_per_hour: 2000, + }, + { + trigger_id: 'rust.server.offline', + name: 'Server went offline', + audience: 'subscribers', + channels: ['inapp', 'push'], + template_keys: { inapp: GENERIC.inapp }, + cooldown_seconds: 3600, + max_sends_per_hour: 2000, + // A plugin reload, or a restart that is back within five minutes, is not + // an outage anybody needs to hear about. `cancel_on` withdraws the + // pending notice when the server comes back inside the window. + delay_seconds: 300, + cancel_on: ['rust.server.online'], + }, + ], + }, + { + key: 'leaderboard-v1', + note: 'module-rust: a new kills leader (disabled)', + rules: [ + { + trigger_id: 'rust.leaderboard.topped', + name: 'New kills leader', + audience: 'subscribers', + channels: ['inapp'], + template_keys: { inapp: GENERIC.inapp }, + cooldown_seconds: 3600, + max_sends_per_hour: 2000, + }, + ], + }, + { + key: 'account-v1', + note: 'module-rust: a Steam account was linked (disabled)', + rules: [ + { + trigger_id: 'rust.player.linked', + name: 'Steam account linked', + audience: 'owner', + // Email as well as in-app: the case this exists for is a link the person + // did NOT make, and they will not be looking at the site's inbox for it. + 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)', + rules: [ + { + trigger_id: 'rust.clan.member.left', + name: 'Clan — a member left', + audience: 'members', + channels: ['inapp'], + template_keys: { inapp: GENERIC.inapp }, + cooldown_seconds: 0, + max_sends_per_hour: 500, + }, + { + trigger_id: 'rust.clan.member.kicked', + name: 'Clan — a member was removed', + audience: 'members', + channels: ['inapp'], + template_keys: { inapp: GENERIC.inapp }, + cooldown_seconds: 0, + max_sends_per_hour: 500, + }, + { + trigger_id: 'rust.clan.disbanded', + name: 'Clan — disbanded', + audience: 'members', + channels: ['email', 'inapp'], + template_keys: GENERIC, + cooldown_seconds: 0, + max_sends_per_hour: 500, + }, + ], + }, + { + key: 'moderation-v1', + note: 'module-rust: reports, bans and unapproved logins, to staff (disabled)', + rules: [ + { + trigger_id: 'rust.player.reported', + name: 'Player reported', + audience: 'staff', + channels: ['email', 'inapp'], + template_keys: GENERIC, + // Per REPORTED player: a pile-on of ten reports is one notice an hour. + cooldown_seconds: 3600, + max_sends_per_hour: 200, + }, + { + trigger_id: 'rust.player.banned', + name: 'Player banned', + audience: 'staff', + channels: ['inapp'], + template_keys: { inapp: GENERIC.inapp }, + cooldown_seconds: 0, + max_sends_per_hour: 200, + }, + { + trigger_id: 'rust.player.unbanned', + name: 'Player unbanned', + audience: 'staff', + channels: ['inapp'], + template_keys: { inapp: GENERIC.inapp }, + cooldown_seconds: 0, + max_sends_per_hour: 200, + }, + { + trigger_id: 'rust.login.denied', + name: 'Login not approved', + audience: 'staff', + channels: ['inapp'], + template_keys: { inapp: GENERIC.inapp }, + cooldown_seconds: 3600, + max_sends_per_hour: 200, + }, + ], + }, +]) + +module.exports = { TEMPLATES, RULE_GROUPS } diff --git a/server/engagement/streams.js b/server/engagement/streams.js new file mode 100644 index 0000000..565a9c5 --- /dev/null +++ b/server/engagement/streams.js @@ -0,0 +1,53 @@ +// ── The push facet: which triggers may reach a phone ────────────────────── +// +// `registerNotificationStreams` (MODULE_API.md §2.4). A stream is what a device +// subscribes to, and **core delivers an engagement rule's push only to devices +// subscribed to a stream whose id IS the trigger id** (`pushChannel.deliver` -> +// `publishToUsers(row.trigger_id)`). So a trigger with no stream here can never +// buzz a phone, however its rule is set — which is exactly how the families +// that should not are kept off it (D65). +// +// Every id here is ALSO a trigger in `triggers.js`. That is the one namespace +// core enforces across both facets: one event, with a payload contract and a +// subscription toggle, owned by one module. An id that appeared only here would +// be a toggle nothing could ever fire. +// +// **The tickle carries nothing.** A push is `{ stream, ref }` and the app pulls +// the real item over the authenticated inbox API, so a leaked relay topic says +// that something happened and not what. That is core's guarantee and it is why +// a raid alert may be a push at all. + +const STREAMS = Object.freeze([ + { + id: 'rust.base.destroyed', + label: 'Your base was raided', + description: 'Part of a base you are authorised on was destroyed by another player.', + // Delivered only to the owner's devices, never fanned out: `owner` ceiling, + // one emit per authorised person (D59). + personal: true, + requiresLinkedAccount: true, + }, + { + id: 'rust.server.online', + label: 'A server came online', + description: 'A Rust server started or came back.', + personal: false, + requiresLinkedAccount: false, + }, + { + id: 'rust.server.offline', + label: 'A server went offline', + description: 'A Rust server stopped or stopped answering.', + personal: false, + requiresLinkedAccount: false, + }, + { + id: 'rust.wipe.started', + label: 'A server wiped', + description: 'A Rust server started a new wipe.', + personal: false, + requiresLinkedAccount: false, + }, +]) + +module.exports = { STREAMS } diff --git a/server/engagement/triggers.js b/server/engagement/triggers.js new file mode 100644 index 0000000..ea8485c --- /dev/null +++ b/server/engagement/triggers.js @@ -0,0 +1,381 @@ +// ── 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 `::`. 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.' }, + ], +} + +// ── 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.' }, + ], + }, +] + +const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, ...CLANS, ...MODERATION]) + +const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id]))) + +module.exports = { TRIGGERS, TRIGGER_IDS, PATHS, serverPath, leaderboardPath, clanPath } diff --git a/server/index.js b/server/index.js index aff5741..42ee166 100644 --- a/server/index.js +++ b/server/index.js @@ -52,6 +52,10 @@ module.exports = function register(ctx, api) { const adminRust = require('./router/admin/rust.router') const usersRust = require('./router/admin/usersRust.router') const teamProvider = require('./model/clans/teamProvider') + const { TRIGGERS } = require('./engagement/triggers') + const { STREAMS } = require('./engagement/streams') + const { AUDIENCES } = require('./engagement/audiences') + const seeds = require('./engagement/seeds') const boot = require('./boot') /* eslint-enable global-require */ @@ -105,6 +109,31 @@ module.exports = function register(ctx, api) { // UO + Rust site; it is recorded in §24 rather than worked around here. api.registerTeamProvider(teamProvider) + // Notifications and engagement (R7, PLAN.md §25). Four registrations that are + // one decision, because they only mean something together: + // + // triggers what can happen, what a template may say about it, and the + // widest audience a rule on it may EVER have — the security + // boundary; core refuses a rule that widens a ceiling + // streams which of those may reach a phone. Core pushes an engagement + // rule only to devices subscribed to a stream of the SAME id, so + // a trigger missing here can never buzz anybody (D65) + // audiences named sets of people over this module's data, for an operator + // to point a rule at; each answers user ids and nothing else + // seeds the two bodies worth writing, and one disabled rule group per + // family — installing this module mails nobody + // + // What fires them is `engagement/emit.js`, off the ingest cursor and the + // 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. + api.registerEventTriggers(TRIGGERS) + api.registerNotificationStreams(STREAMS) + api.registerAudiences(AUDIENCES) + api.registerEngagementSeeds({ templates: seeds.TEMPLATES, ruleGroups: seeds.RULE_GROUPS }) + // The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this // module's schema fragment, and BEFORE the HTTP listener binds — so a module // that must not serve traffic until it has warmed a cache gets that for free. @@ -117,10 +146,9 @@ module.exports = function register(ctx, api) { api.onBoot(boot.onBoot) api.onShutdown(boot.onShutdown) - // Everything else this module will register — the event triggers and - // audiences, the engagement seeds, the four event catalogues, the - // notification streams and the slash commands — is deliberately absent. Each - // arrives with the phase that has something real to put in it. A registration + // Everything else this module will register — the four event catalogues, the + // announce leg and 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. @@ -130,5 +158,8 @@ module.exports = function register(ctx, api) { routes: 'public:/rust player:/rust admin:/rust', extensions: 'admin.users.detail', teams: 'first-party clans', + triggers: TRIGGERS.length, + streams: STREAMS.length, + audiences: AUDIENCES.length, }) } diff --git a/server/ingest.js b/server/ingest.js index 2c33545..41b7de3 100644 --- a/server/ingest.js +++ b/server/ingest.js @@ -35,6 +35,7 @@ const core = require('./core') const clans = require('./model/clans/clans.model') const db = require('./model/events/events.db') +const engagement = require('./engagement/emit') const links = require('./model/links/links.model') const permissionsDb = require('./model/permissions/permissions.db') const sidecar = require('./sidecarClient') @@ -64,7 +65,7 @@ const MAX_BATCHES_PER_TICK = 10 * the alternative is losing the one copy of an event the next version will know * how to read. */ -async function apply(serverId, item) { +async function apply(serverId, item, server = null) { const frame = (item && item.frame) || {} const kind = item.kind || frame.kind const wipeId = frame.wipeId || null @@ -82,6 +83,12 @@ async function apply(serverId, item) { raw: frame, }) + // What core's engagement engine is told (PLAN.md §25). BEFORE the frame is + // applied, because applying a disband deletes the roster the notification is + // for. Never throws, and does not hold the cursor on core: `onEvent` resolves + // who a frame is about and hands it over, and delivery is core's own time. + if (server) await engagement.onEvent(server, item) + const at = { serverId, wipeId, steamId: frame.steamId } switch (kind) { @@ -257,7 +264,7 @@ async function ingestServer(server) { for (const item of items) { try { - await apply(server.id, item) + await apply(server.id, item, server) applied += 1 } catch (err) { // One malformed event must not wedge a server's cursor for ever. It is @@ -284,7 +291,11 @@ async function ingestServer(server) { if (!res.data.more) break } - if (applied > 0) log.info('ingested', { server: server.id, events: applied, cursor: since }) + if (applied > 0) { + log.info('ingested', { server: server.id, events: applied, cursor: since }) + // A leader can only change when something was applied. Never throws. + await engagement.checkLeader(server) + } return applied } diff --git a/server/model/clans/clans.model.js b/server/model/clans/clans.model.js index 47477c4..b40a100 100644 --- a/server/model/clans/clans.model.js +++ b/server/model/clans/clans.model.js @@ -563,6 +563,7 @@ module.exports = { normaliseClan, applyBoard, applyEvent, + resolveExternalId, reofferActivity, activityItem, dedupeKeyOf, diff --git a/server/model/events/events.db.js b/server/model/events/events.db.js index a547660..08f0692 100644 --- a/server/model/events/events.db.js +++ b/server/model/events/events.db.js @@ -272,6 +272,38 @@ async function presenceFor(serverId) { ) } +/** + * Login attempts in `[from, to]` that no approval answered (D64). + * + * An attempt is answered by a `player.approved` for the same Steam id on the + * same server stamped from `slackMs` before it to `windowMs` after it. The + * slack is clock grain: both frames come off one game thread, and an approval + * stamped a millisecond "early" is still the answer. + * + * Grouped on (steam id, t) because a cursor replayed after a crash can store the + * same attempt twice, and one attempt is one denial however often it was + * written down. + */ +async function unapprovedLogins({ serverId, from, to, windowMs, slackMs }) { + return core.query( + `SELECT a.steam_id AS steamId, a.t AS t, + MAX(JSON_UNQUOTE(JSON_EXTRACT(a.raw, '$.name'))) AS name + FROM ${EVENTS} a + WHERE a.server_id = ? AND a.kind = 'player.login.attempt' + AND a.steam_id IS NOT NULL AND a.t BETWEEN ? AND ? + AND NOT EXISTS ( + SELECT 1 FROM ${EVENTS} b + WHERE b.server_id = a.server_id AND b.kind = 'player.approved' + AND b.steam_id = a.steam_id + AND b.t BETWEEN a.t - ? AND a.t + ? + ) + GROUP BY a.steam_id, a.t + ORDER BY a.t ASC + LIMIT 200`, + [serverId, from, to, slackMs, windowMs], + ) +} + module.exports = { getCursor, setCursor, @@ -286,4 +318,5 @@ module.exports = { leaderboard, listWipes, presenceFor, + unapprovedLogins, } diff --git a/server/model/links/links.db.js b/server/model/links/links.db.js index b25d33f..613eacf 100644 --- a/server/model/links/links.db.js +++ b/server/model/links/links.db.js @@ -148,6 +148,23 @@ async function statsForSteamId(steamId) { ) } +/** + * Which of these Steam ids are linked, and to whom. + * + * The one question every notification asks — "who on the website is this + * player?" — asked for a set at once, because a raid names a cupboard's whole + * authorisation list and a clan event a whole roster. An unlinked id is simply + * absent from the answer: there is nobody to tell. + */ +async function userIdsForSteamIds(steamIds) { + if (!steamIds.length) return [] + const marks = steamIds.map(() => '?').join(', ') + return core.query( + `SELECT steam_id AS steamId, user_id AS userId FROM ${LINKS} WHERE steam_id IN (${marks})`, + steamIds, + ) +} + module.exports = { getBySteamId, listForUser, @@ -156,4 +173,5 @@ module.exports = { removeOwned, removeBySteamId, statsForSteamId, + userIdsForSteamIds, } diff --git a/server/model/links/links.model.js b/server/model/links/links.model.js index 6359fe9..24fa4dc 100644 --- a/server/model/links/links.model.js +++ b/server/model/links/links.model.js @@ -18,6 +18,7 @@ const core = require('../../core') const db = require('./links.db') +const engagement = require('../../engagement/emit') const servers = require('../servers/servers.model') const sidecar = require('../../sidecarClient') @@ -150,6 +151,10 @@ async function confirmOne({ server, code, userId }) { const link = shape(await db.getBySteamId(steamId)) log.info('steam account linked', { steamId, userId, server: server.id }) linksChanged('rust account linked') + // Only a NEW link is news. The `already` path above is somebody pressing the + // button twice, and telling them twice would make the notice meaningless for + // the one case it exists for: a link they did not make. + engagement.linked({ userId, steamId, name: frame.name }) return { ok: true, link } } diff --git a/server/model/servers/servers.db.js b/server/model/servers/servers.db.js index 76fb8d6..0c4e242 100644 --- a/server/model/servers/servers.db.js +++ b/server/model/servers/servers.db.js @@ -146,7 +146,7 @@ async function putState(state) { `INSERT INTO ${STATE} (server_id, reachable, online, players, max_players, hostname, level, seed, world_size, boot_id, save_created_at, wipe_id, protocol, raw, last_seen_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, IF(?, CURRENT_TIMESTAMP, NULL), CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE reachable = VALUES(reachable), online = VALUES(online), players = VALUES(players), max_players = VALUES(max_players), hostname = VALUES(hostname), level = VALUES(level), @@ -154,10 +154,11 @@ async function putState(state) { save_created_at = VALUES(save_created_at), wipe_id = VALUES(wipe_id), protocol = VALUES(protocol), raw = VALUES(raw), - -- Only a frame moves this; an unreachable write leaves it alone, which is - -- what lets a page say how long a server has been down rather than how - -- recently we failed to reach it. - last_seen_at = CURRENT_TIMESTAMP, + -- Only a CONNECTED game moves this; an unreachable write leaves it alone, + -- and so does a board the sidecar kept after the game went away (D68). + -- That is what lets a page say how long a server has been down rather + -- than how recently we failed to reach it. + last_seen_at = IF(?, CURRENT_TIMESTAMP, last_seen_at), updated_at = CURRENT_TIMESTAMP`, [ state.serverId, @@ -174,6 +175,8 @@ async function putState(state) { state.wipeId || null, state.protocol === undefined ? null : state.protocol, state.raw ? JSON.stringify(state.raw) : null, + state.seen === false ? 0 : 1, + state.seen === false ? 0 : 1, ], ) } diff --git a/server/package.json b/server/package.json index ab6ac27..6e9c59d 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,9 @@ "check:imports": "node scripts/checkImports.js", "check:bundle": "node scripts/checkBundle.js", "swagger": "node scripts/swaggerFragment.js", - "check:swagger": "node scripts/swaggerFragment.js --check" + "check:swagger": "node scripts/swaggerFragment.js --check", + "engagement:manifest": "node scripts/engagementManifest.js", + "check:engagement": "node scripts/engagementManifest.js --check" }, "engines": { "node": ">=20" diff --git a/server/scripts/engagementManifest.js b/server/scripts/engagementManifest.js new file mode 100644 index 0000000..cfb22ed --- /dev/null +++ b/server/scripts/engagementManifest.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +// +// The engagement freeze: every trigger, stream and audience this module +// declares, and every rule it seeds, as one committed file whose DIFF is the +// review signal (MODULE_API.md §2.4: "a module ships a prebuilt +// `engagement-triggers.json` in its bundle"). +// +// **Why it exists when core never reads it.** A trigger declaration is what an +// operator's templates interpolate and their rules are written against. +// Renaming a variable, changing its type or widening a ceiling breaks stored +// templates and rules — silently, at send time, in a mail somebody already +// got. Committing the declarations as data turns that edit into a visible diff +// in the PR that makes it, the same job `routes.manifest.json` does for URLs. +// +// **It is generated from the registrations, not from the source files**, by +// running `register()` against a recording api — so what is frozen is what core +// would be handed, including anything `index.js` does on the way. +// +// **It records `coreApi`, not core's `MODULE_API_VERSION`.** Core's own manifest +// embeds the API version, and every API bump then makes it stale with no change +// to a single declaration — which is how it once sat stale for a whole phase. +// The range this module declares moves only when this module decides it should. +// +// Usage (from server/): +// node scripts/engagementManifest.js write ../engagement-triggers.json +// node scripts/engagementManifest.js --check exit 1 if the committed file is stale + +const fs = require('fs') +const path = require('path') + +const { fakeCtx, fakeApi } = require('../test/_fakes') + +const MODULE_ROOT = path.resolve(__dirname, '..', '..') +const MANIFEST = path.join(MODULE_ROOT, 'engagement-triggers.json') + +const COMMENT = + 'Generated freeze of module-rust\'s engagement contract (docs/modules/rust/PLAN.md §25). ' + + 'Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type ' + + 'or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.' + +function build() { + require('../core')._reset() + const api = fakeApi() + require('../index')(fakeCtx(), api) + const { triggers, streams, audiences, engagementSeeds } = api.record + const manifest = require('../../module.json') + + const byId = (a, b) => a.id.localeCompare(b.id) + + return { + _comment: COMMENT, + coreApi: manifest.coreApi, + // Sorted by id: reordering a declaration in the source is not a contract + // change and must not produce a diff that looks like one. Variables keep + // their DECLARED order, which is the order the template editor shows. + triggers: [...triggers].sort(byId).map((t) => ({ + id: t.id, + label: t.label, + description: t.description, + kind: t.kind, + subjectKey: t.subjectKey, + audience: t.audience, + ceiling: t.ceiling, + version: t.version, + variables: t.variables.map((v) => ({ + name: v.name, + type: v.type, + required: v.required, + example: v.example, + description: v.description, + })), + })), + streams: [...streams].sort(byId).map((s) => ({ + id: s.id, + label: s.label, + personal: s.personal, + requiresLinkedAccount: s.requiresLinkedAccount, + })), + // `resolve` is a function over this module's store and cannot be frozen. + // What is frozen is the part an operator's saved rule depends on. + audiences: [...audiences].sort(byId).map((a) => ({ + id: a.id, + label: a.label, + params: a.params, + ceiling: a.ceiling, + })), + ruleGroups: engagementSeeds.ruleGroups.map((g) => ({ + key: g.key, + rules: g.rules.map((r) => ({ + trigger_id: r.trigger_id, + audience: r.audience, + channels: r.channels, + template_keys: r.template_keys, + conditions: r.conditions === undefined ? null : r.conditions, + cooldown_seconds: r.cooldown_seconds, + delay_seconds: r.delay_seconds || 0, + cancel_on: r.cancel_on || [], + })), + })), + templates: engagementSeeds.templates.map((t) => ({ + key: t.key, + channel: t.channel, + triggerId: t.triggerId, + seedVersion: t.seedVersion, + })), + } +} + +function main() { + const check = process.argv.includes('--check') + const next = `${JSON.stringify(build(), null, 2)}\n` + + if (!check) { + fs.writeFileSync(MANIFEST, next) + process.stdout.write(`wrote ${path.relative(MODULE_ROOT, MANIFEST)}\n`) + return + } + + // Line endings normalised, as `swaggerFragment.js` does: a Windows checkout + // under `core.autocrlf=true` turns the committed LF blob into CRLF, and a byte + // comparison would then call an unchanged file stale on every such machine — + // a check that cries wolf is a check nobody reads. + const lf = (s) => s.replace(/\r\n/g, '\n') + let committed + try { + committed = fs.readFileSync(MANIFEST, 'utf8') + } catch { + process.stderr.write('engagement-triggers.json is missing — run `npm run engagement:manifest`\n') + process.exit(1) + } + + if (lf(committed) !== lf(next)) { + process.stderr.write( + 'engagement-triggers.json is stale: a trigger, stream, audience or seeded rule changed.\n' + + 'Run `npm run engagement:manifest` in server/ and commit the result — and read the diff,\n' + + 'because a changed variable or ceiling is a change to every rule an operator has saved.\n', + ) + process.exit(1) + } + + process.stdout.write('engagement-triggers.json is current\n') +} + +if (require.main === module) main() + +module.exports = { build } diff --git a/server/sidecarClient.js b/server/sidecarClient.js index 7012dbf..8fffaad 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -52,11 +52,13 @@ const TIMEOUT_MS = 12000 * here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge * plugin, and `protocol` in its `overlay.toml`. * - * **6 — first-party clans.** Protocol 2 was the read path, 3 the first + * **7 — the raid frame.** Protocol 2 was the read path, 3 the first * message the WEBSITE originates (`link.confirm`), 4 the first that writes to * the game's permission store, 5 the first that writes to the game HOST'S * FILESYSTEM; 6 adds the `clans` board and five clan events core's Teams are - * built from, and no route at all. The bump lands here in the same change as the emitters, + * built from, and no route at all; **7** widens `entity.destroyed` to doors, + * walls and the cupboard and names who is authorised there, which is what the + * raid alert is sent to (PLAN.md §25). 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 @@ -66,7 +68,7 @@ const TIMEOUT_MS = 12000 * deployment into a `409` naming both numbers instead of a parse failure three * layers further in. */ -const PROTOCOL_VERSION = 6 +const PROTOCOL_VERSION = 7 /** What a caller gets back. Shaped once so every call site reads the same. */ function reply(ok, status, data = null) { diff --git a/server/test/engagement.test.js b/server/test/engagement.test.js new file mode 100644 index 0000000..6445e01 --- /dev/null +++ b/server/test/engagement.test.js @@ -0,0 +1,492 @@ +// ── Notifications and engagement (phase 10, PLAN.md §25) ─────────────────── +// +// The properties this suite holds, each with a failure behind it: +// +// • every declaration is one core will accept — a ceiling, a subjectKey that +// names a declared variable, an example on every variable, a closed type — +// because core refuses the WHOLE module at boot over one bad declaration; +// • no trigger declares an address, or the raider (D66); +// • the raid alert reaches exactly the authorised, linked people, one emit +// each with `ownerUserId`, and nobody when there is no cupboard (D59, D67); +// • a replayed event is told only while it is still news (D63); +// • a clan notice goes to the clan and never to whoever caused it; +// • a transition is announced once, and a first sighting never; +// • a tie at the top of the leaderboard is not a new leader; +// • every seed body interpolates only variables its trigger declares. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx, spy } = require('./_fakes') + +// Core's own check on a `url` variable (utils/engagementEmit.js RELATIVE_URL), +// copied so a path this module builds is held to the rule it will meet. +const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/ + +const CEILINGS = ['everyone', 'authenticated', 'subscribers', 'members', 'staff', 'admin', 'owner'] +const TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url'] + +const SERVER = { id: 'main', name: 'Main' } +const NOW = Date.now() + +/** A fresh ctx, a recording emit, and a link table the tests control. */ +function setup({ links = {}, members = {}, state = null, board = [] } = {}) { + require('../core')._reset() + const ctx = fakeCtx() + require('../core').init(ctx) + + const emit = require('../engagement/emit') + emit.reset() + + const linksDb = require('../model/links/links.db') + const clansDb = require('../model/clans/clans.db') + const clans = require('../model/clans/clans.model') + const serversDb = require('../model/servers/servers.db') + const eventsDb = require('../model/events/events.db') + + const originals = [ + [linksDb, { ...linksDb }], [clansDb, { ...clansDb }], [clans, { ...clans }], + [serversDb, { ...serversDb }], [eventsDb, { ...eventsDb }], + ] + + linksDb.userIdsForSteamIds = async (ids) => + ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] })) + clans.resolveExternalId = async (serverId, frame) => (frame.clanId ? `${serverId}:${frame.clanId}:1` : null) + clansDb.listMembers = async (externalId) => (members[externalId] || []).map((steamId) => ({ steamId })) + serversDb.getState = async () => state + eventsDb.leaderboard = async () => board.shift() || [] + + const restore = () => { + for (const [mod, copy] of originals) Object.assign(mod, copy) + } + + return { emit, calls: ctx.events.emit.calls, restore, eventsDb } +} + +const raidFrame = (extra = {}) => ({ + kind: 'entity.destroyed', + t: NOW, + ownerId: '100', + prefab: 'door.hinged.metal', + structure: 'door', + attackerId: '900', + attackerName: 'Raider', + grid: 'H7', + buildingId: '8113', + authorized: [ + { steamId: '101', online: false }, + { steamId: '102', online: true }, + { steamId: '103', online: false }, + ], + ...extra, +}) + +// ── The declarations ─────────────────────────────────────────────────────── + +test('every trigger is a declaration core will accept', () => { + const { TRIGGERS } = require('../engagement/triggers') + const ids = new Set() + + for (const t of TRIGGERS) { + assert.ok(t.id.startsWith('rust.'), `${t.id} is namespaced`) + assert.ok(!ids.has(t.id), `${t.id} is declared once`) + ids.add(t.id) + assert.ok(CEILINGS.includes(t.ceiling), `${t.id} has a real ceiling (there is no "self")`) + assert.ok(CEILINGS.includes(t.audience), `${t.id} has a real default audience`) + assert.strictEqual(t.kind, 'event') + + const names = new Set(t.variables.map((v) => v.name)) + assert.ok(names.has(t.subjectKey), `${t.id}'s subjectKey names a declared variable`) + + for (const v of t.variables) { + assert.ok(TYPES.includes(v.type), `${t.id}.${v.name} has a closed type`) + assert.ok(v.example !== undefined, `${t.id}.${v.name} has an example`) + if (v.type === 'url') assert.match(v.example, RELATIVE_URL, `${t.id}.${v.name}'s example is site-relative`) + } + } +}) + +test('the default audience is never wider than the ceiling', () => { + const { TRIGGERS } = require('../engagement/triggers') + // The pairs this catalogue uses, each one core's `permits` accepts. A new + // pairing is a new line here — decided, not assumed. + const ALLOWED = new Set(['everyone>subscribers', 'owner>owner', 'members>members', 'staff>staff']) + for (const t of TRIGGERS) { + assert.ok(ALLOWED.has(`${t.ceiling}>${t.audience}`), `${t.id}: ${t.audience} under ${t.ceiling}`) + } +}) + +test('no trigger declares an address, or who raided whom (D66)', () => { + const { TRIGGERS } = require('../engagement/triggers') + for (const t of TRIGGERS) { + for (const v of t.variables) { + // By camelCase WORD: `wipeId` contains the letters "ip" and is not one. + const words = v.name.split(/(?=[A-Z])/).map((w) => w.toLowerCase()) + for (const banned of ['ip', 'address', 'attacker', 'raider']) { + assert.ok(!words.includes(banned), `${t.id}.${v.name}`) + } + } + } +}) + +test('the ceilings are the ones §25.2 decided', () => { + const { TRIGGERS } = require('../engagement/triggers') + const by = Object.fromEntries(TRIGGERS.map((t) => [t.id, t.ceiling])) + assert.strictEqual(by['rust.base.destroyed'], 'owner') + assert.strictEqual(by['rust.player.linked'], 'owner') + for (const id of ['rust.player.reported', 'rust.player.banned', 'rust.player.unbanned', 'rust.login.denied']) { + assert.strictEqual(by[id], 'staff', id) + } + 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. + assert.strictEqual(by['rust.clan.member.added'], undefined) + assert.strictEqual(by['rust.kit.entitled'], undefined) +}) + +test('a clan path survives core\'s url check, colons and all', () => { + const { clanPath, serverPath, leaderboardPath } = require('../engagement/triggers') + assert.match(clanPath('main:12:1790142840000'), RELATIVE_URL) + assert.match(serverPath('eu 2'), RELATIVE_URL) + assert.match(leaderboardPath('main'), RELATIVE_URL) +}) + +test('every trigger names what happened and where, even with its optionals missing', () => { + // The walk found a multi-server site's generic notice reading "A server came + // online" — core's projection falls back to the LABEL when the payload carries + // no `title`. Every trigger therefore declares its own, and the emitter writes + // it; a headline that printed "undefined" would be worse than the label. + const { TRIGGERS } = require('../engagement/triggers') + const { headline } = require('../engagement/emit') + for (const t of TRIGGERS) { + const minimal = Object.fromEntries(t.variables.filter((v) => v.required).map((v) => [v.name, v.example])) + const h = headline(t.id, minimal) + assert.ok(h.title && h.intro, `${t.id} has a headline`) + assert.ok(!/undefined|null/.test(h.title + h.intro), `${t.id}: ${h.title} / ${h.intro}`) + } + const online = headline('rust.server.online', { server: 'EU 2' }) + assert.match(online.title, /EU 2/, 'the notice says WHICH server') +}) + +// ── The seeds ────────────────────────────────────────────────────────────── + +test('every seeded rule is ours, off, and names bodies that exist', () => { + const { TRIGGERS } = require('../engagement/triggers') + const { TEMPLATES, RULE_GROUPS } = require('../engagement/seeds') + const triggerIds = new Set(TRIGGERS.map((t) => t.id)) + const own = new Set(TEMPLATES.map((t) => t.key)) + const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest']) + const groupKeys = new Set() + + for (const group of RULE_GROUPS) { + assert.ok(!groupKeys.has(group.key), `group ${group.key} is unique`) + groupKeys.add(group.key) + for (const r of group.rules) { + assert.ok(triggerIds.has(r.trigger_id), `${r.trigger_id} is one of ours`) + assert.strictEqual(r.enabled, undefined, 'enabled is never a seed parameter') + assert.ok(Number.isInteger(r.max_sends_per_hour) && r.max_sends_per_hour >= 1) + for (const [slot, key] of Object.entries(r.template_keys)) { + assert.ok(own.has(key) || coreKeys.has(key), `${r.trigger_id}: ${key}`) + if (slot !== 'digest') assert.ok(r.channels.includes(slot), `${r.trigger_id}: ${slot} is a channel`) + } + for (const channel of r.channels) { + if (channel !== 'push') assert.ok(r.template_keys[channel], `${r.trigger_id}: a body for ${channel}`) + } + } + } +}) + +test('push appears only on the rules whose trigger is also a stream (D65)', () => { + const { STREAMS } = require('../engagement/streams') + const { RULE_GROUPS } = require('../engagement/seeds') + const pushable = new Set(STREAMS.map((s) => s.id)) + for (const group of RULE_GROUPS) { + for (const r of group.rules) { + if (r.channels.includes('push')) assert.ok(pushable.has(r.trigger_id), r.trigger_id) + } + } +}) + +test('every body interpolates only what its trigger declares', () => { + const { TRIGGERS } = require('../engagement/triggers') + const { TEMPLATES } = require('../engagement/seeds') + const declared = new Map(TRIGGERS.map((t) => [t.id, new Set(t.variables.map((v) => v.name))])) + // Core's per-delivery and ambient variables — supplied by the renderer. + const ambient = new Set(['unsubscribeUrl', 'siteName', 'siteUrl', 'logoUrl', 'year']) + + for (const t of TEMPLATES) { + const vars = declared.get(t.triggerId) + assert.ok(vars, `${t.key}'s trigger exists`) + const text = JSON.stringify([t.subject, t.blocks]) + for (const [, name] of text.matchAll(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g)) { + assert.ok(vars.has(name) || ambient.has(name), `${t.key} uses {{${name}}}`) + } + assert.ok(t.key.startsWith('rust.')) + if (t.channel === 'email') assert.ok(t.subject) + else assert.strictEqual(t.subject, null) + } +}) + +test('the seeded raid rule is the OFFLINE raid alert, as a condition (D61)', () => { + const { RULE_GROUPS } = require('../engagement/seeds') + const raid = RULE_GROUPS.find((g) => g.key === 'raid-v1').rules[0] + assert.deepStrictEqual(raid.conditions, { variable: 'ownerOnline', cmp: 'eq', value: false }) + assert.strictEqual(raid.audience, 'owner') +}) + +// ── The raid alert ───────────────────────────────────────────────────────── + +test('the raid alert reaches each authorised, linked person, and nobody else', async () => { + const { emit, calls, restore } = setup({ links: { 101: 11, 102: 12 } }) + try { + const sent = await emit.onEvent(SERVER, { id: 1, kind: 'entity.destroyed', frame: raidFrame() }) + assert.strictEqual(sent, 2) + assert.deepStrictEqual(calls.map((c) => c[1].ownerUserId).sort(), [11, 12]) + + for (const [trigger, env] of calls) { + assert.strictEqual(trigger, 'rust.base.destroyed') + assert.strictEqual(env.recipientUserIds, undefined, 'owner-shaped, never a recipient list') + assert.strictEqual(env.data.building, '8113') + assert.strictEqual(env.data.structure, 'door') + assert.strictEqual(env.data.atGrid, ' in H7') + assert.ok(!JSON.stringify(env.data).includes('Raider'), 'the raider is never named') + assert.ok(!JSON.stringify(env.data).includes('900')) + } + const online = Object.fromEntries(calls.map((c) => [c[1].ownerUserId, c[1].data.ownerOnline])) + assert.deepStrictEqual(online, { 11: false, 12: true }) + } finally { + restore() + } +}) + +test('two Steam accounts held by one person are one alert, online if either is', async () => { + const { emit, calls, restore } = setup({ links: { 101: 11, 102: 11 } }) + try { + await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame() }) + assert.strictEqual(calls.length, 1) + assert.strictEqual(calls[0][1].data.ownerOnline, true) + } finally { + restore() + } +}) + +test('an authorised attacker is demolishing their own base: no alert', async () => { + const { emit, calls, restore } = setup({ links: { 101: 11, 102: 12 } }) + try { + await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ attackerId: '102' }) }) + assert.strictEqual(calls.length, 0) + } finally { + restore() + } +}) + +test('no cupboard, nobody to tell — and a protocol-6 frame is the same (D67)', async () => { + const { emit, calls, restore } = setup({ links: { 100: 10, 101: 11 } }) + try { + await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ buildingId: undefined, authorized: undefined }) }) + // A protocol-6 frame: a BuildingBlock with an owner and no `authorized`. + await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: { kind: 'entity.destroyed', t: NOW, ownerId: '100', prefab: 'wall' } }) + assert.strictEqual(calls.length, 0, 'the placer is never a fallback') + } finally { + restore() + } +}) + +test('a replayed raid is still told for a day, and not after (D63)', async () => { + const { emit, calls, restore } = setup({ links: { 101: 11 } }) + try { + await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ t: NOW - 3 * 3600 * 1000 }) }) + assert.strictEqual(calls.length, 1) + await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ t: NOW - 25 * 3600 * 1000 }) }) + assert.strictEqual(calls.length, 1) + } finally { + restore() + } +}) + +test('the same frame replayed carries the same dedupe key, within core\'s bound', async () => { + const { emit, calls, restore } = setup({ links: { 101: 11 } }) + try { + const item = { id: 7, kind: 'entity.destroyed', frame: raidFrame() } + await emit.onEvent(SERVER, item) + await emit.onEvent(SERVER, { ...item, id: 99 }) + assert.strictEqual(calls[0][1].dedupeKey, calls[1][1].dedupeKey, 'keyed on the event, not the row id') + assert.ok(calls[0][1].dedupeKey.length <= 190) + } finally { + restore() + } +}) + +// ── Broadcasts ───────────────────────────────────────────────────────────── + +test('a wipe is news for fifteen minutes (D63)', async () => { + const { emit, calls, restore } = setup() + try { + await emit.onEvent(SERVER, { kind: 'server.wipe', frame: { kind: 'server.wipe', t: NOW - 60 * 1000, wipeId: 'w2' } }) + await emit.onEvent(SERVER, { kind: 'server.wipe', frame: { kind: 'server.wipe', t: NOW - 20 * 60 * 1000, wipeId: 'w3' } }) + assert.strictEqual(calls.length, 1) + assert.strictEqual(calls[0][0], 'rust.wipe.started') + assert.strictEqual(calls[0][1].data.wipeId, 'w2') + } finally { + restore() + } +}) + +test('online and offline are transitions, and a first sighting is not one', () => { + const { emit, calls, restore } = setup() + try { + emit.serverObserved(SERVER, true) + emit.serverObserved(SERVER, true) + assert.strictEqual(calls.length, 0, 'a restart announces nothing') + emit.serverObserved(SERVER, false) + emit.serverObserved(SERVER, false) + emit.serverObserved(SERVER, true) + assert.deepStrictEqual(calls.map((c) => c[0]), ['rust.server.offline', 'rust.server.online']) + assert.strictEqual(calls[0][1].data.serverId, 'main') + } finally { + restore() + } +}) + +test('a new leader is announced once; a tie is not a new leader', async () => { + const row = (steamId, kills) => ({ steamId, name: `P${steamId}`, kills }) + const { emit, calls, restore } = setup({ + state: { wipeId: 'w1' }, + board: [ + [row('1', 5), row('2', 3)], // first sight: remembered, not announced + [row('2', 5), row('1', 5)], // level on kills: not a change + [row('2', 6), row('1', 5)], // strictly ahead: announced + [row('2', 7), row('1', 5)], // the same leader: nothing + ], + }) + try { + for (let i = 0; i < 4; i += 1) await emit.checkLeader(SERVER) + assert.strictEqual(calls.length, 1) + assert.strictEqual(calls[0][0], 'rust.leaderboard.topped') + assert.strictEqual(calls[0][1].data.leader, 'P2') + assert.strictEqual(calls[0][1].data.kills, 6) + } finally { + restore() + } +}) + +// ── Clans ────────────────────────────────────────────────────────────────── + +test('a disband goes to the roster on the frame, not to the one who did it', async () => { + const { emit, calls, restore } = setup({ links: { 201: 21, 202: 22, 203: 23 } }) + try { + await emit.onEvent(SERVER, { + kind: 'clan.disbanded', + frame: { kind: 'clan.disbanded', t: NOW, clanId: 4, clanName: 'Belt', steamId: '201', name: 'Boss', members: ['201', '202', '203'] }, + }) + assert.strictEqual(calls.length, 1) + assert.strictEqual(calls[0][0], 'rust.clan.disbanded') + assert.deepStrictEqual(calls[0][1].recipientUserIds.sort(), [22, 23]) + assert.strictEqual(calls[0][1].data.by, 'Boss') + assert.match(calls[0][1].data.clanUrl, RELATIVE_URL) + } finally { + restore() + } +}) + +test('the one kicked is told; the one who kicked is not', async () => { + const { emit, calls, restore } = setup({ + links: { 301: 31, 302: 32, 303: 33 }, + members: { 'main:5:1': ['301', '302'] }, // the board already dropped 303 + }) + try { + await emit.onEvent(SERVER, { + kind: 'clan.member.kicked', + frame: { kind: 'clan.member.kicked', t: NOW, clanId: 5, steamId: '303', name: 'Out', bySteamId: '301', byName: 'Boss' }, + }) + assert.deepStrictEqual(calls[0][1].recipientUserIds.sort(), [32, 33]) + } finally { + restore() + } +}) + +test('a leaver is not told they left, and a lone leaver tells nobody', async () => { + const { emit, calls, restore } = setup({ links: { 401: 41, 402: 42 }, members: { 'main:6:1': ['401', '402'] } }) + try { + await emit.onEvent(SERVER, { kind: 'clan.member.left', frame: { kind: 'clan.member.left', t: NOW, clanId: 6, steamId: '402' } }) + assert.deepStrictEqual(calls[0][1].recipientUserIds, [41]) + await emit.onEvent(SERVER, { kind: 'clan.member.left', frame: { kind: 'clan.member.left', t: NOW, clanId: 7, steamId: '402' } }) + assert.strictEqual(calls.length, 1) + } finally { + restore() + } +}) + +// ── Moderation ───────────────────────────────────────────────────────────── + +test('a ban never carries the address the frame does', async () => { + const { emit, calls, restore } = setup() + try { + await emit.onEvent(SERVER, { + kind: 'player.banned', + frame: { kind: 'player.banned', t: NOW, steamId: '555', name: 'Cheater', ip: '203.0.113.9', reason: 'aimbot' }, + }) + assert.strictEqual(calls.length, 1) + assert.ok(!JSON.stringify(calls[0][1]).includes('203.0.113.9')) + assert.strictEqual(calls[0][1].data.reason, 'aimbot') + } finally { + restore() + } +}) + +test('an unapproved login becomes a staff notice, keyed on the attempt', async () => { + const { emit, calls, restore, eventsDb } = setup() + try { + const asked = [] + eventsDb.unapprovedLogins = async (q) => { + asked.push(q) + return [{ steamId: '777', t: NOW - 120000, name: 'Knocker' }] + } + const sent = await emit.sweepLoginDenied([SERVER], NOW) + await emit.sweepLoginDenied([SERVER], NOW) + assert.strictEqual(sent, 1) + assert.strictEqual(asked[0].to, NOW - emit.LOGIN_APPROVAL_WINDOW_MS, 'an attempt waits its minute first') + assert.strictEqual(calls[0][0], 'rust.login.denied') + assert.strictEqual(calls[0][1].dedupeKey, calls[1][1].dedupeKey, 'a second sweep is a no-op in core') + } finally { + restore() + } +}) + +// ── The rest ─────────────────────────────────────────────────────────────── + +test('a new link tells its owner, and only its owner', () => { + const { emit, calls, restore } = setup() + try { + emit.linked({ userId: 5, steamId: '76561198000000001', name: 'Me' }) + assert.strictEqual(calls[0][0], 'rust.player.linked') + assert.strictEqual(calls[0][1].ownerUserId, 5) + assert.strictEqual(emit.linked({ userId: 0, steamId: 'x' }), 0) + } finally { + restore() + } +}) + +test('a frame the fan-out cannot handle costs one notice, never the caller', async () => { + const { emit, restore } = setup() + try { + const linksDb = require('../model/links/links.db') + linksDb.userIdsForSteamIds = async () => { throw new Error('database gone') } + const sent = await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame() }) + assert.strictEqual(sent, 0) + } finally { + restore() + } +}) + +test('an audience that fails answers nobody, never everybody', async () => { + require('../core')._reset() + const ctx = fakeCtx({ db: { query: spy(() => Promise.reject(new Error('down'))), pool: {} } }) + require('../core').init(ctx) + const { AUDIENCES } = require('../engagement/audiences') + for (const a of AUDIENCES) { + assert.deepStrictEqual(await a.resolve({ clan: 'main:1:1', serverId: 'main' }), [], a.id) + assert.deepStrictEqual(await a.resolve({}), [], `${a.id} with no param`) + } +}) diff --git a/server/test/entry.test.js b/server/test/entry.test.js index 2204fb2..27e1ff0 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -137,17 +137,36 @@ test('nothing is registered that has nothing behind it yet', () => { // surfaces an operator can configure and then wait on — worse than an absent // one, because the absence is visible. Each of these arrives with the phase // that has something real to put in it, and this assertion is what that phase - // deletes. Phase 9 deleted the Team provider's line. - assert.strictEqual(api.record.triggers, null) - assert.strictEqual(api.record.audiences, null) - assert.strictEqual(api.record.engagementSeeds, null) - assert.strictEqual(api.record.streams, null) + // deletes. Phase 9 deleted the Team provider's line; phase 10 the four + // engagement lines, and the announce leg and post hook it deliberately did + // NOT register (D62) moved into the assertions below. + assert.deepStrictEqual(api.record.legs, []) + assert.strictEqual(api.record.hooks.post, undefined) assert.strictEqual(api.record.eventBudgets, null) assert.strictEqual(api.record.eventOptionSources, null) assert.strictEqual(api.record.eventLeases, null) assert.strictEqual(api.record.eventActions, null) }) +test('the engagement set is registered as one decision (phase 10, R7)', () => { + const { api } = register() + + const triggers = api.record.triggers + const streams = api.record.streams + assert.ok(Array.isArray(triggers) && triggers.length > 0) + assert.ok(Array.isArray(api.record.audiences) && api.record.audiences.length === 3) + assert.ok(api.record.engagementSeeds && Array.isArray(api.record.engagementSeeds.ruleGroups)) + + // A stream is a toggle for a trigger; one with no trigger behind it is a + // toggle nothing can ever fire (D65, one namespace across both facets). + const triggerIds = new Set(triggers.map((t) => t.id)) + for (const s of streams) assert.ok(triggerIds.has(s.id), `stream ${s.id} has no trigger`) + assert.deepStrictEqual( + streams.map((s) => s.id).sort(), + ['rust.base.destroyed', 'rust.server.offline', 'rust.server.online', 'rust.wipe.started'], + ) +}) + test('the module’s protocol version agrees with the manifest it ships beside', () => { const sidecar = require('../sidecarClient') diff --git a/server/test/refresh.test.js b/server/test/refresh.test.js index 42309c0..4352c51 100644 --- a/server/test/refresh.test.js +++ b/server/test/refresh.test.js @@ -91,9 +91,12 @@ test('neither unhappy path calls putState', async () => { const originalPut = db.putState const originalMark = db.markUnreachable const originalBoards = sidecar.boards + const originalHealth = sidecar.health const marked = [] let putCalls = 0 + sidecar.health = async () => ({ ok: false, status: 0, data: null }) + db.putState = async () => { putCalls += 1 } db.markUnreachable = async (id, reachable) => { marked.push([id, reachable]) } @@ -112,5 +115,83 @@ test('neither unhappy path calls putState', async () => { db.putState = originalPut db.markUnreachable = originalMark sidecar.boards = originalBoards + sidecar.health = originalHealth } }) + +test('a board the game left behind is not a game that is up (D68)', async () => { + // The sidecar keeps its last `server.hello` after the plugin disconnects, so + // until phase 10 a hung game — or an unloaded bridge — with the sidecar still + // up read as ONLINE here, with the players it had when it stopped. Only + // `/health` knows whether the plugin is connected now. + withCore(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } })) + + const db = require('../model/servers/servers.db') + const sidecar = require('../sidecarClient') + const ingest = require('../ingest') + const boot = require('../boot') + const engagement = require('../engagement/emit') + + const saved = { put: db.putState, boards: sidecar.boards, health: sidecar.health, apply: ingest.applyBoards } + const puts = [] + const applied = [] + engagement.reset() + + db.putState = async (state) => { puts.push(state) } + ingest.applyBoards = async (id, boards) => { applied.push(boards) } + sidecar.boards = async () => ({ + ok: true, + status: 200, + data: { boards: { + 'server.hello': { players: 12, maxPlayers: 100, hostname: 'Main' }, + 'players.online': { players: [{ steamId: '1', name: 'Still here?' }] }, + } }, + }) + + const server = { id: 'main', name: 'Main', baseUrl: 'http://127.0.0.1:1', token: 't', protocol: 7 } + + try { + sidecar.health = async () => ({ ok: true, status: 200, data: { plugin_connected: false } }) + await boot.refreshOne(server) + + assert.strictEqual(puts[0].online, false) + assert.strictEqual(puts[0].players, 0, 'the last count is not a count') + assert.strictEqual(puts[0].seen, false, 'a stale board must not move "last seen"') + assert.strictEqual(puts[0].hostname, 'Main', 'the description is still written') + assert.deepStrictEqual(applied[0]['players.online'].players, [], 'nobody is named as online') + + sidecar.health = async () => ({ ok: true, status: 200, data: { plugin_connected: true } }) + await boot.refreshOne(server) + assert.strictEqual(puts[1].online, true) + assert.strictEqual(puts[1].players, 12) + assert.notStrictEqual(puts[1].seen, false) + assert.strictEqual(applied[1]['players.online'].players.length, 1) + + // An unanswered /health is unknown, and unknown is not up. + sidecar.health = async () => ({ ok: false, status: 0, data: null }) + await boot.refreshOne(server) + assert.strictEqual(puts[2].online, false) + } finally { + db.putState = saved.put + sidecar.boards = saved.boards + sidecar.health = saved.health + ingest.applyBoards = saved.apply + engagement.reset() + } +}) + +test('putState moves last_seen_at only for a game that was seen', async () => { + const queries = [] + withCore(fakeCtx({ + db: { query: (sql, params) => { queries.push({ sql, params }); return Promise.resolve([]) }, pool: {} }, + })) + const db = require('../model/servers/servers.db') + + await db.putState({ serverId: 'main', reachable: true, online: false, seen: false }) + await db.putState({ serverId: 'main', reachable: true, online: true }) + + // The two trailing parameters feed the two IF(?, CURRENT_TIMESTAMP, …)s. + assert.deepStrictEqual(queries[0].params.slice(-2), [0, 0]) + assert.deepStrictEqual(queries[1].params.slice(-2), [1, 1]) + assert.strictEqual((queries[0].sql.match(/\?/g) || []).length, queries[0].params.length, 'every placeholder has a value') +})