feat(rust): Teams from first-party clans (phase 9, protocol 6)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 46s
PR Checks / client-build (pull_request) Successful in 8m3s

A first-party Rust clan is a Team (R5). This module becomes the site's
Team provider and answers core from the plugin's `clans` board. Design
of record: docs/modules/rust/PLAN.md §24, D47-D58.

- The store: rust_clans, rust_clan_members and rust_clan_boards. A clan's
  identity is <serverId>:<clanId>:<createdMs> (D52), because the game
  restarts clan ids whenever its clan database version changes.
- The provider (D53): getTeams is complete only when every server's
  board is fresh, supported and untruncated. It is partial when some
  are, and refuses when none are. Freshness is judged by the website's
  clock, from when the board's `t` last advanced.
- Only a complete board may mark a clan gone. A board at the game's
  100-clan ceiling (D55), or one with an unreadable row, proves nothing
  about what it leaves out.
- Leadership is diffed board to board and published (D54). The five clan
  events are published as team.* kinds, and written to the Team feed as
  members-only lines (D49).
- Core only writes feed items for a Team it already holds. So the last 10
  minutes of clan events are re-offered on each board refresh, deduped by
  a sha1 key: core clamps a dedupeKey to 40 characters, and a readable key
  would be truncated into collisions.
- projectRoster and the clan page share one audience rule (D48): the
  clan's linked members and staff by default, re-read from the users row.
  The setting lives on Admin > Rust visibility, which also warns about
  uMod Clans (D47) and the ceiling.
- Public: GET servers/:id/clans (the list is public, D58) and
  GET clans/:externalId. The client adds a Clans tab and
  /rust/clans/:externalId, with three module slots for core's notify,
  activity and forum contributions (D56).
- Linking and unlinking an account ask core to reconcile Teams (D57).
- The clan kinds are staff-class in the public feed allowlist.
- PROTOCOL_VERSION is now 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-23 05:14:18 -05:00
parent da1a393702
commit c94271104f
33 changed files with 3456 additions and 32 deletions

View File

