feat(kit): the two shapes Teams added, taught and built

MODULE_API 1.6.0 expands the contract this book teaches against, so the book
owes two shapes and one correction. Chapter 2 gains both and the template grows
a working version of each, because a reader following a snippet has no way to
find out whether it runs.

ONE SENTENCE WAS WRONG. Chapter 2 said, of extension slots, "Only core may
declare a slot; a module may only fill one". 1.6.0 inverted exactly that: a
module declares a place on its OWN page and core fills it. That is not a stale
detail - a new game's module cannot implement Teams at all without the inverted
direction, so it is the shape the reader needs and did not have.

THE TWO SHAPES

- The inverted slot. A new "Slots go the other way too" section: why the
  direction has to invert (core owns the Team, not the word for one), the
  namespace rule, one slot per PLACE, the optional { core } naming which of
  core's three contributions goes there, and why asking for one core does not
  offer throws when almost everything else in that registry fails open.

- registerTeamProvider, in "Becoming the source of Teams". The first
  registration where core calls YOU and waits, which is where every rule in it
  comes from: the envelope, the ten-second budget, refusing as a normal answer,
  and the one mistake worth naming - answering with an empty list because the
  game is unreachable, which core reads as authoritative and acts on.
  projectRoster gets its own treatment because it is the exception that fails
  CLOSED. pageUrlTemplate is a footnote beside it, as intended.

WHAT THE TEMPLATE GREW

model/clans/ - the provider over two tables, with the guards that matter: an
unreachable game refuses rather than reporting no clans, an empty roster is
refused unless the game says the clan is empty (which is why the schema keeps a
member count the rows cannot supply), and the audience rule lives in one file
that both projectRoster and the module's own page consult, because a second copy
drifts in the direction that publishes what core is withholding.

Its own /clans routes, deliberately not /teams - core mounts that itself, and
the loader would refuse the collision. A clan list page and a clan page that
declares three slots for core.

12 provider tests and three registration tests, 47 server and 20 client in
total. The purge test finally proves something: two of the three tables are now
a parent and its child.

WHAT IT DOES NOT DO. Enumerate the contract. The kit teaches one path end to end
and links out; it has never mentioned three pre-Teams registrations and that is
the design, not a gap.

FOUND WHILE WRITING IT: core filled three literal uo.guild.* slot names, so the
inverted direction reached exactly one module and every other game's page came
up empty with nothing logged. Fixed in website#160 / Module-uo#15 / docs#165
before this chapter could teach it - which is what this phase is for.

The ci/core-ref.json pin moves in a later commit on this branch: checkCoreApi is
an equality against a core on main, and 1.6.0 does not reach main until the
cutover.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-19 01:21:55 -05:00
parent 77418aaef5
commit 7875848ee7
25 changed files with 1889 additions and 43 deletions

View File

