diff --git a/server/ingest.js b/server/ingest.js index 6fac315..dffb5bc 100644 --- a/server/ingest.js +++ b/server/ingest.js @@ -72,7 +72,9 @@ const MAX_BATCHES_PER_TICK = 10 * how to read. */ async function apply(serverId, item, server = null) { - const frame = (item && item.frame) || {} + // A downed helicopter, Chinook or Bradley is stored with the rule that + // credits it now, so its line and its counts agree for good (W4). + const frame = worldEvents.stampRule((item && item.frame) || {}, server && server.creditRule) const kind = item.kind || frame.kind const wipeId = frame.wipeId || null @@ -278,8 +280,7 @@ async function apply(serverId, item, server = null) { // A protocol-13 plugin still sends `heliKills` / `apcKills` on its tally // instead, and the tally path above adds those as it always has. case 'world.event': { - const rule = worldEvents.creditRuleOf(server && server.creditRule) - for (const kill of worldEvents.killsOf(frame, rule)) { + for (const kill of worldEvents.killsOf(frame, worldEvents.ruleOf(frame, server && server.creditRule))) { await db.touchPlayer(kill.steamId, kill.name || null) await db.addTitleStats({ serverId, wipeId, steamId: kill.steamId }, { [kill.field]: 1 }) } diff --git a/server/permSync.js b/server/permSync.js index 872435b..c3d3b3e 100644 --- a/server/permSync.js +++ b/server/permSync.js @@ -314,13 +314,7 @@ async function syncOne(server, { authored, sync, state, force, policy = 'auto-ad held: planned.hold.size, }) - const result = await sidecar.permSync(server, { - setId: desired.hash, - groups: withExpect(held.payload.groups, pushed), - grants: held.payload.grants, - credits: held.payload.credits, - retire, - }) + const result = await sidecar.permSync(server, pushBody(desired.hash, held.payload, pushed, retire)) if (!result.ok) { await failed(server, { reason, error: result.status, desiredHash: desired.hash, sync, bootId, wipeId }) @@ -358,6 +352,23 @@ async function syncOne(server, { authored, sync, state, force, policy = 'auto-ad return 'ok' } +/** + * The sync as the wire carries it: every field of the built payload, the + * groups with their style `expect`s, and what to retire. `notices` rides here + * too (D333) — the push once named its fields one by one and left it out, so + * no reward message ever reached the game (W21). + */ +function pushBody(setId, payload, pushed, retire) { + return { + setId, + groups: withExpect(payload.groups, pushed), + grants: payload.grants, + credits: payload.credits, + notices: payload.notices, + retire, + } +} + /** * The groups as the wire carries them: each style field with the value this * site last pushed to THIS server (`expect`), or null for one it never has. @@ -526,6 +537,7 @@ module.exports = { syncOne, reasonToSync, applyReport, + pushBody, withExpect, styleRetirements, withLock, diff --git a/server/router/public/rust.router.js b/server/router/public/rust.router.js index 4c0c615..7fcb284 100644 --- a/server/router/public/rust.router.js +++ b/server/router/public/rust.router.js @@ -68,7 +68,7 @@ rustRouter.get( '/servers/:id/events', // #swagger.tags = ['Public · Rust'] // #swagger.summary = 'Recent events on one Rust server' - // #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule`.' + // #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes.' // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } } // #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } } // #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } } diff --git a/server/test/stepEditor.test.js b/server/test/stepEditor.test.js index 1ca19f3..5126be5 100644 --- a/server/test/stepEditor.test.js +++ b/server/test/stepEditor.test.js @@ -28,6 +28,7 @@ const rewards = require('../eventRewards') const leases = require('../eventLeases') const templates = require('../eventTemplates') const model = require('../model/permissions/permissions.model') +const reconcile = require('../model/permissions/reconcile') const entitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle') const ok = (data) => ({ ok: true, status: 'ok', data }) @@ -163,6 +164,19 @@ test('the sync carries each untold message to the account the reward picked', () ]) }) +test('the push carries the notices, and every other field the payload built (W21)', () => { + const permSync = require('../permSync') + const desired = model.buildDesired('main', authored([grantRow({ notice: 'You earned kit vip', noticeDelivery: 'chat' })])) + const held = reconcile.withHold(desired, new Set()) + const body = permSync.pushBody(desired.hash, held.payload, [], []) + assert.deepStrictEqual(body.notices, [ + { key: '7:3:11', steamId: '76561190000000001', message: 'You earned kit vip', delivery: 'chat' }, + ]) + // A field the model adds and the push forgets is how W21 happened. + for (const field of Object.keys(held.payload)) assert.ok(field in body, `the push drops payload.${field}`) + assert.strictEqual(body.setId, desired.hash) +}) + test('a notice moves the digest, so a new one is pushed and a told one is dropped', () => { const none = model.buildDesired('main', authored([grantRow({})])) const one = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeDelivery: 'chat' })])) diff --git a/server/test/worldEvents.test.js b/server/test/worldEvents.test.js index c337dfe..ee818d5 100644 --- a/server/test/worldEvents.test.js +++ b/server/test/worldEvents.test.js @@ -216,6 +216,19 @@ test('a Bradley counts toward Tankbreaker, and an arrival counts nothing', async assert.strictEqual(rec2.touching('rust_player_wipe_stats').length, 0) }) +test('a downed one is stored with the rule that credited it; an arrival is stored as sent (W4)', async () => { + const { apply } = require('../ingest') + const storedRaw = async (frame) => { + const rec = withRecorder() + await apply('main', { id: 1, kind: 'world.event', frame }, { id: 'main', creditRule: 'all' }) + const insert = rec.touching('rust_events').find((s) => /INSERT/i.test(s.sql)) + return JSON.parse(insert.params.find((p) => typeof p === 'string' && p.startsWith('{'))) + } + + assert.strictEqual((await storedRaw(downed())).creditRule, 'all') + assert.strictEqual((await storedRaw(world('heli.arrived', { x: 1, z: 1 }))).creditRule, undefined) +}) + test('the tally’s crates hacked and drops looted are added up (D356)', async () => { const rec = withRecorder() const { apply } = require('../ingest') @@ -251,6 +264,21 @@ test('the feed reads each world event for its viewer, with the server’s rule a assert.deepStrictEqual(member.frame.credited, [{ name: 'Bob' }]) }) +test('a line keeps the rule it was stored with after the server’s rule changes (W4)', async () => { + const stamped = downed({ creditRule: 'all' }) + withRecorder((sql) => { + if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'world.event', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stamped) }] + if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', creditRule: 'most', enabled: 1 }] + return [] + }) + const events = require('../model/events/events.model') + + const [member] = await events.recent({ serverId: 'main', presence: true }) + assert.strictEqual(member.frame.creditRule, 'all') + assert.strictEqual(member.frame.creditedCount, 2) + assert.deepStrictEqual(member.frame.credited.map((c) => c.name).sort(), ['Alice', 'Bob']) +}) + // ── Triggers (D355) ──────────────────────────────────────────────────────── test('one public trigger per type, none of them declaring a Steam id', () => { diff --git a/server/worldEvents.js b/server/worldEvents.js index 6b217d5..bb49a5c 100644 --- a/server/worldEvents.js +++ b/server/worldEvents.js @@ -178,7 +178,8 @@ function columnName(index) { * `staff` the viewer is an admin or a moderator: Steam ids, and a type this * build does not know * `presence` the viewer is in the server's presence audience: names - * `rule` the server's credit rule + * `rule` the server's credit rule, for a row stored before the frame + * carried its own (`stampRule`) * `geometry` the server's map geometry, for the grid * * What comes back keeps the plugin's own fields for what it describes (`event`, @@ -215,8 +216,8 @@ function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDI // Credit, by the server's rule. if (known && TYPES[event].credit) { - out.creditRule = creditRuleOf(rule) - const ids = credited(frame, rule) + out.creditRule = ruleOf(frame, rule) + const ids = credited(frame, out.creditRule) out.creditedCount = ids.length if (presence || staff) { out.credited = ids.map((id) => ({ @@ -235,6 +236,25 @@ function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDI return out } +/** + * The frame as it is stored: a downed helicopter, Chinook or Bradley carries + * the rule that credited it when it arrived (`creditRule`), so the feed line + * keeps naming who the counts were given to after the server's rule changes + * (W4). Every other frame is stored as sent. + */ +function stampRule(frame, rule) { + if (!frame || frame.kind !== 'world.event' || !isKnownType(frame.event) || !TYPES[frame.event].credit) return frame + return { ...frame, creditRule: creditRuleOf(rule) } +} + +/** + * The rule a stored line is read by: its own stamp, or — for a row from before + * the stamp — the server's rule now, which is how those rows always read. + */ +function ruleOf(frame, rule) { + return frame && CREDIT_RULE_IDS.includes(frame.creditRule) ? frame.creditRule : creditRuleOf(rule) +} + /** * What one `world.event` adds to the counts: a credited kill of a helicopter, * a Chinook or a Bradley, for each player the server's rule names. `[{ steamId, @@ -270,6 +290,8 @@ module.exports = { creditRuleOf, isKnownType, credited, + stampRule, + ruleOf, nameOf, gridOf, shapeFor, diff --git a/swagger-fragment.json b/swagger-fragment.json index 2c8b3fe..8142f49 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -3203,7 +3203,7 @@ "Public · Rust" ], "summary": "Recent events on one Rust server", - "description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule`.", + "description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes.", "parameters": [ { "name": "id",