feat(rust): Teams from first-party clans (phase 9, protocol 6) #12

Merged
whitlocktech merged 1 commits from feat/phase-9-clans into edge 2026-09-23 10:33:14 +00:00
33 changed files with 3456 additions and 32 deletions

View File

@@ -40,11 +40,15 @@ rows here; the website core never learns there is more than one.
| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) |
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed |
| Public | `GET …/servers/:id/wipes` and `…/online` |
| Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) |
| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
| Pages | `/rust` — the server list, and the module's landing page |
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes |
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes, clans |
| Pages | `/rust/clans/:externalId` — one clan, with core's Team notify, activity and forum in three module slots |
| Teams | The deployment's Team provider: a first-party Rust clan is a Team |
| Slot | `site.footer.status` — a live server/player count in core's footer |
**Nothing names who is online by default.** The Online list, every feed item that says a named
@@ -62,7 +66,14 @@ Seven tables: `rust_servers` (configuration), `rust_server_state` and `rust_pres
state), `rust_wipes`, `rust_players`, `rust_player_wipe_stats` and `rust_gather_totals` (the record a
wipe does not erase), plus the bounded `rust_events` window and the `rust_ingest_cursor`.
The rest of the module — identity, site-owned permissions, Teams from Rust's clans, notifications,
**Teams come from Rust's own clans**, not from the uMod Clans plugin, which is optional and whose
clans never become Teams. A clan's roster reaches its own members and staff unless an operator
widens it in Admin → Rust visibility; its name, colour, score and count are public. The game lists
at most 100 clans per server, and a server at that ceiling answers core partially, so core never
removes a Team on its word. Core holds one Team provider per site, which is one reason **a site runs
one module**: core's installer refuses a second.
The rest of the module — notifications,
events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it
has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are
both surfaces an operator can configure and then wait on, which is worse than an absent one.

View File

@@ -47,6 +47,21 @@ export const servers = {
wipes: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/wipes`),
online: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/online`),
// Phase 9. The clan list is public (D58): name, colour, score and member count
// name nobody. `board` says whether the list can be trusted right now.
clans: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/clans`),
}
// One clan. Its roster comes back only for a viewer inside the operator's roster
// audience (D48) — clan members and staff by default — and `roster.visible`
// says which answer this was, so a page can explain an empty roster rather than
// imply an empty clan.
//
// The id carries colons (`<server>:<clan>:<created>`). They are legal in a path
// segment, and encoded anyway so that a server slug is never read as structure.
export const clans = {
get: (externalId) => req(`/public/rust/clans/${encodeURIComponent(externalId)}`),
}
/**
@@ -231,6 +246,7 @@ export { BASE, query }
export default {
servers,
clans,
playerServers,
playerLinks,
playerPermissions,

View File

@@ -0,0 +1,118 @@
// ── The clans on one server ───────────────────────────────────────────────
//
// Rust's OWN clans (R5), best score first. Public at every setting (D58): a
// clan's name, colour, score and member count name nobody. Who is IN a clan is
// the roster, and that lives on the clan's own page behind the operator's
// roster audience (D48).
//
// **The list is only as good as the board it came from**, and the answer says
// how good that is. Three cases would all look like an empty list if rendered
// bare, and they are three different sentences:
//
// • the bridge cannot read this server's clans at all (an older plugin, or a
// Nexus server whose clans live elsewhere) — "unavailable";
// • the game's clan system is switched off — "this server has no clans";
// • it can, and there are none — "nobody has founded one yet".
//
// And a board at the game's 100-clan ceiling (D55) says there may be more.
import { Link } from 'react-router-dom'
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
import { count } from '../lib/format.js'
import api from '../api.js'
export default function Clans({ serverId }) {
const { data, loading, error } = useAsync(() => api.servers.clans(serverId), [serverId])
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
const clans = (data && data.clans) || []
const board = (data && data.board) || {}
if (clans.length === 0) {
if (!board.supported) {
return (
<Empty
title="Clans are unavailable for this server"
message={board.reason ? `${capitalise(board.reason)}.` : 'The bridge has not reported this servers clans yet.'}
/>
)
}
if (board.enabled === false) {
return <Empty title="This server has no clans" message="Its operator has switched the games clan system off." />
}
return <Empty title="No clans yet" message="Nobody on this server has founded a clan." />
}
return (
<>
{board.truncated && (
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
The game lists at most 100 clans, by score, so there may be more on this server than are shown here.
</p>
)}
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{clans.map((clan, index) => (
<li
key={clan.externalId}
style={{
display: 'flex',
alignItems: 'baseline',
gap: 12,
padding: '10px 0',
borderBottom: '1px solid var(--line-soft, var(--line))',
}}
>
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', minWidth: 22, textAlign: 'right' }}>
{index + 1}
</span>
<Swatch color={clan.color} />
<Link to={clanPath(clan.externalId)} style={{ fontWeight: 600, flex: 1, minWidth: 0 }}>
{clan.name}
</Link>
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', whiteSpace: 'nowrap' }}>
{count(clan.memberCount)} {clan.memberCount === 1 ? 'member' : 'members'}
</span>
<span className="sans" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap', minWidth: 70, textAlign: 'right' }}>
{count(clan.score)} pts
</span>
</li>
))}
</ul>
</>
)
}
/** Where a clan's page is: the same template the Team provider hands core. */
export function clanPath(externalId) {
return `/rust/clans/${encodeURIComponent(externalId)}`
}
/**
* A clan's colour, as a small square. The server has already checked it is a
* `#rrggbb` — it ends up in a style — and a clan with no colour gets an outline
* rather than a guess.
*/
export function Swatch({ color, size = 12 }) {
return (
<span
aria-hidden="true"
style={{
display: 'inline-block',
flex: 'none',
width: size,
height: size,
borderRadius: 3,
background: color || 'transparent',
border: color ? 'none' : '1px solid var(--line)',
alignSelf: 'center',
}}
/>
)
}
function capitalise(text) {
return text ? text.charAt(0).toUpperCase() + text.slice(1) : text
}

View File