@@ -0,0 +1,207 @@
// ── The Team provider, with no core and no database ───────────────────────
//
// The provider is the one part of a module that CORE calls, which makes it the
// one part whose failures reach further than its own pages: a wrong answer here
// is not a broken screen, it is core archiving Teams or departing members on your
// authority. So it gets the most tests in the template, and they are mostly about
// what it says when things are wrong.
//
// Everything is stubbed at the `.db.js` seam, the same way `worldStatus.test.js`
// does it. There is no database and no `ctx` — the provider only reaches core for
// its logger, and the one path that logs is exercised by installing a fake `ctx`.
const test = require('node:test')
const assert = require('node:assert')
const core = require('../core')
const db = require('../model/clans/clanProvider.db')
const settings = require('../model/clans/clanSettings')
const worldStatus = require('../model/worldStatus/worldStatus.model')
const provider = require('../model/clans/clanProvider.model')
const { fakeCtx } = require('./_fakes')
const CLAN = { externalId: 'clan-1', name: 'The Gilded Company', abbr: 'GC', memberCount: 2 }
const ROSTER = [
{ memberKey: 'char-001', displayName: 'Aldric', rankLabel: 'Warlord', isLeader: 1, isOnline: 1, userId: 7 },
{ memberKey: 'char-002', displayName: 'Bryn', rankLabel: 'Member', isLeader: 0, isOnline: 0, userId: null },
]
/** Swap out the db seam and the world-status read for one test. */
function withGame({ online = true, stale = false, clan = CLAN, roster = ROSTER, throws = null }, fn) {
const real = {
getPublicStatus: worldStatus.getPublicStatus,
findClan: db.findClan,
listClans: db.listClans,
listMembers: db.listMembers,
}
core._reset()
core.init(fakeCtx())
worldStatus.getPublicStatus = async () => ({ online, stale, players: 0, worldName: 'Example World', updatedAt: null })
db.findClan = async () => { if (throws) throw new Error(throws); return clan }
db.listClans = async () => { if (throws) throw new Error(throws); return clan ? [clan] : [] }
db.listMembers = async () => { if (throws) throw new Error(throws); return roster }
return Promise.resolve(fn()).finally(() => {
Object.assign(worldStatus, { getPublicStatus: real.getPublicStatus })
Object.assign(db, { findClan: real.findClan, listClans: real.listClans, listMembers: real.listMembers })
core._reset()
})
}
test('getTeams answers an envelope, not an array', () =>
withGame({}, async () => {
const answer = await provider.getTeams()
assert.strictEqual(answer.ok, true)
assert.strictEqual(answer.complete, true)
assert.strictEqual(answer.teams[0].externalId, 'clan-1')
// A bare array has exactly one shape for "I cannot answer" — `[]` — and it is
// the same shape as "there are none". The envelope exists to keep those two
// apart, so the array must never be the return value itself.
assert.ok(!Array.isArray(answer))
}))
test('an unreachable game REFUSES rather than reporting no clans', () =>
withGame({ online: false }, async () => {
// The most important assertion in this file. `{ ok: true, teams: [] }` reads
// as an authoritative "this deployment has no clans", and core acts on
// authoritative answers: it archives the Teams that are missing from one. A
// cold start would empty the site.
for (const answer of [
await provider.getTeams(),
await provider.getTeamMembers('clan-1'),
await provider.getTeamLeaders('clan-1'),
]) {
assert.strictEqual(answer.ok, false)
assert.ok(answer.reason, 'a refusal without a reason is what an operator has to debug from')
assert.strictEqual(answer.teams, undefined)
}
}))
test('stale data refuses too, even though the rows are readable', () =>
withGame({ online: true, stale: true }, async () => {
// The tables still hold a perfectly good snapshot, which is what makes this
// tempting to get wrong. Core cannot tell a snapshot five minutes old from one
// five days old, so an answer it would act on must be current.
assert.strictEqual((await provider.getTeams()).ok, false)
}))
test('a database error is caught and becomes a refusal', () =>
withGame({ throws: 'connection lost' }, async () => {
// Core reads a rejected promise as a refusal anyway. Catching it is what puts
// the module's own name on the log line, instead of an operator seeing core
// blamed for a fault in a module.
const answer = await provider.getTeams()
assert.strictEqual(answer.ok, false)
assert.match(answer.reason, /connection lost/)
}))
test('an empty roster is refused when the game says the clan is not empty', () =>
withGame({ roster: [] }, async () => {
// The clan row and the roster arrive on separate frames in any real ingest, so
// there is a window where this module knows a clan exists and not who is in
// it. Answering "nobody" there would have core depart every member.
const answer = await provider.getTeamMembers('clan-1')
assert.strictEqual(answer.ok, false)
assert.match(answer.reason, /has not arrived/)
}))
test('a genuinely empty clan is answered, not refused', () =>
withGame({ clan: { ...CLAN, memberCount: 0 }, roster: [] }, async () => {
// The other half of the rule above, and the reason `member_count` is in the
// schema at all: without a count from the game there is no way to tell these
// two cases apart, and a provider that refuses both can never report a clan
// emptying.
const answer = await provider.getTeamMembers('clan-1')
assert.strictEqual(answer.ok, true)
assert.deepStrictEqual(answer.members, [])
}))
test('members carry the contract shape, with userId resolved by this module', () =>
withGame({}, async () => {
const { members } = await provider.getTeamMembers('clan-1')
assert.deepStrictEqual(members[0], {
memberKey: 'char-001',
displayName: 'Aldric',
rankLabel: 'Warlord',
leader: true,
online: true,
userId: 7,
})
// Not linked to a site account is the ordinary case and must be `null` rather
// than absent or `0`: core stores it, and `0` is a user id.
assert.strictEqual(members[1].userId, null)
}))
test('getTeamLeaders answers keys, plurally', () =>
withGame({ roster: [...ROSTER, { ...ROSTER[0], memberKey: 'char-003', isLeader: 1 }] }, async () => {
const answer = await provider.getTeamLeaders('clan-1')
assert.deepStrictEqual(answer.leaders, ['char-001', 'char-003'])
// Core grants forum moderation and Team management from this list, so a
// provider that can only name one leader locks the others out of their own
// clan.
assert.ok(answer.leaders.length > 1)
}))
test('projectRoster returns member keys the caller supplied, in cores snake_case', () =>
withGame({}, async () => {
// Core hands back the rows as IT stores them — this is the module's own data
// coming home — so the key is `member_key` and not the `memberKey` the
// provider sent out. Reading the wrong one silently answers with a list of
// `undefined`, which core filters to nothing: an empty roster with `ok: true`.
const answer = await provider.projectRoster('clan-1', [{ member_key: 'char-001' }], null)
assert.deepStrictEqual(answer, { ok: true, members: ['char-001'] })
}))
test('projectRoster fails CLOSED when it cannot resolve the question', () =>
withGame({}, async () => {
// The asymmetry that matters. The other three methods refuse and core keeps
// what it has; this one refuses and core serves an EMPTY roster, because for a
// visibility question "keep what you have" means publishing it. So a provider
// that cannot answer must say so rather than falling back to "show everything".
const real = settings.getRosterAudience
settings.getRosterAudience = async () => { throw new Error('settings unreadable') }
try {
const answer = await provider.projectRoster('clan-1', [{ member_key: 'char-001' }], null)
assert.strictEqual(answer.ok, false)
// Not `{ ok: true, members: [...everything] }`, which is the tempting
// fallback — the rows are right there and the lookup is the only thing that
// failed. That publishes a roster an operator may have gated to staff.
assert.strictEqual(answer.members, undefined)
} finally {
settings.getRosterAudience = real
}
}))
test('a members-only audience withholds from an anonymous viewer and answers for one inside', () =>
withGame({}, async () => {
const real = settings.getRosterAudience
settings.getRosterAudience = async () => 'members'
try {
const rows = [{ member_key: 'char-001' }, { member_key: 'char-002' }]
// Anonymous is an ANSWER — `{ ok: true }` with nothing visible — and not a
// refusal. A provider that refuses here tells core its rule broke, and core
// reports the roster as unavailable rather than as private.
const anon = await provider.projectRoster('clan-1', rows, null)
assert.deepStrictEqual(anon, { ok: true, members: [] })
// Aldric's account, resolved from this module's own roster — the only place
// the game↔site mapping exists.
const inside = await provider.projectRoster('clan-1', rows, { userId: 7, role: 'user' })
assert.deepStrictEqual(inside.members, ['char-001', 'char-002'])
// All or none. The audience is a property of the FEATURE, not of a member;
// there is no configuration in which half a roster is public.
const outside = await provider.projectRoster('clan-1', rows, { userId: 99, role: 'user' })
assert.deepStrictEqual(outside.members, [])
} finally {
settings.getRosterAudience = real
}
}))
test('pageUrlTemplate is a relative path carrying the substitution core makes', () => {
// Core substitutes `{externalId}` and does nothing else with it. A template
// naming its own host is refused at registration — there is no reason for a
// module to redirect the site's outbound mail — and so is a protocol-relative
// `//host/x`.
assert.match(provider.pageUrlTemplate, /^\/[^/]/)
assert.ok(provider.pageUrlTemplate.includes('{externalId}'))
})