@@ -65,6 +65,16 @@ function fakeCtx(overrides = {}) {
// the envelope. Only the module knows when the game restarted, so only the
// module can ask for the sweep.
events: { emit: spy(undefined), reconcile: spy(undefined) },
// Teams (§2.3, 1.6.0). Push only — there is no reader, because a module
// ANSWERS questions about Teams rather than asking them. `publish` and
// `activity.push` resolve like core's; `reconcile` returns nothing, because
// core's returns at once and a fake that returned a promise would invite a
// module to wait on a sweep it does not own.
teams: {
publish: spy(Promise.resolve()),
reconcile: spy(undefined),
activity: { push: spy(Promise.resolve(0)) },
},
// A REVERSIBLE fake, not a recording one. Core's box is AES-256-GCM keyed by
// the deployment's SECRET_ENC_KEY; what a test needs from it is that
// `decrypt(encrypt(x)) === x`, because the bug this module could have is a

View File

@@ -95,7 +95,7 @@ test('every kind is classified exactly once', () => {
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
})
test('the classification covers exactly the kinds protocol 4 defines', () => {
test('the classification covers exactly the kinds the protocol defines, through protocol 6', () => {
// The spec lives in another repository, so the list is restated here rather
// than parsed — and restating it is the point: adding a kind to the protocol
// without deciding who may see it has to fail somewhere, and this is where.
@@ -120,9 +120,20 @@ test('the classification covers exactly the kinds protocol 4 defines', () => {
'account.link.requested',
'account.unlinked',
'perm.drift',
// Protocol 6 (§12). Clan membership is members-only (D49), so every one of
// these is staff-class here and reaches members through core's Team feed.
'clan.created',
'clan.disbanded',
'clan.member.added',
'clan.member.left',
'clan.member.kicked',
]
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
for (const kind of PROTOCOL_4.filter((k) => k.startsWith('clan.'))) {
assert.equal(catalogue.isPublic(kind), false, `${kind} is members-only and must not be public`)
}
})
test('every kind that names a player who was on is behind the presence setting', () => {

552
server/test/clans.test.js Normal file
View File

@@ -0,0 +1,552 @@
// ── First-party clans → core's Teams (phase 9) ─────────────────────────────
//
// The properties this suite holds, each with a failure behind it:
//
// • a clan's identity carries its creation time (D52), so a reset clan
// database cannot hand an old clan's Team to a new one;
// • only a COMPLETE board may say a clan is gone — a board at the game's
// 100-clan ceiling (D55), or one with an unreadable row, proves nothing
// about what it leaves out;
// • leadership is learned from the board, diffed (D54);
// • `getTeams` is complete only when EVERY server vouches (D53), and refuses
// rather than answering empty when none does;
// • a roster is shown to the clan's own members and staff by default (D48),
// re-read from the users row, and a failure withholds it;
// • every feed item is members-only (D49) and carries a dedupe key core will
// not truncate into a collision.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, spy } = require('./_fakes')
const SERVER = 'main'
const T0 = 1790142840000
function member(steamId, rank = 2, extra = {}) {
return { steamId, rank, role: rank === 1 ? 'Leader' : 'Member', joinedMs: T0, name: `P${steamId.slice(-2)}`, ...extra }
}
function clanRow(clanId, createdMs, members, extra = {}) {
return { clanId, createdMs, name: `Clan ${clanId}`, color: '#3FA9F5', score: 10, maxMembers: 100, members, ...extra }
}
/**
* The model and provider over an in-memory store, with a chosen viewer row.
*
* The store is small enough to reason about: clans and members by external id,
* and one board record per server. Every `clans.db` function the code under test
* calls is replaced; anything else it reached for would throw on the fake ctx.
*/
function setup({ users = {}, rosterSetting = null, servers = [{ id: SERVER }] } = {}) {
require('../core')._reset()
const ctx = fakeCtx({
users: { getById: async (id) => users[id] || null },
})
require('../core').init(ctx)
const db = require('../model/clans/clans.db')
const visibilityDb = require('../model/visibility/visibility.db')
const serversModel = require('../model/servers/servers.model')
const store = { clans: new Map(), members: new Map(), boards: new Map(), links: new Map(), online: new Set(), names: [] }
const originals = { db: { ...db }, visibilityDb: { ...visibilityDb }, servers: { ...serversModel } }
db.getBoard = async (serverId) => store.boards.get(serverId) || null
db.listBoards = async () =>
servers.map((s) => ({ serverId: s.id, serverName: s.id.toUpperCase(), ...(store.boards.get(s.id) || {}) }))
db.putBoard = async (b) => {
const prev = store.boards.get(b.serverId) || {}
store.boards.set(b.serverId, {
serverId: b.serverId,
boardT: b.boardT,
seenAt: b.advanced ? new Date() : prev.seenAt || null,
enabled: b.enabled ? 1 : 0,
supported: b.supported ? 1 : 0,
truncated: b.truncated ? 1 : 0,
backend: b.backend,
reason: b.reason,
umodClans: b.umodClans ? 1 : 0,
clanCount: b.clanCount,
})
}
db.listClansForServer = async (serverId) => [...store.clans.values()].filter((c) => c.serverId === serverId)
db.listMembersForServer = async (serverId) => {
const out = []
for (const c of store.clans.values()) {
if (c.serverId !== serverId || c.goneAt) continue
for (const m of store.members.get(c.externalId) || []) out.push({ externalId: c.externalId, ...m })
}
return out
}
db.upsertClan = async (c) => {
const prev = store.clans.get(c.externalId)
store.clans.set(c.externalId, { ...prev, ...c, members: undefined, goneAt: null })
}
db.replaceMembers = spy(async (externalId, members) => {
store.members.set(externalId, members.map((m) => ({ ...m })))
})
db.markGone = async (ids) => {
for (const id of ids) {
const c = store.clans.get(id)
if (c && !c.goneAt) c.goneAt = new Date()
store.members.delete(id)
}
}
db.findClan = async (id) => {
const c = store.clans.get(id)
return c ? { ...c, serverName: c.serverId.toUpperCase() } : null
}
db.findByGameId = async (serverId, clanId) => {
const hits = [...store.clans.values()]
.filter((c) => c.serverId === serverId && c.clanId === clanId)
.sort((a, b) => b.createdMs - a.createdMs)
return hits[0] ? { externalId: hits[0].externalId, name: hits[0].name } : null
}
db.listActiveClans = async () =>
[...store.clans.values()].filter((c) => !c.goneAt).map((c) => ({ ...c, serverName: c.serverId.toUpperCase() }))
db.listPublicForServer = async (serverId) =>
[...store.clans.values()].filter((c) => c.serverId === serverId && !c.goneAt)
db.listMembers = async (externalId) => {
const c = store.clans.get(externalId)
return (store.members.get(externalId) || []).map((m) => ({
...m,
userId: store.links.get(m.steamId) || null,
online: c && store.online.has(m.steamId) ? 1 : 0,
}))
}
db.userIsMember = async (externalId, userId) =>
(store.members.get(externalId) || []).some((m) => store.links.get(m.steamId) === userId)
db.recentClanEvents = async () => store.recent || []
db.rememberName = async (steamId, name) => store.names.push({ steamId, name })
visibilityDb.getSetting = async (key) => (key === 'clans.roster.audience' ? rosterSetting : null)
serversModel.listForPolling = async () => servers
const clans = require('../model/clans/clans.model')
const provider = require('../model/clans/teamProvider')
return {
ctx,
store,
clans,
provider,
restore: () => {
Object.assign(db, originals.db)
Object.assign(visibilityDb, originals.visibilityDb)
Object.assign(serversModel, originals.servers)
},
}
}
const board = (clans, extra = {}) => ({ kind: 'clans', type: 'snapshot', t: T0, supported: true, truncated: false, enabled: true, clans, ...extra })
// ── Identity ───────────────────────────────────────────────────────────────
test('a clan is keyed on server, game id AND creation time (D52)', () => {
const { clans, restore } = setup()
try {
const a = clans.normaliseClan(SERVER, clanRow(1, T0, [member('76561198000000001', 1)]))
const b = clans.normaliseClan(SERVER, clanRow(1, T0 + 5000, [member('76561198000000001', 1)]))
// Same game id, different clan: a reset database re-used id 1.
assert.notStrictEqual(a.externalId, b.externalId)
assert.strictEqual(a.externalId, `main:1:${T0}`)
// No id, no creation time, or no name: there is nothing to key it on.
assert.strictEqual(clans.normaliseClan(SERVER, { clanId: 1, name: 'x' }), null)
assert.strictEqual(clans.normaliseClan(SERVER, { createdMs: T0, name: 'x' }), null)
assert.strictEqual(clans.normaliseClan(SERVER, { clanId: 1, createdMs: T0 }), null)
// A colour ends up in a style, so anything that is not #rrggbb is dropped.
assert.strictEqual(clans.normaliseClan(SERVER, clanRow(2, T0, [], { color: 'red;background:url(x)' })).color, null)
// A member whose Steam id is not one is dropped, not the clan.
const partial = clans.normaliseClan(SERVER, clanRow(3, T0, [member('7656'), { steamId: 'robert' }]))
assert.deepStrictEqual(partial.members.map((m) => m.steamId), ['7656'])
} finally {
restore()
}
})
// ── The board ──────────────────────────────────────────────────────────────
test('a first board stores its clans and asks core to reconcile', async () => {
const { ctx, store, clans, restore } = setup()
try {
const result = await clans.applyBoard(SERVER, board([
clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000002')]),
]))
assert.strictEqual(result.applied, true)
assert.strictEqual(result.created, 1)
assert.strictEqual(store.clans.size, 1)
assert.strictEqual(store.members.get(`main:1:${T0}`).length, 2)
assert.strictEqual(ctx.teams.reconcile.calls.length, 1)
// Leaders of a brand-new clan reach core WITH the Team, not as a delta
// against a Team core does not hold yet.
assert.strictEqual(ctx.teams.publish.calls.length, 0)
} finally {
restore()
}
})
test('a board whose t has not moved is not applied again', async () => {
const { ctx, clans, store, restore } = setup()
try {
const b = board([clanRow(1, T0, [member('76561198000000001', 1)])])
await clans.applyBoard(SERVER, b)
store.members.clear()
const again = await clans.applyBoard(SERVER, b)
assert.strictEqual(again.applied, false)
assert.strictEqual(store.members.size, 0, 'nothing was rewritten')
assert.strictEqual(ctx.teams.reconcile.calls.length, 1)
} finally {
restore()
}
})
test('an unchanged roster is not rewritten when the board moves on', async () => {
const { clans, restore } = setup()
const db = require('../model/clans/clans.db')
try {
const members = [member('76561198000000001', 1)]
await clans.applyBoard(SERVER, board([clanRow(1, T0, members)]))
const writes = db.replaceMembers.calls.length
await clans.applyBoard(SERVER, board([clanRow(1, T0, members)], { t: T0 + 60000 }))
assert.strictEqual(db.replaceMembers.calls.length, writes)
} finally {
restore()
}
})
test('a complete board says a missing clan is gone; a truncated one does not (D55)', async () => {
const { clans, store, restore } = setup()
try {
await clans.applyBoard(SERVER, board([
clanRow(1, T0, [member('76561198000000001', 1)]),
clanRow(2, T0, [member('76561198000000002', 1)]),
]))
// At the ceiling: clan 2 is not listed, and that proves nothing.
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])], { t: T0 + 60000, truncated: true }))
assert.strictEqual(store.clans.get(`main:2:${T0}`).goneAt, null)
// A row this build could not read counts the same way.
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)]), { name: 'broken' }], { t: T0 + 90000 }))
assert.strictEqual(store.clans.get(`main:2:${T0}`).goneAt, null)
// Complete, and still not listed: now it is gone.
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])], { t: T0 + 120000 }))
assert.ok(store.clans.get(`main:2:${T0}`).goneAt)
} finally {
restore()
}
})
test('a change of leader is published from the board diff (D54)', async () => {
const { ctx, clans, restore } = setup()
try {
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000002', 2)])]))
await clans.applyBoard(SERVER, board(
[clanRow(1, T0, [member('76561198000000001', 2), member('76561198000000002', 1)])],
{ t: T0 + 60000 },
))
const kinds = ctx.teams.publish.calls.map(([e]) => `${e.kind}:${e.memberKey}`).sort()
assert.deepStrictEqual(kinds, [
'team.leader.added:76561198000000002',
'team.leader.removed:76561198000000001',
])
} finally {
restore()
}
})
test('an unsupported board is recorded with its reason and touches no clan', async () => {
const { clans, store, restore } = setup()
try {
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])]))
const result = await clans.applyBoard(SERVER, {
kind: 'clans', t: T0 + 60000, supported: false, reason: 'held by a NexusClanBackend', clans: [],
})
assert.strictEqual(result.applied, false)
assert.strictEqual(store.boards.get(SERVER).supported, 0)
assert.match(store.boards.get(SERVER).reason, /Nexus/)
assert.strictEqual(store.clans.get(`main:1:${T0}`).goneAt, null, 'an unreadable server says nothing about its clans')
// No board at all: a plugin older than protocol 6. Recorded, nothing touched.
await clans.applyBoard(SERVER, undefined)
assert.match(store.boards.get(SERVER).reason, /protocol 6/)
assert.strictEqual(store.clans.get(`main:1:${T0}`).goneAt, null)
} finally {
restore()
}
})
// ── The events ─────────────────────────────────────────────────────────────
test('each clan event is published as the Team kind core takes', async () => {
const { ctx, clans, restore } = setup()
try {
const base = { clanId: 1, createdMs: T0, clanName: 'Clan 1', t: T0 + 1 }
await clans.applyEvent(SERVER, { kind: 'clan.created', ...base, steamId: '76561198000000001', name: 'Ann' })
await clans.applyEvent(SERVER, { kind: 'clan.member.added', ...base, steamId: '76561198000000002', name: 'Bob' })
await clans.applyEvent(SERVER, { kind: 'clan.member.left', ...base, steamId: '76561198000000002', name: 'Bob' })
await clans.applyEvent(SERVER, { kind: 'clan.member.kicked', ...base, steamId: '76561198000000003', bySteamId: '76561198000000001' })
assert.deepStrictEqual(ctx.teams.publish.calls.map(([e]) => [e.kind, e.memberKey]), [
['team.created', undefined],
['team.member.added', '76561198000000002'],
['team.member.removed', '76561198000000002'],
['team.member.removed', '76561198000000003'],
])
for (const [e] of ctx.teams.publish.calls) assert.strictEqual(e.externalId, `main:1:${T0}`)
} finally {
restore()
}
})
test('every feed item is members-only, and its dedupe key fits core’s 40 characters', async () => {
const { ctx, clans, restore } = setup()
try {
const base = { clanId: 1, createdMs: T0, clanName: 'Clan 1', t: T0 + 1 }
await clans.applyEvent(SERVER, { kind: 'clan.created', ...base, steamId: '76561198000000001', name: 'Ann' })
await clans.applyEvent(SERVER, { kind: 'clan.member.kicked', ...base, steamId: '76561198000000003', name: 'Cy', byName: 'Ann', bySteamId: '76561198000000001' })
await clans.applyEvent(SERVER, { kind: 'clan.disbanded', ...base, steamId: '76561198000000001' })
const items = ctx.teams.activity.push.calls.map(([batch]) => batch[0])
// D49: founded and removed made lines; the disband did not.
assert.deepStrictEqual(items.map((i) => i.kind), ['rust.clan.founded', 'rust.clan.removed'])
assert.strictEqual(items[0].summary, 'Ann founded the clan.')
assert.strictEqual(items[1].summary, 'Cy was removed from the clan by Ann.')
assert.strictEqual(items[1].actorMemberKey, '76561198000000001', 'the actor of a kick is the kicker')
for (const item of items) {
assert.strictEqual(item.visibility, 'members')
// Core clamps a dedupe key to 40 characters. A readable one would be cut
// short into collisions; a sha1 is exactly 40.
assert.match(item.dedupeKey, /^[0-9a-f]{40}$/)
assert.strictEqual(typeof item.occurredAt, 'number', 'core reads occurredAt as epoch ms')
}
assert.notStrictEqual(items[0].dedupeKey, items[1].dedupeKey)
} finally {
restore()
}
})
test('the same frame offered twice carries the same key, so a re-offer is a no-op', async () => {
const { ctx, clans, store, restore } = setup()
try {
const frame = { kind: 'clan.member.added', clanId: 1, createdMs: T0, t: T0 + 5, steamId: '76561198000000002', name: 'Bob' }
await clans.applyEvent(SERVER, frame)
store.recent = [{ id: 1, kind: frame.kind, t: frame.t, raw: JSON.stringify(frame) }]
const offered = await clans.reofferActivity(SERVER)
assert.strictEqual(offered, 1)
const [first, second] = ctx.teams.activity.push.calls.map(([batch]) => batch[0].dedupeKey)
assert.strictEqual(first, second)
} finally {
restore()
}
})
test('a join without a creation time is matched on the game id, newest clan first', async () => {
const { ctx, clans, restore } = setup()
try {
await clans.applyBoard(SERVER, board([
clanRow(1, T0, [member('76561198000000001', 1)]),
]))
const result = await clans.applyEvent(SERVER, { kind: 'clan.member.added', clanId: 1, t: T0 + 1, steamId: '76561198000000009' })
assert.strictEqual(result.externalId, `main:1:${T0}`)
// A clan this module has never heard of is skipped, not guessed at.
const unknown = await clans.applyEvent(SERVER, { kind: 'clan.member.added', clanId: 77, t: T0 + 2, steamId: '76561198000000009' })
assert.strictEqual(unknown.applied, false)
assert.ok(ctx.teams.publish.calls.every(([e]) => e.externalId === `main:1:${T0}`))
} finally {
restore()
}
})
test('a disband marks the clan gone even when the board could not say so', async () => {
const { clans, store, restore } = setup()
try {
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])], { truncated: true }))
await clans.applyEvent(SERVER, { kind: 'clan.disbanded', clanId: 1, createdMs: T0, t: T0 + 1, steamId: '76561198000000001' })
assert.ok(store.clans.get(`main:1:${T0}`).goneAt)
} finally {
restore()
}
})
// ── The provider ───────────────────────────────────────────────────────────
test('getTeams is complete only when every server vouches (D53)', async () => {
const both = [{ id: 'main' }, { id: 'pvp' }]
const { clans, provider, restore } = setup({ servers: both })
try {
// Neither server has a board: refuse, never "no teams".
const none = await provider.getTeams()
assert.strictEqual(none.ok, false)
// One current, one never heard from: partial, so core removes nothing.
await clans.applyBoard('main', board([clanRow(1, T0, [member('76561198000000001', 1)])]))
const partial = await provider.getTeams()
assert.strictEqual(partial.ok, true)
assert.strictEqual(partial.complete, false)
assert.deepStrictEqual(partial.teams.map((t) => t.externalId), [`main:1:${T0}`])
assert.strictEqual(partial.teams[0].meta.serverId, 'main')
// Both current: complete.
await clans.applyBoard('pvp', board([]))
assert.strictEqual((await provider.getTeams()).complete, true)
// One at the ceiling: partial again.
await clans.applyBoard('pvp', board([], { t: T0 + 60000, truncated: true }))
assert.strictEqual((await provider.getTeams()).complete, false)
} finally {
restore()
}
})
test('a board that stops advancing stops vouching', async () => {
const { store, clans, provider, restore } = setup()
try {
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])]))
assert.strictEqual((await provider.getTeams()).ok, true)
store.boards.get(SERVER).seenAt = new Date(Date.now() - clans.FRESH_MS - 1000)
assert.strictEqual((await provider.getTeams()).ok, false)
assert.strictEqual((await provider.getTeamMembers(`main:1:${T0}`)).ok, false)
} finally {
restore()
}
})
test('getTeams refuses on a site with no Rust servers', async () => {
const { provider, restore } = setup({ servers: [] })
try {
const answer = await provider.getTeams()
assert.deepStrictEqual(answer.ok, false)
assert.match(answer.reason, /no Rust servers/)
} finally {
restore()
}
})
test('a roster names its members, its leaders, the linked account and who is on', async () => {
const { store, clans, provider, restore } = setup()
try {
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000002'), member('76561198000000003', null, { rank: null, role: null })])]))
store.links.set('76561198000000002', 42)
store.online.add('76561198000000001')
const roster = await provider.getTeamMembers(`main:1:${T0}`)
assert.strictEqual(roster.ok, true)
const byKey = Object.fromEntries(roster.members.map((m) => [m.memberKey, m]))
assert.strictEqual(byKey['76561198000000001'].leader, true)
assert.strictEqual(byKey['76561198000000001'].online, true)
assert.strictEqual(byKey['76561198000000002'].userId, 42)
// A rank the board could not match is not a leader.
assert.strictEqual(byKey['76561198000000003'].leader, false)
const leaders = await provider.getTeamLeaders(`main:1:${T0}`)
assert.deepStrictEqual(leaders, { ok: true, leaders: ['76561198000000001'] })
// A clan with a count but no stored rows is a read between two writes.
store.members.set(`main:1:${T0}`, [])
assert.strictEqual((await provider.getTeamMembers(`main:1:${T0}`)).ok, false)
} finally {
restore()
}
})
// ── Who may see a roster (D48) ─────────────────────────────────────────────
const USERS = {
1: { id: 1, role: 'player', status: 'active' }, // linked to a member
2: { id: 2, role: 'player', status: 'active' }, // not a member
3: { id: 3, role: 'moderator', status: 'active' },
4: { id: 4, role: 'player', status: 'banned' }, // linked to a member, banned
}
async function rosterFixture(options) {
const fx = setup({ users: USERS, ...options })
await fx.clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000004')])]))
fx.store.links.set('76561198000000001', 1)
fx.store.links.set('76561198000000004', 4)
return fx
}
const keysFor = async (provider, viewer) =>
(await provider.projectRoster(`main:1:${T0}`, [{ member_key: '76561198000000001' }, { member_key: '76561198000000004' }], viewer)).members.length
test('by default a roster is for the clan’s own members and staff', async () => {
const { provider, restore } = await rosterFixture()
try {
assert.strictEqual(await keysFor(provider, null), 0, 'anonymous')
assert.strictEqual(await keysFor(provider, { userId: 2, role: 'player' }), 0, 'a stranger')
assert.strictEqual(await keysFor(provider, { userId: 1, role: 'player' }), 2, 'a member')
assert.strictEqual(await keysFor(provider, { userId: 3, role: 'moderator' }), 2, 'staff')
// The row, not the claim: a banned member sees nothing, and a claimed role
// the row does not hold grants nothing.
assert.strictEqual(await keysFor(provider, { userId: 4, role: 'player' }), 0, 'banned')
assert.strictEqual(await keysFor(provider, { userId: 2, role: 'admin' }), 0, 'a claim is not a role')
} finally {
restore()
}
})
test('the operator can widen it, and an unknown setting narrows back', async () => {
const signedIn = await rosterFixture({ rosterSetting: 'signed_in' })
try {
assert.strictEqual(await keysFor(signedIn.provider, { userId: 2, role: 'player' }), 2)
assert.strictEqual(await keysFor(signedIn.provider, null), 0)
} finally {
signedIn.restore()
}
const open = await rosterFixture({ rosterSetting: 'public' })
try {
assert.strictEqual(await keysFor(open.provider, null), 2)
} finally {
open.restore()
}
const typo = await rosterFixture({ rosterSetting: 'everyone' })
try {
assert.strictEqual(await keysFor(typo.provider, { userId: 2, role: 'player' }), 0)
} finally {
typo.restore()
}
})
test('a roster question that cannot be answered withholds the roster', async () => {
const { provider, restore } = await rosterFixture()
const visibilityDb = require('../model/visibility/visibility.db')
try {
visibilityDb.getSetting = async () => {
throw new Error('pool exhausted')
}
const answer = await provider.projectRoster(`main:1:${T0}`, [{ member_key: '76561198000000001' }], { userId: 3 })
// Core fails CLOSED on this one call: a refusal serves an empty roster.
assert.strictEqual(answer.ok, false)
} finally {
restore()
}
})
test('the clan page carries no Steam id and no account id, and no names below the audience', async () => {
const { clans, restore } = await rosterFixture()
try {
const outside = await clans.getForViewer(`main:1:${T0}`, null)
assert.strictEqual(outside.roster.visible, false)
assert.deepStrictEqual(outside.roster.members, [])
assert.strictEqual(outside.clan.memberCount, 2, 'the count is public (D58)')
const inside = await clans.getForViewer(`main:1:${T0}`, { userId: 1 })
assert.strictEqual(inside.roster.visible, true)
assert.strictEqual(inside.roster.members.length, 2)
for (const m of inside.roster.members) {
assert.ok(!('steamId' in m) && !('userId' in m), 'no identifier leaves on a roster row')
}
assert.strictEqual(await clans.getForViewer('main:99:1', null), null)
} finally {
restore()
}
})