@@ -20,6 +20,7 @@ import { registry, coreApiVersion } from './core.js'
import Servers from './routes/public/Servers.jsx'
import ServerDetail from './routes/public/ServerDetail.jsx'
import Clan from './routes/public/Clan.jsx'
import Account from './routes/player/Account.jsx'
import Permissions from './routes/admin/Permissions.jsx'
import ModConfig from './routes/admin/ModConfig.jsx'
@@ -82,6 +83,10 @@ registry.registerRoutes(ID, {
public: [
{ path: '', element: <Servers /> },
{ path: 'servers/:id', element: <ServerDetail /> },
// Phase 9 (D56). Not nested under its server: core links here from Team
// notification email through `pageUrlTemplate`, which substitutes
// `{externalId}` and nothing else — and the server is inside that id.
{ path: 'clans/:externalId', element: <Clan /> },
],
player: [{ path: '', element: <Account /> }],
admin: [
@@ -175,6 +180,22 @@ registry.registerExtension(ID, 'site.footer.status', FooterStatus)
// no linked Steam account, which is most of them.
registry.registerExtension(ID, 'admin.users.detail', UserRustSections)
// ── Inverted slots: core's Team contributions on OUR clan page ─────────────
//
// §3.7a. A clan is a Team (R5), and core renders no Team page because it does
// not own the word "clan". So the page is `routes/public/Clan.jsx` and core
// contributes the three things only it can render — into places this module
// names, in this module's vocabulary. Core offers a CONTRIBUTION; it never names
// a slot, which is what lets a second game use the same contract as module-uo.
//
// One slot per PLACE (D56): a slot holds one component, and a collapsed slot
// would hand core the decision about where each part sits on a page it does not
// own. Asking for a contribution core does not offer throws here, at
// registration — a typo fails loudly rather than rendering nothing for ever.
registry.declareModuleSlot(ID, 'rust.clan.header', { core: 'team.notify' })
registry.declareModuleSlot(ID, 'rust.clan.detail', { core: 'team.activity' })
registry.declareModuleSlot(ID, 'rust.clan.forum', { core: 'team.forum' })
// `module.json`'s `coreApi` range was checked by the loader before this file was
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
// between the core that validated the manifest and the core that published this

View File

@@ -12,6 +12,13 @@
// The page says what "who is online" covers, because it is wider than the tab
// of the same name: the killfeed, chat and joins in the feed, and the
// leaderboard's "last seen" all name a player who was on at a given moment.
//
// Phase 9 adds a second setting beside it: who may see a CLAN ROSTER (D48). It
// defaults to the clan's own members and staff, and widening it widens online
// status too, because a roster row carries it — the page says so. The same card
// lists each server's clan board: a server whose clans cannot be read, one at
// the game's 100-clan ceiling (D55), and one running the uMod Clans plugin,
// whose clans are a separate system and never Teams (D47).
import { useCallback, useEffect, useState } from 'react'
@@ -26,6 +33,18 @@ const LABEL = {
public: 'Everyone',
}
const CLAN_LABEL = {
members: 'The clans members and staff',
signed_in: 'Signed-in members',
public: 'Everyone',
}
const CLAN_DESCRIBE = {
members: 'Players whose linked Rust account is in the clan, plus admins and moderators. The default.',
signed_in: 'Anybody with an account on this site.',
public: 'Anybody at all, signed in or not.',
}
const DESCRIBE = {
staff: 'Admins and moderators. The default.',
signed_in: 'Anybody with an account on this site.',
@@ -66,6 +85,7 @@ export default function Visibility() {
const { data, error: loadError } = useAsync(() => api.adminVisibility.read(), [reloads])
const [fleet, setFleet] = useState('staff')
const [clanRoster, setClanRoster] = useState('members')
const [servers, setServers] = useState({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
@@ -76,6 +96,7 @@ export default function Visibility() {
// the site's word rather than what this page sent.
const load = useCallback((state) => {
setFleet(state.presence.fleet)
setClanRoster((state.clans && state.clans.roster) || 'members')
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
}, [])
@@ -91,7 +112,9 @@ export default function Visibility() {
const dirtyFleet = fleet !== data.presence.fleet
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
const dirty = dirtyFleet || dirtyServers.length > 0
const clans = data.clans || { audiences: [], roster: 'members', servers: [] }
const dirtyClans = clanRoster !== clans.roster
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans
const effective = (id) => servers[id] || fleet
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
@@ -104,6 +127,7 @@ export default function Visibility() {
try {
const body = {}
if (dirtyFleet) body.fleet = fleet
if (dirtyClans) body.clanRoster = clanRoster
if (dirtyServers.length) {
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
}
@@ -175,6 +199,32 @@ export default function Visibility() {
</p>
)}
<Card title="Clan rosters" subtitle="who is in each clan, on every server">
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.86rem' }}>
<select
value={clanRoster}
onChange={(e) => setClanRoster(e.target.value)}
style={selectStyle}
aria-label="Who may see a clan roster"
>
{clans.audiences.map((a) => (
<option key={a} value={a}>{CLAN_LABEL[a] || a}</option>
))}
</select>
<span className="dim" style={{ fontSize: '0.78rem' }}>{CLAN_DESCRIBE[clanRoster]}</span>
</div>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '10px 0 0' }}>
Each clans name, colour, score and member count are always public.
</p>
{clanRoster !== 'members' && (
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.8rem', margin: '8px 0 0' }}>
A roster also shows which members are online right now, so this shows who is on to{' '}
{clanRoster === 'public' ? 'everyone' : 'every signed-in member'} as well.
</p>
)}
<ClanBoards servers={clans.servers || []} />
</Card>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button type="submit" className="btn" disabled={busy || !dirty}>
{busy ? 'Saving…' : 'Save'}
@@ -186,6 +236,38 @@ export default function Visibility() {
)
}
/**
* What each server's clan board says about itself. Only the servers with
* something to report are listed: a board that is current, complete and read
* normally is the case that needs no sentence.
*/
function ClanBoards({ servers }) {
const notes = []
for (const s of servers) {
if (s.umodClans) {
notes.push([s, 'is running the uMod Clans plugin. Its clans are a separate system from the games own, and only the games clans appear on this site.'])
}
if (!s.supported) {
notes.push([s, s.reason ? `cannot report its clans: ${s.reason}.` : 'has not reported its clans yet.'])
} else if (s.truncated) {
notes.push([s, 'is at the games limit of 100 listed clans, so clans beyond the top 100 by score are not shown, and a disbanded clan is not removed until it drops below.'])
} else if (!s.fresh) {
notes.push([s, 'has not reported its clans recently, so they are shown as last reported.'])
}
}
if (!notes.length) return null
return (
<ul className="sans" style={{ margin: '12px 0 0', paddingLeft: 18, fontSize: '0.8rem' }}>
{notes.map(([s, text], i) => (
// eslint-disable-next-line react/no-array-index-key
<li key={`${s.id}-${i}`} style={{ margin: '4px 0' }}>
<strong style={{ color: 'var(--head)' }}>{s.name}</strong> {text}
</li>
))}
</ul>
)
}
const selectStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',

View File

@@ -0,0 +1,161 @@
// ── One clan ──────────────────────────────────────────────────────────────
//
// A first-party Rust clan is a Team (R5), and this is its page. Core owns the
// Team — the reconciler, the access rules, the activity feed, the forum — but
// not the word "clan", so it publishes no Team page of its own (MODULE_API.md
// §3.7a). The page is this module's, and the three parts only core can render
// are contributed into places this page names:
//
// rust.clan.header ← core's `team.notify` (above the roster: an action ON the page)
// rust.clan.detail ← core's `team.activity` (the members-only feed, D49)
// rust.clan.forum ← core's `team.forum`
//
// One slot per PLACE, as module-uo does, so core never decides the layout of a
// page it does not own. **Every slot may be empty** — a core without Teams, a
// deployment with the forum switched off, a clan whose Team core has not created
// yet — and the page has to read correctly anyway. That is the phase criterion,
// and it is why nothing here says "see below" about something core may not put
// below.
//
// The roster comes from this module's own board, through the same function core
// asks when it projects a roster (D48), so the two cannot disagree about who may
// look. Below the audience the clan is still described — its name, score and
// count are public (D58) — and the roster says who may see it instead.
import { useParams, Link } from 'react-router-dom'
import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
import Empty from '../../components/Empty.jsx'
import { Swatch } from '../../components/Clans.jsx'
import { count, day } from '../../lib/format.js'
import api from '../../api.js'
const ID = 'rust'
export default function Clan() {
const { externalId } = useParams()
const { data, loading, error } = useAsync(() => api.clans.get(externalId), [externalId])
if (loading) {
return (
<PublicLayout shell="mid">
<Loading />
</PublicLayout>
)
}
// A mistyped or out-of-date address is not an outage, and must not read as
// one — the same rule the server page learned in phase 4.
if (error || !data || !data.clan) {
const missing = !error || error.status === 404
return (
<PublicLayout shell="mid">
<PageHeader
title={missing ? 'No such clan' : 'That clan could not be loaded'}
lead={
missing
? 'This address does not name a clan this site knows about.'
: 'The site could not read this clan just now. It is worth trying again.'
}
/>
{!missing && <ErrorState error={error} />}
<p className="sans" style={{ marginTop: 20 }}>
<Link to="/rust">Back to the server list</Link>
</p>
</PublicLayout>
)
}
const { clan, roster } = data
const serverLink = `/rust/servers/${encodeURIComponent(clan.serverId)}?tab=clans`
return (
<PublicLayout shell="mid">
<p className="sans" style={{ margin: '0 0 12px' }}>
<Link to={serverLink}> Clans on {clan.serverName || clan.serverId}</Link>
</p>
<PageHeader eyebrow="Rust clan" title={clan.name} lead={describe(clan)} />
{clan.gone && (
<p className="sans" style={{ color: 'var(--dim)', marginTop: 0 }}>
This clan has been disbanded, or has left its servers clan list. What is shown is the last the site heard.
</p>
)}
<Slot name="rust.clan.header" externalId={clan.externalId} moduleId={ID} />
<h2 className="sans" style={{ fontSize: '1rem', margin: '24px 0 8px' }}>Members</h2>
<Roster roster={roster} memberCount={clan.memberCount} gone={clan.gone} />
<Slot name="rust.clan.detail" externalId={clan.externalId} moduleId={ID} />
<Slot name="rust.clan.forum" externalId={clan.externalId} moduleId={ID} />
</PublicLayout>
)
}
function describe(clan) {
const parts = [
<Swatch key="c" color={clan.color} />,
` ${count(clan.memberCount)} ${clan.memberCount === 1 ? 'member' : 'members'}`,
clan.maxMembers ? ` of ${count(clan.maxMembers)}` : '',
` · ${count(clan.score)} points`,
clan.founded ? ` · founded ${day(clan.founded)}` : '',
]
return <span>{parts}</span>
}
function Roster({ roster, memberCount, gone }) {
if (!roster || !roster.visible) {
return <Empty title={`${count(memberCount)} ${memberCount === 1 ? 'member' : 'members'}`} message={withheld(roster && roster.audience)} />
}
if (roster.members.length === 0) {
return gone
? <Empty title="No roster" message="A clan that has left its servers list has no members to show." />
: <Empty title="No roster yet" message="The server has not sent this clans members yet." />
}
return (
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{roster.members.map((m, i) => (
<li
// The roster carries no identifier on purpose (a Steam id and a site
// account are withheld from every public roster), so the row's place is
// its key. The list is re-rendered whole, never reordered in place.
// eslint-disable-next-line react/no-array-index-key
key={i}
style={{
display: 'flex',
alignItems: 'baseline',
gap: 12,
padding: '8px 0',
borderBottom: '1px solid var(--line-soft, var(--line))',
}}
>
<strong style={{ color: 'var(--ink)', flex: 1, minWidth: 0 }}>
{m.name || 'Unknown player'}
{m.leader && (
<span className="sans" style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.72rem' }}>Leader</span>
)}
</strong>
{m.role && !m.leader && (
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>{m.role}</span>
)}
{/* Inside the roster audience by construction (D48): a viewer who may
not see the roster sees no row to hang this on. */}
<span className="sans" style={{ color: m.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
{m.online ? 'online' : ''}
</span>
</li>
))}
</ul>
)
}
/** Why the roster was withheld, in words a visitor can act on. */
function withheld(audience) {
if (audience === 'signed_in') return 'Sign in to see who is in this clan.'
if (audience === 'public') return 'This site is not showing clan rosters right now.'
return 'Only this clans own members, with a linked Rust account, and this sites staff can see who is in it.'
}

View File

@@ -23,6 +23,7 @@
import { useSearchParams, useParams, Link } from 'react-router-dom'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import Clans from '../../components/Clans.jsx'
import Feed from '../../components/Feed.jsx'
import Leaderboard from '../../components/Leaderboard.jsx'
import Online from '../../components/Online.jsx'
@@ -37,6 +38,8 @@ const TABS = [
{ id: 'leaderboard', label: 'Leaderboard' },
{ id: 'online', label: 'Online' },
{ id: 'wipes', label: 'Wipes' },
// Phase 9. The list is public (D58); each clan's roster is on its own page.
{ id: 'clans', label: 'Clans' },
]
export default function ServerDetail() {
@@ -152,6 +155,8 @@ export default function ServerDetail() {
{tab === 'online' && <Online serverId={server.id} online={server.online} />}
{tab === 'clans' && <Clans serverId={server.id} />}
{tab === 'wipes' && (
<Wipes
serverId={server.id}

View File

@@ -257,6 +257,23 @@ it('every declared slot names a core contribution core actually offers', () => {
}
})
it('the clan page gets all three of cores Team contributions, one per place (phase 9, D56)', () => {
// Core contributes three things to a Team page it does not own. Each has its
// own place on the clan page, so no contribution is decided by another's
// position — and a slot missing here is a clan page with no feed, no forum or
// no notification switch, with nothing logged anywhere.
const byName = Object.fromEntries(registered.declaredSlots.map((s) => [s.name, s.wants]))
assert.deepEqual(byName, {
'rust.clan.header': 'team.notify',
'rust.clan.detail': 'team.activity',
'rust.clan.forum': 'team.forum',
})
// And the page is at the address the Team provider hands core.
const paths = registered.routes.public.map((r) => r.path)
assert.ok(paths.includes('rust/clans/:externalId'), paths.join(', '))
})
it('registers under exactly one module id, matching the manifest', () => {
const owners = new Set([
...Object.values(registered.routes).flat().map((r) => r.moduleId),

View File

@@ -96,6 +96,11 @@
"path": "/api/v1/player/rust/servers",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/clans/:externalId",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers",
@@ -106,6 +111,11 @@
"path": "/api/v1/public/rust/servers/:id",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/clans",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/events",

View File

@@ -80,6 +80,15 @@ const STAFF_KINDS = Object.freeze([
// changed it by hand — a question about a person's standing and about an
// operator's own console, neither of which is a public page's business.
'perm.drift',
// Protocol 6. Clan membership, which the org lead made members-only (D49):
// who joined which clan, and who threw whom out, is the clan's business. It
// reaches a clan's own members through core's Team feed, where core resolves
// who is a member, and it reaches the server's public feed not at all.
'clan.created',
'clan.disbanded',
'clan.member.added',
'clan.member.left',
'clan.member.kicked',
])
/**
@@ -109,7 +118,7 @@ const PRESENCE_KINDS = Object.freeze([
'player.tally',
])
/** Every kind protocol 3 defines. */
/** Every kind the protocol defines, through protocol 6. */
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
const PUBLIC = new Set(PUBLIC_KINDS)

View File

@@ -149,6 +149,24 @@ module.exports = {
// would be worse, since a module has more than one thing it could reconcile.
reconcileEvents: () => need().events.reconcile(),
// Teams (MODULE_API.md §2.3, 1.6.0) — the push half of the provider this
// module registers (`model/clans/teamProvider.js`). Three calls, all
// fire-and-forget, and core's contract is that none of them can make this
// module's call site slow or turn a background failure into its error:
//
// publish(event) a membership or leadership change, as it happened
// reconcile({reason}) "the set may have changed, come and ask" — debounced
// pushActivity(items) the per-Team feed, idempotent on each item's dedupeKey
//
// Correctness comes from reconciliation either way; `publish` only makes a
// change visible sooner. Wrapped as calls, like `emit`, so a file that takes
// `core.teams` at require time still resolves `ctx` when it is used.
teams: {
publish: (event) => need().teams.publish(event),
reconcile: (options) => need().teams.reconcile(options),
pushActivity: (items) => need().teams.activity.push(items),
},
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
// only correct way to find a file you shipped, because the working directory is
// core's and the module's location is the loader's business.

View File

@@ -20,6 +20,9 @@
-- registrant owned what.
-- Phase 7b.
DROP TABLE IF EXISTS rust_clan_boards;
DROP TABLE IF EXISTS rust_clan_members;
DROP TABLE IF EXISTS rust_clans;
DROP TABLE IF EXISTS rust_settings;
DROP TABLE IF EXISTS rust_config_writes;

View File

@@ -701,3 +701,94 @@ CREATE TABLE IF NOT EXISTS rust_settings (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS presence_audience VARCHAR(16) NULL;
-- ── Clans (phase 9, protocol 6) ───────────────────────────────────────────
--
-- Rust's FIRST-PARTY clans, which this module answers core's Team questions
-- from (R5, PLAN.md §24). Three tables, and the split is the same one the rest
-- of this file makes: what a board said (`rust_clans`, `rust_clan_members`),
-- and what this module knows about the board itself (`rust_clan_boards`).
--
-- **`external_id` is the Team's identity, and it is NOT the game's clan id.**
-- It is `<serverId>:<clanId>:<createdMs>` (D52). The game keeps clans in
-- `clans.<version>.db` with the version hard-coded, so a game update that bumps
-- it starts a fresh file whose ids restart at 1. Keyed on the id alone, the new
-- clan #1 would inherit the old clan #1's Team — its forum, its members-only
-- history — and core would read the swap as a rename.
--
-- **A clan that leaves the board is marked gone, not deleted.** `gone_at` is set
-- only when a board that is COMPLETE for its server no longer carries it: a
-- board truncated at the game's 100-clan ceiling (D55) proves nothing about a
-- clan it does not list. A gone clan is not offered to core, which is what lets
-- core archive its Team.
CREATE TABLE IF NOT EXISTS rust_clans (
external_id VARCHAR(160) NOT NULL PRIMARY KEY,
server_id VARCHAR(64) NOT NULL,
clan_id BIGINT NOT NULL,
created_ms BIGINT NOT NULL,
name VARCHAR(191) NOT NULL,
-- `#rrggbb`, as the plugin spells it. Stored as sent rather than parsed, and
-- re-checked on the way out (`model/clans`), because it ends up in a style.
color VARCHAR(16) NULL,
score BIGINT NOT NULL DEFAULT 0,
member_count INT UNSIGNED NOT NULL DEFAULT 0,
max_members INT UNSIGNED NULL,
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
gone_at DATETIME NULL,
CONSTRAINT fk_rust_clans_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE,
KEY idx_rust_clans_server (server_id, gone_at),
KEY idx_rust_clans_game_id (server_id, clan_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One row per member per clan, replaced whole from each board.
--
-- `rank` is the role's rank, and **rank 1 is leader** — the game's own rule, and
-- several members may hold it. It is NULL when the member's role id matched no
-- role on the board: "not known" must never be read as "leads this clan".
--
-- There is deliberately no `last_seen`. The game has one; the plugin does not
-- send it, because when somebody was last on is presence (PLAN.md §23).
CREATE TABLE IF NOT EXISTS rust_clan_members (
external_id VARCHAR(160) NOT NULL,
steam_id VARCHAR(32) NOT NULL,
name VARCHAR(191) NULL,
role_rank INT NULL,
role_name VARCHAR(64) NULL,
joined_ms BIGINT NULL,
PRIMARY KEY (external_id, steam_id),
CONSTRAINT fk_rust_clan_members_clan
FOREIGN KEY (external_id) REFERENCES rust_clans (external_id) ON DELETE CASCADE,
KEY idx_rust_clan_members_steam (steam_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- What this module knows about each server's clan board, as opposed to what the
-- board said.
--
-- **Freshness is judged by THIS side's clock.** `board_t` is the plugin's own
-- timestamp on the board; `seen_at` is when this module first saw that value.
-- A board whose `t` stops advancing is a game that stopped talking, and the
-- age of `seen_at` is how long ago that was — comparing `board_t` to the
-- website's clock instead would let a game host whose clock runs ahead make a
-- stale board look current for as long as the skew lasts.
--
-- `umod_clans` is whether the optional uMod Clans plugin is loaded on that
-- server (D47): its clans are a separate system and never Teams, and the admin
-- page says so.
CREATE TABLE IF NOT EXISTS rust_clan_boards (
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
board_t BIGINT NULL,
seen_at DATETIME NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
supported TINYINT(1) NOT NULL DEFAULT 0,
truncated TINYINT(1) NOT NULL DEFAULT 0,
backend VARCHAR(64) NULL,
reason VARCHAR(255) NULL,
umod_clans TINYINT(1) NOT NULL DEFAULT 0,
clan_count INT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_clan_boards_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -51,6 +51,7 @@ module.exports = function register(ctx, api) {
const playerRust = require('./router/player/rust.router')
const adminRust = require('./router/admin/rust.router')
const usersRust = require('./router/admin/usersRust.router')
const teamProvider = require('./model/clans/teamProvider')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -93,6 +94,17 @@ module.exports = function register(ctx, api) {
// slot and fails the load outright when named there.
api.registerExtension('admin.users.detail', usersRust)
// Teams (R5, PLAN.md §24). A first-party Rust clan is a Team, and this module
// becomes the deployment's one authoritative source of them. Core asks; the
// provider answers from the clan boards (`model/clans`), and refuses rather
// than guessing whenever no board is current.
//
// **One provider per deployment**, so a site running module-uo as well cannot
// have both — the second registration is a collision core reports against the
// module that made it. That is core's rule and a real constraint on a mixed
// UO + Rust site; it is recorded in §24 rather than worked around here.
api.registerTeamProvider(teamProvider)
// 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.
@@ -105,8 +117,8 @@ module.exports = function register(ctx, api) {
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
// Everything else this module will register — the Team provider, the event
// triggers and audiences, the engagement seeds, the four event catalogues, the
// 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
// with nothing behind it is worse than a missing one: a declared trigger
@@ -117,5 +129,6 @@ module.exports = function register(ctx, api) {
version: require('../module.json').version,
routes: 'public:/rust player:/rust admin:/rust',
extensions: 'admin.users.detail',
teams: 'first-party clans',
})
}

View File

@@ -33,6 +33,7 @@
const core = require('./core')
const clans = require('./model/clans/clans.model')
const db = require('./model/events/events.db')
const links = require('./model/links/links.model')
const permissionsDb = require('./model/permissions/permissions.db')
@@ -191,6 +192,24 @@ async function apply(serverId, item) {
await permissionsDb.markDirty(serverId)
break
// ── Protocol 6: first-party clans ──────────────────────────────────────
//
// Each one is told to core as it happens (`ctx.teams.publish`) and written
// to the clan's Team feed as a members-only line (D49). Neither is the
// record: the `clans` board the plugin re-sends a few seconds later is what
// the store is rebuilt from, so an event this module never saw costs a
// feed line and nothing else.
//
// No `touchPlayer` here, on purpose: it moves `last_seen`, and a kick is
// done TO somebody who may be offline. `model/clans` notes names without it.
case 'clan.created':
case 'clan.disbanded':
case 'clan.member.added':
case 'clan.member.left':
case 'clan.member.kicked':
await clans.applyEvent(serverId, frame)
break
default:
// Stored, not counted. Moderation frames, the server lifecycle, and
// anything a newer protocol sends that this build does not understand.
@@ -284,6 +303,22 @@ async function applyBoards(serverId, boards) {
if (presence && Array.isArray(presence.players)) {
await db.replacePresence(serverId, presence.players)
}
// Clans only once the game has spoken at all. A sidecar that has never heard
// from its plugin holds no boards, and recording "no clan board" then would
// blame the plugin's protocol for a game server that is simply not up. Left
// alone, the stored board ages past fresh on its own, which is the true answer.
if (boards && boards['server.hello']) {
// Fenced: clans are the one board here that core's Teams depend on, and a
// failure applying them must cost the clans rather than the presence board
// above or the server state the caller writes next.
try {
await clans.applyBoard(serverId, boards.clans)
await clans.reofferActivity(serverId)
} catch (err) {
log.warn('could not apply the clan board', { server: serverId, error: err.message })
}
}
}
module.exports = { apply, applyBoards, ingestServer, BATCH, MAX_BATCHES_PER_TICK }

View File

@@ -0,0 +1,298 @@
// ── SQL for first-party clans ─────────────────────────────────────────────
//
// Three tables (see `schema.sql`): the clans a board carried, their members, and
// what this module knows about each server's board. Raw parameterised SQL, as
// everywhere in this module; the model decides what any of it means.
const core = require('../../core')
const CLANS = 'rust_clans'
const MEMBERS = 'rust_clan_members'
const BOARDS = 'rust_clan_boards'
const LINKS = 'rust_account_links'
const PLAYERS = 'rust_players'
const SERVERS = 'rust_servers'
// ── Boards ─────────────────────────────────────────────────────────────────
/** One server's board record, or null when it has never sent one. */
async function getBoard(serverId) {
const rows = await core.query(
`SELECT server_id AS serverId, board_t AS boardT, seen_at AS seenAt, enabled, supported,
truncated, backend, reason, umod_clans AS umodClans, clan_count AS clanCount
FROM ${BOARDS} WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/** Every configured server beside its board record, which may be absent. */
async function listBoards() {
return core.query(
`SELECT s.id AS serverId, s.name AS serverName, s.enabled AS serverEnabled,
b.board_t AS boardT, b.seen_at AS seenAt, b.enabled, b.supported, b.truncated,
b.backend, b.reason, b.umod_clans AS umodClans, b.clan_count AS clanCount
FROM ${SERVERS} s
LEFT JOIN ${BOARDS} b ON b.server_id = s.id
ORDER BY s.sort_order ASC, s.id ASC`,
)
}
/**
* Records what a board said about itself.
*
* `seenAt` is passed only when the board's `t` ADVANCED, and is then the
* website's own now; otherwise the stored one is kept. That is the whole of the
* freshness rule (see `schema.sql`), so it is done in SQL rather than trusted to
* every caller to read-then-write.
*/
async function putBoard({ serverId, boardT, advanced, enabled, supported, truncated, backend, reason, umodClans, clanCount }) {
await core.query(
`INSERT INTO ${BOARDS}
(server_id, board_t, seen_at, enabled, supported, truncated, backend, reason, umod_clans, clan_count, updated_at)
VALUES (?, ?, ${advanced ? 'CURRENT_TIMESTAMP' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
board_t = VALUES(board_t),
seen_at = ${advanced ? 'CURRENT_TIMESTAMP' : 'seen_at'},
enabled = VALUES(enabled), supported = VALUES(supported), truncated = VALUES(truncated),
backend = VALUES(backend), reason = VALUES(reason), umod_clans = VALUES(umod_clans),
clan_count = VALUES(clan_count), updated_at = CURRENT_TIMESTAMP`,
[
serverId,
boardT,
enabled ? 1 : 0,
supported ? 1 : 0,
truncated ? 1 : 0,
backend || null,
reason ? String(reason).slice(0, 255) : null,
umodClans ? 1 : 0,
clanCount || 0,
],
)
}
// ── Clans ──────────────────────────────────────────────────────────────────
/** Every clan this module holds for one server, gone or not. */
async function listClansForServer(serverId) {
return core.query(
`SELECT external_id AS externalId, clan_id AS clanId, created_ms AS createdMs, name,
member_count AS memberCount, gone_at AS goneAt
FROM ${CLANS} WHERE server_id = ?`,
[serverId],
)
}
/**
* Every member of one server's current clans, as the board last stated them,
* for diffing the next board against. The name is the BOARD's, not the player
* table's, because it is compared with the board.
*/
async function listMembersForServer(serverId) {
return core.query(
`SELECT m.external_id AS externalId, m.steam_id AS steamId, m.role_rank AS rank,
m.role_name AS role, m.name
FROM ${MEMBERS} m
JOIN ${CLANS} c ON c.external_id = m.external_id
WHERE c.server_id = ? AND c.gone_at IS NULL`,
[serverId],
)
}
async function upsertClan({ externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers }) {
await core.query(
`INSERT INTO ${CLANS}
(external_id, server_id, clan_id, created_ms, name, color, score, member_count, max_members,
first_seen, updated_at, gone_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)
ON DUPLICATE KEY UPDATE
name = VALUES(name), color = VALUES(color), score = VALUES(score),
member_count = VALUES(member_count), max_members = VALUES(max_members),
updated_at = CURRENT_TIMESTAMP, gone_at = NULL`,
[externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers],
)
}
/**
* Replaces one clan's members.
*
* Delete then insert, not wrapped in a transaction — the same trade the presence
* board makes (`events.db.replacePresence`): a fraction of a second in which a
* roster read might come back short, against holding a lock on a table that core's
* reconciler and two public routes read.
*/
async function replaceMembers(externalId, members) {
await core.query(`DELETE FROM ${MEMBERS} WHERE external_id = ?`, [externalId])
for (const m of members) {
// eslint-disable-next-line no-await-in-loop
await core.query(
`INSERT INTO ${MEMBERS} (external_id, steam_id, name, role_rank, role_name, joined_ms)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), role_rank = VALUES(role_rank),
role_name = VALUES(role_name), joined_ms = VALUES(joined_ms)`,
[externalId, m.steamId, m.name, m.rank, m.role, m.joinedMs],
)
}
}
/** Marks clans gone. Their members are removed with them; a gone clan has no roster. */
async function markGone(externalIds) {
if (!externalIds.length) return
const marks = externalIds.map(() => '?').join(', ')
await core.query(
`UPDATE ${CLANS} SET gone_at = CURRENT_TIMESTAMP WHERE external_id IN (${marks}) AND gone_at IS NULL`,
externalIds,
)
await core.query(`DELETE FROM ${MEMBERS} WHERE external_id IN (${marks})`, externalIds)
}
/** One clan by its Team identity, with its server's name, or null. */
async function findClan(externalId) {
const rows = await core.query(
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
c.clan_id AS clanId, c.created_ms AS createdMs, c.name, c.color, c.score,
c.member_count AS memberCount, c.max_members AS maxMembers,
c.first_seen AS firstSeen, c.updated_at AS updatedAt, c.gone_at AS goneAt
FROM ${CLANS} c
JOIN ${SERVERS} s ON s.id = c.server_id
WHERE c.external_id = ?`,
[externalId],
)
return rows[0] || null
}
/**
* The newest clan this module holds under a game id on one server, or null.
*
* The fallback for the one event that can arrive without a creation time
* (`clan.member.added`, when the plugin could not read the clan back). Newest,
* because an id that the game has re-used belongs to the clan that re-used it.
*/
async function findByGameId(serverId, clanId) {
const rows = await core.query(
`SELECT external_id AS externalId, name
FROM ${CLANS} WHERE server_id = ? AND clan_id = ?
ORDER BY created_ms DESC LIMIT 1`,
[serverId, clanId],
)
return rows[0] || null
}
/** Every clan still on a board, for core's `getTeams`. */
async function listActiveClans() {
return core.query(
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
c.name, c.color, c.score, c.member_count AS memberCount
FROM ${CLANS} c
JOIN ${SERVERS} s ON s.id = c.server_id
WHERE c.gone_at IS NULL
ORDER BY c.server_id ASC, c.score DESC, c.name ASC`,
)
}
/** One server's clans still on its board, for the public Clans tab. Best first. */
async function listPublicForServer(serverId) {
return core.query(
`SELECT external_id AS externalId, name, color, score, member_count AS memberCount,
max_members AS maxMembers
FROM ${CLANS}
WHERE server_id = ? AND gone_at IS NULL
ORDER BY score DESC, name ASC`,
[serverId],
)
}
/**
* One clan's roster, with the website account behind each member when there is
* one and whether they are on the clan's server right now.
*
* Three joins, all of this module's own tables: the link (a Steam id to a user),
* the player table (the newest name the game has sent for them) and the presence
* board. Presence is joined on the CLAN's server — a member on another server of
* the fleet is not online here.
*/
async function listMembers(externalId) {
return core.query(
`SELECT m.steam_id AS steamId, COALESCE(p.name, m.name) AS name, m.role_rank AS rank,
m.role_name AS role, m.joined_ms AS joinedMs, l.user_id AS userId,
(pr.steam_id IS NOT NULL) AS online
FROM ${MEMBERS} m
JOIN ${CLANS} c ON c.external_id = m.external_id
LEFT JOIN ${LINKS} l ON l.steam_id = m.steam_id
LEFT JOIN ${PLAYERS} p ON p.steam_id = m.steam_id
LEFT JOIN rust_presence pr ON pr.server_id = c.server_id AND pr.steam_id = m.steam_id
WHERE m.external_id = ?
ORDER BY (m.role_rank IS NULL) ASC, m.role_rank ASC, name ASC`,
[externalId],
)
}
/** Whether a website user holds a linked Steam account that is a member of this clan. */
async function userIsMember(externalId, userId) {
const rows = await core.query(
`SELECT 1 AS yes
FROM ${MEMBERS} m
JOIN ${LINKS} l ON l.steam_id = m.steam_id
WHERE m.external_id = ? AND l.user_id = ?
LIMIT 1`,
[externalId, userId],
)
return rows.length > 0
}
/**
* Recent clan events for one server, oldest first, for re-offering their feed
* items to core until the Team they name exists (see `model/clans`).
*/
async function recentClanEvents(serverId, sinceMs) {
return core.query(
`SELECT id, kind, t, raw
FROM rust_events
WHERE server_id = ? AND kind LIKE 'clan.%' AND t >= ?
ORDER BY t ASC, id ASC
LIMIT 200`,
[serverId, sinceMs],
)
}
/**
* Notes a player's name WITHOUT touching `last_seen`.
*
* `events.db.touchPlayer` also moves `last_seen`, which is right for a frame that
* says a player was on and wrong for a clan frame: a kick is done TO somebody who
* may be offline, and a leaderboard's "last seen" would then read as a presence
* signal for a player who never connected (PLAN.md §23).
*
* A player this module has never heard of still gets a on the new row,
* because the column is NOT NULL; what matters is that an existing row's is left
* alone, and every surface that reads it is behind the presence gate anyway.
*/
async function rememberName(steamId, name) {
if (!steamId) return
await core.query(
`INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE name = COALESCE(VALUES(name), name)`,
[steamId, name || null],
)
}
module.exports = {
getBoard,
listBoards,
putBoard,
listClansForServer,
listMembersForServer,
upsertClan,
replaceMembers,
markGone,
findClan,
findByGameId,
listActiveClans,
listPublicForServer,
listMembers,
userIsMember,
recentClanEvents,
rememberName,
}

View File

@@ -0,0 +1,573 @@
// ── First-party clans: the board, the events, and who may see a roster ────
//
// Rust's OWN clan system, which this module turns into core's Teams (R5,
// PLAN.md §24). Three jobs, one file, because all three have to agree on what a
// clan's identity is:
//
// applyBoard a `clans` snapshot → the store, plus what changed
// applyEvent a `clan.*` event → core (publish) and the Team feed
// canSeeRoster D48's audience, for core's `projectRoster` and our own page
//
// ── The identity (D52) ────────────────────────────────────────────────────
//
// `<serverId>:<clanId>:<createdMs>`. The game's clan id alone is not one: its
// database file carries a hard-coded version, so a game update that bumps it
// starts a fresh file and ids restart at 1. Keyed on the id, the new clan #1
// would inherit the old clan #1's Team, forum and history.
//
// ── What a board may conclude, and what it may not ───────────────────────
//
// A board is authoritative for the clans it CARRIES. It is authoritative about
// the clans it does NOT carry only when it is complete: a board truncated at the
// game's 100-clan ceiling (D55), or one with a row this build could not read,
// proves nothing about a clan it leaves out, and marking that clan gone would
// hand core an archive on no evidence.
const crypto = require('node:crypto')
const core = require('../../core')
const db = require('./clans.db')
const visibility = require('../visibility/visibility.model')
const log = core.logger('clans')
/**
* How long a board may go without its `t` advancing and still count as current.
*
* The plugin re-sends it every 60 seconds and this module reads it every 30, so
* three minutes tolerates two missed boards before a server stops vouching for
* its clans.
*/
const FRESH_MS = 3 * 60 * 1000
/**
* How far back a clan event's feed item is offered to core again.
*
* Core writes an item only for a Team it already holds, and a clan founded a
* moment ago is not one yet: its Team appears on core's next reconcile, which is
* debounced by up to 30 seconds. So the "founded" line — the first line of every
* clan's feed — would always be dropped if it were offered once. It is offered
* on every board refresh for this long instead, and core's dedupe key makes every
* offer after the first that lands a no-op.
*/
const REOFFER_MS = 10 * 60 * 1000
/** Team kinds core's `publish` takes, by the clan event that produces them. */
const PUBLISH = Object.freeze({
'clan.created': 'team.created',
'clan.disbanded': 'team.disbanded',
'clan.member.added': 'team.member.added',
'clan.member.left': 'team.member.removed',
'clan.member.kicked': 'team.member.removed',
})
/**
* The feed items D49 allows: membership, and nothing else. Every one is
* members-only. A disband is not here — it was not one of the four the org lead
* chose, and the Team it would be written to is about to be archived anyway.
*/
const ACTIVITY = Object.freeze({
'clan.created': 'rust.clan.founded',
'clan.member.added': 'rust.clan.joined',
'clan.member.left': 'rust.clan.left',
'clan.member.kicked': 'rust.clan.removed',
})
const CLAN_KINDS = Object.freeze(Object.keys(PUBLISH))
const STEAM_ID = /^\d{1,32}$/
const COLOR = /^#[0-9a-f]{6}$/i
/** The Team identity (D52). */
function externalIdOf(serverId, clanId, createdMs) {
return `${serverId}:${clanId}:${createdMs}`
}
const text = (value, max) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null)
const int = (value) => (Number.isInteger(Number(value)) && value !== null && value !== '' ? Number(value) : null)
/**
* One board row as this module stores it, or null when it cannot be read.
*
* A member whose Steam id is not a Steam id is dropped rather than failing the
* clan: the roster is still true about everybody else. A clan with no id, no
* creation time or no name fails as a whole, because it has no identity to
* store it under.
*/
function normaliseClan(serverId, raw) {
if (!raw || typeof raw !== 'object') return null
const clanId = int(raw.clanId)
const createdMs = int(raw.createdMs)
const name = text(raw.name, 191)
if (clanId == null || createdMs == null || createdMs <= 0 || !name) return null
const members = []
for (const m of Array.isArray(raw.members) ? raw.members : []) {
const steamId = m && typeof m.steamId === 'string' && STEAM_ID.test(m.steamId) ? m.steamId : null
if (!steamId) continue
members.push({
steamId,
name: text(m.name, 191),
rank: int(m.rank),
role: text(m.role, 64),
joinedMs: int(m.joinedMs),
})
}
return {
externalId: externalIdOf(serverId, clanId, createdMs),
serverId,
clanId,
createdMs,
name,
color: typeof raw.color === 'string' && COLOR.test(raw.color) ? raw.color.toLowerCase() : null,
score: int(raw.score) || 0,
maxMembers: int(raw.maxMembers),
memberCount: members.length,
members,
}
}
/** A member signature, so an unchanged roster is not rewritten every minute. */
const signature = (members) =>
members
.map((m) => `${m.steamId}|${m.rank == null ? '' : m.rank}|${m.role || ''}|${m.name || ''}`)
.sort()
.join('\n')
const leadersOf = (members) => new Set(members.filter((m) => Number(m.rank) === 1).map((m) => m.steamId))
/**
* Tells core something, and never lets core's answer become this module's
* problem. Both calls are fire-and-forget by contract; the catch is for a core
* that throws synchronously all the same.
*/
function publish(event) {
try {
Promise.resolve(core.teams.publish(event)).catch((err) => {
log.warn('teams publish failed', { kind: event.kind, externalId: event.externalId, error: err.message })
})
} catch (err) {
log.warn('teams publish threw', { kind: event.kind, externalId: event.externalId, error: err.message })
}
}
function requestReconcile(reason) {
try {
core.teams.reconcile({ reason })
} catch (err) {
log.warn('teams reconcile request threw', { reason, error: err.message })
}
}
function pushActivity(items) {
if (!items.length) return
try {
Promise.resolve(core.teams.pushActivity(items)).catch((err) => {
log.warn('teams activity push failed', { items: items.length, error: err.message })
})
} catch (err) {
log.warn('teams activity push threw', { items: items.length, error: err.message })
}
}
// ── The board ──────────────────────────────────────────────────────────────
/**
* Applies one server's `clans` board.
*
* `board` is undefined when the sidecar holds none — a plugin older than
* protocol 6, or one that has not connected since it was upgraded. That is
* recorded as unsupported, and the clans already stored are left exactly as they
* are: a missing board is the absence of an answer, not an answer of absence.
*
* Returns what happened, for the log and the tests.
*/
async function applyBoard(serverId, board) {
if (!board || typeof board !== 'object') {
await db.putBoard({
serverId,
boardT: null,
advanced: false,
enabled: true,
supported: false,
truncated: false,
backend: null,
reason: "this server has not sent a clan board; its plugin may predate protocol 6",
umodClans: false,
clanCount: 0,
})
return { applied: false, reason: 'no board' }
}
const previous = await db.getBoard(serverId)
const boardT = Number(board.t)
const known = previous && previous.boardT != null ? Number(previous.boardT) : null
const advanced = Number.isFinite(boardT) && (known == null || boardT > known)
const supported = board.supported === true
const raw = supported && Array.isArray(board.clans) ? board.clans : null
const clans = []
let unreadable = 0
for (const row of raw || []) {
const clan = normaliseClan(serverId, row)
if (clan) clans.push(clan)
else unreadable += 1
}
// A row this build could not read is treated like the ceiling: the board no
// longer vouches for what it leaves out.
const truncated = board.truncated === true || unreadable > 0
await db.putBoard({
serverId,
boardT: Number.isFinite(boardT) ? boardT : null,
advanced,
enabled: board.enabled !== false,
supported,
truncated,
backend: text(board.backend, 64),
reason: supported ? null : text(board.reason, 255) || 'the plugin could not read this server\'s clans',
umodClans: board.umodClans === true,
clanCount: clans.length,
})
if (unreadable) log.warn('clan board carried rows this build could not read', { server: serverId, unreadable })
// A board whose `t` has not moved is the one already applied. Re-applying it
// would rewrite every roster every 30 seconds to say what it already says.
if (!advanced || !raw) return { applied: false, reason: advanced ? 'unsupported' : 'unchanged' }
const [before, beforeMembers] = await Promise.all([
db.listClansForServer(serverId),
db.listMembersForServer(serverId),
])
const wasActive = new Map(before.filter((c) => !c.goneAt).map((c) => [c.externalId, c]))
const rosterBefore = new Map()
for (const m of beforeMembers) {
if (!rosterBefore.has(m.externalId)) rosterBefore.set(m.externalId, [])
rosterBefore.get(m.externalId).push(m)
}
let created = 0
let rosterChanged = 0
const leaderEvents = []
for (const clan of clans) {
// eslint-disable-next-line no-await-in-loop
await db.upsertClan(clan)
const old = rosterBefore.get(clan.externalId) || []
if (!wasActive.has(clan.externalId)) created += 1
if (signature(old) !== signature(clan.members)) {
// eslint-disable-next-line no-await-in-loop
await db.replaceMembers(clan.externalId, clan.members)
rosterChanged += 1
}
// Leadership is only ever learned here (D54): the game raises no hook when
// somebody is promoted. Published only for a clan that was already on the
// previous board — a brand-new clan's leaders reach core with the Team.
if (wasActive.has(clan.externalId)) {
const was = leadersOf(old)
const now = leadersOf(clan.members)
for (const key of now) if (!was.has(key)) leaderEvents.push({ kind: 'team.leader.added', externalId: clan.externalId, memberKey: key })
for (const key of was) if (!now.has(key)) leaderEvents.push({ kind: 'team.leader.removed', externalId: clan.externalId, memberKey: key })
}
}
// Only a complete board may say a clan is gone.
const onBoard = new Set(clans.map((c) => c.externalId))
const gone = truncated ? [] : [...wasActive.keys()].filter((id) => !onBoard.has(id))
await db.markGone(gone)
for (const event of leaderEvents) publish(event)
if (created || gone.length || rosterChanged) {
requestReconcile('rust clans board changed')
}
if (created || gone.length || rosterChanged || leaderEvents.length) {
log.info('clan board applied', {
server: serverId, clans: clans.length, created, gone: gone.length, rosterChanged,
leaderChanges: leaderEvents.length, truncated,
})
}
return { applied: true, clans: clans.length, created, gone: gone.length, rosterChanged, leaderChanges: leaderEvents.length }
}
// ── The events ─────────────────────────────────────────────────────────────
const nameOr = (name) => name || 'A player'
/** The feed line for one clan event, as core stores it verbatim. */
function summaryOf(kind, frame) {
switch (kind) {
case 'clan.created':
return `${nameOr(frame.name)} founded the clan.`
case 'clan.member.added':
return `${nameOr(frame.name)} joined the clan.`
case 'clan.member.left':
return `${nameOr(frame.name)} left the clan.`
case 'clan.member.kicked':
return frame.byName
? `${nameOr(frame.name)} was removed from the clan by ${frame.byName}.`
: `${nameOr(frame.name)} was removed from the clan.`
default:
return null
}
}
/**
* A key core can dedupe on, from the frame's own content.
*
* Content rather than this module's event row id, so that the same frame read
* twice — a cursor replayed after a crash, or the re-offer below — is the same
* item. **Hashed, because core clamps a dedupe key to 40 characters**, and a
* readable key long enough to be unique (server, clan, creation time, kind,
* player, instant) would be cut short into collisions without a word.
*/
function dedupeKeyOf(serverId, kind, frame) {
const parts = [serverId, frame.clanId, frame.createdMs, kind, frame.steamId || '', frame.t]
return crypto.createHash('sha1').update(parts.join('|')).digest('hex')
}
/** One clan event as a Team feed item, or null when D49 does not allow it. */
function activityItem(serverId, externalId, kind, frame) {
const itemKind = ACTIVITY[kind]
const summary = itemKind && summaryOf(kind, frame)
if (!summary) return null
const t = Number(frame.t)
return {
externalId,
kind: itemKind,
summary,
occurredAt: Number.isFinite(t) ? t : Date.now(),
visibility: 'members',
actorMemberKey: kind === 'clan.member.kicked' ? frame.bySteamId || null : frame.steamId || null,
payload: { serverId, steamId: frame.steamId || null },
dedupeKey: dedupeKeyOf(serverId, kind, frame),
}
}
/** The Team identity a clan event names, or null when it cannot be worked out. */
async function resolveExternalId(serverId, frame) {
const clanId = int(frame.clanId)
const createdMs = int(frame.createdMs)
if (clanId == null) return null
if (createdMs != null && createdMs > 0) return externalIdOf(serverId, clanId, createdMs)
// `clan.member.added` can arrive without a creation time when the plugin could
// not read the clan back. Matched on the game id, newest first.
const known = await db.findByGameId(serverId, clanId)
return known ? known.externalId : null
}
/**
* Applies one `clan.*` event: tells core, and writes the Team feed.
*
* Called from ingest, after the raw frame is stored. The board that follows
* every one of these (the plugin re-sends it a few seconds later) is what the
* store is rebuilt from; this only makes the change visible sooner and records
* the line for the feed.
*/
async function applyEvent(serverId, frame) {
const kind = frame && frame.kind
if (!PUBLISH[kind]) return { applied: false }
if (frame.steamId) await db.rememberName(frame.steamId, text(frame.name, 191))
if (frame.bySteamId) await db.rememberName(frame.bySteamId, text(frame.byName, 191))
const externalId = await resolveExternalId(serverId, frame)
if (!externalId) {
log.info('clan event names a clan this module has never seen', { server: serverId, kind, clanId: frame.clanId })
return { applied: false }
}
// The game said it: this clan is gone. Recorded here as well as by the next
// board, because a board truncated at the ceiling would never say so.
if (kind === 'clan.disbanded') await db.markGone([externalId])
const event = { kind: PUBLISH[kind], externalId }
if (event.kind.startsWith('team.member.')) {
if (!frame.steamId) return { applied: false }
event.memberKey = String(frame.steamId)
}
publish(event)
const item = activityItem(serverId, externalId, kind, frame)
if (item) pushActivity([item])
return { applied: true, externalId }
}
/**
* Offers the last few minutes of one server's clan feed items to core again.
*
* See `REOFFER_MS`. Called after each board refresh; idempotent by construction.
*/
async function reofferActivity(serverId, now = Date.now()) {
const rows = await db.recentClanEvents(serverId, now - REOFFER_MS)
const items = []
for (const row of rows) {
let frame
try {
frame = typeof row.raw === 'string' ? JSON.parse(row.raw) : row.raw
} catch (err) {
continue
}
if (!frame || !ACTIVITY[frame.kind]) continue
// eslint-disable-next-line no-await-in-loop
const externalId = await resolveExternalId(serverId, frame)
const item = externalId && activityItem(serverId, externalId, frame.kind, frame)
if (item) items.push(item)
}
pushActivity(items)
return items.length
}
// ── Who may see a roster (D48) ─────────────────────────────────────────────
/**
* May this viewer see this clan's roster?
*
* `viewer` is `{ userId, role }` or null — the shape core hands `projectRoster`,
* so core's roster and this module's page decide it with one function.
*
* The viewer's standing is re-read from the `users` row, never taken from what
* the caller says, for the same reason the presence gate does it: a moderator
* demoted this morning, or an account banned, must lose the roster on the next
* request. Everything that cannot be answered answers no.
*/
async function canSeeRoster(viewer, externalId) {
const audience = await visibility.clanRosterAudience()
if (audience === 'public') return true
if (!viewer || viewer.userId == null) return false
const user = await core.users.getById(viewer.userId)
if (!user || (user.status && user.status !== 'active')) return false
if (audience === 'signed_in') return true
if (user.role === 'admin' || user.role === 'moderator') return true
return db.userIsMember(externalId, user.id)
}
// ── The public reads ───────────────────────────────────────────────────────
const shapeBoard = (board, now = Date.now()) => {
if (!board || board.supported == null) {
return { supported: false, fresh: false, truncated: false, enabled: true, reason: 'this server has not sent a clan board yet' }
}
const seenAt = board.seenAt ? new Date(board.seenAt).getTime() : null
return {
supported: Boolean(board.supported),
enabled: Boolean(board.enabled),
truncated: Boolean(board.truncated),
fresh: Boolean(board.supported) && seenAt != null && now - seenAt < FRESH_MS,
reason: board.reason || null,
}
}
/** The Clans tab (D58): every clan on one server's board, best first. Public. */
async function listForServer(serverId, now = Date.now()) {
const [clans, board] = await Promise.all([db.listPublicForServer(serverId), db.getBoard(serverId)])
return {
clans: clans.map((c) => ({
externalId: c.externalId,
name: c.name,
color: c.color || null,
score: Number(c.score) || 0,
memberCount: Number(c.memberCount) || 0,
maxMembers: c.maxMembers == null ? null : Number(c.maxMembers),
})),
board: shapeBoard(board, now),
}
}
/**
* One clan, and its roster if the viewer may see it.
*
* The roster carries no Steam id and no website account id — the same two fields
* core withholds from every public roster. `online` is inside the audience by
* construction (D48): a viewer who may not see the roster sees no names at all.
*/
async function getForViewer(externalId, viewer) {
const clan = await db.findClan(externalId)
if (!clan) return null
const allowed = await canSeeRoster(viewer, externalId)
const audience = await visibility.clanRosterAudience()
const members = allowed && !clan.goneAt ? await db.listMembers(externalId) : []
return {
clan: {
externalId: clan.externalId,
name: clan.name,
color: clan.color || null,
score: Number(clan.score) || 0,
memberCount: Number(clan.memberCount) || 0,
maxMembers: clan.maxMembers == null ? null : Number(clan.maxMembers),
serverId: clan.serverId,
serverName: clan.serverName,
founded: Number(clan.createdMs) || null,
gone: Boolean(clan.goneAt),
},
roster: {
visible: allowed,
audience,
members: members.map((m) => ({
name: m.name || null,
role: m.role || null,
leader: Number(m.rank) === 1,
online: Boolean(Number(m.online)),
joined: m.joinedMs == null ? null : Number(m.joinedMs),
})),
},
}
}
/**
* Every configured server's clan board as the admin page shows it: whether it is
* current, whether it is at the ceiling (D55), why it cannot be read, and
* whether the uMod Clans plugin is loaded there (D47) — whose clans are a
* separate system and never Teams.
*/
async function boardsForAdmin(now = Date.now()) {
const rows = await db.listBoards()
return rows.map((row) => ({
id: row.serverId,
name: row.serverName,
...shapeBoard(row.supported == null ? null : row, now),
clans: Number(row.clanCount) || 0,
umodClans: Boolean(row.umodClans),
}))
}
module.exports = {
FRESH_MS,
boardsForAdmin,
REOFFER_MS,
CLAN_KINDS,
externalIdOf,
normaliseClan,
applyBoard,
applyEvent,
reofferActivity,
activityItem,
dedupeKeyOf,
canSeeRoster,
shapeBoard,
listForServer,
getForViewer,
}

View File

@@ -0,0 +1,202 @@
// ── module-rust's Team provider ────────────────────────────────────────────
//
// The questions core asks this module about Teams (MODULE_API.md
// `api.registerTeamProvider`, TEAMS.md §2.3). A first-party Rust clan is a Team
// (R5); this file is the whole of the translation, and `model/clans` is where
// the clans themselves are kept.
//
// ── The envelope is the contract ──────────────────────────────────────────
//
// Every method answers `{ ok, ... }` and `{ ok: false, reason }` is an ordinary
// answer. Core reads it as "keep what you have" — staleness, never emptiness —
// and there is no shape a failure can take that core reads as "zero Teams". An
// empty array is the one thing this file must never say while it does not know.
//
// ── Many servers, one answer (D53) ─────────────────────────────────────────
//
// `module-uo` has one shard and one socket, so "is the board current" has one
// answer. This module has a fleet, and the answer is per server. `getTeams` is
// therefore:
//
// • `complete: true` only when EVERY configured server's board is fresh,
// supported and untruncated — then core may archive a
// Team that is missing;
// • `complete: false` when at least one is current and some are not — core
// adds and updates, and removes nothing. One server being
// off for a patch must never archive its clans;
// • a refusal when none is current.
//
// A clan is only ever as current as its own server's board, so the roster
// methods ask about that server alone.
const core = require('../../core')
const db = require('./clans.db')
const clans = require('./clans.model')
const servers = require('../servers/servers.model')
const log = core.logger('teams')
const refuse = (reason) => ({ ok: false, reason })
/** Is this board record current? The rule `model/clans` states, applied to one row. */
function isFresh(board, now = Date.now()) {
return clans.shapeBoard(board, now).fresh
}
/**
* `getTeams()` — every clan on every server's board.
*
* `meta` carries the server and the clan's colour and score, opaquely: core
* stores and shows it and never branches on it.
*/
async function getTeams(now = Date.now()) {
try {
const configured = await servers.listForPolling()
if (!configured.length) return refuse('no Rust servers are configured')
const boards = await db.listBoards()
const byServer = new Map(boards.map((b) => [b.serverId, b]))
const fresh = []
const behind = []
for (const server of configured) {
const board = byServer.get(server.id)
if (isFresh(board, now)) fresh.push(server.id)
else behind.push(server.id)
}
if (!fresh.length) {
return refuse(`no server has sent a current clan board (${behind.join(', ')})`)
}
// Complete only when nothing is behind, and nothing is at the ceiling. A
// server that is configured but switched off in this module is "behind" by
// construction — its board is never read — which is the conservative answer:
// switching a server off is not a statement that its clans are gone.
const truncated = fresh.filter((id) => byServer.get(id).truncated)
const complete = behind.length === 0 && truncated.length === 0
const rows = await db.listActiveClans()
const known = new Set(configured.map((s) => s.id))
return {
ok: true,
complete,
teams: rows
.filter((row) => known.has(row.serverId))
.map((row) => ({
externalId: row.externalId,
name: row.name,
abbr: null,
meta: {
server: row.serverName || row.serverId,
serverId: row.serverId,
color: row.color || null,
score: Number(row.score) || 0,
},
})),
}
} catch (err) {
log.warn('getTeams failed', { error: err.message })
return refuse(`clans unreadable: ${err.message}`)
}
}
/** A clan and whether its server's board vouches for it right now, or a refusal. */
async function currentClan(externalId, now) {
const clan = await db.findClan(externalId)
if (!clan) return { refusal: refuse(`clan ${externalId} is not on any board`) }
if (clan.goneAt) return { refusal: refuse(`clan ${externalId} has left its server's board`) }
const board = await db.getBoard(clan.serverId)
if (!isFresh(board, now)) {
return { refusal: refuse(`server ${clan.serverId} has not sent a current clan board`) }
}
return { clan }
}
/**
* `getTeamMembers(externalId)` — one clan's roster.
*
* **A clan with no roster rows is refused, not reported empty**, unless the board
* said it has none. A clan always has at least its leader, so an empty roster
* beside a non-zero count is a read that happened between two writes, and
* reporting it would tell core every member left.
*/
async function getTeamMembers(externalId, now = Date.now()) {
try {
const { clan, refusal } = await currentClan(externalId, now)
if (refusal) return refusal
const rows = await db.listMembers(externalId)
if (!rows.length && Number(clan.memberCount) > 0) {
return refuse(`roster for clan ${externalId} is not stored yet (board says ${clan.memberCount} members)`)
}
return {
ok: true,
complete: true,
members: rows.map((row) => ({
memberKey: row.steamId,
displayName: row.name || null,
rankLabel: row.role || null,
// Rank 1 is leader and several may hold it. A NULL rank — a role id the
// board could not match — is not a leader: "not known" must never read
// as "leads this clan".
leader: Number(row.rank) === 1,
online: Boolean(Number(row.online)),
userId: Number.isInteger(Number(row.userId)) && Number(row.userId) > 0 ? Number(row.userId) : null,
})),
}
} catch (err) {
log.warn('getTeamMembers failed', { externalId, error: err.message })
return refuse(`roster unreadable: ${err.message}`)
}
}
/** `getTeamLeaders(externalId)` — everyone at rank 1, which may be several. */
async function getTeamLeaders(externalId, now = Date.now()) {
try {
const { refusal } = await currentClan(externalId, now)
if (refusal) return refusal
const rows = await db.listMembers(externalId)
return { ok: true, leaders: rows.filter((row) => Number(row.rank) === 1).map((row) => row.steamId) }
} catch (err) {
log.warn('getTeamLeaders failed', { externalId, error: err.message })
return refuse(`leadership unreadable: ${err.message}`)
}
}
/**
* Which roster rows a viewer may see (D48, MODULE_API 1.6.0).
*
* The one provider method core calls on a REQUEST path, and the one that fails
* CLOSED: core serves an empty roster when this refuses, because for a
* visibility question "keep what you have" would mean publishing the roster to
* whoever asked. So every path that cannot reach a confident answer refuses.
*
* All or nothing, and that is the model rather than a shortcut: the audience is
* a property of the ROSTER, not of a member. There is no setting in which some
* of a clan's members are visible and others are not.
*/
async function projectRoster(externalId, members, viewer) {
try {
const allowed = await clans.canSeeRoster(viewer, externalId)
if (!allowed) return { ok: true, members: [] }
return { ok: true, members: (members || []).map((m) => m.member_key).filter(Boolean) }
} catch (err) {
log.warn('projectRoster could not resolve the audience; withholding the roster', {
externalId, error: err.message,
})
return refuse(`the roster audience could not be resolved: ${err.message}`)
}
}
// Where core should point a link at a clan (MODULE_API 1.6.0, TEAMS.md §6.4).
// Core substitutes `{externalId}` and nothing else, which is why the page is not
// nested under its server (D56): the server is inside the id already.
const pageUrlTemplate = '/rust/clans/{externalId}'
module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, pageUrlTemplate, isFresh }

View File

@@ -23,6 +23,24 @@ const sidecar = require('../../sidecarClient')
const log = core.logger('links')
/**
* A link changed, so a clan member's website account changed (D57).
*
* Core resolves a Team member's `userId` from the provider's answer, and that
* answer comes from this table. Without asking, a member who links today is not
* a member of their clan's Team on the site until core's next scheduled sweep —
* fifteen minutes by default — which is exactly when a player tries the clan
* forum for the first time. A request, not a wait: it returns at once and never
* throws into the link flow.
*/
function linksChanged(reason) {
try {
core.teams.reconcile({ reason })
} catch (err) {
log.warn('could not ask core to reconcile Teams after a link change', { reason, error: err.message })
}
}
/** What a link looks like to any caller. Never carries a raw code. */
function shape(row) {
if (!row) return null
@@ -131,6 +149,7 @@ 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')
return { ok: true, link }
}
@@ -184,7 +203,9 @@ async function redeem({ code, userId }) {
/** Remove a link the caller owns. False when they did not hold it. */
async function unlinkOwned(steamId, userId) {
return (await db.removeOwned(steamId, userId)) > 0
const removed = (await db.removeOwned(steamId, userId)) > 0
if (removed) linksChanged('rust account unlinked')
return removed
}
/**
@@ -200,7 +221,9 @@ async function unlinkOwned(steamId, userId) {
* and the game one has no operator to name.
*/
async function unlinkAnyOwner(steamId) {
return (await db.removeBySteamId(steamId)) > 0
const removed = (await db.removeBySteamId(steamId)) > 0
if (removed) linksChanged('rust account unlinked')
return removed
}
/**

View File

@@ -47,6 +47,33 @@ const PRESENCE_KEY = 'presence.audience'
const isAudience = (value) => RANK.has(value)
// ── Who may see a clan's roster (phase 9, D48) ────────────────────────────
//
// The same rule applied to a roster: a roster says who is in a clan and, inside
// its audience, which of them is on. So it defaults to the clan's OWN members
// plus staff, and an operator widens it deliberately.
//
// members staff, and a website account linked to one of the clan's members
// signed_in any active website account
// public anybody
//
// One fleet-wide setting (D48), deliberately without a per-server override: the
// presence override exists because a PvE server may publish a roll call a PvP one
// must not, and a roster is the same answer on every server of the fleet.
//
// **Widening it widens online status too.** Core's `projectRoster` can withhold a
// roster's rows but not its fields, so there is no rung that shows who is in a
// clan and hides which of them is on. The admin page says so beside the switch.
const CLAN_AUDIENCES = Object.freeze(['public', 'signed_in', 'members'])
/** The narrowest rung, and the default until an operator chooses. */
const DEFAULT_CLAN_ROSTER = 'members'
/** The `rust_settings` key the roster audience lives under. */
const CLAN_ROSTER_KEY = 'clans.roster.audience'
const isClanAudience = (value) => CLAN_AUDIENCES.includes(value)
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('staff')
@@ -124,11 +151,26 @@ async function canSeePresence(req, serverId) {
}
}
/**
* The clan roster audience. An unrecognised stored word narrows to `members`,
* and a read that fails throws — every caller answers "no" on a throw, which is
* the direction a roster must fail in.
*/
async function clanRosterAudience() {
const stored = await db.getSetting(CLAN_ROSTER_KEY)
return isClanAudience(stored) ? stored : DEFAULT_CLAN_ROSTER
}
/** The admin screen's read: the fleet default and every server beside it. */
async function describe() {
const [fleet, servers] = await Promise.all([fleetPresence(), db.listServerPresence()])
const [fleet, servers, clanRoster] = await Promise.all([
fleetPresence(),
db.listServerPresence(),
clanRosterAudience(),
])
return {
audiences: [...AUDIENCES],
clans: { audiences: [...CLAN_AUDIENCES], roster: clanRoster },
presence: {
fleet,
servers: servers.map((s) => {
@@ -155,11 +197,19 @@ async function describe() {
* Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
* sentence the page can show.
*/
async function update({ fleet, servers } = {}, actor = null) {
async function update({ fleet, servers, clanRoster } = {}, actor = null) {
if (fleet !== undefined && !isAudience(fleet)) {
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
}
if (clanRoster !== undefined && !isClanAudience(clanRoster)) {
return {
ok: false,
status: 400,
message: `"${clanRoster}" is not a clan roster audience. Choose one of: ${CLAN_AUDIENCES.join(', ')}.`,
}
}
const changes = Object.entries(servers || {})
for (const [id, value] of changes) {
if (value !== null && !isAudience(value)) {
@@ -174,6 +224,7 @@ async function update({ fleet, servers } = {}, actor = null) {
const userId = actor && actor.id != null ? actor.id : null
if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
if (clanRoster !== undefined) await db.setSetting(CLAN_ROSTER_KEY, clanRoster, userId)
for (const [id, value] of changes) {
// eslint-disable-next-line no-await-in-loop
await db.setServerPresence(id, value)
@@ -186,6 +237,7 @@ async function update({ fleet, servers } = {}, actor = null) {
ok: true,
changed: {
...(fleet !== undefined ? { fleet } : {}),
...(clanRoster !== undefined ? { clanRoster } : {}),
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
},
}
@@ -196,6 +248,11 @@ module.exports = {
DEFAULT_PRESENCE,
PRESENCE_KEY,
isAudience,
CLAN_AUDIENCES,
DEFAULT_CLAN_ROSTER,
CLAN_ROSTER_KEY,
isClanAudience,
clanRosterAudience,
meets,
normalise,
viewerLevel,

View File

@@ -2,13 +2,25 @@
const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const visibility = require('../../model/visibility/visibility.model')
const log = core.logger('visibility')
/**
* The page's whole state: both settings, and each server's clan board beside
* the roster setting. The board is where "this server runs the uMod Clans
* plugin, whose clans are not Teams" (D47) and "this server is at the game's
* 100-clan ceiling" (D55) come from.
*/
async function describe() {
const [settings, boards] = await Promise.all([visibility.describe(), clans.boardsForAdmin()])
return { ...settings, clans: { ...settings.clans, servers: boards } }
}
async function read(req, res) {
try {
res.json(await visibility.describe())
res.json(await describe())
} catch (err) {
log.error('failed to read visibility settings', { error: err.message })
res.status(500).json({ message: 'Failed to read the visibility settings' })
@@ -17,8 +29,8 @@ async function read(req, res) {
async function update(req, res) {
try {
const { fleet, servers } = req.body || {}
const result = await visibility.update({ fleet, servers }, req.user)
const { fleet, servers, clanRoster } = req.body || {}
const result = await visibility.update({ fleet, servers, clanRoster }, req.user)
if (!result.ok) {
res.status(result.status || 400).json({ message: result.message })
return
@@ -29,7 +41,7 @@ async function update(req, res) {
// person and a time.
await core.activity.log({ req, action: 'rust.visibility.save', detail: result.changed })
res.json(await visibility.describe())
res.json(await describe())
} catch (err) {
log.error('failed to save visibility settings', { error: err.message })
res.status(500).json({ message: 'Failed to save the visibility settings' })

View File

@@ -21,12 +21,13 @@ const { body } = core.validator
const visibilityRouter = express.Router()
const AUDIENCES = ['staff', 'signed_in', 'public']
const CLAN_AUDIENCES = ['members', 'signed_in', 'public']
visibilityRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Who may see who is online'
// #swagger.description = 'The fleet default and every servers optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboards `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting.'
// #swagger.summary = 'Who may see who is online, and who may see a clan roster'
// #swagger.description = 'The presence fleet default and every servers optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboards `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clans own linked members, and staff) and each servers clan board — whether it is current, at the games 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams.'
/* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
requireRole('admin'),
visibility.read,
@@ -35,8 +36,8 @@ visibilityRouter.get(
visibilityRouter.put(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change who may see who is online'
// #swagger.description = 'Sets the fleet default, one or more server overrides, or both. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing.'
// #swagger.summary = 'Change who may see who is online, or who may see a clan roster'
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, or any of them together. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
@@ -44,6 +45,7 @@ visibilityRouter.put(
requireRole('admin'),
body('fleet').optional().isIn(AUDIENCES).withMessage(`fleet must be one of ${AUDIENCES.join(', ')}`),
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
body('clanRoster').optional().isIn(CLAN_AUDIENCES).withMessage(`clanRoster must be one of ${CLAN_AUDIENCES.join(', ')}`),
validate,
visibility.update,
)

View File

@@ -11,6 +11,7 @@
const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const events = require('../../model/events/events.model')
const servers = require('../../model/servers/servers.model')
const visibility = require('../../model/visibility/visibility.model')
@@ -160,4 +161,59 @@ async function listOnline(req, res) {
}
}
module.exports = { listServers, getServer, listEvents, listLeaderboard, listWipes, listOnline }
/**
* Who is asking, as core describes a viewer to `projectRoster`: `{ userId, role }`
* or null. Only the id is trusted — `model/clans` re-reads the row — so a token
* that cannot be decoded is simply nobody.
*/
function viewerOf(req) {
try {
const claimed = req.user || core.auth.getUserFromRequest(req)
if (!claimed || claimed.id == null) return null
return { userId: claimed.id, role: claimed.role || null }
} catch (err) {
return null
}
}
/**
* One server's clans (D58): name, colour, score and member count, best first.
*
* Public at every setting, because none of it names a player. `board` says
* whether the list can be trusted — a server whose plugin predates protocol 6,
* or whose clans the bridge cannot read, answers an empty list AND the reason,
* so the tab can say "unavailable" rather than "no clans".
*/
async function listClans(req, res) {
try {
res.json(await clans.listForServer(req.params.id))
} catch (err) {
log.error('failed to read clans', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read clans' })
}
}
/**
* One clan, and its roster when the viewer is inside the roster audience (D48).
*
* The same decision core's `projectRoster` makes, from the same function, so
* this page and core's roster cannot disagree about who may look. Below the
* audience the clan is still described — its name and its count are public —
* and `roster.visible` is false with no names at all.
*/
async function getClan(req, res) {
try {
const answer = await clans.getForViewer(req.params.externalId, viewerOf(req))
perViewer(res)
if (!answer) {
res.status(404).json({ message: 'No such clan' })
return
}
res.json(answer)
} catch (err) {
log.error('failed to read a clan', { clan: req.params.externalId, error: err.message })
res.status(500).json({ message: 'Failed to read the clan' })
}
}
module.exports = { listServers, getServer, listEvents, listLeaderboard, listWipes, listOnline, listClans, getClan }

View File

@@ -114,4 +114,32 @@ rustRouter.get(
servers.listOnline,
)
// ── Clans (phase 9) ───────────────────────────────────────────────────────
//
// Rust's own clans, which this module also answers core's Team questions from.
// The list is public (D58); a roster is not (D48).
rustRouter.get(
'/servers/:id/clans',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The clans on one Rust server'
// #swagger.description = 'Every clan on the servers clan board, best score first: name, colour, score and member count. Public, because none of it names a player. `board` says whether the list is current and complete — a server whose bridge cannot read its clans answers an empty list and the reason, and a server at the games 100-clan ceiling says `truncated`.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The servers slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The clans', content: { "application/json": { schema: { $ref: "#/components/schemas/RustClanList" } } } } */
siteMode,
servers.listClans,
)
rustRouter.get(
'/clans/:externalId',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'One Rust clan'
// #swagger.description = 'A clan and, when the viewer is inside the operators clan roster audience, its roster. The audience defaults to the clans own members (a website account linked to one of them) and staff. Below it the clan is still described and `roster.visible` is false with no names. The roster never carries a Steam id or a website account id. `externalId` is `<server>:<clan>:<created>`, the same identity the sites Team pages use.'
// #swagger.parameters['externalId'] = { in: 'path', required: true, description: 'The clans identity: server, clan id and creation time in epoch ms, joined by colons', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The clan', content: { "application/json": { schema: { $ref: "#/components/schemas/RustClan" } } } } */
/* #swagger.responses[404] = { description: 'No such clan' } */
siteMode,
servers.getClan,
)
module.exports = rustRouter

View File

@@ -52,11 +52,11 @@ const TIMEOUT_MS = 12000
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
* plugin, and `protocol` in its `overlay.toml`.
*
* **5configuration from the site.** 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 is the first that writes to the game
* HOST'S FILESYSTEM — a plugin's settings, and a reload watched closely enough
* to be undone. The bump lands here in the same change as the emitters,
* **6first-party clans.** 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,
* 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 +66,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 5
const PROTOCOL_VERSION = 6
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {

View File

@@ -535,6 +535,110 @@ module.exports = {
},
},
},
clans: {
type: 'object',
description: 'Who may see a clan roster, and each servers clan board.',
properties: {
audiences: { type: 'array', items: { $ref: '#/components/schemas/RustClanAudience' } },
roster: { $ref: '#/components/schemas/RustClanAudience' },
servers: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
supported: { type: 'boolean', example: true },
enabled: { type: 'boolean', example: true },
fresh: { type: 'boolean', example: true },
truncated: { type: 'boolean', example: false },
reason: { type: 'string', nullable: true },
clans: { type: 'integer', example: 14 },
umodClans: { type: 'boolean', description: 'Is the uMod Clans plugin loaded? Its clans are a separate system and are not Teams.', example: false },
},
},
},
},
},
},
},
RustClanAudience: {
type: 'string',
enum: ['members', 'signed_in', 'public'],
description: 'Who may see a clans roster: the clans own members (a website account linked to one of them) and staff, any signed-in account, or anybody. Widening it also shows which members are online to that audience.',
example: 'members',
},
RustClanBoard: {
type: 'object',
description: 'Whether a servers clan list can be trusted right now.',
properties: {
supported: { type: 'boolean', description: 'Could the bridge read this servers clans at all?', example: true },
enabled: { type: 'boolean', description: 'Is the games clan system switched on?', example: true },
fresh: { type: 'boolean', description: 'Has the board been re-sent within the last three minutes?', example: true },
truncated: { type: 'boolean', description: 'At the games 100-clan ceiling, or too large for one line: there may be clans the list does not show.', example: false },
reason: { type: 'string', nullable: true, description: 'Why the clans cannot be read, when they cannot.' },
},
},
RustClanList: {
type: 'object',
description: 'One servers clans (GET /public/rust/servers/{id}/clans). Public: nothing here names a player.',
properties: {
clans: {
type: 'array',
items: {
type: 'object',
properties: {
externalId: { type: 'string', example: 'main:12:1790142840535' },
name: { type: 'string', example: 'Northwatch' },
color: { type: 'string', nullable: true, example: '#3fa9f5' },
score: { type: 'integer', example: 140 },
memberCount: { type: 'integer', example: 6 },
maxMembers: { type: 'integer', nullable: true, example: 100 },
},
},
},
board: { $ref: '#/components/schemas/RustClanBoard' },
},
},
RustClan: {
type: 'object',
description: 'One clan (GET /public/rust/clans/{externalId}) and, inside the roster audience, its roster.',
properties: {
clan: {
type: 'object',
properties: {
externalId: { type: 'string', example: 'main:12:1790142840535' },
name: { type: 'string', example: 'Northwatch' },
color: { type: 'string', nullable: true, example: '#3fa9f5' },
score: { type: 'integer', example: 140 },
memberCount: { type: 'integer', example: 6 },
maxMembers: { type: 'integer', nullable: true, example: 100 },
serverId: { type: 'string', example: 'main' },
serverName: { type: 'string', example: 'Main · Vanilla' },
founded: { type: 'integer', nullable: true, description: 'When the clan was founded, epoch milliseconds.' },
gone: { type: 'boolean', description: 'The clan has been disbanded, or has left its servers board.', example: false },
},
},
roster: {
type: 'object',
properties: {
visible: { type: 'boolean', description: 'Is this viewer inside the roster audience? When false, `members` is empty.', example: false },
audience: { $ref: '#/components/schemas/RustClanAudience' },
members: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', nullable: true, example: 'Wanderer' },
role: { type: 'string', nullable: true, example: 'Leader' },
leader: { type: 'boolean', example: true },
online: { type: 'boolean', example: false },
joined: { type: 'integer', nullable: true, description: 'Epoch milliseconds.' },
},
},
},
},
},
},
},
RustVisibilityUpdate: {
@@ -547,6 +651,7 @@ module.exports = {
additionalProperties: { type: 'string', nullable: true, enum: ['staff', 'signed_in', 'public', null] },
example: { main: 'public', pvp: null },
},
clanRoster: { $ref: '#/components/schemas/RustClanAudience' },
},
},
RustSidecarProbe: {

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 cores 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 clans 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. */

View File

@@ -688,8 +688,8 @@
"tags": [
"Admin · Rust"
],
"summary": "Who may see who is online",
"description": "The fleet default and every servers optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboards `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting.",
"summary": "Who may see who is online, and who may see a clan roster",
"description": "The presence fleet default and every servers optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboards `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clans own linked members, and staff) and each servers clan board — whether it is current, at the games 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams.",
"responses": {
"200": {
"description": "The fleet default and each server",
@@ -710,8 +710,8 @@
"tags": [
"Admin · Rust"
],
"summary": "Change who may see who is online",
"description": "Sets the fleet default, one or more server overrides, or both. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing.",
"summary": "Change who may see who is online, or who may see a clan roster",
"description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, or any of them together. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.",
"responses": {
"200": {
"description": "Saved; answers the new state",
@@ -1246,6 +1246,44 @@
}
}
},
"/api/v1/public/rust/clans/{externalId}": {
"get": {
"tags": [
"Public · Rust"
],
"summary": "One Rust clan",
"description": "A clan and, when the viewer is inside the operators clan roster audience, its roster. The audience defaults to the clans own members (a website account linked to one of them) and staff. Below it the clan is still described and `roster.visible` is false with no names. The roster never carries a Steam id or a website account id. `externalId` is `<server>:<clan>:<created>`, the same identity the sites Team pages use.",
"parameters": [
{
"name": "externalId",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The clans identity: server, clan id and creation time in epoch ms, joined by colons"
}
],
"responses": {
"200": {
"description": "The clan",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustClan"
}
}
}
},
"404": {
"description": "No such clan"
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/rust/servers": {
"get": {
"tags": [
@@ -1301,6 +1339,41 @@
}
}
},
"/api/v1/public/rust/servers/{id}/clans": {
"get": {
"tags": [
"Public · Rust"
],
"summary": "The clans on one Rust server",
"description": "Every clan on the servers clan board, best score first: name, colour, score and member count. Public, because none of it names a player. `board` says whether the list is current and complete — a server whose bridge cannot read its clans answers an empty list and the reason, and a server at the games 100-clan ceiling says `truncated`.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The servers slug"
}
],
"responses": {
"200": {
"description": "The clans",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustClanList"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/rust/servers/{id}/events": {
"get": {
"tags": [
@@ -4263,6 +4336,756 @@
}
}
}
},
"clans": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Who may see a clan roster, and each servers clan board."
},
"properties": {
"type": "object",
"properties": {
"audiences": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"$ref": "#/components/schemas/RustClanAudience"
}
}
},
"roster": {
"$ref": "#/components/schemas/RustClanAudience"
},
"servers": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"id": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "main"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Main · Vanilla"
}
}
},
"supported": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"enabled": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"fresh": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"truncated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"reason": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
}
}
},
"clans": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 14
}
}
},
"umodClans": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Is the uMod Clans plugin loaded? Its clans are a separate system and are not Teams."
},
"example": {
"type": "boolean",
"example": false
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
},
"RustClanAudience": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"members",
"signed_in",
"public"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "Who may see a clans roster: the clans own members (a website account linked to one of them) and staff, any signed-in account, or anybody. Widening it also shows which members are online to that audience."
},
"example": {
"type": "string",
"example": "members"
}
}
},
"RustClanBoard": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Whether a servers clan list can be trusted right now."
},
"properties": {
"type": "object",
"properties": {
"supported": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Could the bridge read this servers clans at all?"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"enabled": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Is the games clan system switched on?"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"fresh": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Has the board been re-sent within the last three minutes?"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"truncated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "At the games 100-clan ceiling, or too large for one line: there may be clans the list does not show."
},
"example": {
"type": "boolean",
"example": false
}
}
},
"reason": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Why the clans cannot be read, when they cannot."
}
}
}
}
}
}
},
"RustClanList": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One servers clans (GET /public/rust/servers/{id}/clans). Public: nothing here names a player."
},
"properties": {
"type": "object",
"properties": {
"clans": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"externalId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "main:12:1790142840535"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Northwatch"
}
}
},
"color": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "#3fa9f5"
}
}
},
"score": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 140
}
}
},
"memberCount": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 6
}
}
},
"maxMembers": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "number",
"example": 100
}
}
}
}
}
}
}
}
},
"board": {
"$ref": "#/components/schemas/RustClanBoard"
}
}
}
}
},
"RustClan": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One clan (GET /public/rust/clans/{externalId}) and, inside the roster audience, its roster."
},
"properties": {
"type": "object",
"properties": {
"clan": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"externalId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "main:12:1790142840535"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Northwatch"
}
}
},
"color": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "#3fa9f5"
}
}
},
"score": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 140
}
}
},
"memberCount": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 6
}
}
},
"maxMembers": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "number",
"example": 100
}
}
},
"serverId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "main"
}
}
},
"serverName": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Main · Vanilla"
}
}
},
"founded": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "When the clan was founded, epoch milliseconds."
}
}
},
"gone": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "The clan has been disbanded, or has left its servers board."
},
"example": {
"type": "boolean",
"example": false
}
}
}
}
}
}
},
"roster": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"visible": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Is this viewer inside the roster audience? When false, `members` is empty."
},
"example": {
"type": "boolean",
"example": false
}
}
},
"audience": {
"$ref": "#/components/schemas/RustClanAudience"
},
"members": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "Wanderer"
}
}
},
"role": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "Leader"
}
}
},
"leader": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"online": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"joined": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Epoch milliseconds."
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
@@ -4326,6 +5149,9 @@
}
}
}
},
"clanRoster": {
"$ref": "#/components/schemas/RustClanAudience"
}
}
}