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

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

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

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

226 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-seven categories carry a default title, and playtime carries none (D172, §11.2.2, D356, D375)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 27)
assert.strictEqual(titles.STATS.treasureswon.title, 'Treasure Hunter')
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' }])
})