diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 052c666..08cb264 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -61,12 +61,19 @@ # # Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only # Node — no Docker socket, no database. +# +# Scope note: `edge` is gated as well as `main`. Multi-phase work lands there +# first, so gating only the `main` hop would run these checks for the first time +# at the cutover — the one moment a red build is most expensive to discover. This +# is the same call `RunicGateway/installer` made for the same reason, and it was +# taken here after a nine-PR Android workstream landed on an ungated `edge` with +# no CI at all. Adding a branch to the `branches:` list is the whole change. name: PR Checks on: pull_request: - branches: [main] + branches: [main, edge] # A newer push to the same PR cancels the in-flight run. concurrency: diff --git a/ci/core-ref.json b/ci/core-ref.json index 9e78853..3119e37 100644 --- a/ci/core-ref.json +++ b/ci/core-ref.json @@ -1,6 +1,6 @@ { "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", - "ref": "87230c879aa6e9adde3507718aed6bc4e4d86009", - "refName": "edge @ phase 3 slice 4 (website#140)" + "ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2", + "refName": "main @ the Teams cutover (website#161)" } diff --git a/client/src/api.js b/client/src/api.js index 28aed0d..2746aab 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -39,6 +39,7 @@ export const shard = { champs: () => req('/public/shard/champs'), // Protocol 2.0 boards. guilds: () => req('/public/shard/guilds'), + guild: (id) => req(`/public/shard/guilds/${encodeURIComponent(id)}`), governors: () => req('/public/shard/governors'), governorHistory: (city, limit) => req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`), diff --git a/client/src/core.js b/client/src/core.js index bce4793..f3da217 100644 --- a/client/src/core.js +++ b/client/src/core.js @@ -48,7 +48,7 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat ) } -// The curated kit (§3.4). Seven members, closed: anything else this module needs +// The curated kit (§3.4). Eight members, closed: anything else this module needs // it bundles itself, which is why `components/` next door exists at all. export const { PublicLayout, @@ -59,6 +59,11 @@ export const { useAsync, useAuth, useSite, + // Eighth member (MODULE_API 1.6.0): the slot renderer, for the INVERTED + // direction — this module declares a place on its own page and CORE fills it. + // Shared rather than reimplemented so core's content failing inside our page is + // contained by core's own error boundary. + Slot, } = rg.ui // The registry, for entry.jsx. Everything else here is read by pages. diff --git a/client/src/entry.jsx b/client/src/entry.jsx index 3b78cf0..3a9b142 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -26,6 +26,7 @@ import Shard from './routes/public/Shard.jsx' import ShardActivity from './routes/public/ShardActivity.jsx' import ChampSpawns from './routes/public/ChampSpawns.jsx' import Guilds from './routes/public/Guilds.jsx' +import Guild from './routes/public/Guild.jsx' import Governors from './routes/public/Governors.jsx' import Houses from './routes/public/Houses.jsx' import Rules from './routes/public/Rules.jsx' @@ -81,6 +82,7 @@ registry.registerRoutes(ID, { { path: 'shard/activity', element: }, { path: 'champs', element: }, { path: 'guilds', element: }, + { path: 'guilds/:id', element: }, { path: 'governors', element: }, { path: 'houses', element: }, { path: 'rules', element: }, @@ -180,6 +182,35 @@ registry.registerFeatureProvider(ID, ID, useShardFlags) registry.registerExtension(ID, 'site.footer.status', ShardStatusLink) registry.registerExtension(ID, 'admin.users.detail', UserShardSections) registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep) +// ── The inverted slot: this module DECLARES, core fills ──────────────────── +// +// The other three above are core's slots that this module fills. This one is the +// reverse (TEAMS.md Part 3): Teams are a core primitive that this module +// populates, but core does not own the word "guild" and publishes no Team page of +// its own — so the page is ours and core contributes the activity feed to it. +// +// Declared under this module's own namespace, which core enforces. The second +// argument is what gets core's content into the place: **core offers a +// CONTRIBUTION and never names a slot**, so this module says where each one goes +// and keeps its own word for the place. Core's fills are applied after every +// module chunk has evaluated, so declaring here is early enough; on a core that +// knows nothing of Teams the slot simply stays empty. +registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' }) + +// A SECOND place on the same page, for core's Team forum (TEAMS.md Part 5). Two +// declarations rather than one, because a slot holds one component and this module +// wants to decide where each of core's two contributions sits on its own page — +// the feed reads as part of the guild's story, the forum is a room you go into. +// Neither knows the other exists, and a core that fills only one leaves the other +// empty. +registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' }) + +// And a THIRD, at the top of the same page, for core's per-Team notification +// control (TEAMS.md §6.3). Same reasoning as the other two and a different place: +// muting a guild is an action ON this page, so it sits with the page's heading +// rather than after its content. Core resolves whether this viewer is in the +// Team at all — this module neither knows nor asks. +registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' }) // `module.json`'s `coreApi` range is checked by the loader before this file is // ever served, so there is nothing to re-check here. It is logged because a diff --git a/client/src/routes/public/Guild.jsx b/client/src/routes/public/Guild.jsx new file mode 100644 index 0000000..eea0df8 --- /dev/null +++ b/client/src/routes/public/Guild.jsx @@ -0,0 +1,122 @@ +import { useParams, Link } from 'react-router-dom' +import api from '../../api.js' +import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js' + +// One guild: its roster, and the place core puts the Team activity feed. +// +// **This page is the reason the extension-slot direction inverts** +// (docs/website/TEAMS.md Part 3). Teams are a core platform primitive and this +// module is what populates them — but core does not own the word "guild", so it +// publishes no Team page of its own. The page is this module's; the activity feed +// on it is core's, because only core can resolve whether the viewer is inside the +// Team, and the public/members split on that feed is a security boundary. +// +// So the module declares `uo.guild.detail` (entry.jsx) and core fills it. On a +// core that does not know about Teams the slot is simply never filled and this +// page renders its roster alone, which is the same tolerance every other slot has. +// +// The roster comes from this module's OWN board — the same data it answers core's +// Team provider from — rather than from core's Team API. That is deliberate: the +// board is the authoritative copy here, and reading core's projection of our own +// answer back would be a round trip through a staler copy of our own data. + +function rankOf(m) { + // Absent rank means NOT KNOWN, never rank 0. The bridge omits it entirely for + // staff, because ServUO reports GameMaster-and-above as Leader whatever their + // real rank — emitting that verbatim would publish every staff member in a + // guild as one of its leaders (docs/link/v4.md). + if (m.rankName) return m.rankName + return null +} + +function MemberRow({ m }) { + const rank = rankOf(m) + const linked = m.webId != null || m.acct != null + return ( + + + {m.name || 'Unknown'} + {m.rank === 4 && ( + Leader + )} + + {rank || '—'} + + {linked ? 'Linked' : '—'} + + + ) +} + +export default function Guild() { + const { id } = useParams() + const { loading, error, data } = useAsync(() => api.shard.guild(id), [id]) + const roster = (data && data.roster) || [] + + return ( + +
+

+ ← All guilds +

