refactor(modules)!: de-UO core's copy, and enforce it (phase 3, slice 4) #140
@@ -47,10 +47,20 @@ jobs:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
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
|
||||
run: npm ci --prefix server
|
||||
- name: Run server tests
|
||||
run: npm test --prefix server
|
||||
|
||||
- name: Check the route manifest is current
|
||||
# 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
|
||||
|
||||
@@ -61,8 +61,8 @@ export default function App() {
|
||||
<AuthProvider>
|
||||
<SiteProvider>
|
||||
{/* Inside the auth and site contexts, because a feature provider is a
|
||||
hook that may well read either — the shard one does, indirectly, by
|
||||
asking an endpoint whose answer depends on the session. Outside the
|
||||
hook that may well read either — a live-status one does, indirectly,
|
||||
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
|
||||
the provider hooks are called once for the whole app rather than
|
||||
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,
|
||||
// because it owns the routes at the other end.
|
||||
//
|
||||
// The `api` object below stays core's own binding surface. Its `atlas` and
|
||||
// `shard` namespaces are module bindings that only still live here because
|
||||
// Phase 3 has not moved them yet.
|
||||
// The `api` object below is core's own binding surface and nothing else: every
|
||||
// namespace in it belongs to a route core still serves. A module binds its own
|
||||
// paths in its own chunk, against this primitive.
|
||||
// `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
|
||||
// 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}`),
|
||||
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: {
|
||||
dashboard: () => req('/admin/dashboard'),
|
||||
@@ -336,21 +232,6 @@ export const api = {
|
||||
createInvite: (email, role, sendEmail = true) =>
|
||||
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
||||
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) -----
|
||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||
@@ -423,19 +304,6 @@ export const api = {
|
||||
linkedIdentities: () => req('/admin/account/identities'),
|
||||
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) -----
|
||||
listAuthProviders: () => req('/admin/auth/providers'),
|
||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||
@@ -446,45 +314,6 @@ export const api = {
|
||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||
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) -----
|
||||
getEmailConfig: () => req('/admin/email/config'),
|
||||
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
||||
@@ -508,19 +337,6 @@ export const api = {
|
||||
linkedIdentities: () => req('/player/account/identities'),
|
||||
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) -----
|
||||
getMyAppeals: () => req('/player/appeals'),
|
||||
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.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).
|
||||
export default function MaintenanceGate({ children }) {
|
||||
const { mode, loading } = useSite()
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function SiteFooter() {
|
||||
</span>
|
||||
</div>
|
||||
<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' }}>
|
||||
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
{contactEmail}
|
||||
|
||||
@@ -56,8 +56,8 @@ export default function SiteHeader() {
|
||||
// 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
|
||||
// filter stays the boundary — an override cannot un-hide a shard surface
|
||||
// this viewer may not see, whatever it says. `pruneNav` applies the same
|
||||
// filter stays the boundary — an override cannot un-hide a surface this
|
||||
// viewer may not see, whatever it says. `pruneNav` applies the same
|
||||
// check inside a section and drops one it leaves empty, so a dropdown
|
||||
// never opens onto nothing;
|
||||
// • 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
|
||||
// staff publish their own. Font sizes use the existing clamp() strings so the
|
||||
// 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') {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -84,9 +89,9 @@ export function defaultLayout(teaser, name = 'Runic Gateway') {
|
||||
align: 'center',
|
||||
width: 760,
|
||||
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: '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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -204,7 +204,7 @@ export function buildNavRows(baseNav, overrides) {
|
||||
//
|
||||
// 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
|
||||
// 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) {
|
||||
const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`))
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* visibility must not render as a menu that opens onto nothing. The predicate
|
||||
* stays the caller's, so this module still knows nothing about shard features.
|
||||
* with real correctness risk: a section whose every entry is hidden by a
|
||||
* module's visibility rules must not render as a menu that opens onto nothing.
|
||||
* 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.
|
||||
*
|
||||
@@ -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
|
||||
// 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)
|
||||
for (const [to, entry] of Object.entries(storedItems)) {
|
||||
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:
|
||||
//
|
||||
// • 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
|
||||
// they cannot see themselves. An override on a row they cannot see is
|
||||
// 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 }}>
|
||||
<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
|
||||
only decides what is advertised, and it can never show anyone a link their role or this shard’s
|
||||
visibility settings would hide.
|
||||
only decides what is advertised, and it can never show anyone a link their role, or the visibility
|
||||
settings of an installed module, would hide.
|
||||
</p>
|
||||
|
||||
{/* ── Tabs ───────────────────────────────────────────────── */}
|
||||
@@ -552,8 +553,8 @@ export default function NavEditor() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
Visibility keeps whatever it was already set to.
|
||||
Only entries you can see yourself are listed. Anything hidden from you by your role, or by a
|
||||
module’s visibility settings, keeps whatever it was already set to.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -10,19 +10,19 @@ export default function About() {
|
||||
<PageHeader eyebrow="About" title={`About ${siteShortName}`} />
|
||||
<div className="prose">
|
||||
<p>
|
||||
{siteShortName} is an independent, privately-run Ultima Online shard built by a small group of long-time players.
|
||||
It is not affiliated with or endorsed by the owners of Ultima Online — it is a labor of love for the old
|
||||
worlds and the friendships made in them.
|
||||
{siteShortName} is an independent, privately-run game server built by a small group of long-time
|
||||
players. It is not affiliated with or endorsed by the owners of the game it runs — it is a labor of
|
||||
love for the old worlds and the friendships made in them.
|
||||
</p>
|
||||
<p>
|
||||
Our aim is a calm, hand-tended world: a contested wilderness worth exploring, safe towns worth living in,
|
||||
and systems that reward curiosity over grind. We are building slowly and in the open, sharing news,
|
||||
Our aim is a calm, hand-tended world: somewhere worth exploring, somewhere worth living in, and
|
||||
systems that reward curiosity over grind. We are building slowly and in the open, sharing news,
|
||||
screenshots, and guides as the world comes online.
|
||||
</p>
|
||||
<h2>What to expect</h2>
|
||||
<ul>
|
||||
<li>A hybrid ruleset — safe towns, a dangerous wild.</li>
|
||||
<li>Custom crafting, housing, and exploration content.</li>
|
||||
<li>A world that is hand-tended rather than left to run itself.</li>
|
||||
<li>Custom content, and changes explained before they land.</li>
|
||||
<li>A small, friendly population and an active wiki.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function Screenshots() {
|
||||
<PageHeader
|
||||
eyebrow="Gallery"
|
||||
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 />}
|
||||
{error && <ErrorState message="Could not load the gallery right now." />}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function Status() {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="Live" title="Shard Status" />
|
||||
<PageHeader eyebrow="Live" title="Site Status" />
|
||||
|
||||
{loading && <Loading />}
|
||||
{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'}
|
||||
</strong>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,12 +4,12 @@ import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
const CARDS = [
|
||||
{ kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots from towns, dungeons, events, and daily life on the shard.', to: '/site/screenshots' },
|
||||
{ kicker: 'Updates', title: 'Development News', body: 'Progress notes, shard milestones, and public announcements.', to: '/site/news' },
|
||||
{ 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, 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: '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: '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() {
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function Wiki() {
|
||||
center
|
||||
eyebrow="Knowledge base"
|
||||
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} />
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
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: {} })
|
||||
await api.shard.governorHistory('Serpent’s Hold', 5)
|
||||
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
|
||||
await api.getInvite('a b/c?d')
|
||||
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 () => {
|
||||
@@ -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.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",
|
||||
"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,
|
||||
"scripts": {
|
||||
"install-server": "npm install --prefix server",
|
||||
@@ -13,7 +13,8 @@
|
||||
"bot": "npm run dev --prefix bot",
|
||||
"seed": "npm run seed --prefix server",
|
||||
"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"],
|
||||
"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_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
|
||||
-- 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
|
||||
@@ -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,
|
||||
-- so the system behaves exactly as today until an admin opts in.
|
||||
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 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
|
||||
-- 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;
|
||||
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).
|
||||
//
|
||||
// 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]
|
||||
const WIKI_CATEGORIES = [
|
||||
['guides', 'Guides', 'Getting started and how-to guides.', 10],
|
||||
['world', 'World & Lore', `Regions, maps, and the story of ${brand.shortName}.`, 20],
|
||||
['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]
|
||||
@@ -39,11 +43,11 @@ const WIKI_PAGES = [
|
||||
['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'],
|
||||
['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'],
|
||||
['monsters', 'Monsters & Encounters', 'Creatures, bosses, spawns, and dangers.', '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() {
|
||||
|
||||
@@ -22,10 +22,15 @@ const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||
const brand = {
|
||||
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:
|
||||
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 || '',
|
||||
url: process.env.BRAND_URL || '',
|
||||
// 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