spike(modules): carry /public/atlas/* behind the proposed module surface
THROWAWAY BRANCH — evidence for the Phase 1 contract, never merged. See
modules/uo/SPIKE.md and docs/website/MODULE_API.md Part 7.
The six public spawn-atlas routes now live in modules/uo/, reached only through
the ctx/register surface, with the client half loading as a prebuilt ESM chunk.
All three exit criteria met:
• zero internal-file imports from the module into core; the built chunk has
zero bare import specifiers and bundles no React
• routes.manifest.json AND routes.guards.json are byte-identical
• /uo/atlas renders from /modules/uo/entry.js under script-src 'self' with
zero CSP violation reports
729 core tests and 81 module tests pass. Verified end to end against the real
database: the schema fragment replays after core's, onBoot runs the atlas
refresh, and the six API URLs answer unchanged.
Two things the spike changed in the contract:
• ctx.express / ctx.validator. A module lives outside server/, so Node never
reaches server/node_modules and require('express') fails outright — the
server-side twin of the one-React rule, which §2.6 had only for the client.
• window.__rg.jsxRuntime, so a module can build with the automatic JSX
runtime its tooling already assumes rather than being forced to classic.
And it confirmed §6.1 empirically: regenerating the OpenAPI spec silently
deleted all 361 lines of the atlas paths with "Swagger-autogen: Success", while
the route manifest kept all six in the same run. That is exactly the
static-analysis-vs-runtime split the fragment merge exists to prevent.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
50
modules/uo/SPIKE.md
Normal file
50
modules/uo/SPIKE.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# module-uo — the Phase 1 spike
|
||||
|
||||
**This branch is evidence, not implementation.** `spike/module-atlas` is cut from `edge` and is
|
||||
never merged. Phase 2 rebuilds the loader properly, with the `installed_modules` table, the full
|
||||
state machine and the admin panel behind it; Phase 3 does the real extraction.
|
||||
|
||||
What it demonstrates, and the results, are written up in
|
||||
[`docs/website/MODULE_API.md`](../../../docs/website/MODULE_API.md) Part 7. In one line: the six
|
||||
public spawn-atlas routes now live in a module, at byte-identical URLs, with the client half loading
|
||||
as a prebuilt ESM chunk under `script-src 'self'`.
|
||||
|
||||
## Reproducing it
|
||||
|
||||
```bash
|
||||
# 1. build the module's client chunk (its CI would do this and ship the result)
|
||||
cd modules/uo/client && npm install && npm run build # → dist/entry.js
|
||||
|
||||
# 2. build core's client
|
||||
cd ../../../client && npm install && npm run build
|
||||
|
||||
# 3. run the server against the local MariaDB
|
||||
cd ../server && npm start
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
- `/uo/atlas` and `/uo/atlas/lizardman` render from the module's chunk.
|
||||
- `GET /api/v1/public/atlas/*` answers exactly as before — `npm run routes:manifest -- --check`
|
||||
reports the surface unchanged.
|
||||
- `npm test` in `server/` (core, 729) and `node --test` in `modules/uo/server/` (module, 81).
|
||||
|
||||
`dist/entry.js` is committed here **only** because this branch is the evidence for a design
|
||||
decision and a reviewer should be able to inspect the built artifact without a toolchain. A real
|
||||
module publishes it from CI into its release bundle and never commits it.
|
||||
|
||||
## What in here is not design
|
||||
|
||||
Three things are consequences of stopping at six routes, spelled out in MODULE_API.md §7.5:
|
||||
|
||||
1. **Core reaches into this module twice** — `server/src/router/v1/admin/shardAtlas.controller.js`
|
||||
and `server/test/atlasController.test.js`. The five admin atlas routes sit inside the `/shard`
|
||||
admin prefix core still owns, so they cannot move until the whole prefix does.
|
||||
2. **`server/utils/visibility.js` is a copy** of core's `utils/shardVisibility.js`, which core still
|
||||
needs for the shard routes not yet extracted. Two caches over one table, briefly.
|
||||
3. **There is no `swagger-fragment.json`** — it needs core's merge helper on the other side, which
|
||||
is Phase 2.
|
||||
|
||||
Also note the table names here are `shard_*`, not `uo_*`. That is deliberate and grandfathered by an
|
||||
allowlist in the loader: renaming twenty-seven live tables is a data migration this workstream does
|
||||
not do. Every module written after this one carries its id as a table prefix.
|
||||
409
modules/uo/client/dist/entry.js
vendored
Normal file
409
modules/uo/client/dist/entry.js
vendored
Normal file
@@ -0,0 +1,409 @@
|
||||
const B = window.__rg.jsxRuntime, { jsx: t, jsxs: l, Fragment: A } = B, U = window.__rg.react, {
|
||||
useState: h,
|
||||
useEffect: I,
|
||||
useMemo: j,
|
||||
useCallback: W,
|
||||
useRef: ne,
|
||||
useContext: se,
|
||||
useReducer: re,
|
||||
createElement: ie,
|
||||
cloneElement: le,
|
||||
createContext: oe,
|
||||
forwardRef: ce,
|
||||
memo: de,
|
||||
Fragment: me,
|
||||
Children: pe,
|
||||
isValidElement: ue,
|
||||
StrictMode: he,
|
||||
Suspense: ge,
|
||||
lazy: fe
|
||||
} = U, E = window.__rg.router, {
|
||||
Link: z,
|
||||
NavLink: ye,
|
||||
Navigate: xe,
|
||||
Outlet: we,
|
||||
Route: be,
|
||||
Routes: ve,
|
||||
useParams: M,
|
||||
useNavigate: Se,
|
||||
useLocation: Ne,
|
||||
useSearchParams: $e,
|
||||
createBrowserRouter: Ce,
|
||||
RouterProvider: ke
|
||||
} = E, { PublicLayout: F, PageHeader: D, Loading: C, ErrorState: v, EmptyState: S, useAsync: k, useAuth: Re, useSite: ze } = window.__rg.ui, { request: f } = window.__rg.api, w = (e) => e ? `?${e}` : "", O = {
|
||||
creatures: (e = {}) => {
|
||||
const a = new URLSearchParams();
|
||||
return e.q && a.set("q", e.q), e.facet && a.set("facet", e.facet), e.limit && a.set("limit", e.limit), e.offset && a.set("offset", e.offset), f(`/public/atlas/creatures${w(a.toString())}`);
|
||||
},
|
||||
creature: (e, a = {}) => {
|
||||
const s = new URLSearchParams();
|
||||
return a.facet && s.set("facet", a.facet), a.points && s.set("points", a.points), f(`/public/atlas/creatures/${encodeURIComponent(e)}${w(s.toString())}`);
|
||||
},
|
||||
regions: (e = {}) => {
|
||||
const a = new URLSearchParams();
|
||||
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/regions${w(a.toString())}`);
|
||||
},
|
||||
landmarks: (e = {}) => {
|
||||
const a = new URLSearchParams();
|
||||
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/landmarks${w(a.toString())}`);
|
||||
},
|
||||
champions: (e = {}) => {
|
||||
const a = new URLSearchParams();
|
||||
return e.facet && a.set("facet", e.facet), f(`/public/atlas/champions${w(a.toString())}`);
|
||||
},
|
||||
meta: () => f("/public/atlas/meta")
|
||||
}, y = { atlas: O }, H = 50, x = (e) => Number.isFinite(e) ? e.toLocaleString() : "—", Q = [
|
||||
{ key: "creatures", label: "Creatures" },
|
||||
{ key: "champions", label: "Champion altars" },
|
||||
{ key: "places", label: "Places" }
|
||||
];
|
||||
function R({ active: e, onClick: a, children: s }) {
|
||||
return /* @__PURE__ */ t(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
onClick: a,
|
||||
className: "sans",
|
||||
style: {
|
||||
fontSize: "0.78rem",
|
||||
padding: "5px 12px",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
color: e ? "var(--bg-deep)" : "var(--muted)",
|
||||
background: e ? "var(--accent)" : "transparent",
|
||||
border: `1px solid ${e ? "var(--accent)" : "var(--line)"}`
|
||||
},
|
||||
children: s
|
||||
}
|
||||
);
|
||||
}
|
||||
function G({ creature: e }) {
|
||||
const a = Object.entries(e.facets || {}).sort((s, r) => r[1] - s[1]);
|
||||
return /* @__PURE__ */ l(
|
||||
z,
|
||||
{
|
||||
to: `/uo/atlas/${encodeURIComponent(e.slug)}`,
|
||||
className: "panel",
|
||||
style: {
|
||||
padding: "13px 15px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
textDecoration: "none",
|
||||
color: "inherit"
|
||||
},
|
||||
children: [
|
||||
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
|
||||
/* @__PURE__ */ t(
|
||||
"div",
|
||||
{
|
||||
className: "display",
|
||||
style: {
|
||||
fontSize: "0.98rem",
|
||||
color: "var(--head)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
},
|
||||
children: e.name
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ t("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: a.length === 0 ? "—" : a.map(([s, r]) => `${s} (${r})`).join(" · ") })
|
||||
] }),
|
||||
/* @__PURE__ */ l("div", { className: "sans", style: { flex: "none", textAlign: "right" }, children: [
|
||||
/* @__PURE__ */ t("div", { style: { color: "var(--head)", fontSize: "0.92rem" }, children: x(e.total) }),
|
||||
/* @__PURE__ */ l("div", { className: "dim", style: { fontSize: "0.68rem", letterSpacing: "0.05em" }, children: [
|
||||
x(e.points),
|
||||
" spawners"
|
||||
] })
|
||||
] })
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
function V({ q: e, facet: a }) {
|
||||
const [s, r] = h({ loading: !0, error: null, items: [], total: 0 }), [i, p] = h(!1), o = W(
|
||||
async (n) => await y.atlas.creatures({ q: e, facet: a, limit: H, offset: n }),
|
||||
[e, a]
|
||||
);
|
||||
I(() => {
|
||||
let n = !0;
|
||||
return r({ loading: !0, error: null, items: [], total: 0 }), o(0).then((d) => {
|
||||
n && r({ loading: !1, error: null, items: d.creatures || [], total: d.total || 0 });
|
||||
}).catch((d) => n && r({ loading: !1, error: d, items: [], total: 0 })), () => {
|
||||
n = !1;
|
||||
};
|
||||
}, [o]);
|
||||
const c = async () => {
|
||||
p(!0);
|
||||
try {
|
||||
const n = await o(s.items.length);
|
||||
r((d) => ({ ...d, items: [...d.items, ...n.creatures || []], total: n.total ?? d.total }));
|
||||
} catch {
|
||||
} finally {
|
||||
p(!1);
|
||||
}
|
||||
};
|
||||
return s.loading ? /* @__PURE__ */ t(C, {}) : s.error ? /* @__PURE__ */ t(v, { message: "Could not load the bestiary right now." }) : s.items.length === 0 ? /* @__PURE__ */ t(S, { children: "Nothing in the atlas matches that." }) : /* @__PURE__ */ l(A, { children: [
|
||||
/* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.78rem", margin: "0 0 12px" }, children: [
|
||||
"Showing ",
|
||||
x(s.items.length),
|
||||
" of ",
|
||||
x(s.total)
|
||||
] }),
|
||||
/* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: s.items.map((n) => /* @__PURE__ */ t(G, { creature: n }, n.slug)) }),
|
||||
s.items.length < s.total && /* @__PURE__ */ t("div", { style: { textAlign: "center", marginTop: 16 }, children: /* @__PURE__ */ t("button", { type: "button", className: "btn", onClick: c, disabled: i, children: i ? "Loading…" : "Load more" }) })
|
||||
] });
|
||||
}
|
||||
function X({ facet: e }) {
|
||||
const { loading: a, error: s, data: r } = k(() => y.atlas.champions(e), [e]);
|
||||
return a ? /* @__PURE__ */ t(C, {}) : s ? /* @__PURE__ */ t(v, { message: "Could not load the champion altars right now." }) : !r || r.length === 0 ? /* @__PURE__ */ t(S, { children: "No champion altars are configured." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: r.map((i) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "13px 15px", display: "flex", gap: 14, alignItems: "center" }, children: [
|
||||
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
|
||||
/* @__PURE__ */ t("div", { className: "display", style: { fontSize: "0.98rem", color: "var(--head)" }, children: i.label || i.name }),
|
||||
/* @__PURE__ */ l("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: [
|
||||
i.facet,
|
||||
i.group ? ` · ${i.group}` : "",
|
||||
" · ",
|
||||
i.x,
|
||||
", ",
|
||||
i.y
|
||||
] })
|
||||
] }),
|
||||
/* @__PURE__ */ t("span", { className: "sans", style: { flex: "none", fontSize: "0.76rem", color: "var(--muted)" }, children: i.randomType ? "Random champion" : i.type || "—" })
|
||||
] }, i.slug)) });
|
||||
}
|
||||
function J({ q: e, facet: a }) {
|
||||
const { loading: s, error: r, data: i } = k(
|
||||
() => Promise.all([y.atlas.regions({ q: e, facet: a }), y.atlas.landmarks({ q: e, facet: a })]),
|
||||
[e, a]
|
||||
), p = j(() => {
|
||||
if (!i) return [];
|
||||
const [o, c] = i;
|
||||
return [
|
||||
...o.map((n) => ({ key: `r:${n.facet}:${n.name}`, name: n.name, facet: n.facet, detail: n.parent || n.type || "Region", kind: "Region" })),
|
||||
...c.map((n) => ({ key: `l:${n.facet}:${n.group || ""}:${n.name}:${n.x}:${n.y}`, name: n.group ? `${n.group} — ${n.name}` : n.name, facet: n.facet, detail: `${n.x}, ${n.y}`, kind: "Landmark" }))
|
||||
].sort((n, d) => n.name.localeCompare(d.name));
|
||||
}, [i]);
|
||||
return s ? /* @__PURE__ */ t(C, {}) : r ? /* @__PURE__ */ t(v, { message: "Could not load places right now." }) : p.length === 0 ? /* @__PURE__ */ t(S, { children: "No regions or landmarks match that." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: p.map((o) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "10px 14px", display: "flex", gap: 12, alignItems: "baseline" }, children: [
|
||||
/* @__PURE__ */ t("span", { className: "sans", style: { flex: 1, minWidth: 0, color: "var(--head)", fontSize: "0.88rem" }, children: o.name }),
|
||||
/* @__PURE__ */ l("span", { className: "sans dim", style: { fontSize: "0.72rem" }, children: [
|
||||
o.facet,
|
||||
" · ",
|
||||
o.detail
|
||||
] }),
|
||||
/* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.66rem", letterSpacing: "0.06em", flex: "none" }, children: o.kind })
|
||||
] }, o.key)) });
|
||||
}
|
||||
function K() {
|
||||
var L, _, q;
|
||||
const [e, a] = h("creatures"), [s, r] = h(""), [i, p] = h(""), [o, c] = h(""), n = k(() => y.atlas.meta());
|
||||
I(() => {
|
||||
const m = setTimeout(() => p(s.trim()), 250);
|
||||
return () => clearTimeout(m);
|
||||
}, [s]);
|
||||
const d = ((L = n.data) == null ? void 0 : L.facets) || [], u = ((_ = n.data) == null ? void 0 : _.counts) || null, N = (q = n.data) != null && q.importedAt ? new Date(n.data.importedAt) : null;
|
||||
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
|
||||
/* @__PURE__ */ t(
|
||||
D,
|
||||
{
|
||||
eyebrow: "Bestiary",
|
||||
title: "Spawn atlas",
|
||||
lead: "Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
||||
}
|
||||
),
|
||||
u && /* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.76rem", margin: "-12px 0 18px" }, children: [
|
||||
x(u.creatures),
|
||||
" creatures across ",
|
||||
x(u.points),
|
||||
" spawners",
|
||||
Number.isFinite(u.unresolvedPoints) && u.points ? ` · ${Math.round((u.points - u.unresolvedPoints) / u.points * 100)}% placed to a named region or landmark` : "",
|
||||
N ? ` · parsed ${N.toLocaleDateString()}` : ""
|
||||
] }),
|
||||
/* @__PURE__ */ t("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }, children: Q.map((m) => /* @__PURE__ */ t(R, { active: e === m.key, onClick: () => a(m.key), children: m.label }, m.key)) }),
|
||||
e !== "champions" && /* @__PURE__ */ t(
|
||||
"input",
|
||||
{
|
||||
className: "input",
|
||||
type: "search",
|
||||
value: s,
|
||||
onChange: (m) => r(m.target.value),
|
||||
placeholder: e === "creatures" ? "Search creatures…" : "Search regions and landmarks…",
|
||||
style: { width: "100%", marginBottom: 12 }
|
||||
}
|
||||
),
|
||||
d.length > 0 && /* @__PURE__ */ l("div", { style: { display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 18 }, children: [
|
||||
/* @__PURE__ */ t(R, { active: o === "", onClick: () => c(""), children: "All facets" }),
|
||||
d.map((m) => /* @__PURE__ */ t(R, { active: o === m, onClick: () => c(m), children: m }, m))
|
||||
] }),
|
||||
n.error && /* @__PURE__ */ t(v, { message: "Could not load the atlas right now." }),
|
||||
!n.error && !n.loading && !N && /* @__PURE__ */ t(S, { children: "The spawn atlas has not been imported yet." }),
|
||||
!n.error && N && /* @__PURE__ */ l(A, { children: [
|
||||
e === "creatures" && /* @__PURE__ */ t(V, { q: i, facet: o }),
|
||||
e === "champions" && /* @__PURE__ */ t(X, { facet: o }),
|
||||
e === "places" && /* @__PURE__ */ t(J, { q: i, facet: o })
|
||||
] })
|
||||
] }) });
|
||||
}
|
||||
const g = (e) => Number.isFinite(e) ? e.toLocaleString() : "—";
|
||||
function Y(e, a) {
|
||||
const s = (r) => r >= 60 ? `${Math.round(r / 60)}m` : `${r}s`;
|
||||
return !Number.isFinite(e) || !Number.isFinite(a) ? null : e === a ? s(e) : `${s(e)}–${s(a)}`;
|
||||
}
|
||||
function P({ title: e, right: a, children: s }) {
|
||||
return /* @__PURE__ */ l("section", { className: "panel", style: { padding: 18 }, children: [
|
||||
/* @__PURE__ */ l("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12 }, children: [
|
||||
/* @__PURE__ */ t("h2", { className: "display", style: { margin: "0 0 12px", fontSize: "1.02rem", color: "var(--head)" }, children: e }),
|
||||
a
|
||||
] }),
|
||||
s
|
||||
] });
|
||||
}
|
||||
function Z({ places: e }) {
|
||||
return e.length === 0 ? /* @__PURE__ */ t("p", { className: "sans dim", style: { margin: 0 }, children: "No placed spawners." }) : /* @__PURE__ */ t("div", { children: e.map((a) => /* @__PURE__ */ l(
|
||||
"div",
|
||||
{
|
||||
className: "sans",
|
||||
style: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
padding: "6px 0",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
fontSize: "0.86rem"
|
||||
},
|
||||
children: [
|
||||
/* @__PURE__ */ t("span", { style: { minWidth: 0, color: "var(--head)" }, children: a.label }),
|
||||
/* @__PURE__ */ l("span", { className: "dim", style: { flex: "none" }, children: [
|
||||
a.facet,
|
||||
" · ",
|
||||
g(a.spawners),
|
||||
" spawner",
|
||||
a.spawners === 1 ? "" : "s",
|
||||
" · up to",
|
||||
" ",
|
||||
g(a.maxAlive),
|
||||
" at once"
|
||||
] })
|
||||
]
|
||||
},
|
||||
`${a.facet}:${a.label}`
|
||||
)) });
|
||||
}
|
||||
function ee({ spawners: e, truncated: a }) {
|
||||
const [s, r] = h(!1);
|
||||
return e.length === 0 ? null : /* @__PURE__ */ t(
|
||||
P,
|
||||
{
|
||||
title: "Individual spawners",
|
||||
right: /* @__PURE__ */ t(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "sans",
|
||||
onClick: () => r((i) => !i),
|
||||
style: { background: "none", border: "none", color: "var(--accent)", cursor: "pointer", fontSize: "0.78rem" },
|
||||
children: s ? "Hide" : `Show ${g(e.length)}`
|
||||
}
|
||||
),
|
||||
children: s && /* @__PURE__ */ l("div", { style: { overflowX: "auto" }, children: [
|
||||
/* @__PURE__ */ l("table", { className: "sans", style: { width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }, children: [
|
||||
/* @__PURE__ */ t("thead", { children: /* @__PURE__ */ l("tr", { style: { textAlign: "left", color: "var(--muted)" }, children: [
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Place" }),
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Facet" }),
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Coords" }),
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Max" }),
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 0 8px 0" }, children: "Respawn" })
|
||||
] }) }),
|
||||
/* @__PURE__ */ t("tbody", { children: e.map((i) => /* @__PURE__ */ l("tr", { style: { borderTop: "1px solid var(--line)" }, children: [
|
||||
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0", color: "var(--head)" }, children: i.label }),
|
||||
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: i.facet }),
|
||||
/* @__PURE__ */ l("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: [
|
||||
i.x,
|
||||
", ",
|
||||
i.y
|
||||
] }),
|
||||
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: g(i.maxCount) }),
|
||||
/* @__PURE__ */ t("td", { style: { padding: "6px 0" }, className: "dim", children: Y(i.minDelay, i.maxDelay) || "—" })
|
||||
] }, i.id)) })
|
||||
] }),
|
||||
a && /* @__PURE__ */ t("p", { className: "sans dim", style: { fontSize: "0.74rem", margin: "10px 0 0" }, children: "Only the largest spawners are listed." })
|
||||
] })
|
||||
}
|
||||
);
|
||||
}
|
||||
function te() {
|
||||
var o;
|
||||
const { slug: e } = M(), { loading: a, error: s, data: r } = k(() => y.atlas.creature(e), [e]), i = (s == null ? void 0 : s.status) === 404 || (s == null ? void 0 : s.message) === "Not Found", p = j(
|
||||
() => Object.entries((r == null ? void 0 : r.facets) || {}).sort((c, n) => n[1] - c[1]),
|
||||
[r]
|
||||
);
|
||||
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
|
||||
/* @__PURE__ */ t("p", { className: "sans", style: { marginBottom: 8 }, children: /* @__PURE__ */ t(z, { to: "/uo/atlas", style: { color: "var(--accent)", fontSize: "0.78rem" }, children: "← Spawn atlas" }) }),
|
||||
a && /* @__PURE__ */ t(C, {}),
|
||||
s && !i && /* @__PURE__ */ t(v, { message: "Could not load that creature right now." }),
|
||||
i && /* @__PURE__ */ t(S, { children: "Nothing by that name spawns on this shard." }),
|
||||
!a && !s && r && /* @__PURE__ */ l(A, { children: [
|
||||
/* @__PURE__ */ t(
|
||||
D,
|
||||
{
|
||||
eyebrow: "Bestiary",
|
||||
title: r.name,
|
||||
lead: `Up to ${g(r.total)} alive at once across ${g(r.points)} spawner${r.points === 1 ? "" : "s"}.`
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ l("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [
|
||||
/* @__PURE__ */ t(
|
||||
P,
|
||||
{
|
||||
title: "Where it spawns",
|
||||
right: /* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.74rem" }, children: p.map(([c, n]) => `${c} (${n})`).join(" · ") }),
|
||||
children: /* @__PURE__ */ t(Z, { places: r.places || [] })
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ t(ee, { spawners: r.spawners || [], truncated: !!r.spawnersTruncated }),
|
||||
((o = r.alsoHere) == null ? void 0 : o.length) > 0 && /* @__PURE__ */ t(P, { title: "Shares a spawner with", children: /* @__PURE__ */ t("div", { style: { display: "flex", flexWrap: "wrap", gap: 8 }, children: r.alsoHere.map((c) => /* @__PURE__ */ l(
|
||||
z,
|
||||
{
|
||||
to: `/uo/atlas/${encodeURIComponent(c.slug)}`,
|
||||
className: "sans",
|
||||
style: {
|
||||
fontSize: "0.78rem",
|
||||
padding: "4px 11px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid var(--line)",
|
||||
color: "var(--muted)",
|
||||
textDecoration: "none"
|
||||
},
|
||||
children: [
|
||||
c.name,
|
||||
" ",
|
||||
/* @__PURE__ */ l("span", { className: "dim", children: [
|
||||
"×",
|
||||
g(c.shared)
|
||||
] })
|
||||
]
|
||||
},
|
||||
c.slug
|
||||
)) }) })
|
||||
] })
|
||||
] })
|
||||
] }) });
|
||||
}
|
||||
const $ = "uo", T = "^1.0.0";
|
||||
function ae(e) {
|
||||
const [a] = String(e || "").split(".");
|
||||
return a === T.replace(/^\^/, "").split(".")[0];
|
||||
}
|
||||
const b = window.__rg;
|
||||
b ? ae(b.version) ? (b.registry.registerRoutes($, {
|
||||
// Paths are relative to the module's namespace; core prefixes them, so these
|
||||
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
|
||||
public: [
|
||||
{ path: "atlas", element: /* @__PURE__ */ t(K, {}) },
|
||||
{ path: "atlas/:slug", element: /* @__PURE__ */ t(te, {}) }
|
||||
]
|
||||
}), b.registry.registerNav($, {
|
||||
area: "public",
|
||||
items: [{ label: "Atlas", to: "/uo/atlas", feature: "atlas", order: 12 }]
|
||||
})) : console.error(`[module-${$}] needs core API ${T}, this core is ${b.version} — not registering`) : console.error(`[module-${$}] window.__rg is missing — core did not publish its shared dependencies`);
|
||||
1691
modules/uo/client/package-lock.json
generated
Normal file
1691
modules/uo/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
modules/uo/client/package.json
Normal file
13
modules/uo/client/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "module-uo-client",
|
||||
"private": true,
|
||||
"version": "0.1.0-spike",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
47
modules/uo/client/src/api.js
Normal file
47
modules/uo/client/src/api.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// module-uo's API bindings.
|
||||
//
|
||||
// These used to be `api.atlas` inside core's client/src/api/client.js — a module
|
||||
// namespace living in core (MODULE_API.md §3.5). The module owns the paths
|
||||
// because it owns the routes at the other end; core hands over only the request
|
||||
// primitive: same-origin /api/v1, cookies included, JSON in/out, ApiError on a
|
||||
// non-2xx.
|
||||
const { request } = window.__rg.api
|
||||
|
||||
const withQs = (s) => (s ? `?${s}` : '')
|
||||
|
||||
export const 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 request(`/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 request(`/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 request(`/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 request(`/public/atlas/landmarks${withQs(qs.toString())}`)
|
||||
},
|
||||
champions: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
return request(`/public/atlas/champions${withQs(qs.toString())}`)
|
||||
},
|
||||
meta: () => request('/public/atlas/meta'),
|
||||
}
|
||||
|
||||
export const api = { atlas }
|
||||
53
modules/uo/client/src/entry.jsx
Normal file
53
modules/uo/client/src/entry.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// ── module-uo · client entry point ─────────────────────────────────────────
|
||||
//
|
||||
// The prebuilt ESM chunk core loads as
|
||||
// `<script type="module" src="/modules/uo/entry.js">`. Same-origin, so
|
||||
// `script-src 'self'` admits it with no nonce and no inline — which is the
|
||||
// entire reason the client half is shaped this way (MODULE_SYSTEM.md §1.14).
|
||||
//
|
||||
// It evaluates AFTER core's bundle (deferred module scripts run in document
|
||||
// order) and BEFORE core renders (main.jsx waits for DOMContentLoaded), so
|
||||
// registering synchronously here is enough — there is no loading state to
|
||||
// coordinate and no re-render to trigger.
|
||||
|
||||
import Atlas from './pages/Atlas.jsx'
|
||||
import AtlasCreature from './pages/AtlasCreature.jsx'
|
||||
|
||||
const ID = 'uo'
|
||||
const CORE_API = '^1.0.0'
|
||||
|
||||
// The client-side twin of the server's coreApi check. A module built against a
|
||||
// contract this core does not implement must refuse to register rather than
|
||||
// half-work: a missing kit member is a blank page three clicks in, and the
|
||||
// version is knowable now.
|
||||
function compatible(version) {
|
||||
const [major] = String(version || '').split('.')
|
||||
return major === CORE_API.replace(/^\^/, '').split('.')[0]
|
||||
}
|
||||
|
||||
const rg = window.__rg
|
||||
|
||||
if (!rg) {
|
||||
// Not an exception: throwing from a module script is an uncaught error in the
|
||||
// page, and a module failing to load must never be the site failing to load.
|
||||
console.error(`[module-${ID}] window.__rg is missing — core did not publish its shared dependencies`)
|
||||
} else if (!compatible(rg.version)) {
|
||||
console.error(`[module-${ID}] needs core API ${CORE_API}, this core is ${rg.version} — not registering`)
|
||||
} else {
|
||||
rg.registry.registerRoutes(ID, {
|
||||
// Paths are relative to the module's namespace; core prefixes them, so these
|
||||
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
|
||||
public: [
|
||||
{ path: 'atlas', element: <Atlas /> },
|
||||
{ path: 'atlas/:slug', element: <AtlasCreature /> },
|
||||
],
|
||||
})
|
||||
|
||||
// Interleaves into core's public nav rather than appending a "UO" group.
|
||||
// `order: 12` puts it where the Atlas link already sat — after Wiki and the
|
||||
// shard boards, before About. `feature` is resolved by the provider below.
|
||||
rg.registry.registerNav(ID, {
|
||||
area: 'public',
|
||||
items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas', order: 12 }],
|
||||
})
|
||||
}
|
||||
307
modules/uo/client/src/pages/Atlas.jsx
Normal file
307
modules/uo/client/src/pages/Atlas.jsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
|
||||
import { api } from '../api.js'
|
||||
|
||||
// ── The spawn atlas ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// What the shard CONTAINS, as opposed to what it is doing: which creatures
|
||||
// spawn, where, and which champion altars are configured. There is no live feed
|
||||
// here and no `connected` indicator, deliberately — this is parsed from the
|
||||
// shard's own files and stays complete while the shard is down.
|
||||
//
|
||||
// Facet names come from the shard's data, never from a list in this file. A
|
||||
// shard running custom maps gets its own names in the filter with no code
|
||||
// change (docs/link/v3.md §6.1 R2).
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
const TABS = [
|
||||
{ key: 'creatures', label: 'Creatures' },
|
||||
{ key: 'champions', label: 'Champion altars' },
|
||||
{ key: 'places', label: 'Places' },
|
||||
]
|
||||
|
||||
function Chip({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CreatureCard({ creature }) {
|
||||
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||
return (
|
||||
<Link
|
||||
to={`/uo/atlas/${encodeURIComponent(creature.slug)}`}
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '13px 15px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
style={{
|
||||
fontSize: '0.98rem',
|
||||
color: 'var(--head)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{creature.name}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{facets.length === 0
|
||||
? '—'
|
||||
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
|
||||
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
|
||||
{num(creature.points)} spawners
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// The creature list owns its own paging rather than going through useAsync: a
|
||||
// "load more" appends to what is already on screen, which a hook that resets to
|
||||
// `{ loading: true, data: null }` on every dependency change cannot express.
|
||||
function Creatures({ q, facet }) {
|
||||
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
|
||||
const [more, setMore] = useState(false)
|
||||
|
||||
const load = useCallback(
|
||||
async (offset) => {
|
||||
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
|
||||
return page
|
||||
},
|
||||
[q, facet],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState({ loading: true, error: null, items: [], total: 0 })
|
||||
load(0)
|
||||
.then((page) => {
|
||||
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
|
||||
})
|
||||
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const loadMore = async () => {
|
||||
setMore(true)
|
||||
try {
|
||||
const page = await load(state.items.length)
|
||||
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
|
||||
} catch {
|
||||
// A failed "load more" leaves what is already on screen alone; the button
|
||||
// simply stays available to retry.
|
||||
} finally {
|
||||
setMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loading) return <Loading />
|
||||
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
|
||||
if (state.items.length === 0) {
|
||||
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
Showing {num(state.items.length)} of {num(state.total)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{state.items.map((c) => (
|
||||
<CreatureCard key={c.slug} creature={c} />
|
||||
))}
|
||||
</div>
|
||||
{state.items.length < state.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||
{more ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The CONFIGURED altar roster — where the altars are and what each summons. The
|
||||
// live board ("it is on level 3 right now") is a different page, /site/champs,
|
||||
// fed by the sidecar. Both exist; they are not the same thing.
|
||||
function Champions({ facet }) {
|
||||
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load the champion altars right now." />
|
||||
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((champ) => (
|
||||
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
|
||||
{champ.label || champ.name}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{champ.facet}
|
||||
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
|
||||
{champ.randomType ? 'Random champion' : champ.type || '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Regions and landmarks together: both answer "where is that?", and splitting
|
||||
// them into two tabs would make the visitor guess which list a name lives in.
|
||||
function Places({ q, facet }) {
|
||||
const { loading, error, data } = useAsync(
|
||||
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
|
||||
[q, facet],
|
||||
)
|
||||
const rows = useMemo(() => {
|
||||
if (!data) return []
|
||||
const [regions, landmarks] = data
|
||||
return [
|
||||
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
|
||||
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
|
||||
].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [data])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load places right now." />
|
||||
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Atlas() {
|
||||
const [tab, setTab] = useState('creatures')
|
||||
const [input, setInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [facet, setFacet] = useState('')
|
||||
const meta = useAsync(() => api.atlas.meta())
|
||||
|
||||
// Debounced: typing "lizardman" should be one request, not nine.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setQ(input.trim()), 250)
|
||||
return () => clearTimeout(timer)
|
||||
}, [input])
|
||||
|
||||
const facets = meta.data?.facets || []
|
||||
const counts = meta.data?.counts || null
|
||||
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title="Spawn atlas"
|
||||
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
||||
/>
|
||||
|
||||
{/* The atlas is only as good as its placement rate, so the page states
|
||||
it rather than implying every spawner resolved to a named place. */}
|
||||
{counts && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||
{num(counts.creatures)} creatures across {num(counts.points)} spawners
|
||||
{Number.isFinite(counts.unresolvedPoints) && counts.points
|
||||
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
|
||||
: ''}
|
||||
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{TABS.map((t) => (
|
||||
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab !== 'champions' && (
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{facets.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
<Chip active={facet === ''} onClick={() => setFacet('')}>
|
||||
All facets
|
||||
</Chip>
|
||||
{facets.map((f) => (
|
||||
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
|
||||
{f}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.error && <ErrorState message="Could not load the atlas right now." />}
|
||||
{!meta.error && !meta.loading && !imported && (
|
||||
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
|
||||
)}
|
||||
|
||||
{!meta.error && imported && (
|
||||
<>
|
||||
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
|
||||
{tab === 'champions' && <Champions facet={facet} />}
|
||||
{tab === 'places' && <Places q={q} facet={facet} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
198
modules/uo/client/src/pages/AtlasCreature.jsx
Normal file
198
modules/uo/client/src/pages/AtlasCreature.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
|
||||
import { api } from '../api.js'
|
||||
|
||||
// One creature: where it spawns, and what spawns alongside it.
|
||||
//
|
||||
// `places` is the point of the page — the aggregate that turns 62 raw
|
||||
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
|
||||
// available underneath for the reader who actually wants a coordinate, but they
|
||||
// are secondary and collapsed by default.
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
|
||||
function delay(min, max) {
|
||||
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
|
||||
if (min === max) return fmt(min)
|
||||
return `${fmt(min)}–${fmt(max)}`
|
||||
}
|
||||
|
||||
function Panel({ title, right, children }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Places({ places }) {
|
||||
if (places.length === 0) {
|
||||
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{places.map((place) => (
|
||||
<div
|
||||
key={`${place.facet}:${place.label}`}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
|
||||
<span className="dim" style={{ flex: 'none' }}>
|
||||
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
|
||||
{num(place.maxAlive)} at once
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Spawners({ spawners, truncated }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (spawners.length === 0) return null
|
||||
return (
|
||||
<Panel
|
||||
title="Individual spawners"
|
||||
right={
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
|
||||
>
|
||||
{open ? 'Hide' : `Show ${num(spawners.length)}`}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{open && (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
|
||||
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{spawners.map((s) => (
|
||||
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
|
||||
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
|
||||
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{truncated && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
Only the largest spawners are listed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AtlasCreature() {
|
||||
const { slug } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
|
||||
|
||||
// A 404 here means "no such creature in this atlas", which is a real answer
|
||||
// and not a failure — a visitor following a stale link deserves to be told
|
||||
// that plainly rather than shown a generic error box.
|
||||
const missing = error?.status === 404 || error?.message === 'Not Found'
|
||||
|
||||
const facets = useMemo(
|
||||
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
|
||||
[data],
|
||||
)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<p className="sans" style={{ marginBottom: 8 }}>
|
||||
<Link to="/uo/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||
← Spawn atlas
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && !missing && <ErrorState message="Could not load that creature right now." />}
|
||||
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title={data.name}
|
||||
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Panel
|
||||
title="Where it spawns"
|
||||
right={
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Places places={data.places || []} />
|
||||
</Panel>
|
||||
|
||||
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
|
||||
|
||||
{data.alsoHere?.length > 0 && (
|
||||
<Panel title="Shares a spawner with">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{data.alsoHere.map((other) => (
|
||||
<Link
|
||||
key={other.slug}
|
||||
to={`/uo/atlas/${encodeURIComponent(other.slug)}`}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '4px 11px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--line)',
|
||||
color: 'var(--muted)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{other.name} <span className="dim">×{num(other.shared)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
3
modules/uo/client/src/shim/react-dom-client.js
vendored
Normal file
3
modules/uo/client/src/shim/react-dom-client.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
const reactDom = window.__rg.reactDom
|
||||
export default reactDom
|
||||
export const { createRoot, hydrateRoot } = reactDom
|
||||
8
modules/uo/client/src/shim/react-jsx-runtime.js
vendored
Normal file
8
modules/uo/client/src/shim/react-jsx-runtime.js
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// The automatic JSX runtime, from core's global. Every .jsx file in this module
|
||||
// compiles to imports from here, so this is the single hottest path in the
|
||||
// bundle — and the one that would silently produce a SECOND React if it resolved
|
||||
// to a bundled copy instead.
|
||||
const jsx = window.__rg.jsxRuntime
|
||||
export default jsx
|
||||
export const { jsx: jsxFn, jsxs, Fragment } = jsx
|
||||
export { jsxFn as jsx }
|
||||
10
modules/uo/client/src/shim/react-router-dom.js
vendored
Normal file
10
modules/uo/client/src/shim/react-router-dom.js
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
// react-router-dom from core's global. Same singleton argument as React, with a
|
||||
// sharper edge: the router's context is created by whichever copy is loaded, so
|
||||
// a second copy would give module pages an EMPTY router context — <Link> would
|
||||
// throw and useParams() would return {} rather than the URL's params.
|
||||
const router = window.__rg.router
|
||||
export default router
|
||||
export const {
|
||||
Link, NavLink, Navigate, Outlet, Route, Routes, useParams, useNavigate,
|
||||
useLocation, useSearchParams, createBrowserRouter, RouterProvider,
|
||||
} = router
|
||||
19
modules/uo/client/src/shim/react.js
vendored
Normal file
19
modules/uo/client/src/shim/react.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
// React, taken from core's global rather than bundled.
|
||||
//
|
||||
// There is exactly ONE React in the page and core owns it (MODULE_API.md §3.2).
|
||||
// A module that bundled its own would get a second hook dispatcher and fail at
|
||||
// the first useState — so `react` is declared external in vite.config.js and
|
||||
// aliased here.
|
||||
//
|
||||
// Why an alias module rather than rollup's `output.globals`: `globals` only
|
||||
// applies to iife/umd output, and this is an ES module. An alias is the ESM
|
||||
// equivalent, and it also keeps named imports (`import { useState } from
|
||||
// 'react'`) working unchanged in the page source.
|
||||
const react = window.__rg.react
|
||||
|
||||
export default react
|
||||
export const {
|
||||
useState, useEffect, useMemo, useCallback, useRef, useContext, useReducer,
|
||||
createElement, cloneElement, createContext, forwardRef, memo, Fragment,
|
||||
Children, isValidElement, StrictMode, Suspense, lazy,
|
||||
} = react
|
||||
13
modules/uo/client/src/ui.js
Normal file
13
modules/uo/client/src/ui.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Core's shared UI kit, from the global.
|
||||
//
|
||||
// This is the §3.4 kit: a curated, closed set — the layout chrome, the three
|
||||
// page states, the async hook and the two read-only contexts. It exists because
|
||||
// a module page that does not use core's layout is a module page that does not
|
||||
// look like the site it is installed in, and drifts further every time core's
|
||||
// chrome changes.
|
||||
//
|
||||
// Anything NOT in here, the module bundles itself.
|
||||
const { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite } =
|
||||
window.__rg.ui
|
||||
|
||||
export { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite }
|
||||
43
modules/uo/client/vite.config.js
Normal file
43
modules/uo/client/vite.config.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// Library mode: one prebuilt ESM chunk, published by CI and dropped onto the
|
||||
// operator's volume. The operator never builds anything (MODULE_SYSTEM.md §2.5).
|
||||
//
|
||||
// The four externals are the whole contract with core. Declaring them external
|
||||
// alone is not enough, though: rollup would emit bare `import 'react'`
|
||||
// specifiers, which a browser cannot resolve without an import map — and CSP
|
||||
// forbids the inline <script type="importmap"> that would provide one. So each
|
||||
// is ALIASED to a two-line shim that re-exports from window.__rg, and the
|
||||
// external list then only has to stop Vite from following them into node_modules
|
||||
// this package does not have.
|
||||
const shim = (f) => path.resolve(import.meta.dirname, 'src/shim', f)
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// EXACT matches, via the array form. Vite's object form does PREFIX
|
||||
// replacement, so a plain `react` key also rewrote `react/jsx-runtime` into
|
||||
// `src/shim/react.js/jsx-runtime` — a path that does not exist, and the
|
||||
// first thing this build hit.
|
||||
alias: [
|
||||
{ find: /^react$/, replacement: shim('react.js') },
|
||||
{ find: /^react\/jsx-runtime$/, replacement: shim('react-jsx-runtime.js') },
|
||||
{ find: /^react-dom\/client$/, replacement: shim('react-dom-client.js') },
|
||||
{ find: /^react-router-dom$/, replacement: shim('react-router-dom.js') },
|
||||
],
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: path.resolve(import.meta.dirname, 'src/entry.jsx'),
|
||||
formats: ['es'],
|
||||
fileName: () => 'entry.js',
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
// Same reason as core's client: no inline bootstrap script for
|
||||
// `script-src 'self'` to trip on.
|
||||
modulePreload: { polyfill: false },
|
||||
},
|
||||
})
|
||||
14
modules/uo/module.json
Normal file
14
modules/uo/module.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "uo",
|
||||
"name": "Ultima Online",
|
||||
"version": "0.1.0-spike",
|
||||
"coreApi": "^1.0.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
"purge": "server/db/purge.sql",
|
||||
"mounts": {
|
||||
"public": ["/atlas"]
|
||||
},
|
||||
"capabilities": ["atlas"]
|
||||
}
|
||||
88
modules/uo/server/core.js
Normal file
88
modules/uo/server/core.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// ── The module's single point of contact with core ─────────────────────────
|
||||
//
|
||||
// Every other file in this module imports THIS file instead of reaching into
|
||||
// the website's tree. That is the whole mechanical trick behind the
|
||||
// zero-internal-imports rule (docs/website/MODULE_API.md §5.1): the moved files
|
||||
// changed by one `require` line each, and a CI grep for a relative path
|
||||
// escaping the module root can then be an exact test rather than a heuristic.
|
||||
//
|
||||
// It exists because `ctx` arrives as an ARGUMENT to register(), while the files
|
||||
// that need it are plain CommonJS modules that were written against top-level
|
||||
// requires. Rather than thread ctx through nine constructors, register() parks
|
||||
// it here once and everything else reads it lazily.
|
||||
//
|
||||
// Lazily is load-bearing: this file is required at module-require time, which is
|
||||
// during app.js's own require, and reading `ctx.db` eagerly would rebuild the
|
||||
// startup-time database dependency the loader is careful not to have.
|
||||
|
||||
let ctx = null
|
||||
|
||||
/** Called exactly once, by server/index.js, at the top of register(). */
|
||||
function init(next) {
|
||||
if (ctx) throw new Error('module-uo: core.init() called twice')
|
||||
ctx = next
|
||||
}
|
||||
|
||||
function require_() {
|
||||
if (!ctx) throw new Error('module-uo: core used before register() ran')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// Forwarders rather than re-exports: `const { query } = require('./core')`
|
||||
// destructures at require time, which is before init(), so a plain re-export
|
||||
// would capture undefined. Each of these resolves ctx at CALL time.
|
||||
const query = (sql, params) => require_().db.query(sql, params)
|
||||
|
||||
const logger = (namespace) => require_().log(namespace)
|
||||
|
||||
const settings = {
|
||||
get: (key) => require_().settings.get(key),
|
||||
// `updatedBy` is the third parameter core's settings.model.set carries — the
|
||||
// atlas path setter passes it (shardAtlas.model.js:60), so dropping it here
|
||||
// would silently lose the audit attribution rather than fail.
|
||||
set: (key, value, updatedBy) => require_().settings.set(key, value, updatedBy),
|
||||
getInstanceName: () => require_().settings.getInstanceName(),
|
||||
}
|
||||
|
||||
const auth = {
|
||||
getUserFromRequest: (req) => require_().auth.getUserFromRequest(req),
|
||||
}
|
||||
|
||||
const middleware = {
|
||||
siteMode: (req, res, next) => require_().middleware.siteMode(req, res, next),
|
||||
validate: (req, res, next) => require_().middleware.validate(req, res, next),
|
||||
requireAuth: (req, res, next) => require_().middleware.requireAuth(req, res, next),
|
||||
noindex: (req, res, next) => require_().middleware.noindex(req, res, next),
|
||||
requireRole: (...roles) => {
|
||||
// requireRole is a FACTORY, so it must be resolved at call time and the
|
||||
// resulting middleware kept — resolving it per request would build a new
|
||||
// closure on every hit.
|
||||
let built = null
|
||||
return (req, res, next) => {
|
||||
built = built || require_().middleware.requireRole(...roles)
|
||||
return built(req, res, next)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
// Shared server dependencies, taken from core rather than required directly.
|
||||
// A module lives outside server/, so `require('express')` from here does not
|
||||
// resolve at all — and even where it did, a second express in the process
|
||||
// would be a second Router prototype. Same rule as React on the client.
|
||||
get express() { return require_().express },
|
||||
get validator() { return require_().validator },
|
||||
query,
|
||||
logger,
|
||||
settings,
|
||||
auth,
|
||||
middleware,
|
||||
get pool() { return require_().db.pool },
|
||||
get secretBox() { return require_().secretBox },
|
||||
get push() { return require_().push },
|
||||
get uploads() { return require_().uploads },
|
||||
get posts() { return require_().posts },
|
||||
get paths() { return require_().paths },
|
||||
get moduleId() { return require_().moduleId },
|
||||
}
|
||||
21
modules/uo/server/db/purge.sql
Normal file
21
modules/uo/server/db/purge.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- ── module-uo · purge ──────────────────────────────────────────────────────
|
||||
--
|
||||
-- DESTRUCTIVE. Run ONLY by the explicit admin purge action, never by uninstall
|
||||
-- (docs/website/MODULE_API.md §2.6) — uninstalling a module removes its code and
|
||||
-- retains its data, and an operator who wants the data gone has to say so.
|
||||
--
|
||||
-- Required because this module declares a schema fragment: a module that can
|
||||
-- create tables and cannot drop them leaves an operator with orphaned data and
|
||||
-- no supported way to remove it.
|
||||
--
|
||||
-- Dropped children-first even though these tables carry no foreign keys, so the
|
||||
-- order stays correct if Phase 3 adds one.
|
||||
|
||||
DROP TABLE IF EXISTS shard_atlas_pending;
|
||||
DROP TABLE IF EXISTS shard_atlas_meta;
|
||||
DROP TABLE IF EXISTS shard_champion_spawns;
|
||||
DROP TABLE IF EXISTS shard_landmarks;
|
||||
DROP TABLE IF EXISTS shard_regions;
|
||||
DROP TABLE IF EXISTS shard_spawn_point_types;
|
||||
DROP TABLE IF EXISTS shard_spawn_points;
|
||||
DROP TABLE IF EXISTS shard_spawn_creatures;
|
||||
166
modules/uo/server/db/schema.sql
Normal file
166
modules/uo/server/db/schema.sql
Normal file
@@ -0,0 +1,166 @@
|
||||
-- ── module-uo · schema fragment ────────────────────────────────────────────
|
||||
--
|
||||
-- Replayed by core's ensureSchema() immediately after core's own schema.sql,
|
||||
-- statement by statement, split the same way (docs/website/MODULE_API.md §2.6).
|
||||
-- It inherits core's rules because it goes through core's splitter: idempotent
|
||||
-- CREATE/ALTER only, no DROP, and no `--` inside a string literal.
|
||||
--
|
||||
-- SPIKE SCOPE: the eight spawn-atlas tables, lifted verbatim out of
|
||||
-- server/db/schema.sql. Phase 3 brings the other nineteen.
|
||||
--
|
||||
-- These names are NOT `uo_`-prefixed, which the contract otherwise requires of a
|
||||
-- module's tables. module-uo is grandfathered by an explicit allowlist in the
|
||||
-- loader: renaming twenty-seven live tables is a data migration this workstream
|
||||
-- deliberately does not do, and the prefix rule holds for every module written
|
||||
-- after this one.
|
||||
|
||||
-- ── 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);
|
||||
-- `facets` is a per-facet point count, so the facet filter and "where does this
|
||||
-- live" both answer without touching shard_spawn_points.
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
|
||||
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
|
||||
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
|
||||
total INT NOT NULL DEFAULT 0,
|
||||
points INT NOT NULL DEFAULT 0,
|
||||
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
|
||||
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
|
||||
-- creature art: sprites live in the operator's own client .mul/.uop files and
|
||||
-- are theirs to extract and place under uploads/atlas/. The UI renders without
|
||||
-- art when this is NULL, which is the normal case.
|
||||
art VARCHAR(255) NULL,
|
||||
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
|
||||
-- and FULLTEXT's min-token-length would break searches for names like "orc".
|
||||
INDEX idx_shard_spawn_creatures_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One row per spawner. `region`/`landmark` are the resolved place name — the
|
||||
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
|
||||
-- the resolved display string (region, else landmark, else 'Wilderness').
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
width INT NOT NULL DEFAULT 0,
|
||||
height INT NOT NULL DEFAULT 0,
|
||||
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
|
||||
max_count INT NOT NULL DEFAULT 0,
|
||||
min_delay INT NOT NULL DEFAULT 0,
|
||||
max_delay INT NOT NULL DEFAULT 0,
|
||||
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
|
||||
tod_end INT NOT NULL DEFAULT 0,
|
||||
tod_mode INT NOT NULL DEFAULT 0,
|
||||
region VARCHAR(120) NULL,
|
||||
landmark VARCHAR(120) NULL,
|
||||
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
||||
INDEX idx_shard_spawn_points_facet (facet),
|
||||
INDEX idx_shard_spawn_points_label (label)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The many-to-many between the two above: one spawner commonly carries several
|
||||
-- types (a single Trammel point spawns six), each with its own max. This is how
|
||||
-- /atlas/creatures/:slug finds the places a creature appears.
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
|
||||
point_id INT NOT NULL,
|
||||
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
|
||||
max_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (point_id, slug),
|
||||
INDEX idx_shard_spawn_point_types_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
|
||||
-- holds the region's rectangles; `priority` and rect area are what resolved each
|
||||
-- spawn point at build time, kept here so the admin drift check can re-derive.
|
||||
CREATE TABLE IF NOT EXISTS shard_regions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
type VARCHAR(80) NULL, -- ServUO region class
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
parent VARCHAR(120) NULL, -- enclosing named region, if any
|
||||
rects JSON NULL,
|
||||
INDEX idx_shard_regions_facet (facet),
|
||||
INDEX idx_shard_regions_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
|
||||
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
|
||||
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
|
||||
CREATE TABLE IF NOT EXISTS shard_landmarks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
grp VARCHAR(120) NULL,
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
z INT NOT NULL DEFAULT 0,
|
||||
INDEX idx_shard_landmarks_facet (facet),
|
||||
INDEX idx_shard_landmarks_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
||||
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
||||
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
||||
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
|
||||
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
|
||||
name VARCHAR(120) NOT NULL,
|
||||
grp VARCHAR(80) NULL, -- spawn group; one active per group
|
||||
type VARCHAR(80) NULL, -- '' when randomised per activation
|
||||
random_type TINYINT(1) NOT NULL DEFAULT 0,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
z INT NOT NULL DEFAULT 0,
|
||||
radius INT NOT NULL DEFAULT 0,
|
||||
label VARCHAR(120) NULL, -- resolved place name
|
||||
INDEX idx_shard_champion_spawns_facet (facet)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- Singleton (id = 1) describing the artifact currently loaded: when it was
|
||||
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
|
||||
-- compares this against db/data/spawnAtlas.meta.json to report when the database
|
||||
-- is behind the committed artifact.
|
||||
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
payload JSON NOT NULL,
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
|
||||
-- NOT applied, because it would remove a facet the site currently serves.
|
||||
--
|
||||
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
|
||||
-- much as of a real map change, and boot cannot tell the two apart — so the
|
||||
-- refresh is staged here for a human instead of being applied. Startup is never
|
||||
-- blocked by it: the site comes up serving the atlas it already had.
|
||||
--
|
||||
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
|
||||
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
|
||||
-- keeps a multi-megabyte blob out of the database and guarantees the applied
|
||||
-- atlas matches the tree as it is at approval time, not as it was at boot.
|
||||
--
|
||||
-- `rejected` is remembered against those exact source hashes so a declined
|
||||
-- refresh does not re-prompt on every restart; changing the tree changes the
|
||||
-- hashes and asks again.
|
||||
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
|
||||
payload JSON NOT NULL, -- source hashes + facet diff
|
||||
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
59
modules/uo/server/index.js
Normal file
59
modules/uo/server/index.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// ── module-uo · server entry point ─────────────────────────────────────────
|
||||
//
|
||||
// SPIKE SCOPE. Phase 1 carries only /api/v1/public/atlas/* out of core
|
||||
// (MODULE_SYSTEM.md §2.7): six routes, DB-backed, no sidecar, no SSE, one boot
|
||||
// hook. Phase 3 brings the rest — the other 12 router/controller files, the 7
|
||||
// remaining model directories, the notification-stream catalog and the
|
||||
// town-crier announce leg.
|
||||
//
|
||||
// Called ONCE, synchronously, during the website's app.js require. Everything
|
||||
// here must therefore be synchronous and must not touch the database: the route
|
||||
// manifest generator and the OpenAPI generator both require app.js with the
|
||||
// pool pointed at a dead port, and a module that queried here would hang both
|
||||
// (MODULE_API.md §2.2). Anything needing a live database goes in onBoot.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
module.exports = function register(ctx, api) {
|
||||
// Park ctx before requiring anything that reads it. The requires below pull in
|
||||
// the model layer, whose files resolve core lazily — but the ORDER still
|
||||
// matters for the router, which is constructed at require time.
|
||||
core.init(ctx)
|
||||
|
||||
/* eslint-disable global-require */
|
||||
const atlasRouter = require('./router/atlas.router')
|
||||
const atlas = require('./model/shardAtlas/shardAtlas.model')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const log = ctx.log('boot')
|
||||
|
||||
// The URL is unchanged from when this router lived in core's
|
||||
// router/v1/public/index.js — that is the point, and routes.manifest.json is
|
||||
// the proof (MODULE_API.md §5.3).
|
||||
api.registerRoutes({
|
||||
public: { '/atlas': atlasRouter },
|
||||
})
|
||||
|
||||
// Was server.js:92, an explicit call in core's start(). Re-derive the spawn
|
||||
// atlas from the shard's own ServUO tree: the shard's maps change over its
|
||||
// lifetime — facets get added, replaced or renamed — so the atlas is rebuilt
|
||||
// on every boot rather than shipped as a snapshot that would silently go
|
||||
// stale. Hash-gated, so an unchanged tree costs one read pass and no write.
|
||||
//
|
||||
// Best-effort by contract: no configured path, an unreadable mount or a
|
||||
// malformed file must never stop the site coming up, and a refresh that would
|
||||
// REMOVE a facet is staged for admin approval instead of being applied. So it
|
||||
// is caught HERE rather than left to the loader — the loader's catch would be
|
||||
// correct about the failure but wrong about the severity, marking the module
|
||||
// startup_failed and 503-ing six routes that serve perfectly good stale data.
|
||||
api.onBoot(async () => {
|
||||
try {
|
||||
const result = await atlas.refreshOnBoot()
|
||||
log.info('spawn atlas refreshed', { status: result && result.status })
|
||||
} catch (err) {
|
||||
log.warn('spawn atlas refresh failed — serving whatever was last imported', {
|
||||
error: err.message,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
390
modules/uo/server/model/shardAtlas/shardAtlas.db.js
Normal file
390
modules/uo/server/model/shardAtlas/shardAtlas.db.js
Normal file
@@ -0,0 +1,390 @@
|
||||
const { pool, query } = require('../../core')
|
||||
|
||||
// Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas`
|
||||
// empties and refills all six inside one transaction, and nothing else in the
|
||||
// codebase writes to them. There are no foreign keys, consistent with every
|
||||
// other shard_* table.
|
||||
|
||||
const BATCH = 500
|
||||
|
||||
const ATLAS_TABLES = [
|
||||
'shard_spawn_point_types',
|
||||
'shard_spawn_points',
|
||||
'shard_spawn_creatures',
|
||||
'shard_regions',
|
||||
'shard_landmarks',
|
||||
'shard_champion_spawns',
|
||||
]
|
||||
|
||||
async function insertBatched(conn, sql, rows) {
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch(sql, rows.slice(i, i + BATCH))
|
||||
}
|
||||
return rows.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire atlas in one transaction.
|
||||
*
|
||||
* All-or-nothing on purpose: a failed reload must leave the previous atlas
|
||||
* intact rather than a half-loaded world, since a partially-imported atlas is
|
||||
* indistinguishable from a real one to anyone reading it.
|
||||
*
|
||||
* `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly
|
||||
* commits, which would defeat exactly that guarantee. At ~7k rows the cost of
|
||||
* `DELETE` is irrelevant.
|
||||
*/
|
||||
async function replaceAtlas(atlas, art = {}) {
|
||||
const conn = await pool.getConnection()
|
||||
const counts = {}
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
for (const table of ATLAS_TABLES) await conn.query(`DELETE FROM ${table}`)
|
||||
|
||||
counts.creatures = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_creatures (slug, name, total, points, facets, art) VALUES (?,?,?,?,?,?)',
|
||||
atlas.creatures.map((c) => [
|
||||
c.slug,
|
||||
c.name,
|
||||
c.total ?? 0,
|
||||
c.points ?? 0,
|
||||
JSON.stringify(c.facets ?? {}),
|
||||
art[c.slug] ?? null,
|
||||
]),
|
||||
)
|
||||
|
||||
counts.regions = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_regions (facet, name, type, priority, parent, rects) VALUES (?,?,?,?,?,?)',
|
||||
atlas.regions.map((r) => [
|
||||
r.facet,
|
||||
r.name,
|
||||
r.type || null,
|
||||
r.priority ?? 0,
|
||||
r.parent || null,
|
||||
JSON.stringify(r.rects ?? []),
|
||||
]),
|
||||
)
|
||||
|
||||
counts.landmarks = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_landmarks (facet, name, grp, x, y, z) VALUES (?,?,?,?,?,?)',
|
||||
atlas.landmarks.map((l) => [
|
||||
l.facet,
|
||||
l.name,
|
||||
l.group || null,
|
||||
l.x ?? 0,
|
||||
l.y ?? 0,
|
||||
l.z ?? 0,
|
||||
]),
|
||||
)
|
||||
|
||||
counts.champions = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_champion_spawns ' +
|
||||
'(slug, name, grp, type, random_type, facet, x, y, z, radius, label) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||
atlas.champions.map((c) => [
|
||||
c.slug,
|
||||
c.name,
|
||||
c.group || null,
|
||||
c.type || null,
|
||||
c.randomType ? 1 : 0,
|
||||
c.facet,
|
||||
c.x ?? 0,
|
||||
c.y ?? 0,
|
||||
c.z ?? 0,
|
||||
c.radius ?? 0,
|
||||
c.label || null,
|
||||
]),
|
||||
)
|
||||
|
||||
// Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the
|
||||
// join rows need to know them and `conn.batch()` reports no usable insertId
|
||||
// for a multi-row insert. Safe because this transaction just emptied the
|
||||
// table and nothing else writes to it.
|
||||
counts.points = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_points ' +
|
||||
'(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
|
||||
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
atlas.points.map((p, i) => [
|
||||
i + 1,
|
||||
p.facet,
|
||||
p.name,
|
||||
p.x,
|
||||
p.y,
|
||||
p.width ?? 0,
|
||||
p.height ?? 0,
|
||||
p.range ?? 0,
|
||||
p.maxCount ?? 0,
|
||||
p.minDelay ?? 0,
|
||||
p.maxDelay ?? 0,
|
||||
p.todStart ?? 0,
|
||||
p.todEnd ?? 0,
|
||||
p.todMode ?? 0,
|
||||
p.region,
|
||||
p.landmark,
|
||||
p.label || 'Wilderness',
|
||||
]),
|
||||
)
|
||||
|
||||
counts.pointTypes = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_point_types (point_id, slug, max_count) VALUES (?,?,?)',
|
||||
atlas.pointTypes,
|
||||
)
|
||||
|
||||
await conn.query(
|
||||
'INSERT INTO shard_atlas_meta (id, payload) VALUES (1, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||
[JSON.stringify({ ...atlas.meta, importedCounts: counts })],
|
||||
)
|
||||
|
||||
// A completed import answers whatever was pending.
|
||||
await conn.query('DELETE FROM shard_atlas_pending')
|
||||
|
||||
await conn.commit()
|
||||
return counts
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function getMeta() {
|
||||
const rows = await query('SELECT payload, imported_at FROM shard_atlas_meta WHERE id = 1')
|
||||
if (rows.length === 0) return null
|
||||
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||
return { ...payload, importedAt: rows[0].imported_at }
|
||||
}
|
||||
|
||||
/** Facet names currently loaded, used to detect a facet disappearing. */
|
||||
async function getFacets() {
|
||||
const rows = await query('SELECT DISTINCT facet FROM shard_spawn_points ORDER BY facet')
|
||||
return rows.map((row) => row.facet)
|
||||
}
|
||||
|
||||
// ── Pending review ─────────────────────────────────────────────────────────
|
||||
|
||||
async function getPending() {
|
||||
const rows = await query('SELECT payload, status, detected_at FROM shard_atlas_pending WHERE id = 1')
|
||||
if (rows.length === 0) return null
|
||||
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||
return { ...payload, status: rows[0].status, detectedAt: rows[0].detected_at }
|
||||
}
|
||||
|
||||
async function setPending(payload, status = 'pending') {
|
||||
return query(
|
||||
'INSERT INTO shard_atlas_pending (id, status, payload) VALUES (1, ?, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE status = VALUES(status), payload = VALUES(payload), ' +
|
||||
'detected_at = CURRENT_TIMESTAMP',
|
||||
[status, JSON.stringify(payload)],
|
||||
)
|
||||
}
|
||||
|
||||
async function clearPending() {
|
||||
return query('DELETE FROM shard_atlas_pending')
|
||||
}
|
||||
|
||||
// ── Reads (the public /atlas surface) ──────────────────────────────────────
|
||||
//
|
||||
// Every read here is a plain indexed query over ~7k rows and is served entirely
|
||||
// from MariaDB: the atlas is static shard content, so nothing on this path
|
||||
// touches the sidecar and nothing degrades when the shard is down.
|
||||
//
|
||||
// A facet filter is expressed as EXISTS over the points, never as a JSON path
|
||||
// built from caller input. `shard_spawn_creatures.facets` is a JSON object keyed
|
||||
// by facet name, and matching a key means either concatenating the name into a
|
||||
// path or handing it to JSON_SEARCH — whose search string treats `%` and `_` as
|
||||
// wildcards, so `?facet=%` would quietly match everything. The join is exact and
|
||||
// uses the indexes that already exist.
|
||||
const CREATURE_FACET_EXISTS = `EXISTS (
|
||||
SELECT 1 FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = c.slug AND p.facet = ?
|
||||
)`
|
||||
|
||||
// Build the WHERE for a creature search. `q` is a substring match on the display
|
||||
// name — a LIKE scan, which is free at ~800 rows and, unlike FULLTEXT, has no
|
||||
// minimum token length to break a search for "orc".
|
||||
function creatureWhere({ q, facet }) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (q) {
|
||||
where.push('c.name LIKE ?')
|
||||
params.push(`%${q}%`)
|
||||
}
|
||||
if (facet) {
|
||||
where.push(CREATURE_FACET_EXISTS)
|
||||
params.push(facet)
|
||||
}
|
||||
return { sql: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
|
||||
}
|
||||
|
||||
async function countCreatures({ q = '', facet = '' } = {}) {
|
||||
const { sql, params } = creatureWhere({ q, facet })
|
||||
const rows = await query(`SELECT COUNT(*) AS n FROM shard_spawn_creatures c ${sql}`, params)
|
||||
return rows[0] ? Number(rows[0].n) : 0
|
||||
}
|
||||
|
||||
function listCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||
const { sql, params } = creatureWhere({ q, facet })
|
||||
return query(
|
||||
`SELECT c.slug, c.name, c.total, c.points, c.facets, c.art
|
||||
FROM shard_spawn_creatures c
|
||||
${sql}
|
||||
ORDER BY c.total DESC, c.name ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
async function getCreature(slug) {
|
||||
const rows = await query(
|
||||
'SELECT slug, name, total, points, facets, art FROM shard_spawn_creatures WHERE slug = ?',
|
||||
[slug],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a creature spawns, grouped by resolved place.
|
||||
*
|
||||
* This is the answer the atlas exists to give — "lizardman → Shrines,
|
||||
* Isamu-Jima, Yew" — so it is aggregated in SQL rather than by summing 6,455
|
||||
* point rows in Node.
|
||||
*/
|
||||
function listCreaturePlaces(slug, { facet = '' } = {}) {
|
||||
const params = [slug]
|
||||
let facetSql = ''
|
||||
if (facet) {
|
||||
facetSql = 'AND p.facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
return query(
|
||||
`SELECT p.facet, p.label, COUNT(*) AS spawners, SUM(t.max_count) AS max_alive
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = ? ${facetSql}
|
||||
GROUP BY p.facet, p.label
|
||||
ORDER BY spawners DESC, p.facet ASC, p.label ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** The individual spawners for a creature, newest-largest first. Bounded. */
|
||||
function listCreaturePoints(slug, { facet = '', limit = 200 } = {}) {
|
||||
const params = [slug]
|
||||
let facetSql = ''
|
||||
if (facet) {
|
||||
facetSql = 'AND p.facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
params.push(limit)
|
||||
return query(
|
||||
`SELECT p.id, p.facet, p.name, p.x, p.y, p.width, p.height, p.spawn_range,
|
||||
p.min_delay, p.max_delay, p.tod_start, p.tod_end, p.tod_mode,
|
||||
p.region, p.landmark, p.label, t.max_count
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = ? ${facetSql}
|
||||
ORDER BY t.max_count DESC, p.facet ASC, p.label ASC, p.id ASC
|
||||
LIMIT ?`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** Every other creature sharing a spawner with this one. */
|
||||
function listCreatureCompanions(slug, { limit = 24 } = {}) {
|
||||
return query(
|
||||
`SELECT o.slug, c.name, COUNT(*) AS shared
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_point_types o ON o.point_id = t.point_id AND o.slug <> t.slug
|
||||
JOIN shard_spawn_creatures c ON c.slug = o.slug
|
||||
WHERE t.slug = ?
|
||||
GROUP BY o.slug, c.name
|
||||
ORDER BY shared DESC, c.name ASC
|
||||
LIMIT ?`,
|
||||
[slug, limit],
|
||||
)
|
||||
}
|
||||
|
||||
function listRegions({ facet = '', q = '' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (facet) {
|
||||
where.push('facet = ?')
|
||||
params.push(facet)
|
||||
}
|
||||
if (q) {
|
||||
where.push('name LIKE ?')
|
||||
params.push(`%${q}%`)
|
||||
}
|
||||
return query(
|
||||
`SELECT facet, name, type, priority, parent, rects
|
||||
FROM shard_regions
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY facet ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
function listLandmarks({ facet = '', q = '' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (facet) {
|
||||
where.push('facet = ?')
|
||||
params.push(facet)
|
||||
}
|
||||
if (q) {
|
||||
where.push('(name LIKE ? OR grp LIKE ?)')
|
||||
params.push(`%${q}%`, `%${q}%`)
|
||||
}
|
||||
return query(
|
||||
`SELECT facet, name, grp, x, y, z
|
||||
FROM shard_landmarks
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY facet ASC, grp ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
function listChampions({ facet = '' } = {}) {
|
||||
const params = []
|
||||
let where = ''
|
||||
if (facet) {
|
||||
where = 'WHERE facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
return query(
|
||||
`SELECT slug, name, grp, type, random_type, facet, x, y, z, radius, label
|
||||
FROM shard_champion_spawns
|
||||
${where}
|
||||
ORDER BY facet ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceAtlas,
|
||||
getMeta,
|
||||
getFacets,
|
||||
getPending,
|
||||
setPending,
|
||||
clearPending,
|
||||
countCreatures,
|
||||
listCreatures,
|
||||
getCreature,
|
||||
listCreaturePlaces,
|
||||
listCreaturePoints,
|
||||
listCreatureCompanions,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listChampions,
|
||||
}
|
||||
485
modules/uo/server/model/shardAtlas/shardAtlas.model.js
Normal file
485
modules/uo/server/model/shardAtlas/shardAtlas.model.js
Normal file
@@ -0,0 +1,485 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const db = require('./shardAtlas.db')
|
||||
const { settings } = require('../../core')
|
||||
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||
const {
|
||||
AtlasSourceError,
|
||||
PARSER_VERSION,
|
||||
buildAtlas,
|
||||
hashSources,
|
||||
sameSources,
|
||||
} = require('../../utils/spawnAtlasSource')
|
||||
const log = require('../../core').logger('atlas')
|
||||
|
||||
// The spawn atlas, refreshed from the shard's own ServUO tree.
|
||||
//
|
||||
// The tree is the single source of truth. Nothing is precomputed and committed,
|
||||
// because a shard's maps change over its lifetime — facets get added, replaced
|
||||
// or renamed — and a snapshot in the repo would go stale against the world
|
||||
// players actually see. So the atlas is re-derived on every boot.
|
||||
//
|
||||
// Two rules govern the boot path:
|
||||
//
|
||||
// 1. **It never blocks startup.** No configured path, an unreadable path, a
|
||||
// malformed file, a database error — all of it is caught and logged. The
|
||||
// site comes up either way, serving whatever atlas it already had.
|
||||
// 2. **A facet disappearing is not applied automatically.** Losing a facet is
|
||||
// the signature of a half-copied or mid-update tree as much as of a real
|
||||
// map change, and the two are indistinguishable from here. The refresh is
|
||||
// staged for a human instead, and an admin approves or rejects it.
|
||||
//
|
||||
// Everything else — new facets, renamed regions, changed spawns — applies
|
||||
// straight away, because none of it can silently destroy data an operator would
|
||||
// miss.
|
||||
|
||||
const SETTING_KEY = 'spawn_atlas_servuo_path'
|
||||
|
||||
/**
|
||||
* Where the ServUO tree lives.
|
||||
*
|
||||
* The admin setting wins over the environment so an operator can point the
|
||||
* atlas at a different tree without a redeploy, matching how the rest of the
|
||||
* shard integration is admin-managed rather than env-configured. `SERVUO_PATH`
|
||||
* remains as the deploy-time default, since the path usually describes a mount
|
||||
* that the deployment sets up.
|
||||
*/
|
||||
async function getServuoPath() {
|
||||
try {
|
||||
const configured = await settings.get(SETTING_KEY)
|
||||
if (configured && String(configured).trim() !== '') return String(configured).trim()
|
||||
} catch {
|
||||
// Settings unavailable is not fatal — fall through to the env default.
|
||||
}
|
||||
const fromEnv = process.env.SERVUO_PATH
|
||||
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
|
||||
}
|
||||
|
||||
async function setServuoPath(value, updatedBy = null) {
|
||||
return settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional operator-supplied art map, `{ "<slug>": "<file under uploads/atlas/>" }`.
|
||||
*
|
||||
* Never committed and never shipped — creature sprites come out of the
|
||||
* operator's own client `.mul`/`.uop` files, which are theirs, not ours to
|
||||
* redistribute. Absent (the normal case) every `art` stays NULL and the UI
|
||||
* renders text-only.
|
||||
*/
|
||||
function loadArtMap(dir = path.join(__dirname, '..', '..', '..', 'db', 'data')) {
|
||||
try {
|
||||
const file = path.join(dir, 'spawnAtlas.art.json')
|
||||
if (!fs.existsSync(file)) return {}
|
||||
const map = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
return map && typeof map === 'object' ? map : {}
|
||||
} catch (err) {
|
||||
log.warn('spawn atlas art map could not be read', { error: err.message })
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten each point's types into `shard_spawn_point_types` rows.
|
||||
*
|
||||
* A spawner may legitimately list the same type twice, and the primary key is
|
||||
* (point_id, slug), so duplicates collapse to the larger max rather than
|
||||
* failing the insert.
|
||||
*/
|
||||
function pointTypeRows(points) {
|
||||
const rows = []
|
||||
points.forEach((point, i) => {
|
||||
const bySlug = new Map()
|
||||
for (const entry of point.types ?? []) {
|
||||
const slug = slugify(entry.type)
|
||||
if (slug === '') continue
|
||||
bySlug.set(slug, Math.max(bySlug.get(slug) ?? 0, entry.max ?? 1))
|
||||
}
|
||||
for (const [slug, max] of bySlug) rows.push([i + 1, slug, max])
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
async function applyAtlas(atlas) {
|
||||
return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, loadArtMap())
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the atlas from the configured ServUO tree.
|
||||
*
|
||||
* Returns a result describing what happened rather than throwing, so the caller
|
||||
* — including the boot path — can log it and move on:
|
||||
*
|
||||
* `skipped` no path configured
|
||||
* `unavailable` path configured but unreadable / missing required files
|
||||
* `unchanged` source hashes match the loaded atlas; nothing parsed
|
||||
* `imported` parsed and applied
|
||||
* `needsReview` parsed, but a facet would be lost; staged for an admin
|
||||
* `failed` parsed or applied and something went wrong
|
||||
*
|
||||
* `force` skips the hash check (an admin asking for a reimport) and `approve`
|
||||
* additionally accepts facet loss (an admin approving a staged refresh).
|
||||
*/
|
||||
/**
|
||||
* Was the loaded atlas built by THIS parser?
|
||||
*
|
||||
* An atlas imported before `parserVersion` existed reports undefined, which is
|
||||
* correctly "no" — those are exactly the ones carrying the old readings.
|
||||
*/
|
||||
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
||||
|
||||
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
||||
// An explicit override wins outright — it is a one-off "use this tree", and it
|
||||
// must not be silently overruled by the configured path the way an env default
|
||||
// would be.
|
||||
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
|
||||
if (root === '') return { status: 'skipped', reason: 'no ServUO path configured' }
|
||||
|
||||
let hashes
|
||||
try {
|
||||
hashes = hashSources(root)
|
||||
} catch (err) {
|
||||
if (err instanceof AtlasSourceError) {
|
||||
return { status: 'unavailable', reason: err.message, code: err.code, path: root }
|
||||
}
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
}
|
||||
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
const loaded = meta?.source
|
||||
? Object.fromEntries(Object.entries(meta.source).map(([label, v]) => [label, v.sha256]))
|
||||
: null
|
||||
|
||||
// Two things make a loaded atlas stale: the tree changed, or the PARSER did.
|
||||
// Only checking the tree would strand an install whose maps never change on
|
||||
// whatever an older build derived — a corrected parse would ship and never
|
||||
// reach the data.
|
||||
if (!force && sameSources(hashes, loaded) && currentParser(meta)) {
|
||||
return { status: 'unchanged', path: root }
|
||||
}
|
||||
|
||||
// A rejected refresh must not re-prompt on every boot. It stays rejected until
|
||||
// the tree changes again, at which point the hashes differ and it is a new
|
||||
// decision.
|
||||
const pending = await db.getPending().catch(() => null)
|
||||
if (!approve && !force && pending?.status === 'rejected' && sameSources(hashes, pending.hashes)) {
|
||||
return { status: 'unchanged', path: root, reason: 'refresh previously rejected' }
|
||||
}
|
||||
|
||||
let atlas
|
||||
try {
|
||||
atlas = buildAtlas(root)
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
}
|
||||
|
||||
const currentFacets = await db.getFacets().catch(() => [])
|
||||
const incomingFacets = atlas.facets
|
||||
const removedFacets = currentFacets.filter((facet) => !incomingFacets.includes(facet))
|
||||
const addedFacets = incomingFacets.filter((facet) => !currentFacets.includes(facet))
|
||||
|
||||
// Losing a facet is indistinguishable here from a half-copied tree, so it is
|
||||
// staged rather than applied — but startup is never blocked by it.
|
||||
if (removedFacets.length > 0 && !approve) {
|
||||
const summary = {
|
||||
hashes,
|
||||
path: root,
|
||||
currentFacets,
|
||||
incomingFacets,
|
||||
removedFacets,
|
||||
addedFacets,
|
||||
counts: atlas.meta.counts,
|
||||
}
|
||||
await db.setPending(summary, 'pending').catch((err) => {
|
||||
log.warn('could not stage spawn atlas refresh', { error: err.message })
|
||||
})
|
||||
return { status: 'needsReview', ...summary }
|
||||
}
|
||||
|
||||
try {
|
||||
const counts = await applyAtlas(atlas)
|
||||
return { status: 'imported', path: root, counts, addedFacets, removedFacets }
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
}
|
||||
}
|
||||
|
||||
/** Admin approved a staged refresh: apply it, facet loss and all. */
|
||||
async function approvePending(options = {}) {
|
||||
return refresh({ ...options, approve: true, force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin rejected a staged refresh: keep the current atlas and remember the
|
||||
* decision against those exact source hashes, so it does not re-prompt every
|
||||
* boot. A further change to the tree produces different hashes and asks again.
|
||||
*/
|
||||
async function rejectPending() {
|
||||
const pending = await db.getPending()
|
||||
if (!pending) return { status: 'none' }
|
||||
await db.setPending({ ...pending, rejectedAt: new Date().toISOString() }, 'rejected')
|
||||
return { status: 'rejected' }
|
||||
}
|
||||
|
||||
/** Everything the admin panel needs to describe atlas state. */
|
||||
async function status({ path: pathOverride = '' } = {}) {
|
||||
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
|
||||
const [meta, pending, facets] = await Promise.all([
|
||||
db.getMeta().catch(() => null),
|
||||
db.getPending().catch(() => null),
|
||||
db.getFacets().catch(() => []),
|
||||
])
|
||||
|
||||
let treeReadable = false
|
||||
let drift = null
|
||||
if (root !== '') {
|
||||
try {
|
||||
const hashes = hashSources(root)
|
||||
treeReadable = true
|
||||
const loaded = meta?.source
|
||||
? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256]))
|
||||
: null
|
||||
// Same question `refresh` asks: an import picks something up when either
|
||||
// the tree or the parser has moved on.
|
||||
drift = !sameSources(hashes, loaded) || !currentParser(meta)
|
||||
} catch {
|
||||
treeReadable = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
configured: root !== '',
|
||||
path: root,
|
||||
treeReadable,
|
||||
drift,
|
||||
facets,
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
counts: meta?.counts ?? null,
|
||||
pending,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot hook. Best-effort by contract: it logs and returns, never throws, so a
|
||||
* missing tree or a bad file can never stop the site coming up.
|
||||
*/
|
||||
async function refreshOnBoot() {
|
||||
try {
|
||||
const result = await refresh()
|
||||
switch (result.status) {
|
||||
case 'imported':
|
||||
log.info('spawn atlas refreshed from ServUO tree', {
|
||||
...result.counts,
|
||||
added: result.addedFacets,
|
||||
})
|
||||
break
|
||||
case 'needsReview':
|
||||
log.warn(
|
||||
'spawn atlas refresh staged for admin review — a facet would be removed; ' +
|
||||
'the existing atlas is unchanged',
|
||||
{ removed: result.removedFacets, added: result.addedFacets },
|
||||
)
|
||||
break
|
||||
case 'unavailable':
|
||||
log.warn('spawn atlas source unavailable', { reason: result.reason, path: result.path })
|
||||
break
|
||||
case 'failed':
|
||||
log.warn('spawn atlas refresh failed', { reason: result.reason })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
log.warn('spawn atlas refresh errored', { error: err.message })
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The shapes the /public/atlas endpoints serve. Rows are camelCased here rather
|
||||
// than in the controller, for the same reason shardState does it: the column
|
||||
// names are an implementation detail of the import, and the browser contract
|
||||
// should not move when a column is renamed.
|
||||
|
||||
const jsonOr = (value, fallback) => {
|
||||
if (value == null) return fallback
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const shapeCreature = (row) => ({
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
// `total` is the summed MaxCount across every spawner (how many can be alive
|
||||
// at once); `points` is how many spawners mention it. They answer different
|
||||
// questions and the UI shows both.
|
||||
total: row.total,
|
||||
points: row.points,
|
||||
facets: jsonOr(row.facets, {}),
|
||||
art: row.art || null,
|
||||
})
|
||||
|
||||
const shapePlace = (row) => ({
|
||||
facet: row.facet,
|
||||
label: row.label,
|
||||
spawners: Number(row.spawners) || 0,
|
||||
maxAlive: Number(row.max_alive) || 0,
|
||||
})
|
||||
|
||||
const shapePoint = (row) => ({
|
||||
id: row.id,
|
||||
facet: row.facet,
|
||||
name: row.name || null,
|
||||
x: row.x,
|
||||
y: row.y,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
range: row.spawn_range,
|
||||
maxCount: row.max_count,
|
||||
minDelay: row.min_delay,
|
||||
maxDelay: row.max_delay,
|
||||
todStart: row.tod_start,
|
||||
todEnd: row.tod_end,
|
||||
todMode: row.tod_mode,
|
||||
region: row.region || null,
|
||||
landmark: row.landmark || null,
|
||||
label: row.label,
|
||||
})
|
||||
|
||||
/**
|
||||
* Paginated creature search. Returns the page plus the unpaginated total, so
|
||||
* the UI can say "showing 50 of 800" without a second round trip.
|
||||
*/
|
||||
async function searchCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||
const [rows, total] = await Promise.all([
|
||||
db.listCreatures({ q, facet, limit, offset }),
|
||||
db.countCreatures({ q, facet }),
|
||||
])
|
||||
return { total, limit, offset, creatures: rows.map(shapeCreature) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One creature: its totals, the places it spawns (the aggregate the atlas
|
||||
* exists for), the individual spawners, and what else shares those spawners.
|
||||
*
|
||||
* `null` when the slug is unknown — the controller turns that into a 404.
|
||||
*/
|
||||
async function getCreature(slug, { facet = '', points = 200 } = {}) {
|
||||
const row = await db.getCreature(slug)
|
||||
if (!row) return null
|
||||
const [places, pointRows, alsoHere] = await Promise.all([
|
||||
db.listCreaturePlaces(slug, { facet }),
|
||||
db.listCreaturePoints(slug, { facet, limit: points }),
|
||||
db.listCreatureCompanions(slug),
|
||||
])
|
||||
return {
|
||||
...shapeCreature(row),
|
||||
places: places.map(shapePlace),
|
||||
// `spawners`, not `points`: shapeCreature already uses `points` for the
|
||||
// COUNT of spawners, and reusing the key for the list of them would make the
|
||||
// same field a number on the search route and an array here.
|
||||
spawners: pointRows.map(shapePoint),
|
||||
// Bounded by the query, so a creature on hundreds of spawners returns a page
|
||||
// rather than the world.
|
||||
spawnersTruncated: pointRows.length >= points,
|
||||
alsoHere: alsoHere.map((r) => ({
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
shared: Number(r.shared) || 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function listRegions(opts = {}) {
|
||||
const rows = await db.listRegions(opts)
|
||||
return rows.map((r) => ({
|
||||
facet: r.facet,
|
||||
name: r.name,
|
||||
type: r.type || null,
|
||||
priority: r.priority,
|
||||
parent: r.parent || null,
|
||||
rects: jsonOr(r.rects, []),
|
||||
}))
|
||||
}
|
||||
|
||||
async function listLandmarks(opts = {}) {
|
||||
const rows = await db.listLandmarks(opts)
|
||||
return rows.map((r) => ({
|
||||
facet: r.facet,
|
||||
name: r.name,
|
||||
group: r.grp || null,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
}))
|
||||
}
|
||||
|
||||
async function listChampions(opts = {}) {
|
||||
const rows = await db.listChampions(opts)
|
||||
return rows.map((r) => ({
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
group: r.grp || null,
|
||||
// '' on the wire means "randomised at activation"; `randomType` says so
|
||||
// explicitly rather than making the client infer it from an empty string.
|
||||
type: r.type || null,
|
||||
randomType: !!r.random_type,
|
||||
facet: r.facet,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
radius: r.radius,
|
||||
label: r.label || null,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* What is loaded: the facet list, the counts, and when it was imported.
|
||||
*
|
||||
* Deliberately does NOT report the source path, the per-file hashes or whether
|
||||
* a refresh is pending. Those describe the operator's filesystem, and this is a
|
||||
* public endpoint; the admin status route carries them instead.
|
||||
*/
|
||||
async function publicMeta() {
|
||||
const [meta, facets] = await Promise.all([
|
||||
db.getMeta().catch(() => null),
|
||||
db.getFacets().catch(() => []),
|
||||
])
|
||||
return {
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
generatedAt: meta?.generatedAt ?? null,
|
||||
// The parse counts, not the row counts: `unresolvedPoints` is what lets the
|
||||
// page state its own placement accuracy instead of implying it is complete.
|
||||
counts: meta?.counts ?? null,
|
||||
facets,
|
||||
}
|
||||
}
|
||||
|
||||
const listFacets = () => db.getFacets()
|
||||
|
||||
module.exports = {
|
||||
refresh,
|
||||
refreshOnBoot,
|
||||
approvePending,
|
||||
rejectPending,
|
||||
status,
|
||||
getServuoPath,
|
||||
setServuoPath,
|
||||
pointTypeRows,
|
||||
loadArtMap,
|
||||
SETTING_KEY,
|
||||
searchCreatures,
|
||||
getCreature,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listChampions,
|
||||
listFacets,
|
||||
publicMeta,
|
||||
}
|
||||
42
modules/uo/server/model/shardLinks/shardLinks.db.js
Normal file
42
modules/uo/server/model/shardLinks/shardLinks.db.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const { query } = require('../../core')
|
||||
|
||||
const COLS = 'account, user_id, char_name, linked_at'
|
||||
|
||||
// Upsert a link. account is the PK, so a re-link moves the account to the new
|
||||
// user (the sidecar already treats /link/confirm as authoritative).
|
||||
async function upsert({ account, userId, charName }) {
|
||||
await query(
|
||||
`INSERT INTO shard_account_links (account, user_id, char_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`,
|
||||
[account, userId, charName || null],
|
||||
)
|
||||
return getByAccount(account)
|
||||
}
|
||||
|
||||
async function getByAccount(account) {
|
||||
const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const listByUser = (userId) =>
|
||||
query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId])
|
||||
|
||||
async function isOwnedBy(account, userId) {
|
||||
const rows = await query(
|
||||
'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1',
|
||||
[account, userId],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const remove = (account, userId) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
|
||||
|
||||
// Drop the mirror for an account regardless of which user held it — used to
|
||||
// reconcile when the tie is severed at the source (an in-game [unlink →
|
||||
// account.unlinked event, or a site-side DELETE /link/{account}).
|
||||
const removeByAccount = (account) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ?', [account])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
|
||||
37
modules/uo/server/model/shardLinks/shardLinks.model.js
Normal file
37
modules/uo/server/model/shardLinks/shardLinks.model.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Site-side mirror of in-game-account → website-user links. The sidecar owns the
|
||||
// authoritative link (it tags the game account on /link/confirm); this model
|
||||
// records it locally so the player portal can list links and enforce ownership.
|
||||
|
||||
const db = require('./shardLinks.db')
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
account: row.account,
|
||||
userId: row.user_id,
|
||||
charName: row.char_name || null,
|
||||
linkedAt: row.linked_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function link({ account, userId, charName }) {
|
||||
return toSafe(await db.upsert({ account, userId, charName }))
|
||||
}
|
||||
|
||||
async function listForUser(userId) {
|
||||
const rows = await db.listByUser(userId)
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
const ownsAccount = (account, userId) => db.isOwnedBy(account, userId)
|
||||
|
||||
async function getByAccount(account) {
|
||||
return toSafe(await db.getByAccount(account))
|
||||
}
|
||||
|
||||
const unlink = (account, userId) => db.remove(account, userId)
|
||||
|
||||
// Drop the local mirror for an account (source-of-truth severed elsewhere).
|
||||
const removeByAccount = (account) => db.removeByAccount(account)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
|
||||
@@ -0,0 +1,37 @@
|
||||
const { query } = require('../../core')
|
||||
|
||||
// One row per shard feature. Absent rows are fine — utils/shardVisibility.js
|
||||
// compiles a default for every known feature and merges stored rows over it, so
|
||||
// a fresh install with an empty table behaves exactly as the site did pre-v3.
|
||||
|
||||
const COLS = 'feature, enabled, audience, stream, field_rules, updated_by, updated_at'
|
||||
|
||||
const listAll = () => query(`SELECT ${COLS} FROM shard_feature_visibility`)
|
||||
|
||||
const getOne = (feature) =>
|
||||
query(`SELECT ${COLS} FROM shard_feature_visibility WHERE feature = ?`, [feature])
|
||||
|
||||
// Upsert one feature's settings. `fieldRules` is stored as a JSON object of
|
||||
// {field: rung}; the caller has already stripped locked fields and validated
|
||||
// every rung against the ladder.
|
||||
const upsert = ({ feature, enabled, audience, stream, fieldRules, updatedBy }) =>
|
||||
query(
|
||||
`INSERT INTO shard_feature_visibility (feature, enabled, audience, stream, field_rules, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
audience = VALUES(audience),
|
||||
stream = VALUES(stream),
|
||||
field_rules = VALUES(field_rules),
|
||||
updated_by = VALUES(updated_by)`,
|
||||
[
|
||||
feature,
|
||||
enabled ? 1 : 0,
|
||||
audience,
|
||||
stream ? 1 : 0,
|
||||
fieldRules == null ? null : JSON.stringify(fieldRules),
|
||||
updatedBy ?? null,
|
||||
],
|
||||
)
|
||||
|
||||
module.exports = { listAll, getOne, upsert }
|
||||
@@ -0,0 +1,44 @@
|
||||
// ── Shard feature visibility (model) ───────────────────────────────────────
|
||||
//
|
||||
// Thin row-shaping layer over shardVisibility.db. The policy — the ladder, the
|
||||
// feature catalog, the locked fields, the kind→feature map — lives in
|
||||
// utils/shardVisibility.js; this file only reads and writes rows.
|
||||
|
||||
const db = require('./shardVisibility.db')
|
||||
|
||||
// The `field_rules` JSON column comes back as a string on the mariadb driver.
|
||||
function parseRules(raw) {
|
||||
if (raw == null) return {}
|
||||
if (typeof raw === 'object') return raw
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const toSafe = (row) =>
|
||||
row && {
|
||||
feature: row.feature,
|
||||
enabled: !!row.enabled,
|
||||
audience: row.audience,
|
||||
stream: row.stream == null ? null : !!row.stream,
|
||||
fieldRules: parseRules(row.field_rules),
|
||||
updatedBy: row.updated_by,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
|
||||
async function listAll() {
|
||||
const rows = await db.listAll()
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
async function getOne(feature) {
|
||||
const rows = await db.getOne(feature)
|
||||
return toSafe(rows[0])
|
||||
}
|
||||
|
||||
const upsert = (entry) => db.upsert(entry)
|
||||
|
||||
module.exports = { listAll, getOne, upsert }
|
||||
134
modules/uo/server/router/atlas.controller.js
Normal file
134
modules/uo/server/router/atlas.controller.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// ── Public: the spawn atlas ────────────────────────────────────────────────
|
||||
//
|
||||
// A browsable catalogue of what the shard CONTAINS — which creatures spawn,
|
||||
// where, how many, and which champion altars are configured. Everything here is
|
||||
// a plain indexed read of the tables the boot-time import fills from the shard's
|
||||
// own ServUO tree (docs/website/SPAWN_ATLAS.md).
|
||||
//
|
||||
// Two properties separate this from /public/shard/*:
|
||||
//
|
||||
// • **Nothing touches the sidecar.** The atlas is static shard content, not
|
||||
// live shard state, so these pages stay fully populated while the shard is
|
||||
// down. That is why the routes are mounted at /public/atlas and are
|
||||
// siteMode-gated like /posts and /wiki, rather than under /shard.
|
||||
// • **The live champion feed is a different thing.** `/atlas/champions` is the
|
||||
// configured roster ("there is an Unholy Terror altar in Deceit");
|
||||
// `/shard/champs` is the running state ("it is on level 3 right now").
|
||||
//
|
||||
// Every response is still passed through `projectFeature` for the `atlas`
|
||||
// feature. It declares no sensitive fields today, so the projection is a
|
||||
// no-op — but v3.md §3.6.1's rule is that a read path returning shard data and
|
||||
// not projecting is a bug, and the cost of honouring it is one call per handler
|
||||
// rather than a retrofit the first time a field needs gating.
|
||||
|
||||
const atlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
const visibility = require('../utils/visibility')
|
||||
|
||||
const log = require('../core').logger('public-atlas')
|
||||
|
||||
const FEATURE = 'atlas'
|
||||
|
||||
// Query params arrive as strings; express-validator has already bounded them.
|
||||
const int = (value, fallback) => {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
const str = (value) => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
// GET /public/atlas/creatures?q=&facet=&limit=&offset=
|
||||
async function getCreatures(req, res) {
|
||||
try {
|
||||
const page = await atlas.searchCreatures({
|
||||
q: str(req.query.q),
|
||||
facet: str(req.query.facet),
|
||||
limit: int(req.query.limit, 50),
|
||||
offset: int(req.query.offset, 0),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, page, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getCreatures', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/creatures/:slug — one creature, with the places it spawns.
|
||||
//
|
||||
// 404 means "no such creature in this atlas", which also covers "the atlas has
|
||||
// never been imported" — an empty atlas has no slugs, and there is nothing more
|
||||
// specific to say to an anonymous caller.
|
||||
async function getCreature(req, res) {
|
||||
try {
|
||||
const creature = await atlas.getCreature(req.params.slug, {
|
||||
facet: str(req.query.facet),
|
||||
points: int(req.query.points, 200),
|
||||
})
|
||||
if (!creature) return res.status(404).json({ message: 'Not Found' })
|
||||
return res.json(await visibility.project(FEATURE, creature, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getCreature', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/regions?facet=&q=
|
||||
async function getRegions(req, res) {
|
||||
try {
|
||||
const regions = await atlas.listRegions({
|
||||
facet: str(req.query.facet),
|
||||
q: str(req.query.q),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, regions, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getRegions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/landmarks?facet=&q=
|
||||
async function getLandmarks(req, res) {
|
||||
try {
|
||||
const landmarks = await atlas.listLandmarks({
|
||||
facet: str(req.query.facet),
|
||||
q: str(req.query.q),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, landmarks, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getLandmarks', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/champions?facet= — the CONFIGURED altar roster.
|
||||
async function getChampions(req, res) {
|
||||
try {
|
||||
const champions = await atlas.listChampions({ facet: str(req.query.facet) })
|
||||
return res.json(await visibility.project(FEATURE, champions, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getChampions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/meta — what is loaded: facets, counts, when it was imported.
|
||||
//
|
||||
// Public-safe by construction: the model omits the ServUO path, the per-file
|
||||
// hashes and the pending-refresh state, all of which describe the operator's
|
||||
// filesystem rather than the game world. The admin status route carries those.
|
||||
async function getMeta(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project(FEATURE, await atlas.publicMeta(), req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getMeta', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCreatures,
|
||||
getCreature,
|
||||
getRegions,
|
||||
getLandmarks,
|
||||
getChampions,
|
||||
getMeta,
|
||||
}
|
||||
132
modules/uo/server/router/atlas.router.js
Normal file
132
modules/uo/server/router/atlas.router.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// Public · Atlas — the spawn atlas / bestiary. Static shard CONTENT derived from
|
||||
// the shard's own ServUO tree, not live shard state.
|
||||
//
|
||||
// Mounted at /api/v1/public/atlas by public/index.js. Two deliberate differences
|
||||
// from the /public/shard routes next door (docs/link/v3.md §6):
|
||||
//
|
||||
// • **Not under /shard.** Nothing here round-trips the sidecar, and the pages
|
||||
// stay fully populated while the shard is down. Mounting it under /shard
|
||||
// would imply a dependency it does not have.
|
||||
// • **siteMode-gated, like /posts and /wiki.** The shard routes are exempt
|
||||
// because shard status is wanted *during* maintenance; a bestiary is site
|
||||
// content and follows site content's rules.
|
||||
//
|
||||
// Every route also carries `requireFeature('atlas')` — 404 when an admin has
|
||||
// disabled the feature, 403 when the caller sits below its configured audience.
|
||||
// The default audience is `anonymous`, so these gates are inert until an admin
|
||||
// changes something.
|
||||
|
||||
// express and express-validator come from core, never from a require here: this
|
||||
// file lives outside server/, so Node's resolver would not find them, and a
|
||||
// second express in the process would be a second Router prototype
|
||||
// (docs/website/MODULE_API.md §2.3).
|
||||
const core = require('../core')
|
||||
const atlas = require('./atlas.controller')
|
||||
const { requireFeature } = require('../utils/visibility')
|
||||
|
||||
const { express, validator, middleware } = core
|
||||
const { param, query } = validator
|
||||
const { siteMode, validate } = middleware
|
||||
|
||||
const atlasRouter = express.Router()
|
||||
|
||||
// Facet names come from the shard's own files and are never validated against a
|
||||
// list — nothing in the codebase names a facet (§6.1 R2). Only the length is
|
||||
// bounded, and the query matches exactly, so an unknown name returns an empty
|
||||
// result rather than an error.
|
||||
const facetParam = query('facet').optional({ values: 'falsy' }).isString().isLength({ max: 40 })
|
||||
|
||||
atlasRouter.get(
|
||||
'/creatures',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Search the bestiary (paginated)'
|
||||
// #swagger.description = 'Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\'s share on it. Static content parsed from the shard\'s ServUO tree — unaffected by the shard being offline.'
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the creature name (max 60 chars).' }
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to creatures spawning on this facet. Facet names come from the shard\'s own files; an unknown one returns an empty page.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||
/* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreaturePage" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'The atlas feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'The atlas feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
facetParam,
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('offset').optional().isInt({ min: 0, max: 100000 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getCreatures,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/creatures/:slug',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'One creature: where it spawns, and what spawns with it'
|
||||
// #swagger.description = 'The answer the atlas exists to give. `places` is the aggregate — "lizardman → Shrines, Isamu-Jima, Yew" — resolved by point-in-rect against the shard\'s own region rectangles, falling back to the nearest landmark, else "Wilderness". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' }
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Restrict places and spawners to one facet.' }
|
||||
// #swagger.parameters['points'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max spawners to return, 1..1000 (default 200).' }
|
||||
/* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreature" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such creature in this atlas (or the feature is disabled)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('slug').isString().isLength({ min: 1, max: 120 }),
|
||||
facetParam,
|
||||
query('points').optional().isInt({ min: 1, max: 1000 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getCreature,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/regions',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Named regions and their rectangles'
|
||||
// #swagger.description = 'Flattened out of the shard\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the region name.' }
|
||||
/* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasRegion" } } } } } */
|
||||
facetParam,
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getRegions,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/landmarks',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Points of interest (dungeon levels, town markers)'
|
||||
// #swagger.description = 'From the shard\'s Data/Locations files. `group` is the innermost enclosing parent ("Covetous"), which is the label worth showing over the individual marker ("Level 1").'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the landmark name or its group.' }
|
||||
/* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasLandmark" } } } } } */
|
||||
facetParam,
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getLandmarks,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/champions',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Configured champion altars (the roster, not the live board)'
|
||||
// #swagger.description = 'Where the altars are and what each one summons — "there is an Unholy Terror altar in Deceit". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board ("it is on level 3 right now").'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
/* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasChampion" } } } } } */
|
||||
facetParam,
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getChampions,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/meta',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported'
|
||||
// #swagger.description = 'Drives the facet filter and the "parsed from the shard\'s own files on <date>" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.'
|
||||
/* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasMeta" } } } } */
|
||||
siteMode,
|
||||
atlas.getMeta,
|
||||
)
|
||||
|
||||
module.exports = atlasRouter
|
||||
60
modules/uo/server/test/_ctx.js
Normal file
60
modules/uo/server/test/_ctx.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// ── Test harness: a fake ctx ───────────────────────────────────────────────
|
||||
//
|
||||
// A module's tests cannot require core — that is the whole zero-internal-imports
|
||||
// rule (docs/website/MODULE_API.md §5.1), and it applies to test files too. So
|
||||
// instead of stubbing core's modules the way core's own tests do, a module test
|
||||
// hands `core.init()` a ctx it fabricated.
|
||||
//
|
||||
// That turns out to be the nicer story: the seam that exists so a module can be
|
||||
// swapped onto a different core is the same seam that lets its tests run with no
|
||||
// database, no express app and no settings table. Core's tests reach the same
|
||||
// place by pointing the mariadb pool at a dead port; a module does not have to.
|
||||
|
||||
const core = require('../core')
|
||||
|
||||
/**
|
||||
* Build and install a fake ctx. Every member is a stub the test can reassign.
|
||||
* @param {object} [over] members to override, deep-merged one level
|
||||
*/
|
||||
function installFakeCtx(over = {}) {
|
||||
const settings = new Map()
|
||||
|
||||
const ctx = {
|
||||
moduleId: 'uo',
|
||||
paths: { moduleRoot: require('path').join(__dirname, '..', '..') },
|
||||
// Null, not the real packages: a module cannot resolve express from outside
|
||||
// server/ (that is why ctx carries them at all), and these tests construct no
|
||||
// router. A test that needs one passes the real ones in `over`.
|
||||
express: null,
|
||||
validator: null,
|
||||
db: {
|
||||
// Every test that needs a query result reassigns this.
|
||||
query: async () => [],
|
||||
pool: { getConnection: async () => { throw new Error('no pool in tests') } },
|
||||
},
|
||||
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
settings: {
|
||||
get: async (key) => (settings.has(key) ? settings.get(key) : null),
|
||||
set: async (key, value) => { settings.set(key, value) },
|
||||
getInstanceName: async () => 'Test Shard',
|
||||
},
|
||||
auth: { getUserFromRequest: () => null },
|
||||
push: { publish: async () => {} },
|
||||
secretBox: { encrypt: (s) => s, decrypt: (s) => s },
|
||||
middleware: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
requireRole: () => (req, res, next) => next(),
|
||||
siteMode: (req, res, next) => next(),
|
||||
validate: (req, res, next) => next(),
|
||||
noindex: (req, res, next) => next(),
|
||||
},
|
||||
uploads: {},
|
||||
posts: {},
|
||||
...over,
|
||||
}
|
||||
|
||||
core.init(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
module.exports = { installFakeCtx }
|
||||
601
modules/uo/server/test/spawnAtlas.parse.test.js
Normal file
601
modules/uo/server/test/spawnAtlas.parse.test.js
Normal file
@@ -0,0 +1,601 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
parseXml,
|
||||
parseObjects2,
|
||||
parsePoints,
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
buildPlacementIndex,
|
||||
resolveRegion,
|
||||
facetKey,
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
slugify,
|
||||
decodeEntities,
|
||||
} = require('../utils/spawnAtlasParse')
|
||||
|
||||
// These parsers are pure and fs-free precisely so this suite can run in CI,
|
||||
// where there is no ServUO tree. Every fixture below is a literal excerpt of a
|
||||
// real shard file, trimmed — not invented shapes.
|
||||
|
||||
// ── parseObjects2 ──────────────────────────────────────────────────────────
|
||||
|
||||
test('parseObjects2: single type', () => {
|
||||
const types = parseObjects2('Jacob:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1')
|
||||
assert.deepEqual(types, [{ type: 'Jacob', max: 1 }])
|
||||
})
|
||||
|
||||
test('parseObjects2: splits six types on :OBJ= and keeps each MX', () => {
|
||||
// Verbatim from trammel.xml — the case that a naive split(':') destroys.
|
||||
const raw =
|
||||
'Gazer:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||
':OBJ=Giantspider:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||
':OBJ=Harpy:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||
':OBJ=Headlessone:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||
':OBJ=Lizardman:MX=3:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||
':OBJ=Mongbat:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1'
|
||||
const types = parseObjects2(raw)
|
||||
assert.equal(types.length, 6)
|
||||
assert.deepEqual(
|
||||
types.map((t) => t.type),
|
||||
['Gazer', 'Giantspider', 'Harpy', 'Headlessone', 'Lizardman', 'Mongbat'],
|
||||
)
|
||||
// MX is per type, not per spawner: the lizardman entry carries 3.
|
||||
assert.equal(types.find((t) => t.type === 'Lizardman').max, 3)
|
||||
assert.equal(types.find((t) => t.type === 'Gazer').max, 1)
|
||||
})
|
||||
|
||||
test('parseObjects2: empty and whitespace values yield no types', () => {
|
||||
assert.deepEqual(parseObjects2(''), [])
|
||||
assert.deepEqual(parseObjects2(' '), [])
|
||||
assert.deepEqual(parseObjects2(null), [])
|
||||
assert.deepEqual(parseObjects2(undefined), [])
|
||||
})
|
||||
|
||||
test('parseObjects2: strips XmlSpawner property directives after "/"', () => {
|
||||
// Left in place these become creatures that do not exist.
|
||||
assert.deepEqual(parseObjects2('Agralem/Name/Agralem:MX=1'), [{ type: 'Agralem', max: 1 }])
|
||||
assert.deepEqual(parseObjects2('alchemist/z/-50:MX=1'), [{ type: 'alchemist', max: 1 }])
|
||||
assert.deepEqual(parseObjects2('GargishRefugee/hue/34532'), [
|
||||
{ type: 'GargishRefugee', max: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
test('parseObjects2: strips argument lists after ","', () => {
|
||||
assert.deepEqual(parseObjects2('Fairy,{RND,4,8}:MX=1'), [{ type: 'Fairy', max: 1 }])
|
||||
assert.deepEqual(parseObjects2('GargishRouser,1'), [{ type: 'GargishRouser', max: 1 }])
|
||||
assert.deepEqual(parseObjects2('greatape,true'), [{ type: 'greatape', max: 1 }])
|
||||
})
|
||||
|
||||
test('parseObjects2: a directive-laden token slugs the same as the bare one', () => {
|
||||
// The bug this closes: `Fairy` and `Fairy,{RND,4,8}` slugged apart and showed
|
||||
// as two different creatures on the same page.
|
||||
const bare = parseObjects2('Fairy:MX=1')[0]
|
||||
const decorated = parseObjects2('Fairy,{RND,4,8}:MX=1')[0]
|
||||
assert.equal(slugify(decorated.type), slugify(bare.type))
|
||||
})
|
||||
|
||||
test('parseObjects2: strips a long EQUIP directive chain containing "<" and ">"', () => {
|
||||
const raw =
|
||||
'xmlquestnpc/UNEQUIP,Innertorso/UNEQUIP,MiddleTorso/EQUIP/<robe/loottype/blessed' +
|
||||
'/itemid/8259>/blessed/true/name/lord blackthorne/z/:MX=1'
|
||||
assert.deepEqual(parseObjects2(raw), [{ type: 'xmlquestnpc', max: 1 }])
|
||||
})
|
||||
|
||||
test('parseObjects2: a token that is only a directive yields nothing', () => {
|
||||
assert.deepEqual(parseObjects2('/Name/Foo:MX=1'), [])
|
||||
assert.deepEqual(parseObjects2(',1:MX=1'), [])
|
||||
})
|
||||
|
||||
test('parseObjects2: a type with no MX token defaults to 1', () => {
|
||||
assert.deepEqual(parseObjects2('Orc'), [{ type: 'Orc', max: 1 }])
|
||||
assert.deepEqual(parseObjects2('Orc:SB=0:RT=0'), [{ type: 'Orc', max: 1 }])
|
||||
})
|
||||
|
||||
// ── parsePoints ────────────────────────────────────────────────────────────
|
||||
|
||||
const POINTS_XML = `<Spawns>
|
||||
<Points>
|
||||
<Name>CovetousSpawner26</Name>
|
||||
<UniqueId>001a34e5-0efa-46de-9c93-b6a163d96370</UniqueId>
|
||||
<Map>Trammel</Map>
|
||||
<X>5412</X>
|
||||
<Y>1970</Y>
|
||||
<Width>10</Width>
|
||||
<Height>10</Height>
|
||||
<Range>5</Range>
|
||||
<MaxCount>3</MaxCount>
|
||||
<MinDelay>5</MinDelay>
|
||||
<MaxDelay>10</MaxDelay>
|
||||
<ProximityTriggerSound>500</ProximityTriggerSound>
|
||||
<TODStart>0</TODStart>
|
||||
<TODEnd>0</TODEnd>
|
||||
<TODMode>0</TODMode>
|
||||
<IsRunning>True</IsRunning>
|
||||
<Objects2>Lizardman:MX=3:SB=0</Objects2>
|
||||
</Points>
|
||||
<Points>
|
||||
<Name>Disabled</Name>
|
||||
<Map>Felucca</Map>
|
||||
<X>100</X>
|
||||
<Y>200</Y>
|
||||
<MaxCount>1</MaxCount>
|
||||
<IsRunning>False</IsRunning>
|
||||
<Objects2>Orc:MX=1</Objects2>
|
||||
</Points>
|
||||
</Spawns>`
|
||||
|
||||
test('parsePoints: reads the kept fields and drops the rest', () => {
|
||||
const points = parsePoints(POINTS_XML)
|
||||
assert.equal(points.length, 2)
|
||||
const covetous = points[0]
|
||||
assert.equal(covetous.name, 'CovetousSpawner26')
|
||||
assert.equal(covetous.facet, 'Trammel')
|
||||
assert.equal(covetous.x, 5412)
|
||||
assert.equal(covetous.y, 1970)
|
||||
assert.equal(covetous.width, 10)
|
||||
assert.equal(covetous.range, 5)
|
||||
assert.equal(covetous.maxCount, 3)
|
||||
// Delays are normalised to seconds; this record carries no DelayInSec, which
|
||||
// means minutes.
|
||||
assert.equal(covetous.minDelay, 300)
|
||||
assert.equal(covetous.maxDelay, 600)
|
||||
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
|
||||
// Dropped fields must not survive into the artifact — this is what keeps it
|
||||
// under 1 MB.
|
||||
assert.equal(covetous.uniqueId, undefined)
|
||||
assert.equal(covetous.proximityTriggerSound, undefined)
|
||||
})
|
||||
|
||||
test('parsePoints: IsRunning is parsed so the build can drop dead spawners', () => {
|
||||
const points = parsePoints(POINTS_XML)
|
||||
assert.equal(points[0].running, true)
|
||||
assert.equal(points[1].running, false)
|
||||
})
|
||||
|
||||
test('parsePoints: facet comes from <Map>, never the file name', () => {
|
||||
// Eodon.xml holds TerMur points; a file-name assumption would mislabel every
|
||||
// one of them.
|
||||
const points = parsePoints(
|
||||
'<Spawns><Points><Name>a</Name><Map>TerMur</Map><X>1</X><Y>2</Y></Points></Spawns>',
|
||||
)
|
||||
assert.equal(points[0].facet, 'TerMur')
|
||||
})
|
||||
|
||||
test('parsePoints: a record with no <Map> is skipped rather than misfiled', () => {
|
||||
const points = parsePoints('<Spawns><Points><Name>a</Name><X>1</X><Y>2</Y></Points></Spawns>')
|
||||
assert.deepEqual(points, [])
|
||||
})
|
||||
|
||||
test('parsePoints: empty document yields no points', () => {
|
||||
assert.deepEqual(parsePoints('<Spawns></Spawns>'), [])
|
||||
assert.deepEqual(parsePoints(''), [])
|
||||
})
|
||||
|
||||
// ── parseRegions ───────────────────────────────────────────────────────────
|
||||
|
||||
const REGIONS_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ServerRegions>
|
||||
<Facet name="Felucca">
|
||||
<region type="GuardedRegion" priority="50" name="Moongates">
|
||||
<!-- britain -->
|
||||
<rect x="1330" y="1991" width="13" height="13" />
|
||||
<rect x="761" y="741" width="19" height="21" />
|
||||
</region>
|
||||
<region type="MondainRegion" priority="50" name="Prism of Light">
|
||||
<rect x="6400" y="0" width="221" height="255" />
|
||||
<go x="6474" y="188" z="0" />
|
||||
<music name="Dungeon9" />
|
||||
<region type="CrystalField" name="Crystal Field">
|
||||
<rect x="6506" y="83" width="7" height="7" />
|
||||
<zrange min="-4" />
|
||||
</region>
|
||||
<region type="IcyRiver">
|
||||
<rect x="6576" y="73" width="10" height="31" />
|
||||
</region>
|
||||
</region>
|
||||
<region type="TownRegion" priority="10" name="Music Only">
|
||||
<music name="Britain" />
|
||||
</region>
|
||||
</Facet>
|
||||
</ServerRegions>`
|
||||
|
||||
test('parseRegions: flattens nested regions and collects rects', () => {
|
||||
const regions = parseRegions(REGIONS_XML)
|
||||
const byName = new Map(regions.map((r) => [r.name, r]))
|
||||
assert.ok(byName.has('Moongates'))
|
||||
assert.ok(byName.has('Prism of Light'))
|
||||
assert.equal(byName.get('Moongates').rects.length, 2)
|
||||
assert.deepEqual(byName.get('Moongates').rects[0], {
|
||||
x: 1330,
|
||||
y: 1991,
|
||||
width: 13,
|
||||
height: 13,
|
||||
})
|
||||
assert.equal(byName.get('Moongates').facet, 'Felucca')
|
||||
assert.equal(byName.get('Moongates').type, 'GuardedRegion')
|
||||
})
|
||||
|
||||
test('parseRegions: a nested child records its parent', () => {
|
||||
const regions = parseRegions(REGIONS_XML)
|
||||
const crystal = regions.find((r) => r.name === 'Crystal Field')
|
||||
assert.ok(crystal, 'the nested named region should be indexed')
|
||||
assert.equal(crystal.parent, 'Prism of Light')
|
||||
assert.equal(crystal.facet, 'Felucca')
|
||||
})
|
||||
|
||||
test('parseRegions: a child with no priority inherits its parent', () => {
|
||||
// Defaulting to 0 instead would sort this specific room below every
|
||||
// top-level region that contains it.
|
||||
const crystal = parseRegions(REGIONS_XML).find((r) => r.name === 'Crystal Field')
|
||||
assert.equal(crystal.priority, 50)
|
||||
})
|
||||
|
||||
test('parseRegions: unnamed regions are skipped but still walked', () => {
|
||||
const regions = parseRegions(REGIONS_XML)
|
||||
// IcyRiver has a type but no name — it cannot label anything.
|
||||
assert.equal(regions.some((r) => r.type === 'IcyRiver'), false)
|
||||
})
|
||||
|
||||
test('parseRegions: a named region with no rects is not indexed', () => {
|
||||
// It can never contain a point, so indexing it only costs scan time.
|
||||
assert.equal(parseRegions(REGIONS_XML).some((r) => r.name === 'Music Only'), false)
|
||||
})
|
||||
|
||||
// ── parseLocations ─────────────────────────────────────────────────────────
|
||||
|
||||
const LOCATIONS_XML = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
|
||||
<places>
|
||||
<parent name="Trammel">
|
||||
<parent name="Dungeons">
|
||||
<parent name="Covetous">
|
||||
<child name="Entrance" x="2499" y="919" z="0" />
|
||||
<child name="Level 1" x="5456" y="1863" z="0" />
|
||||
</parent>
|
||||
<parent name="Despise">
|
||||
<child name="Level 3" x="5407" y="859" z="45" />
|
||||
</parent>
|
||||
</parent>
|
||||
</parent>
|
||||
</places>`
|
||||
|
||||
test('parseLocations: flattens to points carrying their group', () => {
|
||||
const landmarks = parseLocations(LOCATIONS_XML)
|
||||
assert.equal(landmarks.length, 3)
|
||||
const level1 = landmarks.find((l) => l.name === 'Level 1')
|
||||
assert.equal(level1.x, 5456)
|
||||
assert.equal(level1.y, 1863)
|
||||
assert.equal(level1.z, 0)
|
||||
assert.equal(level1.facet, 'Trammel')
|
||||
// "Covetous" is the useful label, not "Level 1".
|
||||
assert.equal(level1.group, 'Covetous')
|
||||
// The facet-level parent is dropped from the path.
|
||||
assert.deepEqual(level1.path, ['Dungeons', 'Covetous'])
|
||||
})
|
||||
|
||||
// ── Facet canonicalisation ─────────────────────────────────────────────────
|
||||
|
||||
// Facets are NOT a fixed list — a shard may add, replace or rename them when its
|
||||
// maps are updated, so nothing may hardcode the stock six. Reconciliation is by
|
||||
// matching against whatever the shard's own files declare.
|
||||
|
||||
test('facetKey: collapses spelling differences to one key', () => {
|
||||
assert.equal(facetKey('Ter Mur'), facetKey('TerMur'))
|
||||
assert.equal(facetKey('ter-mur'), facetKey('TerMur'))
|
||||
assert.equal(facetKey('Felucca'), 'felucca')
|
||||
assert.equal(facetKey(''), '')
|
||||
assert.equal(facetKey(null), '')
|
||||
})
|
||||
|
||||
test('facetKey: distinct facets keep distinct keys', () => {
|
||||
assert.notEqual(facetKey('Felucca'), facetKey('Trammel'))
|
||||
})
|
||||
|
||||
test('resolveFacetName: matches a loose spelling to the discovered canonical', () => {
|
||||
// The canonical set comes from the shard's own spawn/region data, not a table.
|
||||
const index = buildFacetIndex(['TerMur', 'Tokuno', 'Felucca'])
|
||||
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
|
||||
assert.equal(resolveFacetName('Tokuno Islands', index), 'Tokuno')
|
||||
assert.equal(resolveFacetName('felucca', index), 'Felucca')
|
||||
})
|
||||
|
||||
test('resolveFacetName: works for facets that do not exist in stock UO', () => {
|
||||
// The whole point: a shard running its own maps gets the same treatment as
|
||||
// the stock ones, with no entry anywhere naming them.
|
||||
const index = buildFacetIndex(['Sosaria', 'The Underdark'])
|
||||
assert.equal(resolveFacetName('sosaria', index), 'Sosaria')
|
||||
assert.equal(resolveFacetName('The Underdark', index), 'The Underdark')
|
||||
assert.equal(resolveFacetName('the-underdark', index), 'The Underdark')
|
||||
// Same shape as the real `Tokuno Islands` → `Tokuno` case.
|
||||
assert.equal(resolveFacetName('Sosaria Isles', index), 'Sosaria')
|
||||
})
|
||||
|
||||
test('resolveFacetName: a merely similar name is NOT forced to match', () => {
|
||||
// "Underdark Isles" is not a prefix of "The Underdark" in either direction.
|
||||
// Keeping its own name is right — a wrong match would silently file a real
|
||||
// custom facet's landmarks under the wrong facet.
|
||||
const index = buildFacetIndex(['The Underdark'])
|
||||
assert.equal(resolveFacetName('Underdark Isles', index), 'Underdark Isles')
|
||||
})
|
||||
|
||||
test('resolveFacetName: prefers the longer match when several could prefix', () => {
|
||||
const index = buildFacetIndex(['Tokuno', 'TokunoDeep'])
|
||||
assert.equal(resolveFacetName('TokunoDeep Reaches', index), 'TokunoDeep')
|
||||
})
|
||||
|
||||
test('resolveFacetName: an unmatched facet keeps its own name', () => {
|
||||
// Inventing a match would be worse than leaving a real custom facet alone.
|
||||
const index = buildFacetIndex(['Felucca'])
|
||||
assert.equal(resolveFacetName('Ilshenar', index), 'Ilshenar')
|
||||
assert.equal(resolveFacetName('', index), '')
|
||||
assert.equal(resolveFacetName(null, index), '')
|
||||
})
|
||||
|
||||
test('buildFacetIndex: first spelling wins and is stable', () => {
|
||||
const index = buildFacetIndex(['TerMur', 'Ter Mur', 'ter-mur'])
|
||||
assert.equal(index.size, 1)
|
||||
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
|
||||
})
|
||||
|
||||
test('parsePoints and parseRegions report facet names verbatim', () => {
|
||||
// <Map> and <Facet name> are the authority; they are never rewritten.
|
||||
const points = parsePoints(
|
||||
'<Spawns><Points><Name>a</Name><Map>Sosaria</Map><X>1</X><Y>2</Y></Points></Spawns>',
|
||||
)
|
||||
assert.equal(points[0].facet, 'Sosaria')
|
||||
const regions = parseRegions(
|
||||
'<ServerRegions><Facet name="Sosaria"><region name="Town" priority="1">' +
|
||||
'<rect x="0" y="0" width="10" height="10"/></region></Facet></ServerRegions>',
|
||||
)
|
||||
assert.equal(regions[0].facet, 'Sosaria')
|
||||
})
|
||||
|
||||
test('placement index buckets two spellings of one facet together', () => {
|
||||
// This is the bug the key exists to prevent: unreconciled, the landmark bucket
|
||||
// is keyed apart from the points looking it up, the fallback never fires, and
|
||||
// every unregioned spawn on that facet silently reads "Wilderness".
|
||||
const index = buildPlacementIndex(
|
||||
[],
|
||||
[{ facet: 'Ter Mur', name: 'Bank', group: 'Holy City', path: [], x: 1000, y: 1000, z: 0 }],
|
||||
)
|
||||
assert.equal(resolveRegion(1000, 1000, 'TerMur', index).landmark, 'Holy City')
|
||||
})
|
||||
|
||||
// ── parseChampions ─────────────────────────────────────────────────────────
|
||||
|
||||
const CHAMPIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<championSystem>
|
||||
<!-- comment describing the schema -->
|
||||
<spawn name="Deceit" group="FelDungeons" type="UnholyTerror">
|
||||
<location x="5178" y="708" z="20" map="Felucca" radius="60" />
|
||||
</spawn>
|
||||
<spawn name="Wandering" group="FelDungeons">
|
||||
<location x="100" y="200" z="0" map="Felucca" radius="40" />
|
||||
</spawn>
|
||||
</championSystem>`
|
||||
|
||||
test('parseChampions: reads altar name, type and location', () => {
|
||||
const champs = parseChampions(CHAMPIONS_XML)
|
||||
assert.equal(champs.length, 2)
|
||||
assert.deepEqual(champs[0], {
|
||||
name: 'Deceit',
|
||||
group: 'FelDungeons',
|
||||
type: 'UnholyTerror',
|
||||
randomType: false,
|
||||
facet: 'Felucca',
|
||||
x: 5178,
|
||||
y: 708,
|
||||
z: 20,
|
||||
radius: 60,
|
||||
})
|
||||
})
|
||||
|
||||
test('parseChampions: a spawn with no type is flagged random, not blank', () => {
|
||||
const champs = parseChampions(CHAMPIONS_XML)
|
||||
assert.equal(champs[1].randomType, true)
|
||||
assert.equal(champs[1].type, '')
|
||||
})
|
||||
|
||||
// ── resolveRegion ──────────────────────────────────────────────────────────
|
||||
|
||||
function fixtureIndex() {
|
||||
const regions = [
|
||||
{
|
||||
facet: 'Felucca',
|
||||
name: 'Britain',
|
||||
type: 'TownRegion',
|
||||
priority: 10,
|
||||
parent: null,
|
||||
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
|
||||
},
|
||||
{
|
||||
facet: 'Felucca',
|
||||
name: 'Britain Bank',
|
||||
type: 'TownRegion',
|
||||
priority: 50,
|
||||
parent: 'Britain',
|
||||
rects: [{ x: 1400, y: 1400, width: 20, height: 20 }],
|
||||
},
|
||||
{
|
||||
facet: 'Felucca',
|
||||
name: 'Wide Low Priority',
|
||||
type: 'TownRegion',
|
||||
priority: 10,
|
||||
parent: null,
|
||||
rects: [{ x: 1000, y: 1000, width: 2000, height: 2000 }],
|
||||
},
|
||||
]
|
||||
const landmarks = [
|
||||
{ facet: 'Felucca', name: 'Level 1', group: 'Covetous', path: [], x: 5000, y: 5000, z: 0 },
|
||||
{ facet: 'Felucca', name: 'Far Away', group: 'Vesper', path: [], x: 9000, y: 9000, z: 0 },
|
||||
]
|
||||
return buildPlacementIndex(regions, landmarks)
|
||||
}
|
||||
|
||||
test('resolveRegion: a contained point takes the region name', () => {
|
||||
const result = resolveRegion(1100, 1100, 'Felucca', fixtureIndex())
|
||||
assert.equal(result.region, 'Britain')
|
||||
assert.equal(result.label, 'Britain')
|
||||
assert.equal(result.landmark, null)
|
||||
})
|
||||
|
||||
test('resolveRegion: higher priority wins over a containing region', () => {
|
||||
const result = resolveRegion(1410, 1410, 'Felucca', fixtureIndex())
|
||||
assert.equal(result.region, 'Britain Bank')
|
||||
})
|
||||
|
||||
test('resolveRegion: equal priority breaks toward the smaller rect', () => {
|
||||
// Both "Britain" (500x500) and "Wide Low Priority" (2000x2000) contain this
|
||||
// point at priority 10; the specific one must win.
|
||||
const result = resolveRegion(1200, 1200, 'Felucca', fixtureIndex())
|
||||
assert.equal(result.region, 'Britain')
|
||||
})
|
||||
|
||||
test('resolveRegion: rects are half-open — the far edge is outside', () => {
|
||||
const index = fixtureIndex()
|
||||
// Britain spans x 1000..1499. 1499 is in, 1500 belongs to the next region.
|
||||
assert.equal(resolveRegion(1499, 1499, 'Felucca', index).region, 'Britain')
|
||||
assert.equal(resolveRegion(1500, 1500, 'Felucca', index).region, 'Wide Low Priority')
|
||||
})
|
||||
|
||||
test('resolveRegion: falls back to the nearest landmark group', () => {
|
||||
const result = resolveRegion(5050, 5050, 'Felucca', fixtureIndex())
|
||||
assert.equal(result.region, null)
|
||||
assert.equal(result.landmark, 'Covetous')
|
||||
assert.equal(result.label, 'Covetous')
|
||||
})
|
||||
|
||||
test('resolveRegion: a landmark beyond the radius yields Wilderness', () => {
|
||||
// Without the radius cap the nearest landmark is always *some* landmark, and
|
||||
// open countryside would get labelled with a dungeon across the map.
|
||||
const result = resolveRegion(7000, 7000, 'Felucca', fixtureIndex())
|
||||
assert.equal(result.landmark, null)
|
||||
assert.equal(result.label, 'Wilderness')
|
||||
})
|
||||
|
||||
test('resolveRegion: the radius is configurable', () => {
|
||||
const wide = resolveRegion(7000, 7000, 'Felucca', fixtureIndex(), { landmarkRadius: 5000 })
|
||||
assert.equal(wide.label, 'Covetous')
|
||||
})
|
||||
|
||||
test('resolveRegion: an unknown facet degrades to Wilderness, not a throw', () => {
|
||||
const result = resolveRegion(1100, 1100, 'Malas', fixtureIndex())
|
||||
assert.equal(result.label, 'Wilderness')
|
||||
assert.equal(result.region, null)
|
||||
})
|
||||
|
||||
test('resolveRegion: does not leak across facets', () => {
|
||||
const index = buildPlacementIndex(
|
||||
[
|
||||
{
|
||||
facet: 'Trammel',
|
||||
name: 'Britain',
|
||||
type: 'TownRegion',
|
||||
priority: 10,
|
||||
parent: null,
|
||||
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
assert.equal(resolveRegion(1100, 1100, 'Trammel', index).region, 'Britain')
|
||||
assert.equal(resolveRegion(1100, 1100, 'Felucca', index).region, null)
|
||||
})
|
||||
|
||||
// ── Tokenizer edge cases ───────────────────────────────────────────────────
|
||||
|
||||
test('parseXml: skips comments, declarations and DOCTYPE', () => {
|
||||
const root = parseXml(
|
||||
'<?xml version="1.0"?><!DOCTYPE r><r><!-- <fake a="b"/> --><a x="1"/></r>',
|
||||
)
|
||||
assert.equal(root.name, 'r')
|
||||
assert.equal(root.children.length, 1)
|
||||
assert.equal(root.children[0].name, 'a')
|
||||
assert.equal(root.children[0].attrs.x, '1')
|
||||
})
|
||||
|
||||
test('parseXml: a ">" inside an attribute value does not end the tag', () => {
|
||||
const root = parseXml('<r><a name="1 > 0" b="2"/></r>')
|
||||
assert.equal(root.children[0].attrs.name, '1 > 0')
|
||||
assert.equal(root.children[0].attrs.b, '2')
|
||||
})
|
||||
|
||||
test('parseXml: single-quoted attributes are read', () => {
|
||||
const root = parseXml("<r><a name='Mondain' /></r>")
|
||||
assert.equal(root.children[0].attrs.name, 'Mondain')
|
||||
})
|
||||
|
||||
test('parseXml: a stray closing tag is ignored, not fatal', () => {
|
||||
// Hand-maintained shard config: one malformed element should degrade to a
|
||||
// missing element, not abort an otherwise good build.
|
||||
const root = parseXml('<r><a/></b><c/></r>')
|
||||
assert.equal(root.name, 'r')
|
||||
assert.deepEqual(root.children.map((n) => n.name), ['a', 'c'])
|
||||
})
|
||||
|
||||
test('parseXml: empty or element-free input yields null', () => {
|
||||
assert.equal(parseXml(''), null)
|
||||
assert.equal(parseXml('<!-- only a comment -->'), null)
|
||||
})
|
||||
|
||||
test('decodeEntities: named, numeric and hex refs', () => {
|
||||
assert.equal(decodeEntities("Mondain's Legacy"), "Mondain's Legacy")
|
||||
assert.equal(decodeEntities('a & b'), 'a & b')
|
||||
assert.equal(decodeEntities('<tag>'), '<tag>')
|
||||
assert.equal(decodeEntities('AB'), 'AB')
|
||||
// An unknown entity is left alone rather than silently eaten.
|
||||
assert.equal(decodeEntities('&nosuch;'), '&nosuch;')
|
||||
})
|
||||
|
||||
test('parseXml: decodes entities in attribute values', () => {
|
||||
const root = parseXml('<r><region name="Mondain's Legacy" /></r>')
|
||||
assert.equal(root.children[0].attrs.name, "Mondain's Legacy")
|
||||
})
|
||||
|
||||
// ── slugify ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('slugify: produces URL-safe keys', () => {
|
||||
assert.equal(slugify('Lizardman'), 'lizardman')
|
||||
assert.equal(slugify('Giant Spider'), 'giant-spider')
|
||||
assert.equal(slugify("Mondain's Legacy"), 'mondain-s-legacy')
|
||||
assert.equal(slugify(' Orc '), 'orc')
|
||||
})
|
||||
|
||||
// ── Respawn delays: the unit is per record ──────────────────────────────────
|
||||
// XmlSpawner writes minutes by default and switches to seconds only when a
|
||||
// delay does not divide into whole minutes, flagged by DelayInSec. `5` therefore
|
||||
// means five MINUTES on one spawner and five SECONDS on the next, and a reader
|
||||
// assuming either unit is wrong about the other — silently, since both are
|
||||
// plausible respawn times.
|
||||
const DELAY_XML = `<Spawns>
|
||||
<Points>
|
||||
<Name>Minutes</Name>
|
||||
<Map>Sosaria</Map>
|
||||
<X>1</X><Y>1</Y>
|
||||
<MinDelay>5</MinDelay>
|
||||
<MaxDelay>10</MaxDelay>
|
||||
<IsRunning>True</IsRunning>
|
||||
<Objects2>Orc:MX=1</Objects2>
|
||||
</Points>
|
||||
<Points>
|
||||
<Name>Seconds</Name>
|
||||
<Map>Sosaria</Map>
|
||||
<X>2</X><Y>2</Y>
|
||||
<DelayInSec>True</DelayInSec>
|
||||
<MinDelay>5</MinDelay>
|
||||
<MaxDelay>10</MaxDelay>
|
||||
<IsRunning>True</IsRunning>
|
||||
<Objects2>Orc:MX=1</Objects2>
|
||||
</Points>
|
||||
</Spawns>`
|
||||
|
||||
test('parsePoints: DelayInSec decides the unit, and both come out in seconds', () => {
|
||||
const [minutes, seconds] = parsePoints(DELAY_XML)
|
||||
assert.equal(minutes.minDelay, 300)
|
||||
assert.equal(minutes.maxDelay, 600)
|
||||
assert.equal(seconds.minDelay, 5)
|
||||
assert.equal(seconds.maxDelay, 10)
|
||||
})
|
||||
399
modules/uo/server/test/spawnAtlas.source.test.js
Normal file
399
modules/uo/server/test/spawnAtlas.source.test.js
Normal file
@@ -0,0 +1,399 @@
|
||||
// No dead-port pool trick here: a module test fabricates its ctx instead, so
|
||||
// there is no database to point anywhere (see test/_ctx.js).
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const { test, after, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { installFakeCtx } = require('./_ctx')
|
||||
|
||||
installFakeCtx()
|
||||
|
||||
const {
|
||||
AtlasSourceError,
|
||||
aggregateCreatures,
|
||||
displayName,
|
||||
sameSources,
|
||||
hashSources,
|
||||
buildAtlas,
|
||||
PARSER_VERSION,
|
||||
} = require('../utils/spawnAtlasSource')
|
||||
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
|
||||
// The model captured this object at require time, so reassigning a method on it
|
||||
// is how a test stubs core — the module equivalent of core's own tests
|
||||
// monkey-patching a model.
|
||||
const { settings } = require('../core')
|
||||
|
||||
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
|
||||
//
|
||||
// Deliberately uses facets that do NOT exist in stock UO. The atlas must not
|
||||
// contain a built-in facet list anywhere: a shard may add facets, replace them
|
||||
// outright, or rename them when its maps are updated, and everything has to keep
|
||||
// working with no code change.
|
||||
|
||||
function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {}) {
|
||||
fs.mkdirSync(path.join(root, 'Spawns'), { recursive: true })
|
||||
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
|
||||
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
|
||||
|
||||
for (const facet of facets) {
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Spawns', `${facet}.xml`),
|
||||
`<Spawns>
|
||||
<Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
|
||||
<MaxCount>3</MaxCount><IsRunning>True</IsRunning>
|
||||
<Objects2>Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0</Objects2></Points>
|
||||
<Points><Name>${facet}B</Name><Map>${facet}</Map><X>9000</X><Y>9000</Y>
|
||||
<MaxCount>1</MaxCount><IsRunning>True</IsRunning>
|
||||
<Objects2>lizardman:MX=2:SB=0</Objects2></Points>
|
||||
<Points><Name>${facet}Off</Name><Map>${facet}</Map><X>1</X><Y>1</Y>
|
||||
<MaxCount>1</MaxCount><IsRunning>False</IsRunning>
|
||||
<Objects2>Ghost:MX=1</Objects2></Points>
|
||||
</Spawns>`,
|
||||
'utf8',
|
||||
)
|
||||
// The location file names its facet differently from <Map>, the real
|
||||
// `Ter Mur` / `Tokuno Islands` drift.
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Data', 'Locations', `${facet.toLowerCase()}.xml`),
|
||||
`<places><parent name="${facet} Isles"><parent name="Deep Cave">
|
||||
<child name="Level 1" x="9010" y="9010" z="0" /></parent></parent></places>`,
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Data', 'Regions.xml'),
|
||||
`<ServerRegions>${facets
|
||||
.map(
|
||||
(facet) => `<Facet name="${facet}">
|
||||
<region type="TownRegion" priority="10" name="${facet} City">
|
||||
<rect x="1000" y="1000" width="500" height="500" />
|
||||
</region></Facet>`,
|
||||
)
|
||||
.join('')}</ServerRegions>`,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
if (includeChampions) {
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Config', 'ChampionSpawns.xml'),
|
||||
`<championSystem><spawn name="Deep" group="G" type="Terror">
|
||||
<location x="1100" y="1100" z="0" map="${facets[0]}" radius="40" />
|
||||
</spawn></championSystem>`,
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function tempTree(options) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-test-'))
|
||||
writeTree(root, options)
|
||||
return root
|
||||
}
|
||||
|
||||
// ── buildAtlas against a custom-facet tree ─────────────────────────────────
|
||||
|
||||
test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
|
||||
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||||
const atlas = buildAtlas(root)
|
||||
assert.deepEqual(atlas.facets, ['Sosaria', 'Underdark'])
|
||||
assert.equal(atlas.meta.counts.facets, 2)
|
||||
})
|
||||
|
||||
test('buildAtlas: reconciles a location file that spells the facet differently', () => {
|
||||
// "Sosaria Isles" inside the file vs <Map>Sosaria</Map> — the same drift that
|
||||
// silently emptied the Ter Mur / Tokuno landmark buckets.
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
const atlas = buildAtlas(root)
|
||||
assert.deepEqual([...new Set(atlas.landmarks.map((l) => l.facet))], ['Sosaria'])
|
||||
// And the fallback actually fires, rather than the point reading Wilderness.
|
||||
const far = atlas.points.find((p) => p.name === 'SosariaB')
|
||||
assert.equal(far.landmark, 'Deep Cave')
|
||||
assert.equal(far.label, 'Deep Cave')
|
||||
})
|
||||
|
||||
test('buildAtlas: resolves a contained point to its region', () => {
|
||||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||||
const inCity = atlas.points.find((p) => p.name === 'SosariaA')
|
||||
assert.equal(inCity.region, 'Sosaria City')
|
||||
assert.equal(inCity.label, 'Sosaria City')
|
||||
})
|
||||
|
||||
test('buildAtlas: drops spawners that are switched off in-world', () => {
|
||||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||||
assert.equal(atlas.points.some((p) => p.name === 'SosariaOff'), false)
|
||||
assert.equal(atlas.meta.counts.pointsDisabled, 1)
|
||||
})
|
||||
|
||||
test('buildAtlas: a champion altar resolves through the same placement index', () => {
|
||||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||||
assert.equal(atlas.champions[0].label, 'Sosaria City')
|
||||
assert.equal(atlas.champions[0].facet, 'Sosaria')
|
||||
})
|
||||
|
||||
test('buildAtlas: a tree with no champion file still builds', () => {
|
||||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'], includeChampions: false }))
|
||||
assert.deepEqual(atlas.champions, [])
|
||||
})
|
||||
|
||||
test('buildAtlas: missing path and empty path raise typed errors', () => {
|
||||
assert.throws(() => buildAtlas(''), (err) => err instanceof AtlasSourceError && err.code === 'NO_PATH')
|
||||
assert.throws(
|
||||
() => buildAtlas(path.join(os.tmpdir(), 'definitely-not-a-servuo-tree-xyz')),
|
||||
(err) => err instanceof AtlasSourceError && err.code === 'NOT_FOUND',
|
||||
)
|
||||
})
|
||||
|
||||
test('buildAtlas: a directory with no spawn files raises rather than building empty', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-empty-'))
|
||||
fs.mkdirSync(path.join(root, 'Data'), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, 'Data', 'Regions.xml'), '<ServerRegions/>', 'utf8')
|
||||
assert.throws(() => buildAtlas(root), (err) => err.code === 'NO_SPAWNS')
|
||||
})
|
||||
|
||||
// ── Hashing ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('hashSources: stable across reads, changes when a file changes', () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
const first = hashSources(root)
|
||||
assert.ok(sameSources(first, hashSources(root)))
|
||||
|
||||
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- edit -->', 'utf8')
|
||||
assert.equal(sameSources(first, hashSources(root)), false)
|
||||
})
|
||||
|
||||
test('sameSources: a missing or extra file is a difference', () => {
|
||||
assert.equal(sameSources({ a: '1' }, { a: '1', b: '2' }), false)
|
||||
assert.equal(sameSources({ a: '1' }, { a: '2' }), false)
|
||||
assert.equal(sameSources({ a: '1' }, { a: '1' }), true)
|
||||
assert.equal(sameSources(null, { a: '1' }), false)
|
||||
assert.equal(sameSources({ a: '1' }, null), false)
|
||||
})
|
||||
|
||||
// ── Aggregation ────────────────────────────────────────────────────────────
|
||||
|
||||
const POINTS = [
|
||||
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 3 }, { type: 'Orc', max: 1 }] },
|
||||
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 2 }] },
|
||||
{ facet: 'Underdark', types: [{ type: 'lizardman', max: 5 }] },
|
||||
]
|
||||
|
||||
test('aggregateCreatures: sums each type’s own max and counts per facet', () => {
|
||||
const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
|
||||
assert.equal(lizardman.total, 10)
|
||||
assert.equal(lizardman.points, 3)
|
||||
assert.deepEqual(lizardman.facets, { Sosaria: 2, Underdark: 1 })
|
||||
})
|
||||
|
||||
test('aggregateCreatures: differing case collapses to one creature', () => {
|
||||
const creatures = aggregateCreatures(POINTS)
|
||||
assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
|
||||
assert.deepEqual(creatures.map((c) => c.slug), ['lizardman', 'orc'])
|
||||
assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
|
||||
})
|
||||
|
||||
test('displayName: most common wins, ties break to the capitalised form', () => {
|
||||
assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
|
||||
assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
|
||||
// Deterministic regardless of insertion order — a committed artifact is gone,
|
||||
// but a spurious diff in the DB on every restart would be just as wrong.
|
||||
assert.equal(
|
||||
displayName(new Map([['abc', 1], ['abd', 1]])),
|
||||
displayName(new Map([['abd', 1], ['abc', 1]])),
|
||||
)
|
||||
})
|
||||
|
||||
test('pointTypeRows: collapses a repeated type to the larger max', () => {
|
||||
// The primary key is (point_id, slug), so a duplicate would otherwise fail the
|
||||
// insert and take the whole transaction with it.
|
||||
const rows = shardAtlas.pointTypeRows([
|
||||
{ types: [{ type: 'Orc', max: 1 }, { type: 'orc', max: 4 }, { type: 'Rat', max: 2 }] },
|
||||
])
|
||||
assert.deepEqual(rows.sort(), [[1, 'orc', 4], [1, 'rat', 2]].sort())
|
||||
})
|
||||
|
||||
test('pointTypeRows: point ids are 1-based and line up with insert order', () => {
|
||||
const rows = shardAtlas.pointTypeRows([
|
||||
{ types: [{ type: 'A', max: 1 }] },
|
||||
{ types: [{ type: 'B', max: 1 }] },
|
||||
])
|
||||
assert.deepEqual(rows, [[1, 'a', 1], [2, 'b', 1]])
|
||||
})
|
||||
|
||||
// ── The refresh decision ───────────────────────────────────────────────────
|
||||
//
|
||||
// The boot path's two contracts: it never blocks startup, and it never applies a
|
||||
// facet removal on its own.
|
||||
|
||||
let applied
|
||||
let pendingRow
|
||||
let facetsInDb
|
||||
let metaRow
|
||||
|
||||
beforeEach(() => {
|
||||
applied = null
|
||||
pendingRow = null
|
||||
facetsInDb = []
|
||||
metaRow = null
|
||||
atlasDb.replaceAtlas = async (atlas) => {
|
||||
applied = atlas
|
||||
return { points: atlas.points.length, creatures: atlas.creatures.length }
|
||||
}
|
||||
atlasDb.getMeta = async () => metaRow
|
||||
atlasDb.getFacets = async () => facetsInDb
|
||||
atlasDb.getPending = async () => pendingRow
|
||||
atlasDb.setPending = async (payload, status) => {
|
||||
pendingRow = { ...payload, status }
|
||||
}
|
||||
atlasDb.clearPending = async () => {
|
||||
pendingRow = null
|
||||
}
|
||||
settings.get = async () => ''
|
||||
process.env.SERVUO_PATH = ''
|
||||
})
|
||||
|
||||
test('refresh: no configured path is skipped, not an error', async () => {
|
||||
const result = await shardAtlas.refresh()
|
||||
assert.equal(result.status, 'skipped')
|
||||
})
|
||||
|
||||
test('refresh: an unreadable tree reports unavailable rather than throwing', async () => {
|
||||
const result = await shardAtlas.refresh({ path: path.join(os.tmpdir(), 'no-such-tree-abc') })
|
||||
assert.equal(result.status, 'unavailable')
|
||||
assert.equal(result.code, 'NOT_FOUND')
|
||||
})
|
||||
|
||||
test('refresh: a fresh database imports', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.ok(applied)
|
||||
assert.deepEqual(result.addedFacets, ['Sosaria'])
|
||||
})
|
||||
|
||||
test('refresh: an unchanged tree parses nothing and writes nothing', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
metaRow = buildAtlas(root).meta
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'unchanged')
|
||||
assert.equal(applied, null)
|
||||
})
|
||||
|
||||
// The hash gate alone would strand an install whose maps never change on
|
||||
// whatever an older build derived: a corrected parse would ship and never reach
|
||||
// the data, because the only thing compared is the tree.
|
||||
test('refresh: an unchanged tree is REIMPORTED when the parser has moved on', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
metaRow = { ...buildAtlas(root).meta, parserVersion: PARSER_VERSION - 1 }
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.ok(applied)
|
||||
})
|
||||
|
||||
test('refresh: an atlas imported before parser versions existed is stale', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
const meta = buildAtlas(root).meta
|
||||
delete meta.parserVersion
|
||||
metaRow = meta
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'imported')
|
||||
})
|
||||
|
||||
test('refresh: --force reimports an unchanged tree', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
metaRow = buildAtlas(root).meta
|
||||
const result = await shardAtlas.refresh({ path: root, force: true })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.ok(applied)
|
||||
})
|
||||
|
||||
test('refresh: a NEW facet applies straight away', async () => {
|
||||
// Additions cannot destroy anything an operator would miss.
|
||||
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||||
facetsInDb = ['Sosaria']
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.deepEqual(result.addedFacets, ['Underdark'])
|
||||
})
|
||||
|
||||
test('refresh: a REMOVED facet is staged, not applied', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
facetsInDb = ['Sosaria', 'Underdark']
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'needsReview')
|
||||
assert.deepEqual(result.removedFacets, ['Underdark'])
|
||||
// The critical part: the existing atlas was left alone.
|
||||
assert.equal(applied, null)
|
||||
assert.equal(pendingRow.status, 'pending')
|
||||
})
|
||||
|
||||
test('refresh: approving applies the removal', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
facetsInDb = ['Sosaria', 'Underdark']
|
||||
await shardAtlas.refresh({ path: root })
|
||||
assert.equal(applied, null)
|
||||
|
||||
const result = await shardAtlas.approvePending({ path: root })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.ok(applied)
|
||||
assert.deepEqual(result.removedFacets, ['Underdark'])
|
||||
})
|
||||
|
||||
test('refresh: a rejected refresh does not re-prompt while the tree is unchanged', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
facetsInDb = ['Sosaria', 'Underdark']
|
||||
await shardAtlas.refresh({ path: root })
|
||||
await shardAtlas.rejectPending()
|
||||
assert.equal(pendingRow.status, 'rejected')
|
||||
|
||||
const again = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(again.status, 'unchanged')
|
||||
assert.equal(applied, null)
|
||||
})
|
||||
|
||||
test('refresh: changing the tree asks again after a rejection', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
facetsInDb = ['Sosaria', 'Underdark']
|
||||
await shardAtlas.refresh({ path: root })
|
||||
await shardAtlas.rejectPending()
|
||||
|
||||
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- changed -->', 'utf8')
|
||||
const again = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(again.status, 'needsReview')
|
||||
})
|
||||
|
||||
test('refreshOnBoot: never throws, whatever goes wrong', async () => {
|
||||
atlasDb.getMeta = async () => {
|
||||
throw new Error('database is on fire')
|
||||
}
|
||||
atlasDb.getFacets = async () => {
|
||||
throw new Error('still on fire')
|
||||
}
|
||||
atlasDb.replaceAtlas = async () => {
|
||||
throw new Error('and the import too')
|
||||
}
|
||||
process.env.SERVUO_PATH = tempTree({ facets: ['Sosaria'] })
|
||||
|
||||
const result = await shardAtlas.refreshOnBoot()
|
||||
assert.equal(result.status, 'failed')
|
||||
})
|
||||
|
||||
test('refreshOnBoot: a missing tree is survivable, not fatal', async () => {
|
||||
process.env.SERVUO_PATH = path.join(os.tmpdir(), 'nope-not-here-xyz')
|
||||
const result = await shardAtlas.refreshOnBoot()
|
||||
assert.equal(result.status, 'unavailable')
|
||||
})
|
||||
|
||||
test('refresh: an explicit path overrides the configured one', async () => {
|
||||
const configured = tempTree({ facets: ['Configured'] })
|
||||
const override = tempTree({ facets: ['Override'] })
|
||||
settings.get = async () => configured
|
||||
|
||||
const result = await shardAtlas.refresh({ path: override })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.deepEqual(result.addedFacets, ['Override'])
|
||||
})
|
||||
686
modules/uo/server/utils/spawnAtlasParse.js
Normal file
686
modules/uo/server/utils/spawnAtlasParse.js
Normal file
@@ -0,0 +1,686 @@
|
||||
// Spawn atlas parsers — pure functions over strings, no `fs`, no dependencies.
|
||||
//
|
||||
// These back the CLI build script (`scripts/buildSpawnAtlas.js`), which is the
|
||||
// only thing that reads a ServUO tree. Keeping every parser pure and fs-free is
|
||||
// what lets the test suite cover them in CI, where no ServUO tree exists: the
|
||||
// tests hand these functions literal XML strings.
|
||||
//
|
||||
// Four source shapes, two very different parsing strategies:
|
||||
//
|
||||
// Spawns/*.xml ~10.5 MB across 13 files, FLAT <Points> records
|
||||
// → streaming regex, never a DOM. See parsePoints().
|
||||
// Data/Regions.xml 129 KB, genuinely nested <region> inside <region>
|
||||
// Data/Locations/*.xml nested <parent>/<child>
|
||||
// Config/ChampionSpawns.xml 4.8 KB, <spawn>/<location>
|
||||
// → the small recursive tokenizer below.
|
||||
//
|
||||
// The server has zero XML dependencies and this adds none. The tokenizer is
|
||||
// deliberately a *subset* parser: it handles the constructs these four files
|
||||
// actually use (elements, attributes, self-closing tags, comments, the XML
|
||||
// declaration, CDATA, the five predefined entities plus numeric refs) and
|
||||
// nothing else. It is not a general-purpose XML parser and must not be reused
|
||||
// as one — no namespaces, no DTDs, no entity declarations.
|
||||
|
||||
// ── Entities ───────────────────────────────────────────────────────────────
|
||||
|
||||
const NAMED_ENTITIES = {
|
||||
amp: '&',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
}
|
||||
|
||||
// Region and location names carry apostrophes ("Mondain's Legacy", "Wrong's
|
||||
// Level 3"), so entity decoding is load-bearing here, not decorative.
|
||||
function decodeEntities(text) {
|
||||
if (!text.includes('&')) return text
|
||||
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body) => {
|
||||
if (body[0] === '#') {
|
||||
const code =
|
||||
body[1] === 'x' || body[1] === 'X'
|
||||
? Number.parseInt(body.slice(2), 16)
|
||||
: Number.parseInt(body.slice(1), 10)
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : match
|
||||
}
|
||||
const named = NAMED_ENTITIES[body.toLowerCase()]
|
||||
return named === undefined ? match : named
|
||||
})
|
||||
}
|
||||
|
||||
// ── The tokenizer ──────────────────────────────────────────────────────────
|
||||
|
||||
const ATTR_RE = /([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g
|
||||
|
||||
function parseAttrs(source) {
|
||||
const attrs = {}
|
||||
ATTR_RE.lastIndex = 0
|
||||
let match
|
||||
while ((match = ATTR_RE.exec(source)) !== null) {
|
||||
const raw = match[3] !== undefined ? match[3] : match[4]
|
||||
attrs[match[1]] = decodeEntities(raw)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a small nested XML document into `{ name, attrs, children, text }`.
|
||||
*
|
||||
* Intended for Regions.xml / Locations / ChampionSpawns.xml only — never for
|
||||
* the multi-megabyte Spawns files. Returns the root element, or `null` for a
|
||||
* document with no elements.
|
||||
*
|
||||
* Mismatched or stray closing tags are ignored rather than thrown on: these are
|
||||
* hand-maintained shard config files, and one malformed region should degrade
|
||||
* to a missing region, not abort a build that is otherwise fine.
|
||||
*/
|
||||
function parseXml(source) {
|
||||
const text = String(source)
|
||||
const root = { name: '#document', attrs: {}, children: [], text: '' }
|
||||
const stack = [root]
|
||||
let i = 0
|
||||
|
||||
while (i < text.length) {
|
||||
const lt = text.indexOf('<', i)
|
||||
if (lt === -1) {
|
||||
appendText(stack[stack.length - 1], text.slice(i))
|
||||
break
|
||||
}
|
||||
if (lt > i) appendText(stack[stack.length - 1], text.slice(i, lt))
|
||||
|
||||
// Comment, declaration/DOCTYPE, or CDATA — skipped wholesale.
|
||||
if (text.startsWith('<!--', lt)) {
|
||||
const end = text.indexOf('-->', lt + 4)
|
||||
i = end === -1 ? text.length : end + 3
|
||||
continue
|
||||
}
|
||||
if (text.startsWith('<![CDATA[', lt)) {
|
||||
const end = text.indexOf(']]>', lt + 9)
|
||||
const stop = end === -1 ? text.length : end
|
||||
appendRawText(stack[stack.length - 1], text.slice(lt + 9, stop))
|
||||
i = end === -1 ? text.length : end + 3
|
||||
continue
|
||||
}
|
||||
if (text.startsWith('<?', lt)) {
|
||||
const end = text.indexOf('?>', lt + 2)
|
||||
i = end === -1 ? text.length : end + 2
|
||||
continue
|
||||
}
|
||||
if (text.startsWith('<!', lt)) {
|
||||
const end = text.indexOf('>', lt + 2)
|
||||
i = end === -1 ? text.length : end + 1
|
||||
continue
|
||||
}
|
||||
|
||||
const gt = findTagEnd(text, lt)
|
||||
if (gt === -1) {
|
||||
// Unterminated tag: nothing sane is left to read.
|
||||
break
|
||||
}
|
||||
const inner = text.slice(lt + 1, gt)
|
||||
|
||||
if (inner[0] === '/') {
|
||||
const name = inner.slice(1).trim()
|
||||
// Pop to the nearest matching open element. If there is no match the tag
|
||||
// is stray and we drop it rather than unwinding the whole stack.
|
||||
for (let depth = stack.length - 1; depth > 0; depth -= 1) {
|
||||
if (stack[depth].name === name) {
|
||||
stack.length = depth
|
||||
break
|
||||
}
|
||||
}
|
||||
i = gt + 1
|
||||
continue
|
||||
}
|
||||
|
||||
const selfClosing = inner.endsWith('/')
|
||||
const body = selfClosing ? inner.slice(0, -1) : inner
|
||||
const space = body.search(/\s/)
|
||||
const name = (space === -1 ? body : body.slice(0, space)).trim()
|
||||
const node = {
|
||||
name,
|
||||
attrs: space === -1 ? {} : parseAttrs(body.slice(space)),
|
||||
children: [],
|
||||
text: '',
|
||||
}
|
||||
stack[stack.length - 1].children.push(node)
|
||||
if (!selfClosing) stack.push(node)
|
||||
i = gt + 1
|
||||
}
|
||||
|
||||
return root.children.length > 0 ? root.children[0] : null
|
||||
}
|
||||
|
||||
// `>` inside a quoted attribute value must not end the tag.
|
||||
function findTagEnd(text, from) {
|
||||
let quote = null
|
||||
for (let i = from + 1; i < text.length; i += 1) {
|
||||
const ch = text[i]
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
} else if (ch === '>') {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function appendText(node, chunk) {
|
||||
if (chunk.trim() === '') return
|
||||
appendRawText(node, decodeEntities(chunk))
|
||||
}
|
||||
|
||||
function appendRawText(node, chunk) {
|
||||
node.text = node.text ? `${node.text}${chunk}` : chunk
|
||||
}
|
||||
|
||||
function childrenNamed(node, name) {
|
||||
if (!node || !node.children) return []
|
||||
return node.children.filter((child) => child.name === name)
|
||||
}
|
||||
|
||||
// ── Facet names ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Facets are NOT a fixed list. A shard may add facets, replace them wholesale,
|
||||
// or rename them when its maps are updated, so nothing here may name Felucca,
|
||||
// Trammel or any other stock facet. The facet set is whatever the shard's own
|
||||
// files say it is, discovered at parse time.
|
||||
//
|
||||
// The complication is that the sources disagree about spelling for the SAME
|
||||
// facet and nothing in the files reconciles them: `Spawns/*.xml` `<Map>` and
|
||||
// `Regions.xml` `<Facet name>` say `TerMur`, while `Data/Locations/*.xml` spells
|
||||
// it `Ter Mur` and calls Tokuno `Tokuno Islands`. Left unreconciled this fails
|
||||
// silently — the landmark bucket is keyed differently from the points looking it
|
||||
// up, so the fallback never fires and every unregioned spawn on those facets
|
||||
// reads "Wilderness".
|
||||
//
|
||||
// Reconciliation is therefore done by MATCHING, not by a lookup table:
|
||||
// `facetKey()` collapses spelling differences, and `resolveFacetName()` matches
|
||||
// a loosely-spelled name against the canonical set discovered from the shard's
|
||||
// own data. A facet nobody else mentions keeps its own name rather than being
|
||||
// dropped.
|
||||
|
||||
/**
|
||||
* Collapse a facet name to a comparison key: lowercase, alphanumerics only.
|
||||
* `TerMur`, `Ter Mur` and `ter-mur` all key alike.
|
||||
*/
|
||||
function facetKey(value) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a key → canonical-spelling lookup from the authoritative facet names.
|
||||
*
|
||||
* The authority is what the spawn records and region definitions actually say,
|
||||
* since those are the names the atlas keys everything on. Later names do not
|
||||
* overwrite earlier ones, so the first source wins consistently.
|
||||
*/
|
||||
function buildFacetIndex(names) {
|
||||
const index = new Map()
|
||||
for (const name of names) {
|
||||
const key = facetKey(name)
|
||||
if (key !== '' && !index.has(key)) index.set(key, String(name).trim())
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a loosely-spelled facet name against the discovered canonical set.
|
||||
*
|
||||
* Tried in order: exact key match (`Ter Mur` → `TerMur`), then a prefix match in
|
||||
* either direction (`Tokuno Islands` → `Tokuno`), longest candidate first so a
|
||||
* more specific facet wins over a shorter one that merely prefixes it.
|
||||
*
|
||||
* A name matching nothing is returned trimmed rather than dropped — on a shard
|
||||
* with a custom facet that is a real facet the atlas simply has no spawns for
|
||||
* yet, and inventing a match would be worse than leaving it alone.
|
||||
*/
|
||||
function resolveFacetName(value, index) {
|
||||
const raw = String(value ?? '').trim()
|
||||
const key = facetKey(raw)
|
||||
if (key === '') return ''
|
||||
if (index.has(key)) return index.get(key)
|
||||
|
||||
let best = null
|
||||
for (const [candidateKey, canonical] of index) {
|
||||
if (!key.startsWith(candidateKey) && !candidateKey.startsWith(key)) continue
|
||||
if (best === null || candidateKey.length > facetKey(best).length) best = canonical
|
||||
}
|
||||
return best ?? raw
|
||||
}
|
||||
|
||||
// ── Small coercions ────────────────────────────────────────────────────────
|
||||
|
||||
function toInt(value, fallback = 0) {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
function toBool(value) {
|
||||
return String(value).trim().toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-safe slug used as the creature primary key and in `/atlas/:slug`.
|
||||
* Spawn type tokens are C# class names, so they are already ASCII-ish; this
|
||||
* mainly lowercases and collapses punctuation.
|
||||
*/
|
||||
function slugify(value) {
|
||||
return String(value)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
// ── Objects2 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a `<Objects2>` value into `[{ type, max }]`.
|
||||
*
|
||||
* The format is one or more segments joined by `:OBJ=`, each segment being
|
||||
* `Type:MX=n:SB=0:RT=0:...` — the type is the token before the first `:`, and
|
||||
* every following token is a `KEY=value` pair. Verified against trammel.xml,
|
||||
* where a single point carries six types:
|
||||
*
|
||||
* Giantserpent:MX=1:...:OBJ=Giantspider:MX=1:...:OBJ=Boar:MX=1:...
|
||||
*
|
||||
* Splitting on `:` alone would shred this, which is why the `:OBJ=` split comes
|
||||
* first. `MX` is that type's own max count and is what the atlas displays;
|
||||
* every other flag (spawn/trigger/refractory bookkeeping) is dropped.
|
||||
*
|
||||
* The type token itself may carry XmlSpawner directives appended to the class
|
||||
* name — property assignments after `/` and an amount/argument list after `,`:
|
||||
*
|
||||
* Agralem/Name/Agralem alchemist/z/-50 Fairy,{RND,4,8}
|
||||
* GargishRefugee/hue/34532 greatape,true GargishRouser,1
|
||||
*
|
||||
* Taken literally these produce creatures that do not exist ("alchemist/z/-50")
|
||||
* AND split real ones in two, because `Fairy` and `Fairy,{RND,4,8}` slug apart —
|
||||
* 71 of 845 entries were affected before this was stripped. Only the leading
|
||||
* class name identifies the creature, so everything from the first `/` or `,`
|
||||
* is dropped.
|
||||
*/
|
||||
/** Reduce an XmlSpawner type token to the bare class name. */
|
||||
function stripSpawnerDirectives(token) {
|
||||
const cut = String(token).search(/[/,]/)
|
||||
return (cut === -1 ? String(token) : String(token).slice(0, cut)).trim()
|
||||
}
|
||||
|
||||
function parseObjects2(value) {
|
||||
const source = String(value ?? '').trim()
|
||||
if (source === '') return []
|
||||
|
||||
return source
|
||||
.split(':OBJ=')
|
||||
.map((segment) => {
|
||||
const tokens = segment.split(':')
|
||||
const type = stripSpawnerDirectives(tokens.shift() ?? '')
|
||||
if (type === '') return null
|
||||
let max = 1
|
||||
for (const token of tokens) {
|
||||
const eq = token.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
if (token.slice(0, eq).trim().toUpperCase() === 'MX') {
|
||||
max = toInt(token.slice(eq + 1), 1)
|
||||
}
|
||||
}
|
||||
return { type, max }
|
||||
})
|
||||
.filter((entry) => entry !== null)
|
||||
}
|
||||
|
||||
// ── Spawns/*.xml ───────────────────────────────────────────────────────────
|
||||
|
||||
const POINT_RE = /<Points>([\s\S]*?)<\/Points>/g
|
||||
|
||||
function tagValue(block, name) {
|
||||
const match = block.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))
|
||||
return match ? decodeEntities(match[1]).trim() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `Spawns/<facet>.xml` file into spawn point records.
|
||||
*
|
||||
* Deliberately regex/streaming and NOT `parseXml` — these files total ~10.5 MB
|
||||
* and putting them through a DOM builder would allocate a node per element for
|
||||
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
|
||||
* flat, so a per-record regex sweep is both correct and cheap.
|
||||
*
|
||||
* Only the fields the site can actually show are kept. Everything to do with
|
||||
* triggering, refractory windows, proximity, sequential spawning, sounds and
|
||||
* `UniqueId` is dropped here rather than downstream — that is what holds the
|
||||
* committed artifact under 1 MB.
|
||||
*
|
||||
* NOTE: the facet comes from each record's own `<Map>`, never from the file
|
||||
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
|
||||
* carry TerMur/Trammel points, so there are 13 files but only 6 facets.
|
||||
*/
|
||||
/**
|
||||
* A spawner's respawn window, in seconds.
|
||||
*
|
||||
* `DelayInSec` decides the unit of `MinDelay`/`MaxDelay`; absent (older files)
|
||||
* it is false, which is minutes — the same default XmlSpawner assumes.
|
||||
*/
|
||||
function delaySeconds(block) {
|
||||
const scale = toBool(tagValue(block, 'DelayInSec')) ? 1 : 60
|
||||
return {
|
||||
minDelay: toInt(tagValue(block, 'MinDelay')) * scale,
|
||||
maxDelay: toInt(tagValue(block, 'MaxDelay')) * scale,
|
||||
}
|
||||
}
|
||||
|
||||
function parsePoints(source) {
|
||||
const text = String(source)
|
||||
const points = []
|
||||
POINT_RE.lastIndex = 0
|
||||
let match
|
||||
|
||||
while ((match = POINT_RE.exec(text)) !== null) {
|
||||
const block = match[1]
|
||||
// Reported exactly as written. `<Map>` is the authority the rest of the
|
||||
// atlas keys on, so it is never rewritten.
|
||||
const facet = tagValue(block, 'Map')
|
||||
if (facet === '') continue
|
||||
|
||||
points.push({
|
||||
name: tagValue(block, 'Name'),
|
||||
facet,
|
||||
x: toInt(tagValue(block, 'X')),
|
||||
y: toInt(tagValue(block, 'Y')),
|
||||
width: toInt(tagValue(block, 'Width')),
|
||||
height: toInt(tagValue(block, 'Height')),
|
||||
range: toInt(tagValue(block, 'Range')),
|
||||
maxCount: toInt(tagValue(block, 'MaxCount')),
|
||||
// Normalised to SECONDS here, because the unit is per-record. XmlSpawner
|
||||
// writes minutes by default and switches to seconds only when a spawner's
|
||||
// delay does not divide into whole minutes, flagging that with
|
||||
// `DelayInSec` (XmlSpawner2.cs:7462-7480, read back at :6345-6358). Taken
|
||||
// literally the two are indistinguishable — a `5` means five minutes on
|
||||
// one spawner and five seconds on the next — so a consumer that assumed
|
||||
// either unit would be wrong about the other. Stock ServUO 57.4 has ~30
|
||||
// second-flagged spawners, few enough to look like noise and quietly
|
||||
// mislabel.
|
||||
...delaySeconds(block),
|
||||
// Time-of-day gating: TODMode 0 means "always", in which case the start
|
||||
// and end values are meaningless and the site must not render them.
|
||||
todStart: toInt(tagValue(block, 'TODStart')),
|
||||
todEnd: toInt(tagValue(block, 'TODEnd')),
|
||||
todMode: toInt(tagValue(block, 'TODMode')),
|
||||
// A spawner switched off in-world spawns nothing; the build filters these
|
||||
// out so the atlas describes what actually appears, not what is merely
|
||||
// configured. Parsed here so the decision stays in the build script.
|
||||
running: toBool(tagValue(block, 'IsRunning')),
|
||||
types: parseObjects2(tagValue(block, 'Objects2')),
|
||||
})
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
// ── Data/Regions.xml ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flatten `Data/Regions.xml` into `[{ facet, name, type, priority, parent, rects }]`.
|
||||
*
|
||||
* Regions nest: a `<region>` may contain further `<region>` elements, and the
|
||||
* inner ones frequently omit `name` and `priority` (`<region type="CrystalField">`
|
||||
* inside "Prism of Light"). Unnamed regions are skipped — they cannot label a
|
||||
* spawn point — but their children are still walked, and a child that omits
|
||||
* `priority` inherits its parent's rather than defaulting to 0, which would
|
||||
* quietly sort it below every top-level region.
|
||||
*/
|
||||
function parseRegions(source) {
|
||||
const root = parseXml(source)
|
||||
const regions = []
|
||||
if (!root) return regions
|
||||
|
||||
for (const facetNode of childrenNamed(root, 'Facet')) {
|
||||
const facet = (facetNode.attrs.name || '').trim()
|
||||
if (facet === '') continue
|
||||
walkRegions(facetNode, facet, null, 0, regions)
|
||||
}
|
||||
return regions
|
||||
}
|
||||
|
||||
function walkRegions(node, facet, parentName, parentPriority, out) {
|
||||
for (const regionNode of childrenNamed(node, 'region')) {
|
||||
const name = regionNode.attrs.name || ''
|
||||
const priority = Object.hasOwn(regionNode.attrs, 'priority')
|
||||
? toInt(regionNode.attrs.priority, parentPriority)
|
||||
: parentPriority
|
||||
|
||||
if (name !== '') {
|
||||
const rects = childrenNamed(regionNode, 'rect').map((rect) => ({
|
||||
x: toInt(rect.attrs.x),
|
||||
y: toInt(rect.attrs.y),
|
||||
width: toInt(rect.attrs.width),
|
||||
height: toInt(rect.attrs.height),
|
||||
}))
|
||||
// A named region with no rects (some exist purely to carry music or a
|
||||
// `go` point) can never contain anything, so it is not worth indexing.
|
||||
if (rects.length > 0) {
|
||||
out.push({
|
||||
facet,
|
||||
name,
|
||||
type: regionNode.attrs.type || '',
|
||||
priority,
|
||||
parent: parentName,
|
||||
rects,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
walkRegions(regionNode, facet, name === '' ? parentName : name, priority, out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data/Locations/*.xml ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flatten a `Data/Locations/<facet>.xml` into landmark points.
|
||||
*
|
||||
* The file nests `<parent>` arbitrarily deep and puts coordinates only on
|
||||
* `<child>`: Trammel → Dungeons → Covetous → "Level 1". The outermost parent is
|
||||
* the facet itself and is dropped from `path`; `group` is the innermost
|
||||
* enclosing parent ("Covetous"), which is the label worth showing — "Covetous"
|
||||
* reads better than "Level 1" when naming where a spawn is.
|
||||
*/
|
||||
function parseLocations(source, facetHint = '') {
|
||||
const root = parseXml(source)
|
||||
const landmarks = []
|
||||
if (!root) return landmarks
|
||||
|
||||
for (const top of childrenNamed(root, 'parent')) {
|
||||
// The file name (`Data/Locations/termur.xml`) is the more reliable signal
|
||||
// and is preferred over the display label inside the file, which is where
|
||||
// the `Ter Mur` / `Tokuno Islands` drift lives. Both are carried so the
|
||||
// build can fall back to matching the label if the file name resolves to
|
||||
// nothing — a shard may well name its files differently from its facets.
|
||||
landmarks.push(
|
||||
...collectLocations(top, facetHint || top.attrs.name || '', top.attrs.name || ''),
|
||||
)
|
||||
}
|
||||
return landmarks
|
||||
}
|
||||
|
||||
function collectLocations(top, facet, label) {
|
||||
const out = []
|
||||
walkLocations(top, facet, [], out)
|
||||
for (const landmark of out) landmark.facetLabel = label
|
||||
return out
|
||||
}
|
||||
|
||||
function walkLocations(node, facet, path, out) {
|
||||
for (const child of childrenNamed(node, 'child')) {
|
||||
const name = child.attrs.name || ''
|
||||
if (name === '') continue
|
||||
out.push({
|
||||
facet,
|
||||
name,
|
||||
group: path.length > 0 ? path[path.length - 1] : name,
|
||||
path: [...path],
|
||||
x: toInt(child.attrs.x),
|
||||
y: toInt(child.attrs.y),
|
||||
z: toInt(child.attrs.z),
|
||||
})
|
||||
}
|
||||
for (const parent of childrenNamed(node, 'parent')) {
|
||||
const name = parent.attrs.name || ''
|
||||
walkLocations(parent, facet, name === '' ? path : [...path, name], out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config/ChampionSpawns.xml ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse `Config/ChampionSpawns.xml` into champion altar records.
|
||||
*
|
||||
* This is the shard's *configured* champion roster — which altars exist, where,
|
||||
* and which type each is pinned to. It is static content and distinct from the
|
||||
* live `champ.update` feed the bridge already carries: this says "there is an
|
||||
* Unholy Terror altar in Deceit", the feed says "it is on level 3 right now".
|
||||
*
|
||||
* A spawn with no `type` is randomised on every activation, which the site must
|
||||
* render as "random" rather than as an empty type.
|
||||
*/
|
||||
function parseChampions(source) {
|
||||
const root = parseXml(source)
|
||||
const champions = []
|
||||
if (!root) return champions
|
||||
|
||||
for (const spawnNode of childrenNamed(root, 'spawn')) {
|
||||
const location = childrenNamed(spawnNode, 'location')[0]
|
||||
const attrs = location ? location.attrs : {}
|
||||
champions.push({
|
||||
name: spawnNode.attrs.name || '',
|
||||
group: spawnNode.attrs.group || '',
|
||||
type: spawnNode.attrs.type || '',
|
||||
randomType: !spawnNode.attrs.type,
|
||||
facet: (attrs.map || '').trim(),
|
||||
x: toInt(attrs.x),
|
||||
y: toInt(attrs.y),
|
||||
z: toInt(attrs.z),
|
||||
radius: toInt(attrs.radius),
|
||||
})
|
||||
}
|
||||
return champions
|
||||
}
|
||||
|
||||
// ── Placement ──────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_LANDMARK_RADIUS = 200
|
||||
|
||||
function inRect(x, y, rect) {
|
||||
return (
|
||||
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
|
||||
)
|
||||
}
|
||||
|
||||
function rectArea(rect) {
|
||||
return Math.max(1, rect.width) * Math.max(1, rect.height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Group parsed regions and landmarks by facet once, so the per-point resolve
|
||||
* below is a scan of one facet instead of the whole world. With ~6,500 points
|
||||
* and a few thousand rects this stays comfortably sub-second; there is no need
|
||||
* for a spatial index and none is worth the complexity.
|
||||
*/
|
||||
function buildPlacementIndex(regions, landmarks) {
|
||||
const byFacet = new Map()
|
||||
// Keyed on facetKey(), not the raw name, so two spellings of one facet cannot
|
||||
// land in separate buckets — the failure that silently emptied the landmark
|
||||
// bucket for Ter Mur and Tokuno.
|
||||
const facet = (name) => {
|
||||
const key = facetKey(name)
|
||||
if (!byFacet.has(key)) byFacet.set(key, { regions: [], landmarks: [] })
|
||||
return byFacet.get(key)
|
||||
}
|
||||
for (const region of regions) facet(region.facet).regions.push(region)
|
||||
for (const landmark of landmarks) facet(landmark.facet).landmarks.push(landmark)
|
||||
return byFacet
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw coordinate into a human place name.
|
||||
*
|
||||
* This is the transform the whole atlas exists for: it is what makes a row read
|
||||
* "Lizardman — Despise, Felucca" instead of "Lizardman — 5411, 1234".
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. The highest-`priority` named region whose rect contains the point. Ties
|
||||
* break toward the SMALLEST rect, so a specific room inside a dungeon wins
|
||||
* over the dungeon-wide rect it sits in.
|
||||
* 2. Otherwise the nearest landmark within `landmarkRadius` tiles, labelled by
|
||||
* its group ("Covetous"), not the individual marker ("Level 1").
|
||||
* 3. Otherwise "Wilderness". The radius cap is what keeps step 3 reachable —
|
||||
* without it the nearest landmark is always *some* landmark, however far,
|
||||
* and open countryside would get labelled with a dungeon on the far side
|
||||
* of the map.
|
||||
*/
|
||||
function resolveRegion(x, y, facetName, index, options = {}) {
|
||||
const radius = options.landmarkRadius ?? DEFAULT_LANDMARK_RADIUS
|
||||
const bucket = index.get(facetKey(facetName))
|
||||
const result = { region: null, landmark: null, label: 'Wilderness' }
|
||||
if (!bucket) return result
|
||||
|
||||
let best = null
|
||||
let bestPriority = -Infinity
|
||||
let bestArea = Infinity
|
||||
for (const region of bucket.regions) {
|
||||
for (const rect of region.rects) {
|
||||
if (!inRect(x, y, rect)) continue
|
||||
const area = rectArea(rect)
|
||||
if (region.priority > bestPriority || (region.priority === bestPriority && area < bestArea)) {
|
||||
best = region
|
||||
bestPriority = region.priority
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
result.region = best.name
|
||||
result.label = best.name
|
||||
return result
|
||||
}
|
||||
|
||||
let nearest = null
|
||||
let nearestDistance = Infinity
|
||||
const limit = radius * radius
|
||||
for (const landmark of bucket.landmarks) {
|
||||
const dx = landmark.x - x
|
||||
const dy = landmark.y - y
|
||||
const distance = dx * dx + dy * dy
|
||||
if (distance < nearestDistance) {
|
||||
nearest = landmark
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
if (nearest && nearestDistance <= limit) {
|
||||
result.landmark = nearest.group || nearest.name
|
||||
result.label = result.landmark
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseXml,
|
||||
parseObjects2,
|
||||
parsePoints,
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
buildPlacementIndex,
|
||||
resolveRegion,
|
||||
facetKey,
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
slugify,
|
||||
decodeEntities,
|
||||
DEFAULT_LANDMARK_RADIUS,
|
||||
}
|
||||
336
modules/uo/server/utils/spawnAtlasSource.js
Normal file
336
modules/uo/server/utils/spawnAtlasSource.js
Normal file
@@ -0,0 +1,336 @@
|
||||
// Spawn atlas — the filesystem layer over a ServUO tree.
|
||||
//
|
||||
// `spawnAtlasParse.js` holds the pure parsers; this module is the only thing
|
||||
// that touches a ServUO tree on disk, and it is shared by both callers:
|
||||
//
|
||||
// - the server, which refreshes the atlas on boot (`shardAtlas.model.js`)
|
||||
// - the CLI (`scripts/importSpawnAtlas.js`)
|
||||
//
|
||||
// The shard's own files are the single source of truth. Nothing is precomputed
|
||||
// and committed, because a shard's maps change over its lifetime — facets get
|
||||
// added, replaced or renamed — and a snapshot in the repo would silently go
|
||||
// stale against the world players actually see.
|
||||
//
|
||||
// Reading and hashing the whole tree costs ~120 ms and a full parse ~400 ms, so
|
||||
// the boot path hashes first and only parses when something actually changed.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const {
|
||||
parsePoints,
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
buildPlacementIndex,
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
resolveRegion,
|
||||
facetKey,
|
||||
slugify,
|
||||
} = require('./spawnAtlasParse')
|
||||
|
||||
const REGIONS_FILE = path.join('Data', 'Regions.xml')
|
||||
const LOCATIONS_DIR = path.join('Data', 'Locations')
|
||||
const SPAWNS_DIR = 'Spawns'
|
||||
const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml')
|
||||
|
||||
class AtlasSourceError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message)
|
||||
this.name = 'AtlasSourceError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reading ────────────────────────────────────────────────────────────────
|
||||
|
||||
function sha256(text) {
|
||||
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function listXml(dir) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((name) => name.toLowerCase().endsWith('.xml'))
|
||||
.sort()
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function readIfPresent(file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every atlas source file under `root`.
|
||||
*
|
||||
* Returns `{ files: [{ label, text, sha256, bytes }] }`, labels being
|
||||
* tree-relative and forward-slashed so a hash map compares equal across
|
||||
* platforms — the same tree read on Windows and Linux must produce the same
|
||||
* fingerprint or every boot would look like a change.
|
||||
*/
|
||||
function readSources(root) {
|
||||
if (!root || String(root).trim() === '') {
|
||||
throw new AtlasSourceError('No ServUO path configured', 'NO_PATH')
|
||||
}
|
||||
if (!fs.existsSync(root)) {
|
||||
throw new AtlasSourceError(`ServUO path does not exist: ${root}`, 'NOT_FOUND')
|
||||
}
|
||||
|
||||
const files = []
|
||||
const push = (label, file) => {
|
||||
const text = readIfPresent(file)
|
||||
if (text === null) return false
|
||||
files.push({ label, text, sha256: sha256(text), bytes: Buffer.byteLength(text, 'utf8') })
|
||||
return true
|
||||
}
|
||||
|
||||
if (!push('Data/Regions.xml', path.join(root, REGIONS_FILE))) {
|
||||
throw new AtlasSourceError(`Missing required file: ${REGIONS_FILE}`, 'NO_REGIONS')
|
||||
}
|
||||
|
||||
for (const name of listXml(path.join(root, LOCATIONS_DIR))) {
|
||||
push(`Data/Locations/${name}`, path.join(root, LOCATIONS_DIR, name))
|
||||
}
|
||||
|
||||
const spawnFiles = listXml(path.join(root, SPAWNS_DIR))
|
||||
if (spawnFiles.length === 0) {
|
||||
throw new AtlasSourceError(`No spawn files found in ${SPAWNS_DIR}`, 'NO_SPAWNS')
|
||||
}
|
||||
for (const name of spawnFiles) push(`Spawns/${name}`, path.join(root, SPAWNS_DIR, name))
|
||||
|
||||
push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE))
|
||||
|
||||
return { files }
|
||||
}
|
||||
|
||||
/**
|
||||
* A fingerprint of the tree: `{ "<label>": "<sha256>" }`.
|
||||
*
|
||||
* The boot path compares this against what was last imported and skips the
|
||||
* parse entirely when it matches, which is the normal case on every restart
|
||||
* that did not follow a map update.
|
||||
*/
|
||||
function hashSources(root) {
|
||||
const { files } = readSources(root)
|
||||
const hashes = {}
|
||||
for (const file of files) hashes[file.label] = file.sha256
|
||||
return hashes
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumped whenever the parser produces DIFFERENT data from IDENTICAL source
|
||||
* files — a fixed misreading, a new field, a changed unit.
|
||||
*
|
||||
* Without it the hash gate is a trap: an install whose tree has not changed
|
||||
* would keep serving what an older parser derived, indefinitely, because the
|
||||
* only thing the boot path compares is the tree. The version is stored beside
|
||||
* the source hashes and a mismatch counts as drift, so a deploy that corrects
|
||||
* the parse actually reaches the data.
|
||||
*
|
||||
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
|
||||
* seconds in the source, decided by `DelayInSec`).
|
||||
*/
|
||||
const PARSER_VERSION = 2
|
||||
|
||||
/** True when two source fingerprints describe the same tree. */
|
||||
function sameSources(a, b) {
|
||||
if (!a || !b) return false
|
||||
const aKeys = Object.keys(a).sort()
|
||||
const bKeys = Object.keys(b).sort()
|
||||
if (aKeys.length !== bKeys.length) return false
|
||||
return aKeys.every((key, i) => key === bKeys[i] && a[key] === b[key])
|
||||
}
|
||||
|
||||
// ── Aggregation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Choose one display spelling for a creature.
|
||||
*
|
||||
* Spawn files are not consistent about case — the same creature is `Lizardman`
|
||||
* in one file and `lizardman` in another. Slugging collapses them correctly, but
|
||||
* the display name would otherwise depend on file read order. Most frequent
|
||||
* spelling wins; ties break toward more capitals, then alphabetically.
|
||||
*/
|
||||
function displayName(spellings) {
|
||||
const capitals = (value) => (value.match(/[A-Z]/g) || []).length
|
||||
return [...spellings.entries()].sort((a, b) => {
|
||||
if (b[1] !== a[1]) return b[1] - a[1]
|
||||
const caps = capitals(b[0]) - capitals(a[0])
|
||||
if (caps !== 0) return caps
|
||||
return a[0].localeCompare(b[0])
|
||||
})[0][0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll spawn points up into per-type creature rows.
|
||||
*
|
||||
* `total` is the sum of each type's own max across every point that spawns it —
|
||||
* how many of this creature the world holds at once. `facets` is a per-facet
|
||||
* point count, so "where does this live" answers without touching the points.
|
||||
*/
|
||||
function aggregateCreatures(points) {
|
||||
const creatures = new Map()
|
||||
for (const point of points) {
|
||||
for (const entry of point.types) {
|
||||
const slug = slugify(entry.type)
|
||||
if (slug === '') continue
|
||||
let creature = creatures.get(slug)
|
||||
if (!creature) {
|
||||
creature = { slug, name: '', total: 0, points: 0, facets: {}, spellings: new Map() }
|
||||
creatures.set(slug, creature)
|
||||
}
|
||||
creature.total += entry.max
|
||||
creature.points += 1
|
||||
creature.facets[point.facet] = (creature.facets[point.facet] || 0) + 1
|
||||
creature.spellings.set(entry.type, (creature.spellings.get(entry.type) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return [...creatures.values()]
|
||||
.map(({ spellings, ...creature }) => ({ ...creature, name: displayName(spellings) }))
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug))
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a ServUO tree into the full atlas.
|
||||
*
|
||||
* Pure with respect to the database — it reads files and returns data; nothing
|
||||
* here writes. `shardAtlas.model.js` decides what to do with the result.
|
||||
*/
|
||||
function buildAtlas(root, options = {}) {
|
||||
const { files } = readSources(root)
|
||||
const byLabel = new Map(files.map((file) => [file.label, file]))
|
||||
const source = {}
|
||||
for (const file of files) source[file.label] = { bytes: file.bytes, sha256: file.sha256 }
|
||||
|
||||
const regions = parseRegions(byLabel.get('Data/Regions.xml').text)
|
||||
|
||||
const rawLandmarks = []
|
||||
for (const file of files) {
|
||||
if (!file.label.startsWith('Data/Locations/')) continue
|
||||
const basename = path.basename(file.label, '.xml')
|
||||
rawLandmarks.push(...parseLocations(file.text, basename))
|
||||
}
|
||||
|
||||
const rawPoints = []
|
||||
for (const file of files) {
|
||||
if (!file.label.startsWith('Spawns/')) continue
|
||||
rawPoints.push(...parsePoints(file.text))
|
||||
}
|
||||
|
||||
// The facet set is whatever THIS tree declares — never a built-in list. A
|
||||
// shard may add facets, replace them outright, or rename them when its maps
|
||||
// are updated, and the atlas has to follow without a code change. Spawn
|
||||
// records and region definitions are the authority, because those are the
|
||||
// names everything else is keyed on.
|
||||
const facetIndex = buildFacetIndex([
|
||||
...rawPoints.map((point) => point.facet),
|
||||
...regions.map((region) => region.facet),
|
||||
])
|
||||
|
||||
// Landmark facets are then matched against that set, which is what absorbs the
|
||||
// `Ter Mur` / `Tokuno Islands` spelling drift between Locations and <Map>.
|
||||
const landmarks = rawLandmarks.map(({ facetLabel, ...landmark }) => {
|
||||
const fromFile = resolveFacetName(landmark.facet, facetIndex)
|
||||
const matchedFile = facetIndex.has(facetKey(fromFile))
|
||||
const resolved = matchedFile ? fromFile : resolveFacetName(facetLabel, facetIndex)
|
||||
return { ...landmark, facet: resolved || landmark.facet }
|
||||
})
|
||||
|
||||
const placement = buildPlacementIndex(regions, landmarks)
|
||||
const resolveOpts = options.landmarkRadius ? { landmarkRadius: options.landmarkRadius } : {}
|
||||
|
||||
const disabled = rawPoints.filter((point) => !point.running).length
|
||||
const points = rawPoints
|
||||
// A spawner switched off in-world produces nothing; advertising it would be
|
||||
// a straight lie to a player planning a hunt.
|
||||
.filter((point) => point.running)
|
||||
// A spawner with no types is a placeholder — nothing to show.
|
||||
.filter((point) => point.types.length > 0)
|
||||
.map((point) => {
|
||||
const place = resolveRegion(point.x, point.y, point.facet, placement, resolveOpts)
|
||||
return {
|
||||
name: point.name,
|
||||
facet: point.facet,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
width: point.width,
|
||||
height: point.height,
|
||||
range: point.range,
|
||||
maxCount: point.maxCount,
|
||||
minDelay: point.minDelay,
|
||||
maxDelay: point.maxDelay,
|
||||
todStart: point.todStart,
|
||||
todEnd: point.todEnd,
|
||||
todMode: point.todMode,
|
||||
region: place.region,
|
||||
landmark: place.landmark,
|
||||
label: place.label,
|
||||
types: point.types,
|
||||
}
|
||||
})
|
||||
|
||||
const championsFile = byLabel.get('Config/ChampionSpawns.xml')
|
||||
const champions = (championsFile ? parseChampions(championsFile.text) : []).map((champ) => {
|
||||
const facet = resolveFacetName(champ.facet, facetIndex) || champ.facet
|
||||
return {
|
||||
...champ,
|
||||
facet,
|
||||
slug: slugify(`${facet}-${champ.name}`),
|
||||
label: resolveRegion(champ.x, champ.y, facet, placement, resolveOpts).label,
|
||||
}
|
||||
})
|
||||
|
||||
const creatures = aggregateCreatures(points)
|
||||
const facets = [...new Set(points.map((point) => point.facet))].sort()
|
||||
const unresolved = points.filter((point) => !point.region && !point.landmark).length
|
||||
|
||||
return {
|
||||
meta: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
parserVersion: PARSER_VERSION,
|
||||
landmarkRadius: options.landmarkRadius ?? undefined,
|
||||
counts: {
|
||||
facets: facets.length,
|
||||
points: points.length,
|
||||
pointsDisabled: disabled,
|
||||
creatures: creatures.length,
|
||||
regions: regions.length,
|
||||
landmarks: landmarks.length,
|
||||
champions: champions.length,
|
||||
unresolvedPoints: unresolved,
|
||||
},
|
||||
source,
|
||||
},
|
||||
facets,
|
||||
creatures,
|
||||
regions,
|
||||
landmarks,
|
||||
champions,
|
||||
points,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AtlasSourceError,
|
||||
PARSER_VERSION,
|
||||
readSources,
|
||||
hashSources,
|
||||
sameSources,
|
||||
buildAtlas,
|
||||
aggregateCreatures,
|
||||
displayName,
|
||||
}
|
||||
435
modules/uo/server/utils/visibility.js
Normal file
435
modules/uo/server/utils/visibility.js
Normal file
@@ -0,0 +1,435 @@
|
||||
// ── Shard feature visibility ───────────────────────────────────────────────
|
||||
//
|
||||
// Admin-configurable, per-feature and per-field audience control over every
|
||||
// shard-derived surface on the site. Replaces the hardcoded split that used to
|
||||
// live in two places (the PUBLIC_KINDS allowlist in shardBroadcast.js, and the
|
||||
// ad-hoc `canSeeStaffLocation` style checks in the public controllers).
|
||||
//
|
||||
// Design rules (docs/link/v3.md §3):
|
||||
//
|
||||
// • Visibility lives HERE, on the website — never in the sidecar. The sidecar
|
||||
// is a dumb forwarder: it accepts frames, stores them, forwards them
|
||||
// verbatim, and serves store-backed reads. It defines no audiences.
|
||||
// • Every default reproduces the behavior that shipped before this module, so
|
||||
// installing it changes nothing until an admin edits the config.
|
||||
// • Two rules an admin CANNOT override:
|
||||
// 1. `acct` / `webId` are admin-only, always. They are not in-game
|
||||
// visible (unlike a character name) and are not configurable fields.
|
||||
// 2. A kind absent from KIND_FEATURE is never broadcast below `admin`.
|
||||
// Fail closed — this is what keeps the kind map a security boundary
|
||||
// rather than a convenience filter.
|
||||
//
|
||||
// The audience ladder is ordered; each rung implies the ones below it.
|
||||
|
||||
const db = require('../model/shardVisibility/shardVisibility.model')
|
||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||
const { auth } = require('../core')
|
||||
const log = require('../core').logger('visibility')
|
||||
|
||||
// ── The ladder ─────────────────────────────────────────────────────────────
|
||||
|
||||
const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
|
||||
const RANK = new Map(LADDER.map((level, i) => [level, i]))
|
||||
|
||||
const isLevel = (level) => RANK.has(level)
|
||||
|
||||
// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
|
||||
// point: an unrecognised value must always lose. A single shared fallback cannot
|
||||
// do that — whichever direction it picks, it fails open on one side. So:
|
||||
//
|
||||
// • an unknown VIEWER level floors to the bottom rung (grants nothing), and
|
||||
// • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
|
||||
//
|
||||
// With one `rank()` defaulting to admin, a viewer level that fell through (a
|
||||
// typo, a future rung this build doesn't know, a value from a caller that
|
||||
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
|
||||
const viewerRank = (level) => RANK.get(level) ?? 0
|
||||
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')
|
||||
|
||||
// True when a viewer at `viewer` satisfies a requirement of `required`.
|
||||
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
|
||||
|
||||
// Exported for tests/diagnostics; `meets` is what callers should use.
|
||||
const rank = viewerRank
|
||||
|
||||
// ── Features ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
|
||||
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
|
||||
// not listed here is visible whenever the feature itself is.
|
||||
//
|
||||
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).
|
||||
|
||||
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
|
||||
|
||||
// Rule 1 matches on the FIELD'S MEANING, not on one exact spelling. The wire
|
||||
// frames nest actors (`leader.acct`), but several read models flatten them
|
||||
// instead (`shapeHouse` emits `ownerAcct`, `shapeGuild`'s fallback emits
|
||||
// `leaderAcct`/`leaderWebId`), and an exact-key check silently missed every
|
||||
// flattened one — which is how `GET /public/shard/idoc` served `ownerAcct` to
|
||||
// anonymous callers while the same account name was correctly stripped from the
|
||||
// live `house.decay` frame.
|
||||
//
|
||||
// So a key is locked when it IS `acct`/`webId` or ENDS in one, case-insensitively
|
||||
// (`ownerAcct`, `leaderWebId`, `governorAcct`). Suffix matching is what makes this
|
||||
// fail closed for shapes nobody has written yet.
|
||||
const LOCKED_SUFFIXES = ['acct', 'webid']
|
||||
const isLockedField = (key) => {
|
||||
const k = String(key).toLowerCase()
|
||||
return LOCKED_SUFFIXES.some((suffix) => k === suffix || k.endsWith(suffix))
|
||||
}
|
||||
|
||||
const FEATURES = {
|
||||
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
||||
status: { audience: 'anonymous', fields: {} },
|
||||
activity: { audience: 'anonymous', fields: {} },
|
||||
champs: { audience: 'anonymous', fields: {} },
|
||||
guilds: { audience: 'anonymous', fields: {} },
|
||||
governors: { audience: 'anonymous', fields: {} },
|
||||
// The public Houses page showed IDOC location only; owner/price were staff.
|
||||
// `owner` is the actor object on the house.decay/house.update frames;
|
||||
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
|
||||
// REST read models. Both are listed so one rule covers the wire and the read
|
||||
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
|
||||
houses: {
|
||||
audience: 'anonymous',
|
||||
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
|
||||
},
|
||||
// /public/shard/online listed linked staff to everyone but gated location to
|
||||
// admin+moderator — which is exactly the `staff` rung.
|
||||
presence: { audience: 'anonymous', fields: { location: 'staff' } },
|
||||
|
||||
// ── New in v3. ──
|
||||
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
|
||||
atlas: { audience: 'anonymous', fields: {} },
|
||||
// `name` is the ranked character's name inside points.board's `top` entries, and
|
||||
// it is spelled the way the WIRE spells it, not the way v3.md §7.4 describes it
|
||||
// ("characterName"). projectValue matches on the literal JSON key, so a rule
|
||||
// named for the field's meaning rather than its key silently does nothing — the
|
||||
// same failure §3.6.1 records for the flattened `ownerAcct` spelling. Within a
|
||||
// leaderboards payload `name` can only be a character name: the board's own
|
||||
// display name arrives as `nameString`/`nameNumber`.
|
||||
leaderboards: { audience: 'anonymous', fields: { name: 'anonymous' } },
|
||||
// Shop name, owner character name and vendor location are already globally
|
||||
// visible in-game via the stock Vendor Search gump, so publishing them is not
|
||||
// a new disclosure — but they stay configurable so an admin can tighten them.
|
||||
//
|
||||
// `ownerName` and `location` were pre-wired here by Part A, before the frame
|
||||
// existed; both were re-checked against the real `vendor.listing` and both are
|
||||
// genuine keys on it (unlike leaderboards' `characterName`, which was inert).
|
||||
// `location` is a NESTED object on the wire and on the read model precisely so
|
||||
// that one rule hides map, coordinates, region and house together — five flat
|
||||
// keys would be five rules that drift apart.
|
||||
//
|
||||
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
|
||||
// lists both: an admin who hides the owner's name and is left with a serial
|
||||
// that every other board resolves back to that name has not hidden anything.
|
||||
market: {
|
||||
audience: 'anonymous',
|
||||
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
|
||||
},
|
||||
}
|
||||
|
||||
const FEATURE_NAMES = Object.keys(FEATURES)
|
||||
const isFeature = (name) => Object.hasOwn(FEATURES, name)
|
||||
|
||||
// ── Kind → feature ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every event kind that may ever leave the admin channel must appear here.
|
||||
// Anything else is admin-only by omission (rule 2). This map is seeded from
|
||||
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
|
||||
// same kinds it did — now attributed to a feature that an admin can re-gate.
|
||||
|
||||
const KIND_FEATURE = new Map(
|
||||
Object.entries({
|
||||
// status / lifecycle
|
||||
'server.hello': 'status',
|
||||
'server.shutdown': 'status',
|
||||
'server.crashed': 'status',
|
||||
'economy.supply': 'status',
|
||||
// activity feed
|
||||
'player.death': 'activity',
|
||||
'player.murdered': 'activity',
|
||||
'mob.killed': 'activity',
|
||||
'quest.complete': 'activity',
|
||||
'skill.gain': 'activity',
|
||||
'fame.change': 'activity',
|
||||
'karma.change': 'activity',
|
||||
'mob.login': 'activity',
|
||||
'mob.logout': 'activity',
|
||||
// boards
|
||||
'champ.update': 'champs',
|
||||
'champ.remove': 'champs',
|
||||
'guild.update': 'guilds',
|
||||
'guild.remove': 'guilds',
|
||||
'guild.join': 'guilds',
|
||||
'city.update': 'governors',
|
||||
'presence.online': 'presence',
|
||||
'region.enter': 'presence',
|
||||
// house.decay is the IDOC signal the public Houses page renders. The full
|
||||
// registry (house.update / house.remove — owner, price, co-owners) stays
|
||||
// off the map deliberately, so it remains admin-only exactly as before.
|
||||
'house.decay': 'houses',
|
||||
// v3
|
||||
'world.ruleset': 'ruleset',
|
||||
'points.board': 'leaderboards',
|
||||
// vendor.listing IS mapped, but the market feature ships with its stream
|
||||
// disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
|
||||
// inventories would be the site's biggest bandwidth consumer and no page
|
||||
// needs it live. An admin can turn it on.
|
||||
'vendor.listing': 'market',
|
||||
'vendor.listing.remove': 'market',
|
||||
}),
|
||||
)
|
||||
|
||||
// Features whose SSE fan-out is off unless an admin enables it. The REST reads
|
||||
// are unaffected; only the live stream is suppressed.
|
||||
const DEFAULT_STREAM_OFF = new Set(['market'])
|
||||
|
||||
// Back-compat: the set of kinds that reach an anonymous viewer under the default
|
||||
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
|
||||
// this. Derived from the map above rather than hand-maintained, so the two can
|
||||
// no longer drift.
|
||||
const PUBLIC_KINDS = new Set(
|
||||
[...KIND_FEATURE.entries()]
|
||||
.filter(([, feature]) => {
|
||||
if (DEFAULT_STREAM_OFF.has(feature)) return false
|
||||
return FEATURES[feature].audience === 'anonymous'
|
||||
})
|
||||
.map(([kind]) => kind),
|
||||
)
|
||||
|
||||
// ── Config (DB-backed, cached) ─────────────────────────────────────────────
|
||||
|
||||
const CONFIG_TTL_MS = 5000
|
||||
let cache = null
|
||||
let cachedAt = 0
|
||||
|
||||
// Merge a stored row over its compiled default. Unknown feature names in the DB
|
||||
// are ignored (a stale row from a removed feature must not resurrect it), and an
|
||||
// invalid rung falls back to the default rather than failing open.
|
||||
function applyRow(name, row) {
|
||||
const base = FEATURES[name]
|
||||
const audience = isLevel(row?.audience) ? row.audience : base.audience
|
||||
const fields = { ...base.fields }
|
||||
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
|
||||
if (isLockedField(field)) continue // rule 1: not configurable
|
||||
if (isLevel(level)) fields[field] = level
|
||||
}
|
||||
return {
|
||||
enabled: row ? !!row.enabled : true,
|
||||
audience,
|
||||
fields,
|
||||
stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
|
||||
}
|
||||
}
|
||||
|
||||
function compileDefaults() {
|
||||
const out = {}
|
||||
for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
|
||||
return out
|
||||
}
|
||||
|
||||
// Read the config, cached briefly. Falls back to compiled defaults if the DB is
|
||||
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
|
||||
// "what the site did before" rather than to "everything is public".
|
||||
async function getConfig() {
|
||||
const now = Date.now()
|
||||
if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
|
||||
try {
|
||||
const rows = await db.listAll()
|
||||
const byName = new Map(rows.map((r) => [r.feature, r]))
|
||||
const out = {}
|
||||
for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
|
||||
cache = out
|
||||
cachedAt = now
|
||||
} catch (err) {
|
||||
log.error('getConfig; falling back to defaults', err)
|
||||
cache = cache || compileDefaults()
|
||||
cachedAt = now
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
const invalidate = () => {
|
||||
cache = null
|
||||
cachedAt = 0
|
||||
}
|
||||
|
||||
// ── Viewer level ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// anonymous no session
|
||||
// logged_in authenticated, no linked game account
|
||||
// player authenticated with a linked game account
|
||||
// staff admin | moderator — the same set as the existing `modAccess` gate.
|
||||
// `editor` is a CONTENT role with no shard privilege today, so it
|
||||
// resolves by link status like any other member; mapping it to staff
|
||||
// here would silently widen what editors can see.
|
||||
// admin admin
|
||||
//
|
||||
// Staff always satisfy the `player` rung (rank order guarantees it) even without
|
||||
// a linked account, matching the existing rule that /player/* is role-agnostic
|
||||
// self-service.
|
||||
|
||||
// Same TTL as the config cache: this decides a privilege rung, so an unlinked
|
||||
// (or newly relinked) account must not keep the old answer for long. Anonymous,
|
||||
// staff and admin callers short-circuit before this runs, so the lookup only
|
||||
// costs a query on the logged-in-member path.
|
||||
const LINK_TTL_MS = CONFIG_TTL_MS
|
||||
const linkCache = new Map() // userId → { hasLink, at }
|
||||
|
||||
async function hasLinkedAccount(userId) {
|
||||
const hit = linkCache.get(userId)
|
||||
const now = Date.now()
|
||||
if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
|
||||
let hasLink = false
|
||||
try {
|
||||
const links = await shardLinks.listForUser(userId)
|
||||
hasLink = Array.isArray(links) && links.length > 0
|
||||
} catch (err) {
|
||||
log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
|
||||
}
|
||||
linkCache.set(userId, { hasLink, at: now })
|
||||
return hasLink
|
||||
}
|
||||
|
||||
// Drop a user's cached link status (called when a link is created or removed so
|
||||
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
|
||||
const forgetUser = (userId) => linkCache.delete(userId)
|
||||
|
||||
async function viewerLevel(req) {
|
||||
const viewer = req.user || auth.getUserFromRequest(req)
|
||||
if (!viewer) return 'anonymous'
|
||||
if (viewer.role === 'admin') return 'admin'
|
||||
if (viewer.role === 'moderator') return 'staff'
|
||||
return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
|
||||
}
|
||||
|
||||
// ── Enforcement ────────────────────────────────────────────────────────────
|
||||
|
||||
// Route gate. 404 when the feature is disabled (do not leak that it exists);
|
||||
// 403 when it exists but the viewer sits below its audience. Stashes the
|
||||
// resolved level on the request so controllers can project without re-resolving.
|
||||
function requireFeature(name) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const config = await getConfig()
|
||||
const feature = config[name]
|
||||
if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
|
||||
const level = await viewerLevel(req)
|
||||
req.viewerLevel = level
|
||||
if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
|
||||
return next()
|
||||
} catch (err) {
|
||||
log.error(`requireFeature(${name})`, err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip the fields a viewer at `level` may not see. Applies the locked rules
|
||||
// first (so acct/webId can never survive below admin), then the feature's
|
||||
// configured field rules. Recurses into arrays and nested objects because the
|
||||
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
|
||||
// Only ARRAYS and PLAIN objects are walked. A Date, Buffer or other class
|
||||
// instance is a value, not a bag of fields: rebuilding one key-by-key would
|
||||
// return `{}` (a Date has no enumerable own properties), which is how the DB-
|
||||
// backed read models — whose rows carry real Date columns — differ from the
|
||||
// pure-JSON wire frames the projection was first written against.
|
||||
const isPlainObject = (v) => {
|
||||
if (v === null || typeof v !== 'object') return false
|
||||
const proto = Object.getPrototypeOf(v)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
function projectValue(value, rules, level) {
|
||||
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
|
||||
if (!isPlainObject(value)) return value
|
||||
const out = {}
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
// Locked fields are checked by meaning first, so no configured rule (and no
|
||||
// flattened spelling) can widen them past `admin`.
|
||||
const required = isLockedField(key) ? 'admin' : rules[key]
|
||||
if (required && !meets(level, required)) continue
|
||||
out[key] = projectValue(v, rules, level)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Project a payload for one feature. `level` defaults to admin-equivalent only
|
||||
// when explicitly passed; callers should always pass a resolved level.
|
||||
function projectFeature(name, payload, level, config) {
|
||||
const feature = config?.[name]
|
||||
const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
|
||||
return projectValue(payload, rules, level)
|
||||
}
|
||||
|
||||
// Convenience for controllers: resolve config once, project, return.
|
||||
async function project(name, payload, req) {
|
||||
const config = await getConfig()
|
||||
const level = req.viewerLevel || (await viewerLevel(req))
|
||||
return projectFeature(name, payload, level, config)
|
||||
}
|
||||
|
||||
// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
|
||||
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
|
||||
function kindVisibleTo(kind, level, config) {
|
||||
if (level === 'admin') return true
|
||||
const name = KIND_FEATURE.get(kind)
|
||||
if (!name) return false // rule 2: unmapped ⇒ admin-only
|
||||
const feature = config?.[name]
|
||||
if (!feature || !feature.enabled || !feature.stream) return false
|
||||
return meets(level, feature.audience)
|
||||
}
|
||||
|
||||
// The event kinds a viewer at `level` may read under the CURRENT config. This is
|
||||
// the live counterpart of PUBLIC_KINDS, which is a module-load constant derived
|
||||
// from the compiled DEFAULTS and therefore cannot answer "may THIS viewer see
|
||||
// this kind, given what the admin has configured?".
|
||||
//
|
||||
// Deliberately ignores the `stream` flag: that governs SSE fan-out only, so a
|
||||
// feature whose live firehose is off (market) is still readable from the stored
|
||||
// history. Unmapped kinds are absent by construction (rule 2).
|
||||
function visibleKinds(level, config) {
|
||||
return [...KIND_FEATURE.entries()]
|
||||
.filter(([, name]) => {
|
||||
const feature = config?.[name]
|
||||
return !!feature && feature.enabled && meets(level, feature.audience)
|
||||
})
|
||||
.map(([kind]) => kind)
|
||||
}
|
||||
|
||||
// The features a viewer at `level` can actually see — drives SPA nav so it never
|
||||
// renders a link that would 403.
|
||||
function visibleFeatures(level, config) {
|
||||
return FEATURE_NAMES.filter((name) => {
|
||||
const feature = config[name]
|
||||
return feature.enabled && meets(level, feature.audience)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LADDER,
|
||||
FEATURES,
|
||||
FEATURE_NAMES,
|
||||
LOCKED_FIELDS,
|
||||
KIND_FEATURE,
|
||||
PUBLIC_KINDS,
|
||||
DEFAULT_STREAM_OFF,
|
||||
isLevel,
|
||||
isFeature,
|
||||
isLockedField,
|
||||
rank,
|
||||
meets,
|
||||
getConfig,
|
||||
invalidate,
|
||||
compileDefaults,
|
||||
viewerLevel,
|
||||
forgetUser,
|
||||
requireFeature,
|
||||
projectFeature,
|
||||
project,
|
||||
kindVisibleTo,
|
||||
visibleKinds,
|
||||
visibleFeatures,
|
||||
}
|
||||
Reference in New Issue
Block a user