Core's projection of the module's Teams, kept in step (docs/website/TEAMS.md
§2.4), plus the two ctx members a module pushes through.
The four gates are the file, and each is invariant 1 in a different costume --
module unavailability is staleness, never emptiness:
1. getTeams() not ok -> record the failure, touch NOTHING, return.
2. ok but empty, core holds >=1 -> quarantine; apply only if the NEXT
authoritative answer, an interval later,
agrees.
3. getTeamMembers() not ok -> that Team's roster untouched and stale; the
other Teams sync normally.
4. ok but zero members, had some -> the same two-strikes quarantine, per Team.
Gates 2 and 4 exist because an authoritative-looking empty answer during a cold
start is the one failure indistinguishable from a real wipe. "Every Team on the
shard disbanded at once" costs one interval to confirm; getting it wrong empties
every roster on the site.
Events are an optimisation, never the source of truth. Member and leadership
deltas apply at once for a Team core already knows; team.created and
team.disbanded only ask for a run. §2.2 scopes archival to an authoritative full
list, so a repeated or spurious disband event costs a reconcile rather than a
Team -- and a Team invented from a delta would have no name, no roster and no
leaders anyway.
Two columns TEAMS.md did not contemplate, both on `teams`:
- roster_synced_at, because team_sync_state holds one row per MODULE and gate 3
leaves ONE Team behind while the others sync. Without a per-Team stamp that
Team's page would report the module's last success as its own -- exactly the
staleness the gate exists to surface.
- members_empty_since, gate 4's per-Team quarantine. The twin of
team_sync_state.pending_empty_since, which is per module and cannot express it.
One real bug found by its own test. The roster upsert was writing is_leader, so a
refused getTeamLeaders() left every member demoted -- the roster had already
written `leader: false` before the authoritative call was even made. §2.5 is
explicit that path 2 is answered by getTeamLeaders(), so is_leader is now set on
INSERT only (seeding a Team so it is not leaderless while that call fails) and
moved afterwards by setLeaders() alone. Two writers for one column was the whole
defect.
MODULE_API_VERSION 1.6.0 on both halves -- they state one contract and a module
declares one coreApi range. The number covers the whole Team surface per Part 11;
the members arrive by phase. registerTeamProvider, ctx.teams.publish and
ctx.teams.reconcile are live. ctx.teams.activity.push (§4, phase 3) and
api.registerSlashCommands (§7.1, phase 7) are present and THROW with a sentence
naming their phase, rather than being absent or silently accepting data into
tables that do not exist yet.
39 tests here, and the ctx surface guard in moduleLoader.test.js updated -- it
caught the addition, which is what it is for. Server 809 passed, client 192
passed, 0 failed.
Refs docs/website/TEAMS.md §2.2, §2.3, §2.4, Part 11, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
51 lines
1.9 KiB
JavaScript
51 lines
1.9 KiB
JavaScript
// Deriving a Team's URL slug from a game-written name (TEAMS.md §2.1).
|
|
//
|
|
// A slug is derived ONCE, at create, and then frozen for the life of the row —
|
|
// like `name`, and for the same reason: the Team page URL has to stay stable, and
|
|
// a rename is an archive plus a create rather than an edit.
|
|
|
|
const MAX_SLUG = 180 // the column is 191; leaves room for a -NN suffix
|
|
|
|
/**
|
|
* Reduce a name to a URL-safe stem.
|
|
*
|
|
* Diacritics are folded rather than stripped so "Ünderdark" becomes "underdark"
|
|
* and not "nderdark". A name made entirely of characters that do not survive —
|
|
* which a guild name genuinely can be, since the game accepts far more than a URL
|
|
* does — leaves an empty stem, and the caller substitutes a stable fallback
|
|
* rather than minting a Team with no address.
|
|
*/
|
|
function slugify(name) {
|
|
return String(name || '')
|
|
.normalize('NFKD')
|
|
.replace(/[̀-ͯ]/g, '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, MAX_SLUG)
|
|
.replace(/-+$/g, '')
|
|
}
|
|
|
|
/**
|
|
* A slug not already taken, given the ones that are.
|
|
*
|
|
* `taken` must include ARCHIVED teams' slugs, not only active ones. The unique
|
|
* key constrains active rows alone, so the database would allow a new Team to
|
|
* take a retired Team's slug — and §2.2 promises the retired one stays readable
|
|
* at that address, which is what a bookmark or an old Discord link resolves to.
|
|
*/
|
|
function uniqueSlug(name, taken, { fallback = 'team' } = {}) {
|
|
const base = slugify(name) || fallback
|
|
const used = new Set(taken)
|
|
if (!used.has(base)) return base
|
|
// Bounded rather than unbounded: a suffix search that cannot terminate is worse
|
|
// than a slug with an id in it, and 999 same-named teams is already absurd.
|
|
for (let n = 2; n <= 999; n++) {
|
|
const candidate = `${base}-${n}`
|
|
if (!used.has(candidate)) return candidate
|
|
}
|
|
return `${base}-${Date.now().toString(36)}`
|
|
}
|
|
|
|
module.exports = { slugify, uniqueSlug, MAX_SLUG }
|