Files
Module-Rust/server/test/titleConditions.test.js
wtclaude e7d5c8ed1a
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 36s
PR Checks / frozen-manifest (pull_request) Successful in -1m32s
feat(rust): Rust's own world events — feed, credit rule, triggers, Call a world event (PLAN_REDESIGNS §14.3.1)
The site half of stage 1, on protocol 14:

- server/worldEvents.js: the 24 types, D372's four credit rules, the grid
  from the map's geometry (none off the map), and the per-viewer reading.
- The feed serves `world.event` (a public kind): an unknown type is staff's;
  the event and its grid are public; names only for the presence audience
  (D376); Steam ids and positions only for staff (D353). A downed one carries
  `credited`/`creditedCount` by the server's rule. Client lines for all 24
  types and a World events filter.
- rust_servers.credit_rule (blow, last, most, all; default blow), set on the
  server's settings. Ingest counts heli_kills (patrol helicopters and
  Chinooks, D379) and apc_kills from `world.event` by that rule, so the line
  and Rotorbane / Tankbreaker agree. A protocol-13 tally's heliKills/apcKills
  still add as before.
- The tally's cratesHacked / dropsLooted → crates_hacked / drops_looted;
  title categories Safecracker and Scavenger (D356); leaderboard columns
  Crates hacked and Drops looted, sortable (D377).
- 24 engagement triggers rust.world.<event> (D355): ceiling everyone,
  cooldown subject the server and event (crates add where), names only.
- server/eventWorldCall.js: rust.world.call, "Call a world event"
  (risk change, reversible none, budget rust.world.calls). A position for an
  airdrop or a patrol helicopter only; the Chinook takes none (D378).

Server 585/585 (22 new in test/worldEvents.test.js), client 84/84, every
check; booted against the pinned core (43 triggers, 12 actions, 10 budgets,
20 option sources), routes.manifest unchanged at 89. Engagement manifest and
swagger fragment regenerated.

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

