refactor(modules)!: de-UO core's copy, and enforce it (phase 3, slice 4)
Phase 3's acceptance criterion 1, made real. Three things, one review: **The dead bindings.** `client/src/api/client.js` still carried ~190 lines of UO namespaces — `shard`, `atlas`, the two SSE URLs, `admin.shard/shardOps/atlas/ userShard`, the uo-link and town-crier calls, `player.shard` — with zero core consumers since slice 3 deleted the views. module-uo vendors its own bindings. The five assertions core's `apiClient.test.js` made about those URLs moved with them (Module-uo#5); the encoding test that used `governorHistory` now uses a core route. **The copy.** Core is the platform, not one game's site, so its words are game-neutral now: `About`, `Screenshots`, `Website`'s cards, `Status` (which was never about a game server at all — it reports site mode), `Wiki`, `SiteFooter`, the default hero, `brand.js`'s tagline and description, the seeded wiki categories, and two user-visible NavEditor strings that named a module's admin screen by its proper name. Which game an instance is for is the operator's to say — BRAND_* vars, the hero editor, CMS pages — and every real instance already does: `.env.uomysticmoon.example` sets both brand strings explicitly, so nothing live changes wording. Wiki page SLUGS are untouched: `seedDefault*` only inserts what is absent, so renaming one adds a duplicate page to every install. Also gone: an orphan comment block in `schema.sql` describing the spawn-atlas tables slice 1 took away, and the two settings rows core seeded for a module (`game_account_signup`, `uo_link_protocol_3_migrated`). The second was a live defect — see Module-uo#5, which takes ownership of both and repairs the one-shot migration core's ordering had disabled. **The check.** `scripts/checkModuleIdentifiers.js` + `npm run check:modules`, first step of the server-tests job because it needs no dependencies. It reads CODE, not prose — file names, import specifiers, route path literals, declared identifiers and property names — per §5.2, so core's English may still say "shard" where saying it is worth more than the word costs. Two things it gets right only because getting them wrong was tried first: it matches WHOLE WORDS (a substring pass flags `defaultImage`, which contains "ultIma", four times in this repo), and it strips comments and string bodies in one character walk (a comment contains quotes, a string contains `//`) — the `checkImports.js` lesson. It has its own 17-test suite, because a boundary check that silently stops checking is worse than none. The three §6.5 grandfathering allowlists are exempt by name, and an exemption that stops matching fails the build rather than lingering. BREAKING CHANGE: core no longer seeds `game_account_signup` or `uo_link_protocol_3_migrated`; module-uo's schema fragment does. An install running core without module-uo keeps whatever rows it already has and gains no new ones — nothing in core reads either key. Deferred to slice 5, deliberately: README.md's 48 UO mentions, including a `## Shard integration (uo-link)` section and the architecture diagram. That is documentation, which §5.2 does not cover, and it belongs with the phase-closing docs pass rather than half-done here. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -47,10 +47,20 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: server/package-lock.json
|
cache-dependency-path: server/package-lock.json
|
||||||
|
- name: Check core names no module identifier
|
||||||
|
# Phase 3's acceptance criterion 1 (MODULE_API.md §5.2): core must not
|
||||||
|
# name a module's files, import them, route to them, or declare its
|
||||||
|
# symbols. Before `npm ci`, deliberately — it is plain Node over
|
||||||
|
# server/ and client/ source with no dependency of its own, so putting it
|
||||||
|
# first makes a boundary break the first thing a reviewer sees instead of
|
||||||
|
# something found under a pile of unrelated failures, and it costs
|
||||||
|
# nothing when it passes.
|
||||||
|
run: npm run check:modules
|
||||||
- name: Install server deps
|
- name: Install server deps
|
||||||
run: npm ci --prefix server
|
run: npm ci --prefix server
|
||||||
- name: Run server tests
|
- name: Run server tests
|
||||||
run: npm test --prefix server
|
run: npm test --prefix server
|
||||||
|
|
||||||
- name: Check the route manifest is current
|
- name: Check the route manifest is current
|
||||||
# The URL surface is frozen while the routers are carved up by capability
|
# The URL surface is frozen while the routers are carved up by capability
|
||||||
# (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express
|
# (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ export default function App() {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SiteProvider>
|
<SiteProvider>
|
||||||
{/* Inside the auth and site contexts, because a feature provider is a
|
{/* Inside the auth and site contexts, because a feature provider is a
|
||||||
hook that may well read either — the shard one does, indirectly, by
|
hook that may well read either — a live-status one does, indirectly,
|
||||||
asking an endpoint whose answer depends on the session. Outside the
|
by asking an endpoint whose answer depends on the session. Outside the
|
||||||
routes, so the nav in every layout is filtered by the same gate and
|
routes, so the nav in every layout is filtered by the same gate and
|
||||||
the provider hooks are called once for the whole app rather than
|
the provider hooks are called once for the whole app rather than
|
||||||
once per screen. */}
|
once per screen. */}
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ function safeParse(text) {
|
|||||||
// on a non-2xx — and nothing above them: a module owns the paths it calls,
|
// on a non-2xx — and nothing above them: a module owns the paths it calls,
|
||||||
// because it owns the routes at the other end.
|
// because it owns the routes at the other end.
|
||||||
//
|
//
|
||||||
// The `api` object below stays core's own binding surface. Its `atlas` and
|
// The `api` object below is core's own binding surface and nothing else: every
|
||||||
// `shard` namespaces are module bindings that only still live here because
|
// namespace in it belongs to a route core still serves. A module binds its own
|
||||||
// Phase 3 has not moved them yet.
|
// paths in its own chunk, against this primitive.
|
||||||
// `BASE` goes with it: a module that needs an EventSource URL cannot go through
|
// `BASE` goes with it: a module that needs an EventSource URL cannot go through
|
||||||
// `req` (fetch-only) and must not hardcode `/api/v1`, which is core's choice of
|
// `req` (fetch-only) and must not hardcode `/api/v1`, which is core's choice of
|
||||||
// mount point and not a promise it has made.
|
// mount point and not a promise it has made.
|
||||||
@@ -141,110 +141,6 @@ export const api = {
|
|||||||
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
||||||
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
||||||
|
|
||||||
// ----- shard live data (uo-link) -----
|
|
||||||
// Token-free, same-origin reads backed by the ingested feed + a cached live
|
|
||||||
// character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
|
|
||||||
shard: {
|
|
||||||
status: () => req('/public/shard/status'),
|
|
||||||
feed: (opts = {}) => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts.kind) qs.set('kind', opts.kind)
|
|
||||||
if (opts.limit) qs.set('limit', opts.limit)
|
|
||||||
const s = qs.toString()
|
|
||||||
return req(`/public/shard/feed${withQs(s)}`)
|
|
||||||
},
|
|
||||||
economy: (limit) => {
|
|
||||||
const q = limit ? `limit=${limit}` : ''
|
|
||||||
return req(`/public/shard/economy${withQs(q)}`)
|
|
||||||
},
|
|
||||||
online: () => req('/public/shard/online'),
|
|
||||||
idoc: () => req('/public/shard/idoc'),
|
|
||||||
champs: () => req('/public/shard/champs'),
|
|
||||||
// Protocol 2.0 boards.
|
|
||||||
guilds: () => req('/public/shard/guilds'),
|
|
||||||
governors: () => req('/public/shard/governors'),
|
|
||||||
governorHistory: (city, limit) => {
|
|
||||||
const q = limit ? `limit=${limit}` : ''
|
|
||||||
return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`)
|
|
||||||
},
|
|
||||||
presence: () => req('/public/shard/presence'),
|
|
||||||
houses: () => req('/public/shard/houses'),
|
|
||||||
// Protocol 3.0: the shard's published ruleset. Resolves to null when the
|
|
||||||
// shard has never published one — a real answer, not an error.
|
|
||||||
ruleset: () => req('/public/shard/ruleset'),
|
|
||||||
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
|
|
||||||
// `board` 404s for a system the shard has never published.
|
|
||||||
points: () => req('/public/shard/points'),
|
|
||||||
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
|
|
||||||
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
|
|
||||||
// the page debounces its search box rather than firing per keystroke.
|
|
||||||
market: (opts = {}) => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts.q) qs.set('q', opts.q)
|
|
||||||
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
|
|
||||||
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
|
|
||||||
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
|
|
||||||
if (opts.map) qs.set('map', opts.map)
|
|
||||||
if (opts.region) qs.set('region', opts.region)
|
|
||||||
if (opts.sort) qs.set('sort', opts.sort)
|
|
||||||
if (opts.limit) qs.set('limit', opts.limit)
|
|
||||||
if (opts.offset) qs.set('offset', opts.offset)
|
|
||||||
return req(`/public/shard/market${withQs(qs.toString())}`)
|
|
||||||
},
|
|
||||||
marketMeta: () => req('/public/shard/market/meta'),
|
|
||||||
marketVendor: (serial, opts = {}) => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts.limit) qs.set('limit', opts.limit)
|
|
||||||
if (opts.offset) qs.set('offset', opts.offset)
|
|
||||||
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
|
|
||||||
},
|
|
||||||
// Which shard surfaces this caller may reach, plus the audience rung they
|
|
||||||
// resolved to. Drives nav so we never render a link that would 403.
|
|
||||||
features: () => req('/public/shard/features'),
|
|
||||||
},
|
|
||||||
|
|
||||||
// ----- spawn atlas (Protocol 3.0 Part C) -----
|
|
||||||
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
|
|
||||||
// not under /shard, because nothing here depends on the sidecar and the pages
|
|
||||||
// stay populated while the shard is offline.
|
|
||||||
atlas: {
|
|
||||||
creatures: (opts = {}) => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts.q) qs.set('q', opts.q)
|
|
||||||
if (opts.facet) qs.set('facet', opts.facet)
|
|
||||||
if (opts.limit) qs.set('limit', opts.limit)
|
|
||||||
if (opts.offset) qs.set('offset', opts.offset)
|
|
||||||
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
|
|
||||||
},
|
|
||||||
creature: (slug, opts = {}) => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts.facet) qs.set('facet', opts.facet)
|
|
||||||
if (opts.points) qs.set('points', opts.points)
|
|
||||||
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
|
|
||||||
},
|
|
||||||
regions: (opts = {}) => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts.facet) qs.set('facet', opts.facet)
|
|
||||||
if (opts.q) qs.set('q', opts.q)
|
|
||||||
return req(`/public/atlas/regions${withQs(qs.toString())}`)
|
|
||||||
},
|
|
||||||
landmarks: (opts = {}) => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts.facet) qs.set('facet', opts.facet)
|
|
||||||
if (opts.q) qs.set('q', opts.q)
|
|
||||||
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
|
|
||||||
},
|
|
||||||
// The CONFIGURED altar roster, not the live board — see shard.champs() for
|
|
||||||
// "which spawn is on level 3 right now".
|
|
||||||
champions: (facet) => req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
|
|
||||||
meta: () => req('/public/atlas/meta'),
|
|
||||||
},
|
|
||||||
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
|
||||||
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
|
||||||
// carries every kind (incl. audit/cheat) and needs the staff session cookie.
|
|
||||||
shardStreamUrl: `${BASE}/public/shard/stream`,
|
|
||||||
adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
|
|
||||||
|
|
||||||
// ----- admin -----
|
// ----- admin -----
|
||||||
admin: {
|
admin: {
|
||||||
dashboard: () => req('/admin/dashboard'),
|
dashboard: () => req('/admin/dashboard'),
|
||||||
@@ -336,21 +232,6 @@ export const api = {
|
|||||||
createInvite: (email, role, sendEmail = true) =>
|
createInvite: (email, role, sendEmail = true) =>
|
||||||
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
||||||
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
|
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
|
||||||
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
|
|
||||||
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
|
|
||||||
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
|
|
||||||
// account) so the shared GameAccounts component works unchanged.
|
|
||||||
userShard: (id) => ({
|
|
||||||
accounts: () => req(`/admin/users/${id}/shard/accounts`),
|
|
||||||
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
|
||||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
|
||||||
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
|
||||||
sales: () => req(`/admin/users/${id}/shard/sales`),
|
|
||||||
houses: () => req(`/admin/users/${id}/shard/houses`),
|
|
||||||
online: () => req(`/admin/users/${id}/shard/online`),
|
|
||||||
standing: () => req(`/admin/users/${id}/shard/standing`),
|
|
||||||
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ----- moderation dashboard (admin + moderator) -----
|
// ----- moderation dashboard (admin + moderator) -----
|
||||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||||
@@ -423,19 +304,6 @@ export const api = {
|
|||||||
linkedIdentities: () => req('/admin/account/identities'),
|
linkedIdentities: () => req('/admin/account/identities'),
|
||||||
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// ----- game account linking (self-service, staff) -----
|
|
||||||
shard: {
|
|
||||||
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
|
|
||||||
accounts: () => req('/admin/shard/accounts'),
|
|
||||||
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
|
||||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
|
||||||
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
|
||||||
sales: () => req('/admin/shard/sales'),
|
|
||||||
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
|
|
||||||
createAccount: (account, password) =>
|
|
||||||
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
|
|
||||||
},
|
|
||||||
|
|
||||||
// ----- auth providers / SSO config (admin only) -----
|
// ----- auth providers / SSO config (admin only) -----
|
||||||
listAuthProviders: () => req('/admin/auth/providers'),
|
listAuthProviders: () => req('/admin/auth/providers'),
|
||||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||||
@@ -446,45 +314,6 @@ export const api = {
|
|||||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
||||||
|
|
||||||
// ----- uo-link sidecar control (admin only) -----
|
|
||||||
getUoLinkConfig: () => req('/admin/uo-link/config'),
|
|
||||||
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
|
||||||
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
|
||||||
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
||||||
// Per-feature shard visibility: who may see which shard surface, and which
|
|
||||||
// sensitive fields within it. Admin only — it decides what ANONYMOUS
|
|
||||||
// visitors get. acct/webId are admin-only always and the API rejects any
|
|
||||||
// attempt to configure them.
|
|
||||||
getShardVisibility: () => req('/admin/shard/visibility'),
|
|
||||||
saveShardVisibility: (features) =>
|
|
||||||
req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
|
|
||||||
|
|
||||||
// ----- spawn atlas operation (admin only) -----
|
|
||||||
// The atlas re-derives itself from the ServUO tree on every boot; these are
|
|
||||||
// for applying a map change without a restart, and for the approve/reject
|
|
||||||
// decision on a refresh that would remove a facet.
|
|
||||||
atlas: {
|
|
||||||
status: () => req('/admin/shard/atlas'),
|
|
||||||
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
|
|
||||||
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
|
|
||||||
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
|
|
||||||
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
|
|
||||||
},
|
|
||||||
|
|
||||||
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
|
|
||||||
// `actor` is stamped server-side from the session — never sent from here.
|
|
||||||
shardOps: {
|
|
||||||
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
|
|
||||||
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
|
|
||||||
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
|
|
||||||
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
|
|
||||||
pages: () => req('/admin/shard/pages'),
|
|
||||||
respondPage: (id, data) =>
|
|
||||||
req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
|
|
||||||
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
|
|
||||||
audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
|
|
||||||
},
|
|
||||||
|
|
||||||
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
||||||
getEmailConfig: () => req('/admin/email/config'),
|
getEmailConfig: () => req('/admin/email/config'),
|
||||||
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
||||||
@@ -508,19 +337,6 @@ export const api = {
|
|||||||
linkedIdentities: () => req('/player/account/identities'),
|
linkedIdentities: () => req('/player/account/identities'),
|
||||||
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// ----- game account linking (uo-link) -----
|
|
||||||
shard: {
|
|
||||||
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
|
|
||||||
accounts: () => req('/player/shard/accounts'),
|
|
||||||
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
|
||||||
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
|
||||||
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
|
||||||
sales: () => req('/player/shard/sales'),
|
|
||||||
houses: () => req('/player/shard/houses'), // the caller's own houses
|
|
||||||
createAccount: (account, password) =>
|
|
||||||
req('/player/shard/account', { method: 'POST', body: { account, password } }),
|
|
||||||
},
|
|
||||||
|
|
||||||
// ----- moderation appeals (self-service) -----
|
// ----- moderation appeals (self-service) -----
|
||||||
getMyAppeals: () => req('/player/appeals'),
|
getMyAppeals: () => req('/player/appeals'),
|
||||||
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useAuth } from '../contexts/AuthContext.jsx'
|
|||||||
import { useSite } from '../contexts/SiteContext.jsx'
|
import { useSite } from '../contexts/SiteContext.jsx'
|
||||||
import Maintenance from '../routes/public/Maintenance.jsx'
|
import Maintenance from '../routes/public/Maintenance.jsx'
|
||||||
|
|
||||||
// Wraps the public site. When the shard is in maintenance, visitors see the
|
// Wraps the public site. When the site is in maintenance, visitors see the
|
||||||
// coming-soon page; a logged-in admin sees the real site (live preview).
|
// coming-soon page; a logged-in admin sees the real site (live preview).
|
||||||
export default function MaintenanceGate({ children }) {
|
export default function MaintenanceGate({ children }) {
|
||||||
const { mode, loading } = useSite()
|
const { mode, loading } = useSite()
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function SiteFooter() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="site-footer-info">
|
<div className="site-footer-info">
|
||||||
<span>{siteTitle} is an independent private shard project.</span>
|
<span>{siteTitle} is an independent, privately-run game server.</span>
|
||||||
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
|
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||||
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
{contactEmail}
|
{contactEmail}
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ export default function SiteHeader() {
|
|||||||
// their own (THEMING_AND_NAV.md §7). Two things about the order here:
|
// their own (THEMING_AND_NAV.md §7). Two things about the order here:
|
||||||
//
|
//
|
||||||
// • the override merge runs FIRST and the feature filter after it, so the
|
// • the override merge runs FIRST and the feature filter after it, so the
|
||||||
// filter stays the boundary — an override cannot un-hide a shard surface
|
// filter stays the boundary — an override cannot un-hide a surface this
|
||||||
// this viewer may not see, whatever it says. `pruneNav` applies the same
|
// viewer may not see, whatever it says. `pruneNav` applies the same
|
||||||
// check inside a section and drops one it leaves empty, so a dropdown
|
// check inside a section and drops one it leaves empty, so a dropdown
|
||||||
// never opens onto nothing;
|
// never opens onto nothing;
|
||||||
// • with no stored row this is the coded NAV, in code order, so an
|
// • with no stored row this is the coded NAV, in code order, so an
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ export function parseLayout(str) {
|
|||||||
// The current hardcoded hero as a HeroLayout, so the page is unchanged until
|
// The current hardcoded hero as a HeroLayout, so the page is unchanged until
|
||||||
// staff publish their own. Font sizes use the existing clamp() strings so the
|
// staff publish their own. Font sizes use the existing clamp() strings so the
|
||||||
// default stays responsive (editor-created text uses px).
|
// default stays responsive (editor-created text uses px).
|
||||||
|
//
|
||||||
|
// The copy is deliberately game-neutral, and deliberately still copy: this is
|
||||||
|
// also the starting point the hero editor loads, so an instance that wants to
|
||||||
|
// name its game says so there, once, and the result is stored — rather than core
|
||||||
|
// shipping one game's words for every instance to overwrite in source.
|
||||||
export function defaultLayout(teaser, name = 'Runic Gateway') {
|
export function defaultLayout(teaser, name = 'Runic Gateway') {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -84,9 +89,9 @@ export function defaultLayout(teaser, name = 'Runic Gateway') {
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
width: 760,
|
width: 760,
|
||||||
lines: [
|
lines: [
|
||||||
{ text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
|
{ text: 'Private game server', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
|
||||||
{ text: name, tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 },
|
{ text: name, tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 },
|
||||||
{ text: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
|
{ text: 'A private world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
|
||||||
{ text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
|
{ text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export function buildNavRows(baseNav, overrides) {
|
|||||||
//
|
//
|
||||||
// The base side is restricted to the rows the editor is actually holding: §8.1
|
// The base side is restricted to the rows the editor is actually holding: §8.1
|
||||||
// filters the palette to what this admin can themselves see, and an item that
|
// filters the palette to what this admin can themselves see, and an item that
|
||||||
// their role or a shard feature kept off the screen is not a reorder.
|
// their role or a module's feature gate kept off the screen is not a reorder.
|
||||||
function orderMatchesBase(groups, baseNav) {
|
function orderMatchesBase(groups, baseNav) {
|
||||||
const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`))
|
const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`))
|
||||||
const base = isGrouped(baseNav)
|
const base = isGrouped(baseNav)
|
||||||
@@ -408,9 +408,10 @@ export function buildPublicNav(baseNav, overrides, { keepHidden = false } = {})
|
|||||||
* Apply the caller's visibility gate — and drop a section it leaves empty.
|
* Apply the caller's visibility gate — and drop a section it leaves empty.
|
||||||
*
|
*
|
||||||
* Kept here rather than in SiteHeader because the empty-dropdown case is the one
|
* Kept here rather than in SiteHeader because the empty-dropdown case is the one
|
||||||
* with real correctness risk: a section whose every entry is hidden by shard
|
* with real correctness risk: a section whose every entry is hidden by a
|
||||||
* visibility must not render as a menu that opens onto nothing. The predicate
|
* module's visibility rules must not render as a menu that opens onto nothing.
|
||||||
* stays the caller's, so this module still knows nothing about shard features.
|
* The predicate stays the caller's, so this module still knows nothing about
|
||||||
|
* what any module gates on.
|
||||||
*
|
*
|
||||||
* Added links carry no gate, so they are always visible — see the note above.
|
* Added links carry no gate, so they are always visible — see the note above.
|
||||||
*
|
*
|
||||||
@@ -489,7 +490,7 @@ export function buildPublicNavOverrides(tree, baseNav, stored = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Carry through an entry for a coded item this admin's palette never showed
|
// Carry through an entry for a coded item this admin's palette never showed
|
||||||
// them (shard-feature gated), so their save does not silently reset it.
|
// them (feature-gated by its module), so their save does not silently reset it.
|
||||||
const { items: storedItems } = unwrapPublic(stored)
|
const { items: storedItems } = unwrapPublic(stored)
|
||||||
for (const [to, entry] of Object.entries(storedItems)) {
|
for (const [to, entry] of Object.entries(storedItems)) {
|
||||||
if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry
|
if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ import { NAV as PLAYER_NAV } from '../../player/PlayerPortalLayout.jsx'
|
|||||||
// Three things shape the screen:
|
// Three things shape the screen:
|
||||||
//
|
//
|
||||||
// • The palette is filtered to the editing admin's OWN visible rows (§8.1) —
|
// • The palette is filtered to the editing admin's OWN visible rows (§8.1) —
|
||||||
// the base array run through their role and this shard's feature gates. An
|
// the base array run through their role and the feature gates of whichever
|
||||||
|
// module registered each row (client/src/modules/featureGate.js). An
|
||||||
// admin cannot drag in, and so can never accidentally advertise, something
|
// admin cannot drag in, and so can never accidentally advertise, something
|
||||||
// they cannot see themselves. An override on a row they cannot see is
|
// they cannot see themselves. An override on a row they cannot see is
|
||||||
// carried through their save untouched rather than quietly reset.
|
// carried through their save untouched rather than quietly reset.
|
||||||
@@ -458,8 +459,8 @@ export default function NavEditor() {
|
|||||||
<section style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
<section style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
|
||||||
Rename, reorder and hide the entries in each navigation. The pages themselves are unchanged — this
|
Rename, reorder and hide the entries in each navigation. The pages themselves are unchanged — this
|
||||||
only decides what is advertised, and it can never show anyone a link their role or this shard’s
|
only decides what is advertised, and it can never show anyone a link their role, or the visibility
|
||||||
visibility settings would hide.
|
settings of an installed module, would hide.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* ── Tabs ───────────────────────────────────────────────── */}
|
{/* ── Tabs ───────────────────────────────────────────────── */}
|
||||||
@@ -552,8 +553,8 @@ export default function NavEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
|
||||||
Only entries you can see yourself are listed. Anything hidden from you by your role or by Shard
|
Only entries you can see yourself are listed. Anything hidden from you by your role, or by a
|
||||||
Visibility keeps whatever it was already set to.
|
module’s visibility settings, keeps whatever it was already set to.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ export default function About() {
|
|||||||
<PageHeader eyebrow="About" title={`About ${siteShortName}`} />
|
<PageHeader eyebrow="About" title={`About ${siteShortName}`} />
|
||||||
<div className="prose">
|
<div className="prose">
|
||||||
<p>
|
<p>
|
||||||
{siteShortName} is an independent, privately-run Ultima Online shard built by a small group of long-time players.
|
{siteShortName} is an independent, privately-run game server built by a small group of long-time
|
||||||
It is not affiliated with or endorsed by the owners of Ultima Online — it is a labor of love for the old
|
players. It is not affiliated with or endorsed by the owners of the game it runs — it is a labor of
|
||||||
worlds and the friendships made in them.
|
love for the old worlds and the friendships made in them.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Our aim is a calm, hand-tended world: a contested wilderness worth exploring, safe towns worth living in,
|
Our aim is a calm, hand-tended world: somewhere worth exploring, somewhere worth living in, and
|
||||||
and systems that reward curiosity over grind. We are building slowly and in the open, sharing news,
|
systems that reward curiosity over grind. We are building slowly and in the open, sharing news,
|
||||||
screenshots, and guides as the world comes online.
|
screenshots, and guides as the world comes online.
|
||||||
</p>
|
</p>
|
||||||
<h2>What to expect</h2>
|
<h2>What to expect</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>A hybrid ruleset — safe towns, a dangerous wild.</li>
|
<li>A world that is hand-tended rather than left to run itself.</li>
|
||||||
<li>Custom crafting, housing, and exploration content.</li>
|
<li>Custom content, and changes explained before they land.</li>
|
||||||
<li>A small, friendly population and an active wiki.</li>
|
<li>A small, friendly population and an active wiki.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function Screenshots() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Gallery"
|
eyebrow="Gallery"
|
||||||
title="Gameplay Pictures"
|
title="Gameplay Pictures"
|
||||||
lead="Glimpses of towns, dungeons, events, and daily life on the shard."
|
lead="Glimpses of the world, its events, and daily life on the server."
|
||||||
/>
|
/>
|
||||||
{loading && <Loading />}
|
{loading && <Loading />}
|
||||||
{error && <ErrorState message="Could not load the gallery right now." />}
|
{error && <ErrorState message="Could not load the gallery right now." />}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function Status() {
|
|||||||
return (
|
return (
|
||||||
<PublicLayout section="website">
|
<PublicLayout section="website">
|
||||||
<div className="shell-narrow page-body">
|
<div className="shell-narrow page-body">
|
||||||
<PageHeader eyebrow="Live" title="Shard Status" />
|
<PageHeader eyebrow="Live" title="Site Status" />
|
||||||
|
|
||||||
{loading && <Loading />}
|
{loading && <Loading />}
|
||||||
{error && <ErrorState message="Could not load status right now." />}
|
{error && <ErrorState message="Could not load status right now." />}
|
||||||
@@ -58,7 +58,7 @@ export default function Status() {
|
|||||||
{isLive ? 'Live — the gates are open' : 'Maintenance — building in progress'}
|
{isLive ? 'Live — the gates are open' : 'Maintenance — building in progress'}
|
||||||
</strong>
|
</strong>
|
||||||
<span style={{ color: isLive ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
<span style={{ color: isLive ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
||||||
{statusMessage || (isLive ? 'The shard is online.' : 'The gates are closed while we shape the world. Public login is not open yet.')}
|
{statusMessage || (isLive ? 'The site is open.' : 'The gates are closed while we shape the world. Public login is not open yet.')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import PageHeader from '../../components/PageHeader.jsx'
|
|||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
const CARDS = [
|
const CARDS = [
|
||||||
{ kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots from towns, dungeons, events, and daily life on the shard.', to: '/site/screenshots' },
|
{ kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots of the world, its events, and daily life on the server.', to: '/site/screenshots' },
|
||||||
{ kicker: 'Updates', title: 'Development News', body: 'Progress notes, shard milestones, and public announcements.', to: '/site/news' },
|
{ kicker: 'Updates', title: 'Development News', body: 'Progress notes, project milestones, and public announcements.', to: '/site/news' },
|
||||||
{ kicker: 'Community', title: 'Five on Friday', body: 'Weekly questions, small previews, and notes from the team.', to: '/site/five-on-friday' },
|
{ kicker: 'Community', title: 'Five on Friday', body: 'Weekly questions, small previews, and notes from the team.', to: '/site/five-on-friday' },
|
||||||
{ kicker: 'Long-form', title: 'Monthly Newsletter', body: 'Fuller summaries for players who want the whole picture.', to: '/site/newsletter' },
|
{ kicker: 'Long-form', title: 'Monthly Newsletter', body: 'Fuller summaries for players who want the whole picture.', to: '/site/newsletter' },
|
||||||
{ kicker: 'Reference', title: 'Wiki', body: 'Guides and reference pages for the game world.', to: '/wiki' },
|
{ kicker: 'Reference', title: 'Wiki', body: 'Guides and reference pages for the game world.', to: '/wiki' },
|
||||||
{ kicker: 'Live', title: 'Shard Status', body: 'Launch state, test windows, and known issues.', to: '/site/status' },
|
{ kicker: 'Live', title: 'Site Status', body: 'Launch state, test windows, and known issues.', to: '/site/status' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Website() {
|
export default function Website() {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default function Wiki() {
|
|||||||
center
|
center
|
||||||
eyebrow="Knowledge base"
|
eyebrow="Knowledge base"
|
||||||
title={`${siteShortName} Wiki`}
|
title={`${siteShortName} Wiki`}
|
||||||
lead="A calm starting point for shard guides, the world and its lore, gameplay systems, and community rules."
|
lead="A calm starting point for guides, the world and its lore, gameplay systems, and community rules."
|
||||||
/>
|
/>
|
||||||
<SearchBox initial={activeQ || ''} onSubmit={runSearch} />
|
<SearchBox initial={activeQ || ''} onSubmit={runSearch} />
|
||||||
|
|
||||||
|
|||||||
@@ -128,10 +128,10 @@ test('wiki() with no options sends no query string at all', async () => {
|
|||||||
assert.equal(calls[0].url, '/api/v1/public/wiki')
|
assert.equal(calls[0].url, '/api/v1/public/wiki')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('path params are URL-encoded (a token/city with unsafe characters is escaped)', async () => {
|
test('path params are URL-encoded (a token with unsafe characters is escaped)', async () => {
|
||||||
willReply({ body: {} })
|
willReply({ body: {} })
|
||||||
await api.shard.governorHistory('Serpent’s Hold', 5)
|
await api.getInvite('a b/c?d')
|
||||||
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
|
assert.equal(calls[0].url, '/api/v1/auth/invite/a%20b%2Fc%3Fd')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => {
|
test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => {
|
||||||
@@ -140,44 +140,3 @@ test('DELETE self-service session revoke encodes the id and uses the DELETE meth
|
|||||||
assert.equal(calls[0].opts.method, 'DELETE')
|
assert.equal(calls[0].opts.method, 'DELETE')
|
||||||
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────
|
|
||||||
// The atlas lives at /public/atlas, NOT under /public/shard: it is static shard
|
|
||||||
// content parsed from the shard's own files, so it must not look sidecar-backed.
|
|
||||||
// Asserted here because the split is a design decision, not an accident of
|
|
||||||
// spelling.
|
|
||||||
test('atlas reads hit /public/atlas, not /public/shard', async () => {
|
|
||||||
willReply({ body: { creatures: [] } })
|
|
||||||
await api.atlas.creatures()
|
|
||||||
assert.equal(calls[0].url, '/api/v1/public/atlas/creatures')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('atlas.creatures() sends only the filters that are set', async () => {
|
|
||||||
willReply({ body: { creatures: [] } })
|
|
||||||
await api.atlas.creatures({ q: 'lizard man', facet: 'Ter Mur', limit: 25 })
|
|
||||||
const url = new URL(calls[0].url, 'http://x')
|
|
||||||
assert.equal(url.pathname, '/api/v1/public/atlas/creatures')
|
|
||||||
assert.equal(url.searchParams.get('q'), 'lizard man')
|
|
||||||
assert.equal(url.searchParams.get('facet'), 'Ter Mur')
|
|
||||||
assert.equal(url.searchParams.get('limit'), '25')
|
|
||||||
assert.equal(url.searchParams.get('offset'), null) // 0 is not sent
|
|
||||||
})
|
|
||||||
|
|
||||||
test('atlas.creature() encodes the slug and carries the facet filter through', async () => {
|
|
||||||
willReply({ body: {} })
|
|
||||||
await api.atlas.creature('lizardman/rare', { facet: 'Felucca' })
|
|
||||||
assert.match(calls[0].url, /\/public\/atlas\/creatures\/lizardman%2Frare\?facet=Felucca$/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('admin atlas actions use the right methods and bodies', async () => {
|
|
||||||
willReply({ body: {} })
|
|
||||||
await api.admin.atlas.import(true)
|
|
||||||
assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import')
|
|
||||||
assert.equal(calls[0].opts.method, 'POST')
|
|
||||||
assert.equal(calls[0].opts.body, JSON.stringify({ force: true }))
|
|
||||||
|
|
||||||
willReply({ body: {} })
|
|
||||||
await api.admin.atlas.setPath('/srv/servuo')
|
|
||||||
assert.equal(calls[1].opts.method, 'PUT')
|
|
||||||
assert.equal(calls[1].opts.body, JSON.stringify({ path: '/srv/servuo' }))
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "runic-gateway-website",
|
"name": "runic-gateway-website",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Runic Gateway — public site, wiki, and admin panel for a private Ultima Online shard",
|
"description": "Runic Gateway — public site, wiki, and admin panel for a private game server",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"install-server": "npm install --prefix server",
|
"install-server": "npm install --prefix server",
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"bot": "npm run dev --prefix bot",
|
"bot": "npm run dev --prefix bot",
|
||||||
"seed": "npm run seed --prefix server",
|
"seed": "npm run seed --prefix server",
|
||||||
"build": "npm run build --prefix client",
|
"build": "npm run build --prefix client",
|
||||||
"start": "npm start --prefix server"
|
"start": "npm start --prefix server",
|
||||||
|
"check:modules": "node scripts/checkModuleIdentifiers.js"
|
||||||
},
|
},
|
||||||
"keywords": ["express", "mariadb", "react", "vite", "jwt"],
|
"keywords": ["express", "mariadb", "react", "vite", "jwt"],
|
||||||
"author": "whitlocktech",
|
"author": "whitlocktech",
|
||||||
|
|||||||
328
scripts/checkModuleIdentifiers.js
Normal file
328
scripts/checkModuleIdentifiers.js
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// ── §5.2 — zero module identifiers in core ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// Phase 3's acceptance criterion 1, as a check rather than a review promise: no
|
||||||
|
// `shard`, `uoLink`, `cliloc`, `atlas` or `towncrier` anywhere in core's source
|
||||||
|
// (MODULE_API.md §5.2, MODULE_SYSTEM.md §2.7.1 slice 4). The extraction is only
|
||||||
|
// worth what this is worth — a boundary nothing enforces grows a hole the first
|
||||||
|
// time someone is in a hurry, and the hole looks exactly like the code that was
|
||||||
|
// there before.
|
||||||
|
//
|
||||||
|
// **It reads code, not prose, and that is the whole design.** Four things are
|
||||||
|
// checked, and each is a thing a module owns:
|
||||||
|
//
|
||||||
|
// 1. file and directory names
|
||||||
|
// 2. import and require SPECIFIERS — the path, not the file's contents
|
||||||
|
// 3. route path literals — the string handed to .get/.post/.put/.patch/
|
||||||
|
// .delete/.use
|
||||||
|
// 4. declared identifiers — function, const, class, and object property names
|
||||||
|
//
|
||||||
|
// Comments and string content in general are NOT read. Core's own English may
|
||||||
|
// legitimately say "shard": `About.jsx` did until slice 4 rewrote it, and a
|
||||||
|
// comment explaining *what moved and why* — `AcceptInvite.jsx` has one — is
|
||||||
|
// worth more than the word costs. A literal word grep would fail on both, prove
|
||||||
|
// nothing about the boundary, and teach people to phrase around it. The
|
||||||
|
// boundary this defends is structural: core must not NAME a module's files,
|
||||||
|
// import them, route to them, or declare their symbols. It may talk about them.
|
||||||
|
//
|
||||||
|
// Two things learned the hard way, both of which this file would have got wrong:
|
||||||
|
//
|
||||||
|
// • **Match on word boundaries, not substrings.** `defaultImage` contains
|
||||||
|
// "ultIma"; `atlas` is inside "atlasSomething" legitimately only when it is
|
||||||
|
// the same word. The tokeniser below splits identifiers on camelCase and
|
||||||
|
// separators and compares WHOLE words, so `shardStatus` is a hit and
|
||||||
|
// `defaultImage` is not. A substring pass flagged four innocent lines in
|
||||||
|
// this repo on its first run.
|
||||||
|
// • **Strip comments and strings with a character walk, not a regexp.** The
|
||||||
|
// module's own `checkImports.js` flagged the comments that explain what it
|
||||||
|
// catches. A comment contains quotes (`-- '' when randomised`), a string
|
||||||
|
// contains `//` (any URL), and a regexp literal contains both. Doing it in
|
||||||
|
// one pass, in order, is the only way that comes out right — and this file
|
||||||
|
// has its own test suite (`server/test/checkModuleIdentifiers.test.js`)
|
||||||
|
// because a check that silently stops checking is worse than no check.
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const { execFileSync } = require('child_process')
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
// The trees core owns. `modules/` is deliberately absent — that is where a
|
||||||
|
// module's own code lives, and it is the one place these words belong.
|
||||||
|
const TREES = [
|
||||||
|
path.join(ROOT, 'server', 'src'),
|
||||||
|
path.join(ROOT, 'server', 'scripts'),
|
||||||
|
path.join(ROOT, 'server', 'db'),
|
||||||
|
path.join(ROOT, 'client', 'src'),
|
||||||
|
]
|
||||||
|
|
||||||
|
const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', '.git'])
|
||||||
|
const CODE = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'])
|
||||||
|
|
||||||
|
// The words a module owns. Lower-cased whole words, compared against the
|
||||||
|
// tokeniser's output — so `uoLink`, `uo_link` and `uo-link` all reduce to the
|
||||||
|
// two tokens `uo` and `link`, and the pair is what is matched.
|
||||||
|
const RESERVED = new Set(['shard', 'shards', 'cliloc', 'clilocs', 'atlas', 'towncrier'])
|
||||||
|
// Sequences of tokens that are reserved together but innocent apart: "uo" and
|
||||||
|
// "link" each appear in ordinary core code ("link" especially), and only the
|
||||||
|
// pair names the sidecar.
|
||||||
|
const RESERVED_PAIRS = [['uo', 'link'], ['town', 'crier'], ['spawn', 'atlas'], ['serv', 'uo']]
|
||||||
|
// Standalone `uo` is reserved too: it is the module id, and a core file called
|
||||||
|
// `uo.js` or a route `/uo` is the boundary being crossed in the plainest way.
|
||||||
|
const RESERVED_ALONE = new Set(['uo', 'uolink', 'servuo', 'ultima'])
|
||||||
|
|
||||||
|
// ── The grandfathering exemptions ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Exactly three, and all three are the SAME mechanism: core's per-module legacy
|
||||||
|
// allowlists (MODULE_API.md §6.5). A table prefix, a set of stream ids and an
|
||||||
|
// announce leg all predate the module system, are stored in live rows, and are
|
||||||
|
// read by a shipped Android client — so `uo` keeps them, and keeping them means
|
||||||
|
// core holds a map whose KEY is the module id. There is no way to write that
|
||||||
|
// down without naming the module; that is what grandfathering is.
|
||||||
|
//
|
||||||
|
// Nothing else may be added here without the same kind of reason. In particular
|
||||||
|
// this is not an escape hatch for "core still needs this for now" — that is the
|
||||||
|
// state slice 4 exists to end.
|
||||||
|
//
|
||||||
|
// Each entry must MATCH something. An exemption that no longer fires is deleted
|
||||||
|
// by the check itself (`unused exemption` below), because a stale one is how an
|
||||||
|
// allowlist quietly becomes permission for whatever drifts into it later.
|
||||||
|
const EXEMPT = [
|
||||||
|
{
|
||||||
|
file: 'server/src/modules/loader.js',
|
||||||
|
name: 'uo',
|
||||||
|
kind: 'property name',
|
||||||
|
why: 'LEGACY_TABLE_PREFIXES — the grandfathered shard_/uo_link_ table prefixes (API §6.5)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server/src/modules/registries.js',
|
||||||
|
name: 'uo',
|
||||||
|
kind: 'property name',
|
||||||
|
why: 'LEGACY_STREAM_IDS and LEGACY_LEGS — grandfathered stream ids and the towncrier leg (API §6.5)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const isExempt = (hit) =>
|
||||||
|
EXEMPT.some((e) => e.file === hit.file && e.name === hit.name && e.kind === hit.kind)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a name into lower-case words: camelCase humps, and runs separated by
|
||||||
|
* `-`, `_`, `.`, `/` or digits.
|
||||||
|
*
|
||||||
|
* `shardStatus` → [shard, status]; `uo_link_config` → [uo, link, config];
|
||||||
|
* `defaultImage` → [default, image] — which is the point: the substring
|
||||||
|
* "ultIma" inside it is not a word and never appears here.
|
||||||
|
*/
|
||||||
|
function tokenize(name) {
|
||||||
|
return String(name)
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||||
|
.split(/[^A-Za-z]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((w) => w.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does this name contain a reserved word, as a word? */
|
||||||
|
function reservedWordIn(name) {
|
||||||
|
const words = tokenize(name)
|
||||||
|
for (const w of words) {
|
||||||
|
if (RESERVED.has(w) || RESERVED_ALONE.has(w)) return w
|
||||||
|
}
|
||||||
|
for (const [a, b] of RESERVED_PAIRS) {
|
||||||
|
for (let i = 0; i < words.length - 1; i++) {
|
||||||
|
if (words[i] === a && words[i + 1] === b) return `${a}-${b}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blank out comments, and MASK string/template/regexp contents, in one
|
||||||
|
* left-to-right pass.
|
||||||
|
*
|
||||||
|
* Masking rather than deleting: the checks that run afterwards need to know
|
||||||
|
* WHERE a string was (a route path literal is a string) while not reading what
|
||||||
|
* is in an arbitrary one. So a string's delimiters and length survive and its
|
||||||
|
* body becomes spaces, except that the string-literal check below re-reads the
|
||||||
|
* original text at the same offsets. Comments are replaced by spaces so every
|
||||||
|
* offset in the returned text still lines up with the input — line numbers stay
|
||||||
|
* honest without a second pass.
|
||||||
|
*/
|
||||||
|
function maskCode(src) {
|
||||||
|
const out = Array.from(src)
|
||||||
|
const blank = (from, to) => {
|
||||||
|
for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' '
|
||||||
|
}
|
||||||
|
let i = 0
|
||||||
|
while (i < src.length) {
|
||||||
|
const c = src[i]
|
||||||
|
const next = src[i + 1]
|
||||||
|
if (c === '/' && next === '/') {
|
||||||
|
let j = i
|
||||||
|
while (j < src.length && src[j] !== '\n') j++
|
||||||
|
blank(i, j)
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (c === '/' && next === '*') {
|
||||||
|
const end = src.indexOf('*/', i + 2)
|
||||||
|
const j = end === -1 ? src.length : end + 2
|
||||||
|
blank(i, j)
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (c === '-' && next === '-' && src[i + 2] === ' ') {
|
||||||
|
// SQL line comment; harmless in JS, where `-- ` cannot start an expression.
|
||||||
|
let j = i
|
||||||
|
while (j < src.length && src[j] !== '\n') j++
|
||||||
|
blank(i, j)
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (c === '"' || c === "'" || c === '`') {
|
||||||
|
let j = i + 1
|
||||||
|
while (j < src.length) {
|
||||||
|
if (src[j] === '\\') { j += 2; continue }
|
||||||
|
if (src[j] === c) break
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
blank(i + 1, j) // keep the quotes, blank the body
|
||||||
|
i = j + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return out.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the four checks ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SPECIFIER = /(?:require\(\s*|from\s+|import\(\s*)(['"])([^'"]+)\1/g
|
||||||
|
const ROUTE = /\.(?:get|post|put|patch|delete|use|all)\(\s*(['"`])([^'"`]*)\1/g
|
||||||
|
const DECLARED = /\b(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g
|
||||||
|
const PROPERTY = /(?:^|[{,]\s*)([A-Za-z_$][\w$]*)\s*:/gm
|
||||||
|
|
||||||
|
function lineOf(src, index) {
|
||||||
|
return src.slice(0, index).split('\n').length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check one file. `src` is the raw text; `masked` has comments blanked and
|
||||||
|
* string bodies blanked at the same offsets, so a regexp run over `masked`
|
||||||
|
* finds only real code — and the captured offsets index back into `src` when a
|
||||||
|
* check legitimately needs the string's content (specifiers and route paths).
|
||||||
|
*/
|
||||||
|
function checkFile(rel, src) {
|
||||||
|
const hits = []
|
||||||
|
const masked = maskCode(src)
|
||||||
|
const add = (kind, name, index) => {
|
||||||
|
const word = reservedWordIn(name)
|
||||||
|
if (word) hits.push({ file: rel, line: lineOf(src, index), kind, name, word })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of masked.matchAll(SPECIFIER)) {
|
||||||
|
// Read the specifier out of the ORIGINAL text: its body was masked, and a
|
||||||
|
// path is the one string whose content is structural.
|
||||||
|
const start = m.index + m[0].indexOf(m[1]) + 1
|
||||||
|
add('import specifier', src.slice(start, start + m[2].length), m.index)
|
||||||
|
}
|
||||||
|
for (const m of masked.matchAll(ROUTE)) {
|
||||||
|
const start = m.index + m[0].indexOf(m[1]) + 1
|
||||||
|
add('route path', src.slice(start, start + m[2].length), m.index)
|
||||||
|
}
|
||||||
|
for (const m of masked.matchAll(DECLARED)) add('declared identifier', m[1], m.index)
|
||||||
|
for (const m of masked.matchAll(PROPERTY)) add('property name', m[1], m.index)
|
||||||
|
|
||||||
|
return hits
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir, out = []) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (SKIP_DIRS.has(entry.name)) continue
|
||||||
|
const full = path.join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) walk(full, out)
|
||||||
|
else out.push(full)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The files core SHIPS, which is what the boundary is about — not whatever
|
||||||
|
* happens to be in a working tree.
|
||||||
|
*
|
||||||
|
* `git ls-files` rather than a walk, because an untracked local artifact is not
|
||||||
|
* core's source and must not fail anyone's build. This is not hypothetical: an
|
||||||
|
* operator-supplied `server/db/data/spawnAtlas.art.json` is gitignored, sits in
|
||||||
|
* the tree of anyone who has run the atlas import, and would otherwise report a
|
||||||
|
* file-name violation that no commit could fix. The walk stays as the fallback
|
||||||
|
* for an export with no git in it, where over-reporting is the safer failure.
|
||||||
|
*/
|
||||||
|
function sourceFiles() {
|
||||||
|
try {
|
||||||
|
const out = execFileSync('git', ['ls-files', '-z', '--cached', '--', ...TREES.map((t) => path.relative(ROOT, t))], {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
})
|
||||||
|
const files = out.split('\0').filter(Boolean).map((f) => path.join(ROOT, f))
|
||||||
|
if (files.length) return files
|
||||||
|
} catch {
|
||||||
|
// no git, or not a checkout — fall through
|
||||||
|
}
|
||||||
|
return TREES.filter((t) => fs.existsSync(t)).flatMap((t) => walk(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
const hits = []
|
||||||
|
for (const file of sourceFiles()) {
|
||||||
|
if (!fs.existsSync(file)) continue
|
||||||
|
const rel = path.relative(ROOT, file).split(path.sep).join('/')
|
||||||
|
|
||||||
|
// 1. the name itself
|
||||||
|
const word = reservedWordIn(path.basename(file))
|
||||||
|
if (word) hits.push({ file: rel, line: 0, kind: 'file name', name: path.basename(file), word })
|
||||||
|
|
||||||
|
// 2-4. the contents, for code files only
|
||||||
|
if (!CODE.has(path.extname(file))) continue
|
||||||
|
hits.push(...checkFile(rel, fs.readFileSync(file, 'utf8')))
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = hits.filter((h) => !isExempt(h))
|
||||||
|
// A grandfathering entry that matches nothing is deleted, loudly. Reported as
|
||||||
|
// a failure rather than a warning: the exemption list is the one part of this
|
||||||
|
// check that can only get weaker, so it is the part that needs the noise.
|
||||||
|
const unused = EXEMPT.filter((e) => !hits.some((h) => h.file === e.file && h.name === e.name && h.kind === e.kind))
|
||||||
|
return { hits: live, unused }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { run, checkFile, maskCode, tokenize, reservedWordIn, EXEMPT }
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const { hits, unused } = run()
|
||||||
|
if (hits.length === 0 && unused.length === 0) {
|
||||||
|
console.log('OK — core names no module identifier (MODULE_API.md §5.2).')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
for (const e of unused) {
|
||||||
|
console.error(
|
||||||
|
`\nUnused exemption: ${e.file} "${e.name}" (${e.kind}) matches nothing any more.\n` +
|
||||||
|
` ${e.why}\n` +
|
||||||
|
' Delete it from EXEMPT in this file. A grandfathering entry that has outlived what it ' +
|
||||||
|
'grandfathered is permission with nothing attached to it.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (hits.length === 0) process.exit(1)
|
||||||
|
console.error(
|
||||||
|
`\nCore names ${hits.length} module identifier${hits.length === 1 ? '' : 's'} ` +
|
||||||
|
'(MODULE_API.md §5.2). Each of these belongs to an installed module:\n',
|
||||||
|
)
|
||||||
|
for (const h of hits) {
|
||||||
|
console.error(` ${h.file}:${h.line} ${h.kind} "${h.name}" — reserved word "${h.word}"`)
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
'\nCore may TALK about a module in English; it may not name its files, import them, ' +
|
||||||
|
'route to them, or declare its symbols. If one of these is core\'s own and the word is ' +
|
||||||
|
'a coincidence, the fix is to rename it — the reserved list is short and deliberate.\n',
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
@@ -802,20 +802,6 @@ ALTER TABLE announce_jobs
|
|||||||
DROP INDEX IF EXISTS idx_announce_due,
|
DROP INDEX IF EXISTS idx_announce_due,
|
||||||
DROP INDEX IF EXISTS idx_announce_due_discord;
|
DROP INDEX IF EXISTS idx_announce_due_discord;
|
||||||
|
|
||||||
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
|
|
||||||
-- Static shard CONTENT, not live shard state: what spawns where, which regions
|
|
||||||
-- and landmarks exist, and which champion altars are configured. Nothing here
|
|
||||||
-- comes from the sidecar — it is imported from a committed artifact built off a
|
|
||||||
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
|
|
||||||
-- these tables stay populated whether the shard is up or not.
|
|
||||||
--
|
|
||||||
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
|
|
||||||
-- in one transaction. Nothing else may write here, and nothing else may hold a
|
|
||||||
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
|
|
||||||
|
|
||||||
-- One row per spawnable type, aggregated across the world. `total` is the sum of
|
|
||||||
-- each type's own MX across every point that spawns it (how many exist at once);
|
|
||||||
|
|
||||||
-- Installed modules (module system, docs/website/MODULE_SYSTEM.md §2.4). One row
|
-- Installed modules (module system, docs/website/MODULE_SYSTEM.md §2.4). One row
|
||||||
-- per module the operator has installed onto the modules volume, keyed by the
|
-- per module the operator has installed onto the modules volume, keyed by the
|
||||||
-- module id from its module.json — the same id that names the directory, the URL
|
-- module id from its module.json — the same id that names the directory, the URL
|
||||||
@@ -888,10 +874,6 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
|
|||||||
-- Player self-registration mode: disabled | password | sso | both. Default off,
|
-- Player self-registration mode: disabled | password | sso | both. Default off,
|
||||||
-- so the system behaves exactly as today until an admin opts in.
|
-- so the system behaves exactly as today until an admin opts in.
|
||||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
|
||||||
-- Game-account signup (Protocol 2.0 hybrid mode): whether a signed-in website user
|
|
||||||
-- may provision a linked game account from the site. Default off; the shard's own
|
|
||||||
-- signup mode still has the final say (a 'game'-mode shard refuses regardless).
|
|
||||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
|
|
||||||
|
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
||||||
@@ -921,4 +903,3 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME
|
|||||||
-- trust token. A boolean only — the token is returned over that app→server call
|
-- trust token. A boolean only — the token is returned over that app→server call
|
||||||
-- and never persisted here (only its sha256 lands in trusted_devices).
|
-- and never persisted here (only its sha256 lands in trusted_devices).
|
||||||
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
|
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
|
|
||||||
@@ -26,12 +26,16 @@ const DEFAULT_SETTINGS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Starter wiki sections (editable later via the admin panel).
|
// Starter wiki sections (editable later via the admin panel).
|
||||||
|
//
|
||||||
|
// The SLUGS are deliberately untouched by the de-UO pass: `seedDefault*` only
|
||||||
|
// inserts a row that is not already there, so renaming one adds a duplicate page
|
||||||
|
// to every existing install rather than renaming anything.
|
||||||
// [slug, title, description, sort_order]
|
// [slug, title, description, sort_order]
|
||||||
const WIKI_CATEGORIES = [
|
const WIKI_CATEGORIES = [
|
||||||
['guides', 'Guides', 'Getting started and how-to guides.', 10],
|
['guides', 'Guides', 'Getting started and how-to guides.', 10],
|
||||||
['world', 'World & Lore', `Regions, maps, and the story of ${brand.shortName}.`, 20],
|
['world', 'World & Lore', `Regions, maps, and the story of ${brand.shortName}.`, 20],
|
||||||
['gameplay', 'Systems & Gameplay', 'Mechanics, items, monsters, and crafting.', 30],
|
['gameplay', 'Systems & Gameplay', 'Mechanics, items, monsters, and crafting.', 30],
|
||||||
['community', 'Community & Rules', 'Player conduct and shard policies.', 40],
|
['community', 'Community & Rules', 'Player conduct and server policies.', 40],
|
||||||
]
|
]
|
||||||
|
|
||||||
// The 8 starter pages, each mapped to a section. [slug, title, body, categorySlug]
|
// The 8 starter pages, each mapped to a section. [slug, title, body, categorySlug]
|
||||||
@@ -39,11 +43,11 @@ const WIKI_PAGES = [
|
|||||||
['new-player-guide', 'New Player Guide', 'First steps, basic survival, and early goals.', 'guides'],
|
['new-player-guide', 'New Player Guide', 'First steps, basic survival, and early goals.', 'guides'],
|
||||||
['maps-atlas', 'Maps & Atlas', 'Regions, towns, routes, and travel notes.', 'world'],
|
['maps-atlas', 'Maps & Atlas', 'Regions, towns, routes, and travel notes.', 'world'],
|
||||||
['lore', 'Lore', 'Stories, places, factions, and mysteries.', 'world'],
|
['lore', 'Lore', 'Stories, places, factions, and mysteries.', 'world'],
|
||||||
['systems', 'Server Systems', 'Shard mechanics and custom features.', 'gameplay'],
|
['systems', 'Server Systems', 'Server mechanics and custom features.', 'gameplay'],
|
||||||
['items', 'Items & Rewards', 'Equipment, treasures, rewards, and curiosities.', 'gameplay'],
|
['items', 'Items & Rewards', 'Equipment, treasures, rewards, and curiosities.', 'gameplay'],
|
||||||
['monsters', 'Monsters & Encounters', 'Creatures, bosses, spawns, and dangers.', 'gameplay'],
|
['monsters', 'Monsters & Encounters', 'Creatures, bosses, spawns, and dangers.', 'gameplay'],
|
||||||
['crafting', 'Crafting', 'Professions, materials, recipes, and tools.', 'gameplay'],
|
['crafting', 'Crafting', 'Professions, materials, recipes, and tools.', 'gameplay'],
|
||||||
['rules', 'Rules', 'Player conduct, shard expectations, and policies.', 'community'],
|
['rules', 'Rules', 'Player conduct, server expectations, and policies.', 'community'],
|
||||||
]
|
]
|
||||||
|
|
||||||
async function seedDefaults() {
|
async function seedDefaults() {
|
||||||
|
|||||||
@@ -22,10 +22,15 @@ const name = process.env.BRAND_NAME || 'Runic Gateway'
|
|||||||
const brand = {
|
const brand = {
|
||||||
name,
|
name,
|
||||||
shortName: process.env.BRAND_SHORT_NAME || name,
|
shortName: process.env.BRAND_SHORT_NAME || name,
|
||||||
tagline: process.env.BRAND_TAGLINE || 'an independent private Ultima Online shard',
|
// Game-neutral defaults. Core is the platform, not one game's site: which game
|
||||||
|
// this instance is for is the operator's to say, through these two vars or an
|
||||||
|
// installed module (MODULE_SYSTEM.md §2.7.1, slice 4). Every real instance
|
||||||
|
// overrides both — `.env.uomysticmoon.example` sets its own wording — so these
|
||||||
|
// are what an unconfigured instance shows, not what anyone ships.
|
||||||
|
tagline: process.env.BRAND_TAGLINE || 'an independent, privately-run game server',
|
||||||
description:
|
description:
|
||||||
process.env.BRAND_DESCRIPTION ||
|
process.env.BRAND_DESCRIPTION ||
|
||||||
`${name} — an independent private Ultima Online shard. News, screenshots, guides, and community notes.`,
|
`${name} — an independent, privately-run game server. News, screenshots, guides, and community notes.`,
|
||||||
contactEmail: process.env.BRAND_CONTACT_EMAIL || process.env.CONTACT_TO || '',
|
contactEmail: process.env.BRAND_CONTACT_EMAIL || process.env.CONTACT_TO || '',
|
||||||
url: process.env.BRAND_URL || '',
|
url: process.env.BRAND_URL || '',
|
||||||
// Visual
|
// Visual
|
||||||
|
|||||||
151
server/test/checkModuleIdentifiers.test.js
Normal file
151
server/test/checkModuleIdentifiers.test.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// ── The §5.2 check has to be provably still checking ───────────────────────
|
||||||
|
//
|
||||||
|
// `scripts/checkModuleIdentifiers.js` passes today. So does a check that reads
|
||||||
|
// nothing, and the two are indistinguishable from CI's green tick — which is the
|
||||||
|
// whole failure mode of a boundary test: it is written when the boundary is
|
||||||
|
// clean, it never fires again, and nobody finds out it stopped working until the
|
||||||
|
// thing it guards has been broken for months.
|
||||||
|
//
|
||||||
|
// So this file feeds it code it MUST reject and code it MUST accept. The
|
||||||
|
// accept cases are not filler: every one of them is a false positive that a
|
||||||
|
// simpler implementation actually produced against this repo, and each would
|
||||||
|
// have made the check something people route around rather than obey.
|
||||||
|
|
||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const {
|
||||||
|
run,
|
||||||
|
checkFile,
|
||||||
|
maskCode,
|
||||||
|
tokenize,
|
||||||
|
reservedWordIn,
|
||||||
|
EXEMPT,
|
||||||
|
} = require('../../scripts/checkModuleIdentifiers')
|
||||||
|
|
||||||
|
const words = (src) => checkFile('x.js', src).map((h) => `${h.kind}:${h.name}`)
|
||||||
|
|
||||||
|
// ── the repo itself ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('core, as it stands, names no module identifier', () => {
|
||||||
|
const { hits, unused } = run()
|
||||||
|
assert.deepEqual(
|
||||||
|
hits.map((h) => `${h.file}:${h.line} ${h.kind} "${h.name}"`),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
assert.deepEqual(unused.map((e) => `${e.file} "${e.name}"`), [], 'a grandfathering exemption matches nothing')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every exemption is one of the §6.5 grandfathering allowlists', () => {
|
||||||
|
// Not a count assertion — a reason assertion. The exemption list is the only
|
||||||
|
// part of this check that can weaken it, so what it may contain is pinned.
|
||||||
|
for (const e of EXEMPT) {
|
||||||
|
assert.match(e.file, /^server\/src\/modules\//, `${e.file} is not core's module machinery`)
|
||||||
|
assert.equal(e.name, 'uo')
|
||||||
|
assert.match(e.why, /§6\.5/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── what it must catch ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('an import specifier reaching into a module is a hit', () => {
|
||||||
|
assert.deepEqual(words("const s = require('../shardState/shardState.model')"), [
|
||||||
|
'import specifier:../shardState/shardState.model',
|
||||||
|
])
|
||||||
|
assert.deepEqual(words("import { feed } from './lib/useShardFeed.js'"), [
|
||||||
|
'import specifier:./lib/useShardFeed.js',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a route path literal for a module surface is a hit', () => {
|
||||||
|
assert.deepEqual(words("router.get('/shard/status', handler)"), ['route path:/shard/status'])
|
||||||
|
assert.deepEqual(words("app.use('/uo-link', r)"), ['route path:/uo-link'])
|
||||||
|
assert.deepEqual(words("router.post('/atlas/import', h)"), ['route path:/atlas/import'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a declared identifier carrying a module word is a hit', () => {
|
||||||
|
assert.deepEqual(words('function shardStatus() {}'), ['declared identifier:shardStatus'])
|
||||||
|
assert.deepEqual(words('const uoLinkClient = 1'), ['declared identifier:uoLinkClient'])
|
||||||
|
assert.deepEqual(words('class TownCrier {}'), ['declared identifier:TownCrier'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a property name carrying a module word is a hit', () => {
|
||||||
|
assert.deepEqual(words('const api = {\n shard: {},\n}'), ['property name:shard'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the module id on its own is a hit', () => {
|
||||||
|
assert.deepEqual(words("router.use('/uo', r)"), ['route path:/uo'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── what it must NOT catch ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a substring that is not a word is not a hit — `defaultImage` contains "ultIma"', () => {
|
||||||
|
// The real false positive: a case-insensitive substring pass flagged
|
||||||
|
// heroLayout.js and Portal.jsx four times over on its first run, for a
|
||||||
|
// parameter called `defaultImage`.
|
||||||
|
assert.deepEqual(words('function heroBackground(layout, { defaultImage } = {}) {}'), [])
|
||||||
|
assert.deepEqual(words('const fallback = defaultImage || DEFAULT_HERO_IMAGE'), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('core prose may say the words — comments are not read', () => {
|
||||||
|
assert.deepEqual(words('// the shard one does, indirectly, by asking an endpoint'), [])
|
||||||
|
assert.deepEqual(words('/* Wraps the public site. When the shard is in maintenance … */'), [])
|
||||||
|
assert.deepEqual(words('/**\n * hidden by a module\'s shard visibility rules\n */'), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('core copy may say the words — string CONTENT is not read', () => {
|
||||||
|
assert.deepEqual(words("const lead = 'daily life on the shard'"), [])
|
||||||
|
assert.deepEqual(words('const t = `an independent Ultima Online shard`'), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a comment containing quotes does not swallow the file', () => {
|
||||||
|
// The `checkImports.js` lesson, restated: strip comments BEFORE quotes, in one
|
||||||
|
// pass. A comment with an apostrophe used to open a string that ran to EOF,
|
||||||
|
// and everything after it silently stopped being checked.
|
||||||
|
const src = "// it's the operator's own file\nfunction shardStatus() {}"
|
||||||
|
assert.deepEqual(words(src), ['declared identifier:shardStatus'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a string containing // does not hide the code after it', () => {
|
||||||
|
const src = "const u = 'https://example.com/x'\nfunction shardFeed() {}"
|
||||||
|
assert.deepEqual(words(src), ['declared identifier:shardFeed'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an escaped quote inside a string does not end it early', () => {
|
||||||
|
const src = "const s = 'it\\'s fine, shard'\nconst clilocMap = {}"
|
||||||
|
assert.deepEqual(words(src), ['declared identifier:clilocMap'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ordinary core words that merely resemble a reserved one pass', () => {
|
||||||
|
assert.deepEqual(words('const link = 1'), []) // "link" alone is not "uo-link"
|
||||||
|
assert.deepEqual(words('const unlinkIdentity = () => {}'), [])
|
||||||
|
assert.deepEqual(words('const townName = 1'), []) // "town" alone is not "towncrier"
|
||||||
|
assert.deepEqual(words('const uploadUrl = 1'), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── the internals the above depends on ──────────────────────────────────────
|
||||||
|
|
||||||
|
test('tokenize splits camelCase, snake_case and kebab-case the same way', () => {
|
||||||
|
assert.deepEqual(tokenize('shardStatus'), ['shard', 'status'])
|
||||||
|
assert.deepEqual(tokenize('uo_link_config'), ['uo', 'link', 'config'])
|
||||||
|
assert.deepEqual(tokenize('uo-link'), ['uo', 'link'])
|
||||||
|
assert.deepEqual(tokenize('UOLinkClient'), ['uo', 'link', 'client'])
|
||||||
|
assert.deepEqual(tokenize('defaultImage'), ['default', 'image'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reservedWordIn matches whole words and adjacent pairs only', () => {
|
||||||
|
assert.equal(reservedWordIn('shardFeed'), 'shard')
|
||||||
|
assert.equal(reservedWordIn('uoLinkConfig'), 'uo')
|
||||||
|
assert.equal(reservedWordIn('townCrier'), 'town-crier')
|
||||||
|
assert.equal(reservedWordIn('defaultImage'), null)
|
||||||
|
assert.equal(reservedWordIn('townHall'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maskCode keeps every offset, so reported line numbers are the real ones', () => {
|
||||||
|
const src = "// shard\nconst a = 'shard'\nfunction shardX() {}"
|
||||||
|
const masked = maskCode(src)
|
||||||
|
assert.equal(masked.length, src.length)
|
||||||
|
assert.equal(masked.split('\n').length, src.split('\n').length)
|
||||||
|
const [hit] = checkFile('x.js', src)
|
||||||
|
assert.equal(hit.line, 3)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user