+ + {loading && } + {error && } + + {!loading && !error && data && ( + <> + +

+ {data.members ?? roster.length} members + {data.online != null && ` · ${data.online} online`} + {data.alliance && ` · ${data.alliance}`} +

+ + {/* A third place for core, up here rather than below the roster: core + puts this guild's notification control in it, and a control that + acts on the page belongs beside the page's title and not after its + content. Empty for a visitor with no membership, and on a core + that fills nothing. */} + + + {roster.length > 0 && ( +
+ + + + + + + + + + {/* Keyed by serial: two characters can share a display name, + which this shard's own world actually contains. */} + {roster.map((m) => )} + +
NameRankAccount
+
+ )} + + {roster.length === 0 && ( +

No roster has been received for this guild yet.

+ )} + + {/* Core's Team activity feed lands here. Nothing renders on a core + that does not fill it, or when there is nothing to show. The guild + is named in OUR terms — core maps its own Team from these two. */} + + + {/* And the Team forum, in its own place below the feed. Core resolves + who may read it — membership and manual grants are core's rules — + so this module renders the room and never its door policy. */} + + + )} +
+
+ ) +} diff --git a/client/src/routes/public/Guilds.jsx b/client/src/routes/public/Guilds.jsx index b907c5d..be9ec0e 100644 --- a/client/src/routes/public/Guilds.jsx +++ b/client/src/routes/public/Guilds.jsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' import { useShardFeed } from '../../lib/useShardFeed.js' import api from '../../api.js' import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js' @@ -15,9 +16,12 @@ function Leader({ leader }) { function GuildRow({ g }) { return ( -
@@ -59,7 +63,7 @@ function GuildRow({ g }) {
-
+ ) } diff --git a/client/test/registration.test.js b/client/test/registration.test.js index 24e536f..8a57733 100644 --- a/client/test/registration.test.js +++ b/client/test/registration.test.js @@ -39,11 +39,18 @@ const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js') // nothing here renders, so a named stub is enough to be imported and passed on. const stub = (name) => Object.assign(() => null, { displayName: name }) +// Core's contribution catalogue, as of MODULE_API 1.6.0. Written down rather than +// imported — this suite runs against the BUILT chunk with no core in the process +// — which means it is a claim about core that has to be re-read when core's list +// changes. That is the same trade the rest of this fake makes. +const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify'] + function fakeRg() { const routes = { public: [], admin: [], player: [] } const nav = { public: [], admin: [], player: [] } const providers = new Map() const extensions = new Map() + const declaredSlots = new Map() return { version: '1.3.0', react, @@ -54,7 +61,7 @@ function fakeRg() { // object, so the check compares against whatever is here. reactDom: { createRoot: () => { throw new Error('not in a browser') } }, ui: Object.fromEntries( - ['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite'] + ['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot'] .map((n) => [n, stub(n)]), ), api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' }, @@ -72,10 +79,24 @@ function fakeRg() { if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`) extensions.set(slot, { id, Component }) }, + // The INVERTED direction (core API 1.6.0): this module declares a place on + // its OWN page and core fills it. Core enforces the namespace and the + // contribution name, so the fake does too — a chunk that declared an + // unnamespaced slot, or asked for a contribution core does not offer, would + // pass here and throw in a browser. + declareModuleSlot(id, name, options = {}) { + if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`) + if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`) + const wants = options.core ?? null + if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) { + throw new Error(`declareModuleSlot: "${name}" asks for core contribution "${wants}", which core does not offer`) + } + declaredSlots.set(name, wants) + }, routesFor: (area) => routes[area], navFor: (area) => nav[area], }, - _read: () => ({ routes, nav, providers, extensions }), + _read: () => ({ routes, nav, providers, extensions, declaredSlots }), } } @@ -98,7 +119,7 @@ const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run np it('registers routes in all three areas, namespaced under the module id', () => { const { routes } = registered - assert.equal(routes.public.length, 12) + assert.equal(routes.public.length, 13) assert.equal(routes.admin.length, 7) assert.equal(routes.player.length, 2) for (const area of ['public', 'admin', 'player']) { @@ -166,7 +187,7 @@ it('a nav row that gates on a feature is gated by a namespace this module provid assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered') }) -it('fills the three extension slots, each with a component', () => { +it('fills the three CORE extension slots, each with a component', () => { const { extensions } = registered assert.deepEqual( [...extensions.keys()].sort(), @@ -198,3 +219,34 @@ it('registers under exactly one module id, matching the manifest', () => { ]) assert.deepEqual([...owners], [manifest.id]) }) + +it('declares its own guild slots, each naming the core contribution it wants', () => { + // The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no + // core page: core owns the activity feed and the forum, this module owns the + // word "guild", so this module declares the places and core puts them in. + // + // THREE slots rather than one because a slot holds one component: stacking the + // feed, the forum and the notification control into a single fill would take + // away this module's ability to place them separately on its own page — and it + // does place them separately, the control above the roster and the other two + // below it. + // + // The second argument is what actually gets core's content here. **Core offers + // a contribution and never names a slot** — the first cut of this reached only + // this module, because core filled the literal name `uo.guild.detail` and any + // other game's page went empty with no error. + assert.deepEqual([...registered.declaredSlots.entries()], [ + ['uo.guild.detail', 'team.activity'], + ['uo.guild.forum', 'team.forum'], + ['uo.guild.header', 'team.notify'], + ]) +}) + +it('every declared slot is rendered by the page that owns it', () => { + // A slot nothing renders is a slot core fills into the void. Asserted against + // the source rather than the chunk, since the chunk is minified. + const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8') + for (const name of registered.declaredSlots.keys()) { + assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`)) + } +}) diff --git a/routes.manifest.json b/routes.manifest.json index a588abd..1b8d47d 100644 --- a/routes.manifest.json +++ b/routes.manifest.json @@ -201,6 +201,11 @@ "path": "/api/v1/public/shard/guilds", "tier": "public" }, + { + "method": "GET", + "path": "/api/v1/public/shard/guilds/:id", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/public/shard/houses", diff --git a/server/commands/guild.command.js b/server/commands/guild.command.js new file mode 100644 index 0000000..9908016 --- /dev/null +++ b/server/commands/guild.command.js @@ -0,0 +1,201 @@ +// ── `/guild` — the first chat command through the module contract ────────── +// +// Registered with `api.registerSlashCommands` (MODULE_API 1.6.0, TEAMS.md §7.1). +// The definition and this handler live here; the bot pulls the definition over +// the app's internal API and runs nothing of ours. Nothing in this file knows +// what Discord is — it is handed an `actor` and returns an envelope, and the +// same handler would serve a second platform unchanged. +// +// **Why `/guild` and not `/team`.** Teams are core's primitive and "guild" is +// this module's word for one; core does not own the word, so it does not publish +// the noun in a channel either. That is the same correction that deleted core's +// Team pages in phase 3, applied to the chat surface. +// +// **The audience rungs are enforced here, exactly as they are on the website.** +// A shard whose `guilds` feature is gated to staff does not become public +// because the question arrived over Discord — this handler resolves the caller's +// rung through the same `shardVisibility` config the routes use. It is the one +// piece of this file that is a security boundary rather than presentation. +const core = require('../core') +const db = require('../model/teamProvider/teamProvider.db') +const provider = require('../model/teamProvider/teamProvider.model') +const visibility = require('../utils/shardVisibility') + +const log = core.logger('guild-command') + +// How many guilds the no-argument form lists. A Discord embed takes 25 fields; +// ten is a summary a person reads rather than a table they scroll past. +const LIST_LIMIT = 10 + +/** + * Where the caller sits on this module's ladder. + * + * The same resolution `projectRoster` does, and it is duplicated in shape rather + * than shared because the inputs differ: that one is handed a viewer core + * described, this one an actor. Both end at `viewerLevel`, and both answer + * `anonymous` DIRECTLY for a caller with no site account — handing `viewerLevel` + * a synthetic empty request makes it fall through to `auth.getUserFromRequest`, + * which expects real cookies and throws (the phase 3 bug). + */ +async function levelFor(actor) { + if (!actor || !actor.userId) return 'anonymous' + return visibility.viewerLevel({ user: { id: actor.userId, role: actor.role } }) +} + +// The nudge §9 answer 5 asks for, and only when it is TRUE. +// +// **Linking reaches exactly two rungs and no further.** Signing in gets a caller +// to `logged_in` and linking a game account to `player`; `staff` and `admin` are +// roles an operator grants and no amount of linking will earn. So a shard that +// gates guilds to staff refuses an unlinked caller WITHOUT the invitation — +// telling them to link would be telling them to do something that changes +// nothing, which is worse than saying no. +// +// The live walk found this: gated to `staff`, the refusal still read "this shard +// shows guild information to linked players". +const LINKING_REACHES = new Set(['logged_in', 'player']) + +function linkPrompt(actor, audience) { + if (actor.isLinked) return null + if (!LINKING_REACHES.has(audience)) return null + return 'Link your account on the site to see more — this shard shows guild information to linked players.' +} + +const pageUrl = (externalId) => + `${core.baseUrl}${provider.pageUrlTemplate.replace('{externalId}', externalId)}` + +// Match on abbreviation first, then an exact name, then a unique prefix. Players +// type the abbreviation — it is what appears over a character's head — and a +// wrong-guild answer is worse than "say which one". +function findByName(rows, wanted) { + const needle = wanted.trim().toLowerCase() + const byAbbr = rows.filter((r) => (r.abbr || '').toLowerCase() === needle) + if (byAbbr.length === 1) return { guild: byAbbr[0] } + const exact = rows.filter((r) => r.name.toLowerCase() === needle) + if (exact.length === 1) return { guild: exact[0] } + const partial = rows.filter((r) => r.name.toLowerCase().includes(needle)) + if (partial.length === 1) return { guild: partial[0] } + if (partial.length > 1) return { ambiguous: partial.slice(0, LIST_LIMIT) } + return {} +} + +/** The counts for one guild, from the roster rather than the board's assertions. */ +async function summarise(guild) { + const members = await db.listGuildMembers(guild.id) + const leaders = members + .filter((m) => Number(m.rank) >= db.LEADER_RANK) + .map((m) => m.name) + // The board's founder-leader is folded in as a floor, the same way + // getTeamLeaders does it: it arrives on a different frame, and a shard whose + // roster predates the rank amendment has no other leadership signal. + if (guild.leader_name && !leaders.includes(guild.leader_name)) leaders.push(guild.leader_name) + + return { + // `members`/`online` are the BOARD's counts, which is what the shard asserts; + // the roster is what it enumerated, and the two legitimately disagree for the + // moment between a membership change and the sweep that reports it. The + // assertion is the more current of the two, so it is what is shown. + members: guild.members, + online: guild.online, + linked: members.filter((m) => provider.resolveUserId(m) !== null).length, + leaders, + } +} + +async function detail(guild, actor, audience) { + const counts = await summarise(guild) + const fields = [ + { name: 'Members', value: String(counts.members ?? '—'), inline: true }, + { name: 'Online', value: String(counts.online ?? 0), inline: true }, + { name: 'Linked accounts', value: String(counts.linked), inline: true }, + ] + if (counts.leaders.length) { + fields.push({ name: 'Leaders', value: counts.leaders.join(', ') }) + } + return { + title: guild.abbr ? `${guild.name} [${guild.abbr}]` : guild.name, + text: guild.alliance ? `Alliance: ${guild.alliance}` : undefined, + fields, + url: pageUrl(guild.id), + notice: linkPrompt(actor, audience), + } +} + +/** + * `/guild [name]` — one guild's summary, or the shard's largest guilds. + * + * Never throws for an ordinary miss: "no such guild" and "the shard is offline" + * are answers, and letting either become an exception would turn a routine + * question into "that command failed" with nothing an operator could act on. + */ +async function handler({ options, actor }) { + const config = await visibility.getConfig() + const feature = config.guilds + + // An admin turned guilds off. The switch means "this shard does not publish + // guild data" — over any surface, to anyone, staff included. + if (!feature || !feature.enabled) { + return { text: 'This shard does not publish guild information.', ephemeral: true } + } + + const level = await levelFor(actor) + if (!visibility.meets(level, feature.audience)) { + return { + text: 'Guild information on this shard is not shown to your account.', + ephemeral: true, + notice: linkPrompt(actor, feature.audience), + } + } + + // The provider's own staleness guard, asked before any board read: an + // unreachable sidecar means the board is a snapshot of unknown age, and + // reporting it as current here would contradict what every other surface says. + const ready = await provider.boardIsCurrent() + if (!ready.ok) { + log.info('guild command answered offline', { reason: ready.reason }) + return { text: 'The shard is not connected right now, so guild information may be out of date.', ephemeral: true } + } + + const rows = await db.listGuilds() + if (!rows.length) return { text: 'No guilds are on the board yet.', ephemeral: true } + + const wanted = options && typeof options.name === 'string' ? options.name : null + if (!wanted) { + const top = [...rows].sort((a, b) => (b.members || 0) - (a.members || 0)).slice(0, LIST_LIMIT) + return { + // Not "Guilds on ": `ctx.site` carries a base URL and no brand name, + // so naming the deployment here can only mean printing its hostname into + // an embed title, which is noise on a shard's own Discord server. + title: 'Guilds on this shard', + fields: top.map((g) => ({ + name: g.abbr ? `${g.name} [${g.abbr}]` : g.name, + value: `${g.members || 0} members · ${g.online || 0} online`, + inline: true, + })), + notice: linkPrompt(actor, feature.audience), + } + } + + const { guild, ambiguous } = findByName(rows, wanted) + if (ambiguous) { + return { + text: `Several guilds match “${wanted}”: ${ambiguous.map((g) => g.name).join(', ')}`, + ephemeral: true, + } + } + if (!guild) return { text: `No guild matches “${wanted}”.`, ephemeral: true } + return detail(guild, actor, feature.audience) +} + +module.exports = { + name: 'guild', + description: 'Show a guild on this shard — members, who is online, and its leaders', + options: [ + { name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false }, + ], + // Everyone, deliberately. The gate that matters is the shard's own audience + // rung, resolved inside the handler — `access: 'linked'` would hide the command + // from exactly the unlinked members §9 answer 5 wants to invite to link. + access: 'everyone', + handler, +} diff --git a/server/db/purge.sql b/server/db/purge.sql index 7b6cc48..86d5679 100644 --- a/server/db/purge.sql +++ b/server/db/purge.sql @@ -45,6 +45,7 @@ DROP TABLE IF EXISTS `shard_ruleset`; DROP TABLE IF EXISTS `shard_presence`; DROP TABLE IF EXISTS `shard_governor_terms`; DROP TABLE IF EXISTS `shard_governors`; +DROP TABLE IF EXISTS `shard_guild_members`; DROP TABLE IF EXISTS `shard_guilds`; DROP TABLE IF EXISTS `shard_pages`; DROP TABLE IF EXISTS `shard_champs`; diff --git a/server/db/schema.sql b/server/db/schema.sql index ae263e4..4ce751b 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -220,6 +220,52 @@ CREATE TABLE IF NOT EXISTS shard_guilds ( INDEX idx_shard_guilds_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Guild membership (Protocol 4). One row per member per guild, replaced on +-- guild.roster and thinned by guild.leave. Protocol 2 could only say HOW MANY +-- members a guild had, so this table has no pre-4 equivalent and the Guilds page +-- could show a count but never a roster. +-- +-- `acct` / `web_id` are the site-identity fields and are stored because the +-- sidecar forwards them; they are NOT public. shardVisibility locks any key that +-- is or ends in acct/webId to `admin` and recurses into arrays, so a projected +-- roster loses them below that rung — storing them here is what lets a linked +-- member be matched to a site user at all. +-- +-- A roster over the shard's per-frame cap arrives in several frames, so rows are +-- keyed on (guild_id, serial) and the frame carrying seq 0 clears the guild first; +-- see upsertGuildRoster. +CREATE TABLE IF NOT EXISTS shard_guild_members ( + guild_id INT NOT NULL, + serial VARCHAR(20) NOT NULL, -- in-game mobile serial, "0x1F5" + name VARCHAR(120) NULL, + acct VARCHAR(120) NULL, -- absent for a mobile with no account + web_id INT NULL, -- set only when the account is linked + is_player TINYINT(1) NOT NULL DEFAULT 1, + -- Guild rank, 0-4, with 4 being Leader (ServUO RankDefinition.Ranks). NULL means + -- "not known", which is a real state and not a demotion: the shard omits the rank + -- for a staff account, because PlayerMobile.GuildRank reports Leader for anyone at + -- GameMaster or above whatever their actual rank, and publishing that would put a + -- staff member on a public roster as a guild leader. + -- Backticked, like `int` on shard_online: RANK is a reserved word in MySQL 8 and + -- a non-reserved keyword in MariaDB, so it parses here bare but must not be + -- written that way anywhere it might not. + `rank` TINYINT NULL, + -- The rank's NAME, as the game states it: a cliloc id for the five standard ranks + -- (1062959-1062963, which ship with no text), or a literal string when a shard has + -- replaced the rank table with custom definitions. Resolving one to a label is this + -- module's job -- it owns the cliloc table and the game vocabulary. + rank_cliloc INT NULL, + rank_name VARCHAR(64) NULL, + t BIGINT NULL, -- roster event time, epoch ms + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (guild_id, serial), + INDEX idx_shard_guild_members_acct (acct), + INDEX idx_shard_guild_members_web (web_id), + -- Leadership is "rank >= 4", asked per guild, which is the query the Team provider + -- runs on every reconcile. + INDEX idx_shard_guild_members_rank (guild_id, rank) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on -- city.update (full-state, emitted only on change; there is no remove event since -- the set of cities is fixed). governor / governorElect are actor objects @@ -641,4 +687,14 @@ INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated' -- only a database that has never seen the key gets the default. Nothing in core -- reads either one; `game_account_signup` is read through ctx.settings by -- server/utils/gameSignup.js, which owns the policy. -INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled'); \ No newline at end of file +INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled'); +-- Protocol 4 guild rank, added to databases that already have shard_guild_members. +-- +-- The table itself is new in Protocol 4 and unreleased, so no production install has +-- it — but `edge` deployments do, from the roster work that landed before the rank +-- amendment, and CREATE TABLE IF NOT EXISTS adds a table and never a column. This is +-- the same gap the sidecar's own store hit when `guilds.members` was added. +ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS `rank` TINYINT NULL; +ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_cliloc INT NULL; +ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) NULL; +ALTER TABLE shard_guild_members ADD INDEX IF NOT EXISTS idx_shard_guild_members_rank (guild_id, `rank`); diff --git a/server/index.js b/server/index.js index f62a4b7..1421178 100644 --- a/server/index.js +++ b/server/index.js @@ -45,6 +45,8 @@ module.exports = function register(ctx, api) { const shardStreams = require('./config/shardStreams') const townCrierLeg = require('./utils/shardAnnounce') + const teamProvider = require('./model/teamProvider/teamProvider.model') + const guildCommand = require('./commands/guild.command') const boot = require('./boot') /* eslint-enable global-require */ @@ -86,6 +88,25 @@ module.exports = function register(ctx, api) { api.registerNotificationStreams(shardStreams.STREAMS) api.registerAnnounceLeg(townCrierLeg.leg) + // Teams: a UO guild is a Team, and this module is the authoritative source of + // them for this deployment (MODULE_API 1.6.0). Core asks the three questions; + // everything about what a guild IS stays here. + // + // Registration is a claim, not a call — nothing below runs until core + // reconciles, which is after `onBoot`. That matters because every method reads + // the database, and registration must not. + api.registerTeamProvider(teamProvider) + + // `/guild` — the chat surface for the same guilds (MODULE_API 1.6.0, TEAMS.md + // §7.1). The definition travels to the bot; the handler stays here and runs in + // the website process, because the bot container has no `modules` volume and + // cannot load a line of this module's code. + // + // Core registers NO commands of its own. "Guild" is this module's word — core + // does not own it on a page (phase 3) and does not publish it in a channel + // either. + api.registerSlashCommands([guildCommand]) + api.onBoot(boot.onBoot) api.onShutdown(boot.onShutdown) diff --git a/server/model/shardState/shardState.db.js b/server/model/shardState/shardState.db.js index 5bdacfe..4dbc569 100644 --- a/server/model/shardState/shardState.db.js +++ b/server/model/shardState/shardState.db.js @@ -171,6 +171,50 @@ const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id]) const clearGuilds = () => query('DELETE FROM shard_guilds') const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`) +// ── Guild membership (Protocol 4) ────────────────────────────────────────── +// `rank` is backticked wherever it is written, like `int` on shard_online: it is a +// reserved word in MySQL 8 and merely a keyword in MariaDB, so it parses bare here +// and must not be relied on to. +const MEMBER_COLS = 'guild_id, serial, name, acct, web_id, is_player, `rank`, rank_cliloc, rank_name, t' + +// Upsert rather than plain insert: a roster frame can be redelivered (the /history +// backfill replays stored frames on every reconnect), and a redelivery must be a +// no-op rather than a duplicate-key error. +// +// The rank columns are assigned unconditionally, NULL included. A member whose rank +// the shard withheld — a staff account, whose GuildRank getter reports Leader +// regardless of the truth — must go back to "not known" rather than keeping a rank +// from before they were promoted. +const upsertGuildMembers = (rows) => { + if (!rows.length) return Promise.resolve() + const values = rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ') + const params = rows.flatMap((r) => [ + r.guild_id, r.serial, r.name, r.acct, r.web_id, r.is_player, + r.rank, r.rank_cliloc, r.rank_name, r.t, + ]) + return query( + `INSERT INTO shard_guild_members (${MEMBER_COLS}) VALUES ${values} + ON DUPLICATE KEY UPDATE name = VALUES(name), acct = VALUES(acct), + web_id = VALUES(web_id), is_player = VALUES(is_player), + \`rank\` = VALUES(\`rank\`), rank_cliloc = VALUES(rank_cliloc), + rank_name = VALUES(rank_name), t = VALUES(t)`, + params, + ) +} + +const clearGuildMembers = (guildId) => + query('DELETE FROM shard_guild_members WHERE guild_id = ?', [guildId]) + +const removeGuildMember = (guildId, serial) => + query('DELETE FROM shard_guild_members WHERE guild_id = ? AND serial = ?', [guildId, serial]) + +const clearAllGuildMembers = () => query('DELETE FROM shard_guild_members') + +const listGuildMembers = (guildId) => + query(`SELECT ${MEMBER_COLS} FROM shard_guild_members WHERE guild_id = ? ORDER BY name ASC`, [ + guildId, + ]) + // The guild an actor LEADS — matched on the current board (leader_serial or the // linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders // is not modelled (the board carries only counts + leader), so we don't guess it. @@ -342,6 +386,11 @@ module.exports = { removeGuild, clearGuilds, listGuilds, + upsertGuildMembers, + clearGuildMembers, + removeGuildMember, + clearAllGuildMembers, + listGuildMembers, findGuildLedByActor, listGuildsLedByAccounts, upsertGovernor, diff --git a/server/model/shardState/shardState.model.js b/server/model/shardState/shardState.model.js index 10a3ba2..7c25254 100644 --- a/server/model/shardState/shardState.model.js +++ b/server/model/shardState/shardState.model.js @@ -340,8 +340,88 @@ async function upsertGuild(ev) { }) } -const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id)) -const clearGuilds = () => db.clearGuilds() +const removeGuild = async (id) => { + if (id == null) return + await db.removeGuild(id) + await db.clearGuildMembers(id) +} +const clearGuilds = async () => { + await db.clearGuilds() + await db.clearAllGuildMembers() +} + +// ── Guild membership (Protocol 4) ────────────────────────────────────────── +// Apply one guild.roster frame. +// +// A roster larger than the shard's per-frame cap arrives as several frames +// carrying seq/more/total. The sidecar reassembles them for its OWN board, but the +// live WebSocket feed and the /history backfill both carry the individual frames, +// so this ingest sees them unreassembled and has to cope. +// +// It copes without buffering, because a table can express what a single JSON column +// could not: the frame carrying seq 0 clears the guild first and every frame then +// upserts its own rows. Rows are keyed on (guild_id, serial), so a redelivered frame +// — the /history backfill replays stored frames on every reconnect — is idempotent +// rather than a duplicate-key error. +// +// The cost is a brief window during a multi-frame update where the table holds part +// of a roster. That is acceptable for a projection that is already only as fresh as +// a 60s sweep, and the frames arrive back-to-back in one burst; buffering to close +// it would duplicate the sidecar's reassembly for a sub-second inconsistency. +async function upsertGuildRoster(ev) { + if (!ev || ev.id == null) return + + const seq = Number.isFinite(ev.seq) ? ev.seq : 0 + const members = Array.isArray(ev.members) ? ev.members : [] + + // seq 0 begins a roster and supersedes whatever was held for this guild. + if (seq === 0) await db.clearGuildMembers(ev.id) + + const rows = members + .filter((m) => m && m.serial) + .map((m) => ({ + guild_id: ev.id, + serial: m.serial, + name: m.name ?? null, + acct: m.acct ?? null, + web_id: Number.isFinite(m.webId) ? m.webId : null, + is_player: m.player ? 1 : 0, + // Guild rank (Protocol 4). ABSENT is a real state and is stored as NULL: the + // shard withholds the rank for a staff account, because ServUO's GuildRank + // getter reports Leader for anyone at GameMaster or above whatever their + // actual rank. Defaulting a missing rank to 0 here would turn "we were not + // told" into "rank 0", which is a demotion invented by this line. + rank: Number.isInteger(m.rank) ? m.rank : null, + rank_cliloc: Number.isInteger(m.rankCliloc) ? m.rankCliloc : null, + rank_name: typeof m.rankName === 'string' && m.rankName ? m.rankName.slice(0, 64) : null, + t: Number.isFinite(ev.t) ? ev.t : null, + })) + + await db.upsertGuildMembers(rows) +} + +// A single departure (guild.leave). Advisory: the shard re-emits the full roster +// whenever the member set changes, so the table would converge on the next frame +// even if this were dropped. Applying it makes the change visible immediately +// instead of at the end of the sweep that produced it. +async function removeGuildMember(ev) { + if (!ev || ev.id == null || !ev.who) return + await db.removeGuildMember(ev.id, ev.who) +} + +// The membership roster for one guild, in the wire shape the projection expects +// (an array of actor objects), so shardVisibility strips acct/webId by the same +// rule it applies to guild.leader. +async function listGuildMembers(guildId) { + const rows = await db.listGuildMembers(guildId) + return rows.map((r) => ({ + serial: r.serial, + name: r.name, + ...(r.acct == null ? {} : { acct: r.acct }), + ...(r.web_id == null ? {} : { webId: r.web_id }), + player: !!r.is_player, + })) +} function shapeGuild(r) { const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload @@ -620,6 +700,9 @@ module.exports = { removeGuild, clearGuilds, listGuilds, + upsertGuildRoster, + removeGuildMember, + listGuildMembers, replaceGuilds, findGuildForActor, listGuildsLedForAccounts, diff --git a/server/model/teamProvider/teamProvider.db.js b/server/model/teamProvider/teamProvider.db.js new file mode 100644 index 0000000..88980ff --- /dev/null +++ b/server/model/teamProvider/teamProvider.db.js @@ -0,0 +1,87 @@ +// SQL behind the Team provider — three questions core asks, answered from the +// guild board and the roster Protocol 4 put there. +// +// Every statement reads only THIS module's tables. Core's Team tables are +// core-internal (docs/website/TEAMS.md §10.3) and this module must never name +// one, even though it is what fills them. + +// `query` is destructured from the core facade at require time, like every other +// *.db.js here. The facade resolves `ctx` per call, so taking it now is safe even +// though `ctx` does not exist yet when this file is first required. +const { query } = require('../../core') + +/** ServUO's `RankDefinition.Ranks[4]` is Leader, and 4 is the top of the ladder. */ +const LEADER_RANK = 4 + +/** + * The guild board — one row per guild the shard has told us about. + * + * `members`/`online` here are the COUNTS `guild.update` carries; the roster is a + * separate table (Protocol 4). Both are read, because a count is what the shard + * asserts and a roster is what it enumerated, and they can legitimately disagree + * for the moment between a membership change and the sweep that reports it. + */ +const listGuilds = () => + query( + `SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct + FROM shard_guilds ORDER BY name ASC`, + ) + +const findGuild = (id) => + query( + `SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct + FROM shard_guilds WHERE id = ? LIMIT 1`, + [id], + ) + +/** + * One guild's roster, with the site link and live presence folded in. + * + * Two LEFT JOINs, both deliberate: + * + * - `shard_account_links` resolves `user_id` HERE rather than in core, because + * this module owns that table and a core that read it would be core naming a + * module's table by name (§2.3). It is also why a freshly linked account + * appears as linked on the next reconcile rather than needing core to know + * anything about linking. + * - `shard_online` is how a member's `online` is answered at all. The roster + * frame does not carry it — the wire's member is the standard actor object + * (`serial`, `name`, `player`, `acct?`, `webId?`), and the board's `online` is + * a count, not a set. Presence therefore comes from the online table, which + * is the same source the public "who's online" surface already uses. + * + * `web_id` on the roster row is preferred over the link table when present: it is + * what the shard itself asserted at roster time, and the join is the fallback for + * a member whose row predates their link. + */ +const listGuildMembers = (guildId) => + query( + "SELECT m.serial, m.name, m.acct, m.web_id, m.is_player, m.`rank`, m.rank_cliloc, m.rank_name, " + + ` l.user_id AS linked_user_id, + (o.serial IS NOT NULL) AS is_online + FROM shard_guild_members m + LEFT JOIN shard_account_links l ON l.account = m.acct + LEFT JOIN shard_online o ON o.serial = m.serial + WHERE m.guild_id = ? + ORDER BY m.name ASC`, + [guildId], + ) + +/** + * Every member at leader rank — rank 4, the top of ServUO's `RankDefinition.Ranks`. + * + * A set, not a single row, and that is the whole reason Protocol 4 grew a per-member + * rank: the guild board carries one `leader_serial`, so before this the website could + * only ever be told about one leader, while a UO guild routinely has several. + * + * A NULL rank is excluded by the comparison, which is correct — the shard withholds + * the rank for a staff account rather than publishing the Leader its getter falsely + * reports, and "not known" must not be read as "leads this guild". + */ +const listGuildLeaders = (guildId) => + query( + 'SELECT serial FROM shard_guild_members WHERE guild_id = ? AND `rank` >= ? ORDER BY name ASC', + [guildId, LEADER_RANK], + ) + +module.exports = { listGuilds, findGuild, listGuildMembers, listGuildLeaders, LEADER_RANK } diff --git a/server/model/teamProvider/teamProvider.model.js b/server/model/teamProvider/teamProvider.model.js new file mode 100644 index 0000000..5e61592 --- /dev/null +++ b/server/model/teamProvider/teamProvider.model.js @@ -0,0 +1,339 @@ +// ── module-uo's Team provider ────────────────────────────────────────────── +// +// The three questions core asks this module about Teams +// (docs/website/MODULE_API.md — `api.registerTeamProvider`, and TEAMS.md §2.3). +// A UO guild is a Team; this file is the whole of the translation. +// +// **Every method returns an envelope, and answering `{ ok: false }` is a normal +// outcome, not a failure to handle.** Core's contract is that module +// unavailability becomes staleness and never emptiness, and the only way this +// module can say "I cannot answer" is to say so — an empty array would be read as +// an authoritative "there are none", which during a cold start is how every +// roster on the site gets emptied. So the guard below is the most important code +// in the file, and it is deliberately conservative: **an unreachable or +// never-connected sidecar refuses, rather than reporting the board it happens to +// still hold.** +// +// The board IS durable and would survive a sidecar outage, which is exactly what +// makes this tempting to get wrong. The reason to refuse anyway: core cannot tell +// a board that is five minutes stale from one that is five days stale, and it +// makes destructive decisions — archiving Teams, departing members — from a +// complete answer. Reporting a stale board as authoritative would license those. + +const core = require('../../core') +const db = require('./teamProvider.db') +const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model') +const uoLinkSocket = require('../../utils/uoLinkSocket') +const clilocs = require('../shardClilocs/shardClilocs.model') +const visibility = require('../../utils/shardVisibility') + +const log = core.logger('teams') + +/** + * ServUO's five stock rank names, by the cliloc id the game names them with. + * + * A fallback, not the source of truth: the operator's own cliloc table is consulted + * first, and a shard with custom rank definitions sends a literal string that beats + * both. This exists because the cliloc table is populated only if someone ran the + * client-file extraction, and a roster on a shard that has not should still say + * "Warlord" rather than nothing. + */ +const STANDARD_RANK_NAMES = { + 1062959: 'Leader', + 1062960: 'Warlord', + 1062961: 'Emissary', + 1062962: 'Member', + 1062963: 'Ronin', +} + +/** A refusal, in the shape core reads (§2.3). */ +const refuse = (reason) => ({ ok: false, reason }) + +/** + * Is the bridge in a state where the board can be trusted as current? + * + * The board is only as good as the socket that fills it. Three states refuse, and + * they are asked in this order because each is a different thing being wrong: + * + * - **no uo-link configured** — there is no shard behind this website at all; + * - **the integration is disabled** — an admin turned it off, and the board is + * frozen at whatever it held; + * - **the socket is not connected** — the board is a snapshot of unknown age. + * + * The in-process socket state is preferred over the persisted status column, + * which is written on transitions: a process that has just started has not + * transitioned yet, so the column can still say `connected` from the last run + * while this process has never opened a socket. + */ +async function boardIsCurrent() { + const config = await uoLinkConfig.getSafe() + if (!config || !config.baseUrl) return { ok: false, reason: 'no uo-link configured' } + if (!config.enabled) return { ok: false, reason: 'the uo-link integration is disabled' } + + const state = uoLinkSocket.getState() + if (!state || !state.connected) { + return { ok: false, reason: 'the uo-link socket is not connected; the guild board may be stale' } + } + return { ok: true } +} + +/** + * `getTeams()` — every guild on the board. + * + * `externalId` is the ServUO `Guild.Id`, which survives a rename: renaming a + * guild in-game keeps the id, so core sees "an id whose name changed" and applies + * its rename rule (archive plus create). That mapping is this module's to make — + * only the game knows what identity survives what (§10.5). + * + * `meta` carries the alliance, opaquely. Core stores and displays it and never + * branches on it, which is what lets a UO concept reach a Team page without core + * acquiring an opinion about alliances. + */ +async function getTeams() { + const ready = await boardIsCurrent() + if (!ready.ok) return refuse(ready.reason) + + try { + const rows = await db.listGuilds() + return { + ok: true, + complete: true, + teams: rows.map((row) => ({ + externalId: String(row.id), + name: row.name, + abbr: row.abbr || null, + meta: row.alliance ? { alliance: row.alliance } : null, + })), + } + } catch (err) { + log.warn('getTeams failed', { message: err.message }) + return refuse(`guild board unreadable: ${err.message}`) + } +} + +/** + * `getTeamMembers(externalId)` — one guild's roster. + * + * **A guild with no roster rows is refused, not reported empty**, unless the board + * itself says the guild has no members. Protocol 4's roster arrives on its own + * frames, separately from the `guild.update` that creates the board row, so there + * is a real window — a fresh guild, or a website that connected between the two — + * where core would otherwise be told authoritatively that a 155-member guild has + * nobody in it. The board's own `members` count is what distinguishes the two, + * and it is the only thing that can. + */ +async function getTeamMembers(externalId) { + const ready = await boardIsCurrent() + if (!ready.ok) return refuse(ready.reason) + + try { + const [guild] = await db.findGuild(externalId) + if (!guild) return refuse(`guild ${externalId} is not on the board`) + + const rows = await db.listGuildMembers(externalId) + if (!rows.length && guild.members > 0) { + return refuse(`roster for guild ${externalId} has not arrived yet (board says ${guild.members} members)`) + } + + const labels = await rankLabels(rows) + return { + ok: true, + complete: true, + members: rows.map((row) => ({ + memberKey: row.serial, + displayName: row.name || null, + rankLabel: labels.get(row.serial) || null, + // Rank 4 is Leader, and several members can hold it. A NULL rank is not a + // leader: the shard withholds the rank for a staff account rather than + // publishing the Leader its getter falsely reports, and "not known" must + // never be read as "leads this guild". + leader: Number.isInteger(row.rank) && row.rank >= db.LEADER_RANK, + online: Boolean(row.is_online), + userId: resolveUserId(row), + })), + } + } catch (err) { + log.warn('getTeamMembers failed', { externalId, message: err.message }) + return refuse(`roster unreadable: ${err.message}`) + } +} + +/** + * `getTeamLeaders(externalId)` — everyone at leader rank. + * + * **All of them, which is why Protocol 4 grew a per-member rank.** The guild board + * carries one `leader_serial`, so before the rank amendment this could only ever + * name a single member, while a UO guild routinely has several at rank 4 and + * TEAMS.md §2.5 treats multiple leaders as the normal case. + * + * The board's own `leader_serial` is folded in as a floor. It is the guild's + * founder-leader and it comes from a different frame (`guild.update`), so on a + * shard whose roster has not been re-emitted since the amendment it is the only + * leadership signal there is — and it should never be *lost* by moving to ranks. + */ +async function getTeamLeaders(externalId) { + const ready = await boardIsCurrent() + if (!ready.ok) return refuse(ready.reason) + + try { + const [guild] = await db.findGuild(externalId) + if (!guild) return refuse(`guild ${externalId} is not on the board`) + + const rows = await db.listGuildLeaders(externalId) + const leaders = rows.map((r) => r.serial) + + if (guild.leader_serial && !leaders.includes(guild.leader_serial)) { + leaders.push(guild.leader_serial) + } + return { ok: true, leaders } + } catch (err) { + log.warn('getTeamLeaders failed', { externalId, message: err.message }) + return refuse(`leadership unreadable: ${err.message}`) + } +} + +/** + * Resolve each member's rank to a display label, keyed by serial. + * + * The shard sends the rank's NAME as the game states it — a cliloc id for the five + * standard ranks, or a literal string for a custom rank definition — and never a + * resolved label, because ServUO ships no text for those clilocs. This module does + * have a cliloc table, which is why the resolution belongs here. + * + * Three sources, in order: a custom string wins, then the operator's cliloc table, + * then the five standard names. The last exists because the cliloc table is + * populated only if someone ran the client extraction, and a shard that has not + * should still read "Warlord" rather than nothing. + * + * Never throws: a rank label is decoration on a roster, and a lookup failure must + * not turn a good roster into a refusal. + */ +async function rankLabels(rows) { + const out = new Map() + const wanted = [] + + for (const row of rows) { + if (row.rank_name) { + out.set(row.serial, row.rank_name) + } else if (Number.isInteger(row.rank_cliloc)) { + wanted.push(row.rank_cliloc) + } + } + + let resolved = new Map() + if (wanted.length) { + try { + resolved = await clilocs.resolveMany(wanted) + } catch (err) { + log.warn('rank cliloc lookup failed; falling back to the standard names', { message: err.message }) + } + } + + for (const row of rows) { + if (out.has(row.serial) || !Number.isInteger(row.rank_cliloc)) continue + const label = resolved.get(row.rank_cliloc) || STANDARD_RANK_NAMES[row.rank_cliloc] || null + if (label) out.set(row.serial, label) + } + return out +} + +/** + * The site account behind a character, or null. + * + * `web_id` is what the shard itself asserted when it emitted the roster; the + * account-link join is the fallback for a member whose roster row predates their + * link. Both are coerced through the same check, because `web_id` arrives from + * the wire as a string. + */ +function resolveUserId(row) { + const fromRoster = Number.parseInt(row.web_id, 10) + if (Number.isInteger(fromRoster) && fromRoster > 0) return fromRoster + const fromLink = Number.parseInt(row.linked_user_id, 10) + return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null +} + +/** + * Which roster rows a viewer may see (TEAMS.md §3.3, MODULE_API 1.6.0). + * + * The optional fourth provider method, and the only one core calls on a REQUEST + * path rather than from the reconciler. Core holds the roster and its public + * shape; the question that is this module's is "who is allowed to look", because + * the audience rungs and their configuration live here (`utils/shardVisibility`) + * and core does not know what a rung is. + * + * **The answer is all-or-nothing, and that is correct rather than a shortcut.** + * A rung is a property of the FEATURE, not of a member: `guilds` is either + * visible to this viewer or it is not, and there is no configuration in which + * some members of a guild are public and others are not. Returning every key or + * none is the honest translation of the model this module actually has. + * + * **A refusal here costs visibility, not staleness.** Core fails closed on this + * one call — an unanswered visibility question serves an empty roster rather than + * an unprojected one — so every path below that cannot reach a confident answer + * refuses deliberately, and the catch does too. That is the opposite of the rule + * governing the other three methods, and it is the right way round: for a roster + * SYNC an unanswered call must change nothing, and for a roster READ it must + * publish nothing. + * + * Note what this does NOT do: strip fields. `acct` and `webId` are the leak this + * module's projection exists to prevent on the live feed, and neither is in + * core's roster shape at all — core withholds the member key and the site account + * id from every public roster whatever this returns. So there is nothing here to + * redact, only rows to withhold. + */ +async function projectRoster(externalId, members, viewer) { + try { + const config = await visibility.getConfig() + const feature = config.guilds + // An admin turned guilds off. Nobody sees a roster, including staff — the + // switch means "this shard does not publish guild data", not "publish it + // quietly". + if (!feature || !feature.enabled) return { ok: true, members: [] } + + // `viewerLevel` reads a REQUEST; core hands over a described viewer instead, + // which is deliberate — it keeps the `users` row out of the contract. + // + // The no-viewer case is answered here rather than by handing `viewerLevel` an + // empty object: given a request with no `req.user` it falls through to + // `auth.getUserFromRequest`, which expects real cookies and headers and + // throws on a synthetic one. That throw would land in the catch below and + // become a REFUSAL, so every anonymous visitor would have been served an + // empty roster on a shard whose guilds are public. Anonymous is a known + // answer, not a failed lookup. + const level = viewer + ? await visibility.viewerLevel({ user: { id: viewer.userId, role: viewer.role } }) + : 'anonymous' + if (!visibility.meets(level, feature.audience)) return { ok: true, members: [] } + + return { ok: true, members: members.map((m) => m.member_key).filter(Boolean) } + } catch (err) { + // Core reads this as "withhold the roster". Saying so is the whole point: the + // alternative — answering with every key because the config read failed — + // publishes a roster an operator may have gated to staff. + log.warn('projectRoster could not resolve visibility; withholding the roster', { + externalId, message: err.message, + }) + return refuse(`visibility could not be resolved: ${err.message}`) + } +} + +// Where core should point a link at a guild (MODULE_API 1.6.0, TEAMS.md §6.4). +// +// **Core cannot work this out for itself, and it is not supposed to.** Teams are +// a contract primitive with no core surface — this module owns the guild page, +// because core does not own the word "guild" — so the one thing core needs back +// is where the page it does not own actually lives. A notification email that +// cannot link to the thread it is about is most of the way to useless. +// +// A relative path with `{externalId}` substituted, matching `Guild.jsx`'s route +// (`/uo/guilds/:id`). Core does the substitution and nothing else with it; a +// template naming its own host is refused at registration, which is why this is +// data and not a callback. +const pageUrlTemplate = '/uo/guilds/{externalId}' + +// `resolveUserId` is exported for the `/guild` chat command, which counts linked +// members and must decide "linked" by the same rule the roster does — a second +// copy of that two-source check is a copy that drifts. +module.exports = { + getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate, resolveUserId, +} diff --git a/server/router/public/shard.controller.js b/server/router/public/shard.controller.js index cfa1357..10604e4 100644 --- a/server/router/public/shard.controller.js +++ b/server/router/public/shard.controller.js @@ -177,6 +177,31 @@ async function getGuilds(req, res) { } } +// GET /public/shard/guilds/:id — one guild and its roster. +// +// The board endpoint above returns every guild WITHOUT its roster; this is the +// detail view, and it is the page that hosts core's Team activity feed through +// the `uo.guild.detail` slot (docs/website/TEAMS.md Part 3). +// +// Projected through the same `guilds` feature as the board, so an operator who +// gates guilds to staff gates this too, and `acct`/`webId` on the roster rows +// never survive below admin — those are LOCKED fields, and a roster is where they +// actually appear in bulk. +async function getGuild(req, res) { + try { + const guilds = await shardState.listGuilds() + const guild = guilds.find((g) => String(g.id) === String(req.params.id)) + // 404 rather than an empty object: a guild that disbanded is gone, and the + // page needs to say so rather than render an empty shell. + if (!guild) return res.status(404).json({ message: 'Not Found' }) + const members = await shardState.listGuildMembers(guild.id) + return res.json(await visibility.project('guilds', { ...guild, roster: members }, req)) + } catch (err) { + log.error('shard.getGuild', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + // GET /public/shard/governors — the current town-governor board (empty on shards // without City Loyalty). Live via city.update on the public SSE stream. Projected // for the same reason as getGuilds: `governor` / `governorElect` are actors. @@ -421,6 +446,7 @@ module.exports = { getIdoc, getChamps, getGuilds, + getGuild, getGovernors, getGovernorHistory, getPresence, diff --git a/server/router/public/shard.router.js b/server/router/public/shard.router.js index 4997add..bcd744e 100644 --- a/server/router/public/shard.router.js +++ b/server/router/public/shard.router.js @@ -100,6 +100,17 @@ shardRouter.get( /* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ shard.getGuilds, ) +shardRouter.get( + '/guilds/:id', + requireFeature('guilds'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'One guild and its roster' + // #swagger.description = 'The detail view behind the board. Gated and projected through the same `guilds` feature, so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin — a roster is where they appear in bulk. This page is also where core renders the Team activity feed, through the `uo.guild.detail` extension slot.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The guild id.' } + /* #swagger.responses[200] = { description: 'The guild, with its roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[404] = { description: 'No such guild', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + shard.getGuild, +) shardRouter.get( '/governors', requireFeature('governors'), diff --git a/server/test/_fakes.js b/server/test/_fakes.js index 3f13c62..6397e16 100644 --- a/server/test/_fakes.js +++ b/server/test/_fakes.js @@ -95,6 +95,8 @@ function fakeApi() { extensions: [], streams: null, legs: [], + teamProvider: null, + slashCommands: [], hooks: {}, } const called = new Set() @@ -107,6 +109,14 @@ function fakeApi() { registerExtension(slot, router) { record.extensions.push({ slot, router }) }, registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams }, registerAnnounceLeg(leg) { record.legs.push(leg) }, + // MODULE_API 1.6.0. `once` because core holds a single provider per + // deployment — a second registration is a collision there, so it has to be + // one here too, or this suite would pass a shape core rejects at load. + registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider }, + // MODULE_API 1.6.0, live since phase 7. `once` for the same reason core + // takes it: a second call is a module changing its mind halfway through + // register(), which core rejects. + registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands }, onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn }, onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn }, } diff --git a/server/test/entry.test.js b/server/test/entry.test.js index cc4ee2d..91b9249 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -85,6 +85,35 @@ test('every registered stream is namespaced or grandfathered', () => { } }) +test('registers a Team provider with all three methods', () => { + // Core requires all three: a provider that could list Teams but not their + // members would leave core holding Teams it can never populate, which is not + // the same as a call that fails. Asserted here so a refactor that drops one + // fails in this suite rather than at load on an operator's install. + const api = fakeApi() + register(fakeCtx(), api) + + const provider = api.record.teamProvider + assert.ok(provider, 'a UO guild is a Team; something has to answer for them') + for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) { + assert.strictEqual(typeof provider[method], 'function', `${method} is missing`) + } +}) + +test('registration does not call the provider, or touch the database', async () => { + // register() runs while core's app.js is still being required, with the pool + // pointed at a dead port — routeManifest.js and swagger.js both depend on that. + // Registration is a CLAIM; core does not ask anything until it reconciles, + // which is after onBoot. + const ctx = fakeCtx() + let queried = false + const frozen = Object.freeze({ ...ctx, db: Object.freeze({ query: async () => { queried = true; return [] } }) }) + const api = fakeApi() + + register(frozen, api) + assert.equal(queried, false, 'a query at registration time would hang the manifest and the spec build') +}) + test('takes a frozen ctx and does not try to write to it', () => { const ctx = fakeCtx() assert.ok(Object.isFrozen(ctx)) diff --git a/server/test/guildCommand.test.js b/server/test/guildCommand.test.js new file mode 100644 index 0000000..7782632 --- /dev/null +++ b/server/test/guildCommand.test.js @@ -0,0 +1,148 @@ +// `/guild` — the chat command registered through `api.registerSlashCommands` +// (TEAMS.md §7.1, MODULE_API 1.6.0). +// +// The properties worth pinning are all about the ANSWER being the same answer +// the website gives, because that is the whole risk of a second surface: the +// audience rungs are re-resolved here rather than assumed, the shard's own +// offline guard is honoured, and the link prompt appears only when linking would +// actually change what the caller is told. + +const { test, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const command = require('../commands/guild.command') +const db = require('../model/teamProvider/teamProvider.db') +const provider = require('../model/teamProvider/teamProvider.model') +const visibility = require('../utils/shardVisibility') + +const originals = { + getConfig: visibility.getConfig, + viewerLevel: visibility.viewerLevel, + boardIsCurrent: provider.boardIsCurrent, + listGuilds: db.listGuilds, + listGuildMembers: db.listGuildMembers, +} + +afterEach(() => { + visibility.getConfig = originals.getConfig + visibility.viewerLevel = originals.viewerLevel + provider.boardIsCurrent = originals.boardIsCurrent + db.listGuilds = originals.listGuilds + db.listGuildMembers = originals.listGuildMembers +}) + +const GUILDS = [ + { id: 7, name: 'Knights of the Codex', abbr: 'KOC', alliance: 'The Accord', members: 12, online: 3, leader_name: 'Dain' }, + { id: 9, name: 'Knights Hospitaller', abbr: 'KH', alliance: null, members: 4, online: 0, leader_name: null }, +] + +const MEMBERS = [ + { serial: 1, name: 'Dain', rank: 4, web_id: '31', linked_user_id: null }, + { serial: 2, name: 'Elowen', rank: 4, web_id: null, linked_user_id: 44 }, + { serial: 3, name: 'Wat', rank: 2, web_id: null, linked_user_id: null }, +] + +function stub({ audience = 'anonymous', enabled = true, level = 'anonymous', current = true } = {}) { + visibility.getConfig = async () => ({ guilds: { enabled, audience } }) + visibility.viewerLevel = async () => level + provider.boardIsCurrent = async () => (current ? { ok: true } : { ok: false, reason: 'socket down' }) + db.listGuilds = async () => GUILDS + db.listGuildMembers = async () => MEMBERS +} + +const anonymous = { platform: 'discord', platformUserId: '1', userId: null, role: null, isLinked: false, isStaff: false } +const linked = { platform: 'discord', platformUserId: '2', userId: 31, role: 'player', isLinked: true, isStaff: false } + +test('the definition stays inside the option schema §7.1.1 allows', () => { + assert.equal(command.name, 'guild') + assert.equal(command.access, 'everyone') + for (const option of command.options) { + assert.ok(['string', 'integer', 'boolean', 'user'].includes(option.type)) + assert.ok(option.description.length <= 100) + } +}) + +test('the guilds feature being off withholds everything, staff included', async () => { + stub({ enabled: false, level: 'admin' }) + const res = await command.handler({ options: {}, actor: { ...linked, role: 'admin', isStaff: true } }) + assert.match(res.text, /does not publish guild information/) + assert.equal(res.ephemeral, true) +}) + +// The reason this command is not a thin wrapper over a public route: a rung +// below the feature's audience must be refused HERE, or a shard that gates +// guilds to staff would publish them to a Discord channel. +test('a caller below the feature audience is refused', async () => { + stub({ audience: 'staff', level: 'anonymous' }) + const res = await command.handler({ options: {}, actor: anonymous }) + assert.match(res.text, /not shown to your account/) + assert.equal(res.ephemeral, true) +}) + +test('an unlinked caller is invited to link — but only when linking would change the answer', async () => { + stub({ audience: 'player', level: 'anonymous' }) + const gated = await command.handler({ options: {}, actor: anonymous }) + assert.match(gated.notice, /Link your account/) + + // Public guilds: there is nothing more to see, so there is nothing to prompt. + stub({ audience: 'anonymous', level: 'anonymous' }) + const open = await command.handler({ options: {}, actor: anonymous }) + assert.equal(open.notice, null) + + // Gated to staff: linking reaches `player` and stops there, so the invitation + // would be an instruction to do something that changes nothing. Found on the + // live rig, where a staff-gated shard still offered it. + stub({ audience: 'staff', level: 'anonymous' }) + const unreachable = await command.handler({ options: {}, actor: anonymous }) + assert.match(unreachable.text, /not shown to your account/) + assert.equal(unreachable.notice, null) +}) + +test('a stale board answers offline rather than reporting what it still holds', async () => { + stub({ current: false }) + const res = await command.handler({ options: {}, actor: anonymous }) + assert.match(res.text, /not connected right now/) +}) + +test('no argument lists the largest guilds', async () => { + stub() + const res = await command.handler({ options: {}, actor: anonymous }) + assert.equal(res.title, 'Guilds on this shard') + assert.equal(res.fields.length, 2) + assert.match(res.fields[0].name, /Knights of the Codex/) + assert.match(res.fields[0].value, /12 members · 3 online/) +}) + +test('a name resolves by abbreviation, then exactly, then by unique prefix', async () => { + stub() + const byAbbr = await command.handler({ options: { name: 'koc' }, actor: anonymous }) + assert.match(byAbbr.title, /Knights of the Codex/) + + const exact = await command.handler({ options: { name: 'Knights Hospitaller' }, actor: anonymous }) + assert.match(exact.title, /Hospitaller/) + + // "knights" hits both, and answering with either would be worse than asking. + const ambiguous = await command.handler({ options: { name: 'knights' }, actor: anonymous }) + assert.match(ambiguous.text, /Several guilds match/) + assert.equal(ambiguous.ephemeral, true) +}) + +test('a miss is an answer, not a failure', async () => { + stub() + const res = await command.handler({ options: { name: 'nobody' }, actor: anonymous }) + assert.match(res.text, /No guild matches/) +}) + +// `linked` counts BOTH sources the roster uses — the shard's asserted web id and +// the link table — because that is what "linked" means everywhere else here. +test('the detail carries the counts, the leaders and a link to the module page', async () => { + stub({ level: 'player' }) + const res = await command.handler({ options: { name: 'KOC' }, actor: linked }) + const field = (name) => res.fields.find((f) => f.name === name).value + assert.equal(field('Members'), '12') + assert.equal(field('Online'), '3') + assert.equal(field('Linked accounts'), '2') + assert.equal(field('Leaders'), 'Dain, Elowen') + assert.match(res.url, /\/uo\/guilds\/7$/) + assert.equal(res.notice, null) +}) diff --git a/server/test/shardIngest.guildRoster.test.js b/server/test/shardIngest.guildRoster.test.js new file mode 100644 index 0000000..9981480 --- /dev/null +++ b/server/test/shardIngest.guildRoster.test.js @@ -0,0 +1,75 @@ +// Protocol 4 membership routing: guild.roster (board state, possibly chunked) and +// guild.leave (a real-time departure, logged like its guild.join counterpart). +const { test, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +const shardIngest = require('../utils/shardIngest') + +function makeDeps() { + const calls = { roster: [], memberRemove: [], appended: [], broadcast: [] } + const noop = async () => {} + return { + calls, + shardEvents: { append: async (row) => { calls.appended.push(row); return true } }, + shardState: { + upsertGuildRoster: async (ev) => { calls.roster.push(ev) }, + removeGuildMember: async (ev) => { calls.memberRemove.push(ev) }, + upsertGuild: noop, removeGuild: noop, + clearOnline: noop, upsertOnline: noop, setOffline: noop, + addEconomySample: noop, + }, + shardLinks: { removeByAccount: noop }, + uoLinkConfig: { recordStatus: noop }, + broadcast: (ev) => { calls.broadcast.push(ev) }, + pushDispatch: async () => {}, + log: { warn() {}, info() {}, error() {} }, + } +} + +beforeEach(() => shardIngest.reset()) + +test('guild.roster routes to upsertGuildRoster and is NOT logged', async () => { + // It is board state like guild.update, and the one fat frame on the wire — + // logging it would put a full membership snapshot in shard_events on every + // membership change. + const deps = makeDeps() + const r = await shardIngest.ingest( + { kind: 'guild.roster', id: 7, seq: 0, more: false, total: 2, + members: [{ serial: '0x1', name: 'Ada' }, { serial: '0x2', name: 'Bo' }], t: 1 }, + deps, + ) + + assert.equal(deps.calls.roster.length, 1) + assert.equal(deps.calls.roster[0].id, 7) + assert.equal(deps.calls.roster[0].members.length, 2) + assert.equal(r.logged, false) +}) + +test('every frame of a chunked roster reaches the model, seq intact', async () => { + // The sidecar reassembles for its own board, but the live feed and the /history + // backfill both carry individual frames — so the model must see each one with its + // seq, which is what tells it whether to clear the guild first. + const deps = makeDeps() + + for (const [seq, more, serial] of [[0, true, '0x1'], [1, true, '0x2'], [2, false, '0x3']]) { + await shardIngest.ingest( + { kind: 'guild.roster', id: 7, seq, more, total: 3, members: [{ serial, name: serial }], t: 1 }, + deps, + ) + } + + assert.deepEqual(deps.calls.roster.map((e) => e.seq), [0, 1, 2]) + assert.deepEqual(deps.calls.roster.map((e) => e.more), [true, true, false]) +}) + +test('guild.leave is logged and broadcast, like guild.join', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest( + { kind: 'guild.leave', id: 7, name: 'The Cartographers', who: '0x2', t: 2 }, deps) + + assert.equal(r.logged, true) + assert.equal(deps.calls.appended.length, 1) + assert.equal(deps.calls.appended[0].kind, 'guild.leave') + assert.equal(deps.calls.broadcast.length, 1) + assert.deepEqual(deps.calls.memberRemove.map((e) => e.who), ['0x2']) +}) diff --git a/server/test/shardVisibility.test.js b/server/test/shardVisibility.test.js index 6a49368..3e53c80 100644 --- a/server/test/shardVisibility.test.js +++ b/server/test/shardVisibility.test.js @@ -116,6 +116,52 @@ test('acct and webId are stripped below admin regardless of feature config', () assert.equal(asAdmin.leader.webId, '42') }) +test('acct and webId are stripped from every member of a guild roster (Protocol 4)', () => { + // A roster is the first frame where the locked fields appear inside an ARRAY of + // actors rather than one nested actor. The walker recurses into arrays, so this + // should already hold — this test is here because it is the difference between a + // public Guilds page listing character names and one publishing 150 account names. + const config = visibility.compileDefaults() + const frame = { + kind: 'guild.roster', + id: 7, + total: 3, + seq: 0, + more: false, + members: [ + { serial: '0x1', name: 'Ada', acct: 'ada_acct', webId: '11', player: true }, + { serial: '0x2', name: 'Bo', acct: 'bo_acct', player: true }, + { serial: '0x3', name: 'Cy', player: true }, // a mobile with no account at all + ], + } + + for (const level of ['anonymous', 'logged_in', 'player', 'staff']) { + const out = visibility.projectFeature('guilds', frame, level, config) + assert.equal(out.members.length, 3, `${level} still sees every member`) + assert.deepEqual(out.members.map((m) => m.name), ['Ada', 'Bo', 'Cy']) + for (const m of out.members) { + assert.equal('acct' in m, false, `${level} must not see a member's acct`) + assert.equal('webId' in m, false, `${level} must not see a member's webId`) + } + } + + const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config) + assert.equal(asAdmin.members[0].acct, 'ada_acct') + assert.equal(asAdmin.members[0].webId, '11') +}) + +test('guild.roster and guild.leave are mapped, so neither falls closed to admin-only', () => { + // Rule 2 fails an unmapped kind closed. That is the right default, but for these + // two it would silently keep the public Guilds page from ever seeing a roster. + const config = visibility.compileDefaults() + for (const kind of ['guild.roster', 'guild.leave']) { + assert.equal( + visibility.kindVisibleTo(kind, 'anonymous', config), true, + `${kind} should reach an anonymous viewer under the default guilds config`, + ) + } +}) + test('a stored rule trying to loosen a locked field is ignored', async () => { withRows([ { feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } }, @@ -308,10 +354,16 @@ const PRE_V3_PUBLIC_KINDS = [ // pointedly not among them (its feature ships with stream off). const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board'] -test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => { +// v4 adds guild membership. Both ride the existing `guilds` feature, which is +// already anonymous, so they join the public set — carrying character names and +// serials, never acct/webId, which the locked-field rules strip by suffix even +// inside the roster's member array (see the roster test above). +const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave'] + +test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 and v4 additions', () => { assert.deepEqual( [...visibility.PUBLIC_KINDS].sort(), - [...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS].sort(), + [...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS, ...V4_ADDED_PUBLIC_KINDS].sort(), ) }) diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js new file mode 100644 index 0000000..c5c28fc --- /dev/null +++ b/server/test/teamProvider.test.js @@ -0,0 +1,406 @@ +// module-uo's Team provider (docs/website/TEAMS.md §2.3, MODULE_API.md 1.6.0). +// +// The tests that matter here are the REFUSALS. Core's contract is that module +// unavailability becomes staleness and never emptiness, and this module is the +// only thing that can honour it — an empty array from here is read as an +// authoritative "there are none", and core makes destructive decisions from an +// authoritative answer. Every state where this module cannot honestly claim to +// know is asserted below, because each one is a plausible place for someone to +// later "simplify" the guard away and get a plausible-looking empty list. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const core = require('../core') + +// The provider reaches the database through core, which is initialised with a ctx +// in production. A minimal one is enough here — the db layer is stubbed anyway. +core.init({ + db: { query: async () => [] }, + log: () => ({ error() {}, warn() {}, info() {}, debug() {} }), + moduleId: 'uo', +}) + +const db = require('../model/teamProvider/teamProvider.db') +const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model') +const uoLinkSocket = require('../utils/uoLinkSocket') +const clilocs = require('../model/shardClilocs/shardClilocs.model') +const visibility = require('../utils/shardVisibility') +const provider = require('../model/teamProvider/teamProvider.model') + +const saved = [] +function patch(mod, name, fn) { + saved.push([mod, name, mod[name]]) + mod[name] = fn +} + +// The healthy default: configured, enabled, connected. Each test then breaks only +// the thing it is about. +function healthy() { + patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://127.0.0.1:7787', enabled: true })) + patch(uoLinkSocket, 'getState', () => ({ connected: true, running: true })) + // An operator who has never run the client extraction — the default. The standard + // rank names must still resolve from the fallback table. + patch(clilocs, 'resolveMany', async () => new Map()) + patch(db, 'listGuildLeaders', async () => []) +} + +const guild = (extra = {}) => ({ + id: 1, name: 'The Silver Hand', abbr: 'TSH', alliance: null, + members: 2, online: 1, leader_serial: '0x1', leader_name: 'Aldric', leader_acct: 'aldric', ...extra, +}) + +const member = (extra = {}) => ({ + serial: '0x1', name: 'Aldric', acct: 'aldric', web_id: null, is_player: 1, + rank: 1, rank_cliloc: 1062962, rank_name: null, + linked_user_id: null, is_online: 0, ...extra, +}) + +beforeEach(healthy) +afterEach(() => { + while (saved.length) { + const [mod, name, fn] = saved.pop() + mod[name] = fn + } +}) + +// ── The refusals ─────────────────────────────────────────────────────────── + +test('no uo-link configured refuses, on all three methods', async () => { + patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: null, enabled: false })) + patch(db, 'listGuilds', async () => { throw new Error('must not be read') }) + + for (const answer of [await provider.getTeams(), await provider.getTeamMembers('1'), await provider.getTeamLeaders('1')]) { + assert.equal(answer.ok, false) + assert.match(answer.reason, /no uo-link configured/) + assert.equal(answer.teams, undefined) + assert.equal(answer.members, undefined) + } +}) + +test('a disabled integration refuses rather than reporting a frozen board', async () => { + patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://x', enabled: false })) + const answer = await provider.getTeams() + assert.equal(answer.ok, false) + assert.match(answer.reason, /disabled/) +}) + +test('a disconnected socket refuses, even though the board is still there', async () => { + // The tempting mistake, stated as a test: the board is durable and survives an + // outage, so serving it looks harmless. Core cannot tell a board five minutes + // stale from one five days stale, and it archives Teams and departs members + // from a complete answer. + patch(uoLinkSocket, 'getState', () => ({ connected: false, running: true })) + patch(db, 'listGuilds', async () => [guild()]) + + const answer = await provider.getTeams() + assert.equal(answer.ok, false) + assert.match(answer.reason, /not connected/) + assert.equal(answer.teams, undefined, 'a stale board must not arrive as authoritative') +}) + +test('a database error refuses instead of throwing at core', async () => { + patch(db, 'listGuilds', async () => { throw new Error('table gone') }) + const answer = await provider.getTeams() + assert.equal(answer.ok, false) + assert.match(answer.reason, /table gone/) +}) + +test('a guild absent from the board refuses rather than reporting an empty roster', async () => { + patch(db, 'findGuild', async () => []) + const members = await provider.getTeamMembers('99') + assert.equal(members.ok, false) + assert.match(members.reason, /not on the board/) + + const leaders = await provider.getTeamLeaders('99') + assert.equal(leaders.ok, false) +}) + +test('a roster that has not arrived yet refuses — the board count is what tells us', async () => { + // Protocol 4's roster arrives on its own frames, separately from the + // guild.update that creates the board row, so there is a real window where a + // 155-member guild has no roster rows. Reporting that as an empty roster would + // depart every member. + patch(db, 'findGuild', async () => [guild({ members: 155 })]) + patch(db, 'listGuildMembers', async () => []) + + const answer = await provider.getTeamMembers('1') + assert.equal(answer.ok, false) + assert.match(answer.reason, /has not arrived yet/) + assert.match(answer.reason, /155/, 'the count is in the message, because it is the evidence') +}) + +test('a guild the board says is genuinely empty reports an empty roster', async () => { + // The other side of the same coin: when the board itself says zero, an empty + // roster is the truth and withholding it would freeze a disbanding guild's + // membership forever. + patch(db, 'findGuild', async () => [guild({ members: 0 })]) + patch(db, 'listGuildMembers', async () => []) + + const answer = await provider.getTeamMembers('1') + assert.equal(answer.ok, true) + assert.deepEqual(answer.members, []) +}) + +// ── The good answers ─────────────────────────────────────────────────────── + +test('a guild becomes a Team keyed on its persistent ServUO id', async () => { + // The id survives a rename, which is what lets core apply its rename rule + // instead of seeing an unrelated new guild. + patch(db, 'listGuilds', async () => [guild()]) + const answer = await provider.getTeams() + + assert.equal(answer.ok, true) + assert.equal(answer.complete, true) + assert.deepEqual(answer.teams, [ + { externalId: '1', name: 'The Silver Hand', abbr: 'TSH', meta: null }, + ]) +}) + +test('an alliance rides along as opaque meta', async () => { + patch(db, 'listGuilds', async () => [guild({ alliance: 'The Concord' })]) + const { teams } = await provider.getTeams() + assert.deepEqual(teams[0].meta, { alliance: 'The Concord' }) +}) + +test('the external id is a string, so core never compares a number to one', async () => { + patch(db, 'listGuilds', async () => [guild({ id: 42 })]) + const { teams } = await provider.getTeams() + assert.equal(teams[0].externalId, '42') +}) + +test('a roster maps to the member shape core expects', async () => { + patch(db, 'findGuild', async () => [guild()]) + patch(db, 'listGuildMembers', async () => [ + member({ serial: '0x1', name: 'Aldric', rank: 4, rank_cliloc: 1062959, is_online: 1 }), + member({ serial: '0x2', name: 'Bree', acct: null, rank: 1, is_online: 0 }), + ]) + + const { members } = await provider.getTeamMembers('1') + assert.equal(members.length, 2) + assert.equal(members[0].memberKey, '0x1') + assert.equal(members[0].displayName, 'Aldric') + assert.equal(members[0].online, true) + assert.equal(members[0].leader, true, 'rank 4 is Leader') + assert.equal(members[1].leader, false) + assert.equal(members[1].online, false) +}) + +// ── Rank (the Protocol 4 amendment) ──────────────────────────────────────── + +test('several members can be leaders at once', async () => { + // The whole reason the wire grew a per-member rank: the board carries one + // leader_serial, so before this only a single leader could ever be reported. + patch(db, 'findGuild', async () => [guild()]) + patch(db, 'listGuildMembers', async () => [ + member({ serial: '0x1', rank: 4 }), + member({ serial: '0x2', rank: 4 }), + member({ serial: '0x3', rank: 3 }), + ]) + + const { members } = await provider.getTeamMembers('1') + assert.deepEqual(members.filter((m) => m.leader).map((m) => m.memberKey), ['0x1', '0x2']) +}) + +test('getTeamLeaders returns everyone at rank 4, not just the board’s one', async () => { + patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })]) + patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }]) + assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1', '0x2']) +}) + +test('the board’s leader is kept even when no roster row has rank yet', async () => { + // A shard whose roster has not been re-emitted since the amendment has no ranks + // stored. The founder-leader comes from a different frame and must not be lost + // by moving to ranks. + patch(db, 'findGuild', async () => [guild({ leader_serial: '0x9' })]) + patch(db, 'listGuildLeaders', async () => []) + assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x9']) +}) + +test('the board’s leader is not duplicated when they also hold rank 4', async () => { + patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })]) + patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }]) + const { leaders } = await provider.getTeamLeaders('1') + assert.equal(new Set(leaders).size, leaders.length) +}) + +test('a NULL rank is not a leader — "not known" is not "leads this guild"', async () => { + // The shard withholds the rank for a staff account, because ServUO's GuildRank + // getter reports Leader for anyone at GameMaster or above whatever their real + // rank. Reading the absence as leadership would republish exactly that lie. + patch(db, 'findGuild', async () => [guild()]) + patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: null, rank_cliloc: null })]) + + const { members } = await provider.getTeamMembers('1') + assert.equal(members[0].leader, false) + assert.equal(members[0].rankLabel, null) +}) + +test('a standard rank resolves to its name without a cliloc table', async () => { + // The operator may never have run the client extraction, and a roster should + // still read "Warlord" rather than nothing. + patch(db, 'findGuild', async () => [guild()]) + patch(db, 'listGuildMembers', async () => [ + member({ serial: '0x1', rank: 4, rank_cliloc: 1062959 }), + member({ serial: '0x2', rank: 3, rank_cliloc: 1062960 }), + member({ serial: '0x3', rank: 0, rank_cliloc: 1062963 }), + ]) + + const { members } = await provider.getTeamMembers('1') + assert.deepEqual(members.map((m) => m.rankLabel), ['Leader', 'Warlord', 'Ronin']) +}) + +test('the operator’s cliloc table wins over the built-in names', async () => { + // A localised or edited client should name the ranks, not this module's English + // fallback. + patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Kriegsherr']])) + patch(db, 'findGuild', async () => [guild()]) + patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })]) + + assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Kriegsherr') +}) + +test('a custom rank’s literal name beats both', async () => { + // A shard that replaced RankDefinition.Ranks sends a string instead of a cliloc, + // and its own naming has to survive. + patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Warlord']])) + patch(db, 'findGuild', async () => [guild()]) + patch(db, 'listGuildMembers', async () => [ + member({ serial: '0x1', rank: 3, rank_cliloc: 1062960, rank_name: 'Sword-Captain' }), + ]) + + assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Sword-Captain') +}) + +test('a failing cliloc lookup falls back rather than failing the roster', async () => { + patch(clilocs, 'resolveMany', async () => { throw new Error('cliloc table missing') }) + patch(db, 'findGuild', async () => [guild()]) + patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })]) + + const answer = await provider.getTeamMembers('1') + assert.equal(answer.ok, true, 'a label is decoration; losing it must not lose the roster') + assert.equal(answer.members[0].rankLabel, 'Warlord') +}) + +test('an unknown cliloc leaves the label null rather than inventing one', async () => { + patch(db, 'findGuild', async () => [guild()]) + patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 2, rank_cliloc: 9999999 })]) + assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, null) +}) + +test('a member with no account at all is fine and unlinked', async () => { + // §2.3 of the protocol spec: acct is genuinely optional — a PlayerMobile can + // have no Account, and the local test world contains such mobiles. + patch(db, 'findGuild', async () => [guild()]) + patch(db, 'listGuildMembers', async () => [member({ acct: null, web_id: null, linked_user_id: null })]) + const { members } = await provider.getTeamMembers('1') + assert.equal(members[0].userId, null) +}) + +test('userId comes from the roster’s web_id first, then the link table', async () => { + patch(db, 'findGuild', async () => [guild()]) + patch(db, 'listGuildMembers', async () => [ + member({ serial: '0xA', web_id: '7', linked_user_id: 99 }), // roster wins + member({ serial: '0xB', web_id: null, linked_user_id: 12 }), // fallback + member({ serial: '0xC', web_id: '0', linked_user_id: null }), // neither + ]) + const { members } = await provider.getTeamMembers('1') + assert.equal(members[0].userId, 7, 'what the shard itself asserted at roster time') + assert.equal(members[1].userId, 12, 'the fallback for a row that predates the link') + assert.equal(members[2].userId, null) +}) + +test('web_id arrives as a string from the wire and is coerced', async () => { + patch(db, 'findGuild', async () => [guild()]) + patch(db, 'listGuildMembers', async () => [member({ web_id: '42' })]) + const { members } = await provider.getTeamMembers('1') + assert.equal(members[0].userId, 42) + assert.equal(typeof members[0].userId, 'number') +}) + +test('a guild with no leader anywhere reports none rather than guessing', async () => { + patch(db, 'findGuild', async () => [guild({ leader_serial: null })]) + patch(db, 'listGuildLeaders', async () => []) + const answer = await provider.getTeamLeaders('1') + assert.equal(answer.ok, true) + assert.deepEqual(answer.leaders, []) +}) + +test('an empty board is an authoritative empty list — the shard really has no guilds', async () => { + // Distinct from every refusal above: the socket is connected and the board is + // readable, so "no guilds" is a fact. Core still quarantines it before acting. + patch(db, 'listGuilds', async () => []) + const answer = await provider.getTeams() + assert.equal(answer.ok, true) + assert.deepEqual(answer.teams, []) +}) + +// ── projectRoster (TEAMS.md §3.3) ────────────────────────────────────────── +// +// The refusal semantics INVERT here and that is the point of these tests. For +// the three methods above, a refusal means "change nothing" and an empty array +// would be destructive. For this one, core fails CLOSED — a refusal withholds the +// roster — so the dangerous answer is the opposite: returning every key because +// the config could not be read would publish a roster an operator gated to staff. + +const rows = [{ member_key: '0x1' }, { member_key: '0x2' }] + +function guilds(feature) { + patch(visibility, 'getConfig', async () => ({ guilds: feature })) +} + +test('a viewer at or above the audience sees every row', async () => { + guilds({ enabled: true, audience: 'anonymous' }) + const answer = await provider.projectRoster('1', rows, null) + assert.equal(answer.ok, true) + assert.deepEqual(answer.members, ['0x1', '0x2']) +}) + +test('a viewer below the audience sees none — authoritatively, not as a refusal', async () => { + // `ok: true` with an empty list is the correct answer here: this module KNOWS + // the viewer may see nothing. Core renders an empty roster rather than an + // error, which is what a gated shard is supposed to look like. + guilds({ enabled: true, audience: 'staff' }) + const answer = await provider.projectRoster('1', rows, { userId: 7, role: 'player' }) + assert.equal(answer.ok, true) + assert.deepEqual(answer.members, []) +}) + +test('an admin clears every audience', async () => { + guilds({ enabled: true, audience: 'admin' }) + const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' }) + assert.deepEqual(answer.members, ['0x1', '0x2']) +}) + +test('a disabled guilds feature hides the roster from everyone, staff included', async () => { + // The switch means "this shard does not publish guild data", not "publish it + // quietly to staff". + guilds({ enabled: false, audience: 'anonymous' }) + const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' }) + assert.equal(answer.ok, true) + assert.deepEqual(answer.members, []) +}) + +test('an unreadable visibility config REFUSES rather than publishing', async () => { + // The inversion, stated. Core reads this as "withhold", which is the only safe + // reading of "I could not work out who is allowed to look". + patch(visibility, 'getConfig', async () => { throw new Error('pool down') }) + const answer = await provider.projectRoster('1', rows, null) + assert.equal(answer.ok, false) + assert.match(answer.reason, /visibility could not be resolved/) +}) + +test('an absent viewer is anonymous, not an error', async () => { + guilds({ enabled: true, audience: 'logged_in' }) + const answer = await provider.projectRoster('1', rows, null) + assert.equal(answer.ok, true) + assert.deepEqual(answer.members, [], 'anonymous does not meet logged_in') +}) + +test('rows with no member key are dropped rather than answered as blanks', async () => { + guilds({ enabled: true, audience: 'anonymous' }) + const answer = await provider.projectRoster('1', [{ member_key: '0x1' }, { member_key: null }], null) + assert.deepEqual(answer.members, ['0x1']) +}) diff --git a/server/utils/shardIngest.js b/server/utils/shardIngest.js index a64d902..b21f6f1 100644 --- a/server/utils/shardIngest.js +++ b/server/utils/shardIngest.js @@ -45,6 +45,12 @@ const LOGGED_KINDS = new Set([ 'server.crashed', // Protocol 2.0: a real-time guild join (the board itself is state, not logged). 'guild.join', + // Protocol 4: the departure counterpart to guild.join, and logged for the same + // reason — it is what a "so-and-so left" feed reads. `guild.roster` deliberately + // stays out: it is board state like guild.update, and it is the one fat frame on + // the wire (~69 bytes per member), so logging it would bloat shard_events with + // a full membership snapshot on every membership change. + 'guild.leave', // Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS). 'account.audit', 'account.unlinked', @@ -187,6 +193,16 @@ async function applyStateChange(event, deps) { case 'guild.remove': await shardState.removeGuild(event.id) return + // Protocol 4: membership. A roster arrives in one frame for any realistic + // guild and in several for one over the shard's cap — upsertGuildRoster + // handles both. guild.leave is advisory; the next roster would converge + // anyway, but applying it shows the departure at once. + case 'guild.roster': + await shardState.upsertGuildRoster(event) + return + case 'guild.leave': + await shardState.removeGuildMember(event) + return case 'city.update': // Upserts the board AND captures term history (idempotent). await shardState.upsertGovernor(event) diff --git a/server/utils/shardVisibility.js b/server/utils/shardVisibility.js index 15ac19c..a098569 100644 --- a/server/utils/shardVisibility.js +++ b/server/utils/shardVisibility.js @@ -163,6 +163,13 @@ const KIND_FEATURE = new Map( 'guild.update': 'guilds', 'guild.remove': 'guilds', 'guild.join': 'guilds', + // Protocol 4. Both carry actor data — a roster is an array of actor objects + // and guild.leave names a serial — so they ride the same `guilds` feature and + // the same locked-field rules: `acct`/`webId` inside a roster member are + // stripped below admin by suffix, exactly as `guild.leader.acct` already is. + // Without these two lines rule 2 would fail them closed to admin-only. + 'guild.roster': 'guilds', + 'guild.leave': 'guilds', 'city.update': 'governors', 'presence.online': 'presence', 'region.enter': 'presence', diff --git a/swagger-fragment.json b/swagger-fragment.json index 65e2897..bda4e1d 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -3131,6 +3131,56 @@ } } }, + "/api/v1/public/shard/guilds/{id}": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "One guild and its roster", + "description": "The detail view behind the board. Gated and projected through the same `guilds` feature, so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin — a roster is where they appear in bulk. This page is also where core renders the Team activity feed, through the `uo.guild.detail` extension slot.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The guild id." + } + ], + "responses": { + "200": { + "description": "The guild, with its roster", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "No such guild", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/api/v1/public/shard/houses": { "get": { "tags": [