From 1a13f680f5d7aedf206810142078f0d7fbcc6b74 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 19 Aug 2026 01:15:49 -0500 Subject: [PATCH] feat(guilds): name the core contribution each declared slot wants Core no longer fills a slot by name - it offers a contribution and the module that owns the page says where each one goes (MODULE_API 1.6.0, amended). The three slot names are unchanged and stay this module's own vocabulary; what is new is the second argument saying which of core's three contributions belongs in each place. Nothing here worked differently before. The change is for every game that is not this one: core used to fill the literal name uo.guild.detail, so a second module declaring a place under its own id got an empty page and no error. The registration fake gained the same validation core does, including the contribution catalogue - written down rather than imported, since this suite runs against the built chunk with no core in the process, which makes it a claim about core that has to be re-read when core's list changes. 42 client tests, 437 server tests. Co-Authored-By: Claude --- client/src/entry.jsx | 15 +++++++----- client/test/registration.test.js | 40 ++++++++++++++++++++++---------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/client/src/entry.jsx b/client/src/entry.jsx index 90209b3..3a9b142 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -189,10 +189,13 @@ registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep) // 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. Core's fill is -// applied after every module chunk has evaluated, so declaring it here is early -// enough; on a core that knows nothing of Teams it simply stays empty. -registry.declareModuleSlot(ID, 'uo.guild.detail') +// 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 @@ -200,14 +203,14 @@ registry.declareModuleSlot(ID, 'uo.guild.detail') // 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') +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') +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/test/registration.test.js b/client/test/registration.test.js index 9e41927..8a57733 100644 --- a/client/test/registration.test.js +++ b/client/test/registration.test.js @@ -39,12 +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 Set() + const declaredSlots = new Map() return { version: '1.3.0', react, @@ -74,13 +80,18 @@ function fakeRg() { 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, so the fake - // does too — a chunk that declared an unnamespaced slot would pass here and - // throw in a browser. - declareModuleSlot(id, name) { + // 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`) - declaredSlots.add(name) + 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], @@ -209,7 +220,7 @@ it('registers under exactly one module id, matching the manifest', () => { assert.deepEqual([...owners], [manifest.id]) }) -it('declares its own guild slots, for core to fill', () => { +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. @@ -219,10 +230,15 @@ it('declares its own guild slots, for core to fill', () => { // 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. - assert.deepEqual([...registered.declaredSlots], [ - 'uo.guild.detail', - 'uo.guild.forum', - 'uo.guild.header', + // + // 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'], ]) }) @@ -230,7 +246,7 @@ 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) { + for (const name of registered.declaredSlots.keys()) { assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`)) } })