View File

@@ -116,6 +116,19 @@ test('the manifest declares no extension slot it does not fill', () => {
assert.deepStrictEqual([...declared].sort(), [...filled].sort())
})
test('the Team provider is registered, whole, with the page core links to (phase 9)', () => {
const { api } = register()
const provider = api.record.teamProvider
// The three required methods, the optional fourth (D48's roster audience),
// and the fifth member, which is DATA: core substitutes `{externalId}` and
// nothing else, so the page cannot be nested under its server (D56).
for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders', 'projectRoster']) {
assert.strictEqual(typeof provider[name], 'function', `${name} must be a function`)
}
assert.strictEqual(provider.pageUrlTemplate, '/rust/clans/{externalId}')
})
test('nothing is registered that has nothing behind it yet', () => {
const { api } = register()
@@ -124,8 +137,7 @@ 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.
assert.strictEqual(api.record.teamProvider, null)
// 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)

View File

@@ -292,3 +292,28 @@ test('a player sees the name the GAME last saw, not the one they linked under',
const again = require('../model/links/links.model')
assert.equal((await again.listForUser(4))[0].name, 'Wanderer-old')
})
test('a new link and a removed one ask core to reconcile Teams (D57)', async () => {
// A clan member's website account comes from this table. Without the request,
// somebody who links today is not in their clan's Team until core's next
// scheduled sweep.
const { ctx } = withCore({ select: [[], [{ steamId: '7656', userId: 4 }]] })
const links = require('../model/links/links.model')
fleetOf({ a: linkOk('7656', 'Wanderer') })
await links.redeem({ code: 'K7M2PQ', userId: 4 })
assert.equal(ctx.teams.reconcile.calls.length, 1)
await links.unlinkAnyOwner('7656')
assert.equal(ctx.teams.reconcile.calls.length, 2)
})
test('a link that was already there asks for nothing', async () => {
const { ctx } = withCore({ select: [[{ steamId: '7656', userId: 4 }]] })
const links = require('../model/links/links.model')
fleetOf({ a: linkOk('7656', 'Wanderer') })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
assert.equal(result.already, true)
assert.equal(ctx.teams.reconcile.calls.length, 0)
})

View File

@@ -163,6 +163,28 @@ test('an update naming an unknown audience or server writes nothing at all', asy
}
})
test('the clan roster audience defaults to members, and a bad one writes nothing (D48)', async () => {
const { model, written, restore } = setup({ overrides: { main: null } })
try {
// Nothing stored: the clan's own members and staff. The presence value the
// stub answers ('staff' is not a clan rung) must not leak across keys.
assert.equal(await model.clanRosterAudience(), 'members')
assert.equal((await model.describe()).clans.roster, 'members')
const bad = await model.update({ clanRoster: 'staff' })
assert.equal(bad.ok, false)
assert.equal(bad.status, 400)
assert.deepEqual(written.settings, [])
const ok = await model.update({ clanRoster: 'signed_in' }, { id: 7 })
assert.equal(ok.ok, true)
assert.deepEqual(written.settings, [{ key: 'clans.roster.audience', value: 'signed_in', userId: 7 }])
assert.equal(ok.changed.clanRoster, 'signed_in')
} finally {
restore()
}
})
// ── The public routes ─────────────────────────────────────────────────────
/** A response double recording what a handler answered. */