225 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The chat-title conditions (PLAN_REDESIGNS §5, D172–D175, D209) ─────────
//
// Four things here, each a mistake that would look right in review:
//
// • **a best is not a sum.** The two distance columns take GREATEST at ingest;
// adding two intervals' maxima would give a kill nobody made.
// • **"NPC kills" subtracts in SIGNED arithmetic.** `npc_kills` is unsigned,
// and a row counted before `animal_kills` existed must not wrap to 4 billion.
// • **a rename reaches a rule with no text.** The rule's own text wins, then
// the admin's category title, then the default (§4.6's three layers).
// • **a category's title is never a rule's text by accident.** An empty text is
// "use the category's", and a typed title made of markup is still refused.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/** Installs a core whose `db.query` records every statement and answers `rows`. */
function withRecorder(rows = []) {
const statements = []
const ctx = fakeCtx({
db: {
query: (sql, params = []) => {
statements.push({ sql, params })
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return {
statements,
touching: (table) => statements.filter((s) => s.sql.includes(table)),
ctx,
}
}
withRecorder()
const titles = require('../model/titles/titles')
const lists = require('../model/titles/lists')
const frame = (over = {}) => ({ type: 'event', t: 1, serverId: 'main', wipeId: 'w1', ...over })
const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...over }) })
// ── The categories ────────────────────────────────────────────────────────
test('twenty-six categories carry a default title, and playtime carries none (D172, §11.2.2, D356)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 26)
assert.strictEqual(titles.STATS.richest.title, 'Magnate')
assert.strictEqual(titles.STATS.crateshacked.title, 'Safecracker')
assert.strictEqual(titles.STATS.dropslooted.title, 'Scavenger')
assert.ok(!titles.CATEGORY_IDS.includes('playtime'))
assert.strictEqual(titles.STATS.playtime.title, null)
assert.strictEqual(titles.STATS.animalkills.title, 'Beastbane')
assert.strictEqual(titles.STATS.missions.title, 'Wayfarer')
// Every id fits the column a rule stores it in.
for (const id of Object.keys(titles.STATS)) assert.ok(id.length <= 16, id)
// Every list a category names exists, and is not empty.
for (const { source } of Object.values(titles.STATS)) {
const name = source.weapons || source.gathered
if (name) assert.ok(Array.isArray(lists[name]) && lists[name].length, name)
}
})
test('"NPC kills" is human NPCs: every NPC kill less the animals (D209)', () => {
assert.deepStrictEqual(titles.STATS.npckills.source, { diff: ['npc_kills', 'animal_kills'] })
})
test('every blade is also a melee weapon (§5.3)', () => {
for (const blade of lists.blade) assert.ok(lists.melee.includes(blade), blade)
})
// ── Rules and their text ──────────────────────────────────────────────────
test('a rule may leave its text empty when its category has a title, and not for playtime (D175)', () => {
const ok = titles.validateSettings({ rules: [{ stat: 'animalkills', topN: 1, text: '', color: '#00ff00' }] })
assert.strictEqual(ok.ok, true)
assert.strictEqual(ok.value.rules[0].text, '')
const playtime = titles.validateSettings({ rules: [{ stat: 'playtime', topN: 1, text: '', color: '#00ff00' }] })
assert.strictEqual(playtime.ok, false)
assert.ok(playtime.errors.some((e) => /rule 1: the title needs some text/.test(e)))
})
test('a typed title made only of markup is refused, even where empty would be allowed (reading 3)', () => {
const bad = titles.validateSettings({ rules: [{ stat: 'kills', topN: 1, text: '[]<>', color: '#ff0000' }] })
assert.strictEqual(bad.ok, false)
assert.ok(bad.errors.some((e) => /the title needs some text/.test(e)))
})
test('a rule’s text wins, then the admin’s category title, then the default (§4.6)', () => {
const rules = [
{ stat: 'kills', topN: 1, text: 'Top Gun', color: '#ff0000' },
{ stat: 'animalkills', topN: 1, text: '', color: '#00ff00' },
{ stat: 'headshots', topN: 1, text: '', color: '#0000ff' },
]
const resolved = titles.resolveRules(rules, { animalkills: 'Hunter', kills: 'Ignored' })
assert.deepStrictEqual(resolved.map((r) => r.text), ['Top Gun', 'Hunter', 'Crownbreaker'])
})
test('a playtime rule with no text shows nothing rather than an empty chip', () => {
const resolved = titles.resolveRules([{ stat: 'playtime', topN: 1, text: '', color: '#ffffff' }], {})
assert.deepStrictEqual(resolved, [])
})
test('a category title is checked like a rule’s, and empty is the Reset', () => {
assert.deepStrictEqual(titles.validateCategory('animalkills', ' [Hunter] '), { ok: true, text: 'Hunter' })
assert.deepStrictEqual(titles.validateCategory('animalkills', ''), { ok: true, text: '' })
assert.strictEqual(titles.validateCategory('animalkills', '<>').ok, false)
assert.strictEqual(titles.validateCategory('animalkills', 'x'.repeat(25)).ok, false)
assert.strictEqual(titles.validateCategory('playtime', 'Regular').ok, false, 'playtime has no category title')
assert.strictEqual(titles.validateCategory('deaths', 'X').ok, false)
})
// ── The ingest ────────────────────────────────────────────────────────────
test('a tally’s title counts are added, and its two distances kept as a best', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply(
'main',
item('player.tally', {
steamId: 'p1',
animalKills: 2,
headshots: 1,
bestPvpM: 187.46,
bestPveM: 42.1,
crafted: { attire: 3, weapon: 1, tool: 9 },
built: 12,
missions: 1,
weaponKills: { 'bow_hunting.entity': 2, 'machete.weapon': 1 },
}),
)
const titled = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('animal_kills'))
assert.ok(titled, 'one statement for the title counts')
assert.match(titled.sql, /animal_kills = animal_kills \+ VALUES\(animal_kills\)/)
assert.match(titled.sql, /best_pvp_m = GREATEST\(best_pvp_m, VALUES\(best_pvp_m\)\)/)
assert.match(titled.sql, /best_pve_m = GREATEST\(best_pve_m, VALUES\(best_pve_m\)\)/)
assert.doesNotMatch(titled.sql, /best_pvp_m = best_pvp_m \+/)
const cols = titled.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+), last_seen\)/)[1].split(', ')
const value = (col) => titled.params[3 + cols.indexOf(col)]
assert.strictEqual(value('animal_kills'), 2)
assert.strictEqual(value('crafted_attire'), 3)
assert.strictEqual(value('crafted_weapons'), 1)
assert.strictEqual(value('built'), 12)
assert.strictEqual(value('missions'), 1)
assert.strictEqual(value('best_pvp_m'), 187.5)
assert.strictEqual(value('rockets'), 0)
const weapons = rec.touching('rust_weapon_kills')
assert.deepStrictEqual(
weapons.map((w) => [w.params[3], w.params[4]]),
[['bow_hunting.entity', 2], ['machete.weapon', 1]],
)
assert.match(weapons[0].sql, /kills = kills \+ VALUES\(kills\)/)
})
test('an older plugin’s tally writes no title statement at all', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', item('player.tally', { steamId: 'p1', npcKills: 1 }))
assert.strictEqual(rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('animal_kills')).length, 0)
assert.strictEqual(rec.touching('rust_weapon_kills').length, 0)
})
// ── The standings ─────────────────────────────────────────────────────────
test('a category’s standings read from the right place, in the leaderboard’s order', async () => {
const rec = withRecorder([])
const db = require('../model/titles/titles.db')
const at = { serverId: 'main', wipeId: 'w1', lists, limit: 3 }
await db.standings({ ...at, source: titles.STATS.npckills.source })
const human = rec.statements.at(-1)
assert.match(human.sql, /CAST\(s\.npc_kills AS SIGNED\) - CAST\(s\.animal_kills AS SIGNED\)/)
assert.match(human.sql, /ORDER BY value DESC, MAX\(s\.last_seen\) DESC/)
await db.standings({ ...at, source: titles.STATS.pvpdistance.source })
assert.match(rec.statements.at(-1).sql, /MAX\(s\.best_pvp_m\) AS value/)
await db.standings({ ...at, source: titles.STATS.bowkills.source })
const bow = rec.statements.at(-1)
assert.match(bow.sql, /FROM rust_weapon_kills t/)
assert.deepStrictEqual(bow.params.slice(2, 2 + lists.bow.length), lists.bow)
assert.strictEqual(bow.params.at(-1), 3)
await db.standings({ ...at, source: titles.STATS.ore.source })
assert.match(rec.statements.at(-1).sql, /FROM rust_gather_totals t/)
const before = rec.statements.length
assert.deepStrictEqual(await db.standings({ ...at, source: { weapons: 'nothing' } }), [])
assert.strictEqual(rec.statements.length, before, 'an empty list asks nothing')
})
test('a rename reaches every server’s next answer: the memo is forgotten fleet-wide', async () => {
let override = null
withRecorder((sql) => {
if (sql.includes('FROM rust_title_categories')) return override ? [{ stat: 'animalkills', text: override }] : []
if (sql.includes('FROM rust_title_rules')) return [{ stat: 'animalkills', topN: 1, text: '', color: '#00ff00' }]
if (sql.includes('title_mode')) return [{ mode: 'first', max: 2 }]
if (sql.includes('SUM(s.animal_kills)')) return [{ steamId: '7', value: '4' }]
return []
})
const model = require('../model/titles/titles.model')
model.forget()
const first = await model.heldFor('main', { wipeId: 'w1', now: 1000 })
assert.deepStrictEqual(first.get('7'), [{ text: 'Beastbane', color: '#00ff00' }])
override = 'Hunter'
await model.setCategory('animalkills', 'Hunter', 1)
const after = await model.heldFor('main', { wipeId: 'w1', now: 1001 })
assert.deepStrictEqual(after.get('7'), [{ text: 'Hunter', color: '#00ff00' }])
})