Compare commits
16 Commits
53b3aca0c1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6246383962 | |||
| 76c224fff1 | |||
| f9bbc7a90d | |||
| e7b3412b36 | |||
| 3c087a43cd | |||
| 5c35b4fe97 | |||
| 5705aa9c23 | |||
| 675e879b48 | |||
| 7dbdaa14ad | |||
| d4d5989926 | |||
| 679762b643 | |||
| c6c51b190d | |||
| 50f84b5ea3 | |||
| d6346996d3 | |||
| bbaf08f67c | |||
| ea63ad019c |
@@ -13,6 +13,7 @@ module that follows.
|
||||
│ module-uo (>>> HERE <<<) │
|
||||
│ shard status · spawn atlas · marketplace │
|
||||
│ governors · cliloc · town crier · uo-link│
|
||||
│ client files: portraits, item art, names │
|
||||
└───────────────────────────────────────────┘
|
||||
│ server half: routers, models, schema fragment
|
||||
│ client half: prebuilt ESM chunk, SPA routes + nav
|
||||
@@ -182,7 +183,7 @@ reaches the container.
|
||||
|---|---|---|
|
||||
| `UOLINK_BASE_URL` | — | Default sidecar base URL for a site with nothing saved yet. The admin panel's stored value wins. |
|
||||
| `UOLINK_WS_URL` | — | Same, for the WebSocket URL. |
|
||||
| `UOLINK_PROTOCOL` | `3` | Wire protocol this build speaks. Again only a fallback — set it lower only if you deliberately run an older sidecar. |
|
||||
| `UOLINK_PROTOCOL` | `8` | Wire protocol this build speaks. Again only a fallback — set it lower only if you deliberately run an older sidecar. |
|
||||
| `TOWNCRIER_DURATION_SEC` | `3600` | How long a published news post's in-game town-crier message stays up (≤ `86400`). |
|
||||
|
||||
**The sidecar's auth token is deliberately not here.** It is entered in Admin → Shard, encrypted at
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It points at `edge` for the length of the Event System window** (org lead, 2026-09-04), and that is the one line here a reader should not tidy back. This module registers event actions from EVENTS_PLAN.md Phase 9, and `api.registerEventActions` exists only from MODULE_API 1.10.0 -- under the previous `main` pin `register()` throws and the module does not load at all, so the job would be red by construction for eight phases and would prove nothing while a real regression hid behind it. Phase 16's cutover re-pins it to `main`, which is the same commit that turns the Integration kit green again.",
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It pointed at `edge` for the length of the Event System window** (org lead, 2026-09-04), and this commit ends that: `api.registerEventActions` exists only from MODULE_API 1.10.0, so under the previous `main` pin `register()` threw and the module did not load at all — the job would have been red by construction for eight phases and would have proved nothing while a real regression hid behind it. The Phase 16b cutover put 1.10.0 on `main`, so the pin comes home, and this is the same move that turns the Integration kit green again. **routes.manifest.json needed NO regeneration**: the job's own steps were run against this exact ref and answered `routes.manifest.json is current — 73 routes, all documented`, so the \"commit both together\" instruction above had nothing to pair with this time.",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "d4516739b43de5cb83b8f0333f8f966280a5632f",
|
||||
"refName": "edge @ MODULE_API 1.10.0, the event module contract (website#189, #190)"
|
||||
"ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66",
|
||||
"refName": "main @ MODULE_API 1.10.0, the Event System cutover (website#199)"
|
||||
}
|
||||
|
||||
@@ -164,6 +164,38 @@ export const admin = {
|
||||
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
|
||||
},
|
||||
|
||||
// The Asset Bridge (docs/link/v8.md §6, §14 — protocol 8 phase 8). Client
|
||||
// artwork and the cliloc table both come off the operator's own UO client, over
|
||||
// the same bridge, and boot deliberately never asks the shard for either — so
|
||||
// these calls are the only thing that imports them, and the panel that makes
|
||||
// them is where an operator goes after patching their client.
|
||||
//
|
||||
// `update` and `reimport` are §6's two stages rather than one call with a flag,
|
||||
// because they cost wildly different things: an Update that finds the client
|
||||
// files unchanged transfers nothing, and a re-import fetches every sprite in
|
||||
// the catalogue. A checkbox spells that difference the same size as the button.
|
||||
assets: {
|
||||
status: () => req('/admin/shard/assets'),
|
||||
update: (approve = false) =>
|
||||
req('/admin/shard/assets/import', { method: 'POST', body: { approve } }),
|
||||
reimport: (approve = false) =>
|
||||
req('/admin/shard/assets/import', { method: 'POST', body: { force: true, approve } }),
|
||||
// Item and land pictures, which arrive one at a time because a page asked for
|
||||
// one. The pass runs on its own timer; this is for the operator who has just
|
||||
// patched a client and would rather not wait for the interval.
|
||||
warm: (force = false) => req('/admin/shard/assets/warm', { method: 'POST', body: { force } }),
|
||||
},
|
||||
|
||||
clilocs: {
|
||||
status: () => req('/admin/shard/clilocs'),
|
||||
import: (opts = {}) =>
|
||||
req('/admin/shard/clilocs/import', {
|
||||
method: 'POST',
|
||||
body: { force: !!opts.force, approve: !!opts.approve },
|
||||
}),
|
||||
setPath: (path) => req('/admin/shard/clilocs/path', { method: 'PUT', body: { path } }),
|
||||
},
|
||||
|
||||
// In-game staff operations: write plane + support queue (admin/moderator).
|
||||
// `actor` is stamped server-side from the session — never sent from here.
|
||||
shardOps: {
|
||||
|
||||
31
client/src/components/DetailRow.jsx
Normal file
31
client/src/components/DetailRow.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
// ── A label/value line in an admin detail panel ────────────────────────────
|
||||
//
|
||||
// Extracted from `SpawnAtlas.jsx` in phase 8, when the Client Files panel needed
|
||||
// the same thing for the third time. Two copies of twenty lines is a coincidence;
|
||||
// three is a component, and the reason to make it one here rather than later is
|
||||
// that these lines are read side by side — an operator moves between Spawn Atlas
|
||||
// and Client Files doing one job, and a panel whose rows are a few pixels off
|
||||
// from its neighbour's looks like a different part of the product.
|
||||
//
|
||||
// Deliberately not styled through a class: this module ships as a prebuilt chunk
|
||||
// into core's SPA and owns no stylesheet there (MODULE_API.md §3.2), so its own
|
||||
// layout is inline and only core's theme VARIABLES are borrowed.
|
||||
export default function DetailRow({ label, children }) {
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
padding: '7px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span className="dim">{label}</span>
|
||||
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import ShardAdmin from './routes/admin/ShardAdmin.jsx'
|
||||
import ShardOps from './routes/admin/ShardOps.jsx'
|
||||
import ShardVisibility from './routes/admin/ShardVisibility.jsx'
|
||||
import SpawnAtlas from './routes/admin/SpawnAtlas.jsx'
|
||||
import ClientFiles from './routes/admin/ClientFiles.jsx'
|
||||
import HousesAdmin from './routes/admin/HousesAdmin.jsx'
|
||||
import AdminCharacters from './routes/admin/AdminCharacters.jsx'
|
||||
import AdminCharacter from './routes/admin/AdminCharacter.jsx'
|
||||
@@ -93,12 +94,14 @@ registry.registerRoutes(ID, {
|
||||
{ path: 'market/vendors/:serial', element: <MarketVendor /> },
|
||||
],
|
||||
admin: [
|
||||
// Admin-only: the sidecar's configuration, who may see which surface, and
|
||||
// the atlas import. No `gate` on the other three because AdminLayout already
|
||||
// requires staff and these carry their own role rows below.
|
||||
// Admin-only: the sidecar's configuration, who may see which surface, the
|
||||
// atlas import and the client-file imports. No `gate` on these four because
|
||||
// AdminLayout already requires staff and they carry their own role rows
|
||||
// below.
|
||||
{ path: 'link', element: <ShardAdmin /> },
|
||||
{ path: 'visibility', element: <ShardVisibility /> },
|
||||
{ path: 'atlas', element: <SpawnAtlas /> },
|
||||
{ path: 'files', element: <ClientFiles /> },
|
||||
{ path: 'ops', element: <ShardOps />, gate: STAFF },
|
||||
{ path: 'houses', element: <HousesAdmin />, gate: STAFF },
|
||||
// Self-service, and deliberately ungated: a staff member's own characters
|
||||
@@ -150,6 +153,7 @@ registry.registerNav(ID, {
|
||||
{ label: 'Shard (uo-link)', to: '/admin/uo/link', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
{ label: 'Shard Visibility', to: '/admin/uo/visibility', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
{ label: 'Spawn Atlas', to: '/admin/uo/atlas', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
{ label: 'Client Files', to: '/admin/uo/files', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
// No group: a trailing untitled group of its own, below core's Account row
|
||||
// rather than beside it (§3.3). One position lower than it sits today, and
|
||||
// the alternative — letting a module into core's furniture groups — is worse.
|
||||
|
||||
663
client/src/routes/admin/ClientFiles.jsx
Normal file
663
client/src/routes/admin/ClientFiles.jsx
Normal file
@@ -0,0 +1,663 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading } from '../../core.js'
|
||||
import Row from '../../components/DetailRow.jsx'
|
||||
import { CreaturePortrait } from '../public/Atlas.jsx'
|
||||
|
||||
// ── Admin · Client files ────────────────────────────────────────────────────
|
||||
//
|
||||
// Everything on this site that comes out of the operator's own UO client, and
|
||||
// the buttons that bring it in (docs/link/v8.md §6, §14 — the Asset Bridge,
|
||||
// phase 8).
|
||||
//
|
||||
// Three things, one page, because they are one job. Creature portraits, item and
|
||||
// land pictures, and the cliloc table all live in files inside a UO client
|
||||
// install; the shard decodes them and hands them over the bridge; and every one
|
||||
// of them changes at the same moment, when the operator patches that client. An
|
||||
// operator who has just done that has exactly one place to come.
|
||||
//
|
||||
// **Boot never asks the shard for any of it** (org lead, phase 2 and again in
|
||||
// phase 7). A client patch is an event the operator knows about and the website
|
||||
// does not, and a site that re-read 343 MB of client files on every restart to
|
||||
// discover nothing had changed would be paying for the rare case forever. The
|
||||
// consequence is the reason this panel exists at all: these buttons are the ONLY
|
||||
// thing that imports. Nothing here happens on its own except the item-art warm
|
||||
// pass, which is lazy by design and only fetches what a page has already asked
|
||||
// for.
|
||||
//
|
||||
// **Nothing on this page throws for an operator-visible problem.** A shard that
|
||||
// is down, an asset plane switched off, a Linux host with no libgdiplus, a client
|
||||
// with no cliloc file — each is a reported state with a reason naming what to
|
||||
// fix. A red box that says "500" would be the one thing an operator cannot act
|
||||
// on, and every one of these states is ordinary.
|
||||
|
||||
// ── outcomes ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// An import reports its result rather than throwing, so these are answers, not
|
||||
// errors. They are written in the operator's terms — what happened to their
|
||||
// site — rather than in the protocol's.
|
||||
|
||||
const ASSET_OUTCOME = {
|
||||
imported: (r) =>
|
||||
`Imported — ${r.written?.toLocaleString() ?? 0} picture(s) written, ` +
|
||||
`${r.assets?.toLocaleString() ?? 0} in the catalogue, ` +
|
||||
`${r.bodies?.resolved?.toLocaleString() ?? 0} creature(s) matched to a body.`,
|
||||
unchanged: () =>
|
||||
'Unchanged — the shard’s client files match what was imported, so nothing was transferred.',
|
||||
needsReview: (r) =>
|
||||
`Waiting for you: ${r.vanishedCount?.toLocaleString() ?? 0} picture(s) this site holds are no` +
|
||||
' longer offered by the shard.',
|
||||
unavailable: (r) => `The shard could not serve this: ${r.reason || 'unknown reason'}`,
|
||||
skipped: () => 'No shard is linked, so there are no client files to read.',
|
||||
failed: (r) => `The import failed: ${r.reason || 'unknown reason'}`,
|
||||
}
|
||||
|
||||
// The warm pass speaks the same vocabulary as the body import deliberately
|
||||
// (`skipped` / `unavailable` / `unchanged` / `imported` / `failed`), but its
|
||||
// numbers mean something different: it is bounded, so "imported" routinely
|
||||
// leaves work behind and saying so is the difference between a button that looks
|
||||
// broken and one that is doing what it promised.
|
||||
const WARM_OUTCOME = {
|
||||
imported: (r) =>
|
||||
`Fetched ${r.written?.toLocaleString() ?? 0} picture(s)` +
|
||||
(r.remaining ? `; ${r.remaining.toLocaleString()} still waiting — press again.` : '.'),
|
||||
unchanged: () => 'Nothing waiting — every picture a page has asked for is already here.',
|
||||
unavailable: (r) => `The shard could not serve this: ${r.reason || 'unknown reason'}`,
|
||||
skipped: () => 'No shard is linked, so there is nothing to fetch.',
|
||||
failed: (r) => `That did not work: ${r.reason || 'unknown reason'}`,
|
||||
}
|
||||
|
||||
const CLILOC_OUTCOME = {
|
||||
imported: (r) => `Imported — ${r.count?.toLocaleString() ?? 0} names loaded.`,
|
||||
unchanged: () => 'Unchanged — the source matches the table that is already loaded.',
|
||||
needsReview: (r) =>
|
||||
`Waiting for you: ${r.missingSources?.length ?? 0} overlay file(s) that were loaded last time` +
|
||||
' are missing.',
|
||||
unavailable: (r) => `The source could not be read: ${r.reason || 'unknown reason'}`,
|
||||
skipped: (r) => r.reason || 'There is no cliloc source configured.',
|
||||
failed: (r) => `The import failed: ${r.reason || 'unknown reason'}`,
|
||||
}
|
||||
|
||||
const describe = (table, result) =>
|
||||
(table[result?.status] || (() => `Result: ${result?.status}`))(result || {})
|
||||
|
||||
const num = (n) => (n == null ? '—' : Number(n).toLocaleString())
|
||||
const when = (v) => (v ? new Date(v).toLocaleString() : 'Never')
|
||||
|
||||
// ── the vanished-key review (§6) ───────────────────────────────────────────
|
||||
//
|
||||
// A key the site holds that the shard no longer offers is refused rather than
|
||||
// applied, because an unmounted client volume and a deliberate client downgrade
|
||||
// are the same thing from the server and the wrong guess deletes artwork.
|
||||
//
|
||||
// It is held in this component's state and not in a table, deliberately (org
|
||||
// lead, 2026-09-14). The atlas persists its equivalent because BOOT re-parses the
|
||||
// tree and would otherwise re-prompt on every restart forever; an asset import
|
||||
// only ever happens because somebody pressed a button on this page, so the
|
||||
// review is in front of the person who caused it, by construction. Declining is
|
||||
// therefore not a decision to remember — it is simply not pressing the other
|
||||
// button.
|
||||
//
|
||||
// The pictures matter. `body/820/a23` names nothing a human recognises; the horse
|
||||
// it is a picture of does, and "is it right that these disappear?" is not a
|
||||
// question anyone can answer from a list of keys.
|
||||
function VanishedReview({ review, busy, onApprove, onDismiss }) {
|
||||
const rows = review.result.vanished || []
|
||||
const total = review.result.vanishedCount ?? rows.length
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
border: '1px solid #c58f4a',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
background: 'rgba(197,143,74,0.08)',
|
||||
}}
|
||||
>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||
An import is waiting for you
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
The shard no longer offers <strong>{num(total)}</strong> picture{total === 1 ? '' : 's'} this
|
||||
site is currently serving, so nothing was changed. That is what a client volume that failed
|
||||
to mount looks like as well as a deliberate client downgrade, and only you can tell them
|
||||
apart. Approving re-reads the shard as it is right now — if the mount was the problem and you
|
||||
have since fixed it, what lands is the corrected import, not a deletion.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
maxHeight: 260,
|
||||
overflowY: 'auto',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} style={{ width: 96, textAlign: 'center' }}>
|
||||
<CreaturePortrait art={row.file} name={row.key} size={48} />
|
||||
<div
|
||||
className="sans dim"
|
||||
style={{ fontSize: '0.7rem', wordBreak: 'break-all', marginTop: 2 }}
|
||||
title={row.key}
|
||||
>
|
||||
{row.key}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{total > rows.length && (
|
||||
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.8rem' }}>
|
||||
Showing the first {num(rows.length)} of {num(total)}.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
|
||||
Approve and import
|
||||
</button>
|
||||
<button type="button" className="btn btn-sq" disabled={busy} onClick={onDismiss}>
|
||||
Keep the pictures I have
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// What the last import did. Core's activity log records the same action, but it
|
||||
// is one unfiltered list of every admin action on the site — so the answer to
|
||||
// "did last week's import actually do anything" is here, beside the button that
|
||||
// caused it, rather than twenty pages into a log.
|
||||
function LastImport({ last, at }) {
|
||||
if (!last) {
|
||||
return <Row label="Last import">{at ? when(at) : 'No import recorded yet'}</Row>
|
||||
}
|
||||
|
||||
const tally = last.bodies || {}
|
||||
const unmatched = [
|
||||
tally.unknown ? `${num(tally.unknown)} unknown to the shard` : '',
|
||||
tally.notCreature ? `${num(tally.notCreature)} not a creature` : '',
|
||||
tally.failed ? `${num(tally.failed)} failed` : '',
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row label="Last import">
|
||||
{`${when(last.at || at)}${last.by ? ` · ${last.by}` : ''}${last.force ? ' · full re-import' : ''}`}
|
||||
</Row>
|
||||
<Row label="Pictures written">
|
||||
{`${num(last.written)} written, ${num(last.fetched)} fetched`}
|
||||
{last.removed ? `, ${num(last.removed)} removed` : ''}
|
||||
</Row>
|
||||
{unmatched.length > 0 && (
|
||||
// Only the creatures that did NOT match, because how many did is the row
|
||||
// above this block and a number that means "now" should not also appear
|
||||
// as a number that means "at that import". What is left is the part an
|
||||
// operator can act on: `unknown` is a spawn file naming a type this
|
||||
// shard's scripts do not define, which is real drift.
|
||||
<Row label="Could not be matched">{unmatched.join(', ')}</Row>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ClientFiles() {
|
||||
const [assets, setAssets] = useState(null)
|
||||
const [clilocs, setClilocs] = useState(null)
|
||||
const [clilocPath, setClilocPath] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
// One message per section: three panels that can each speak means an operator
|
||||
// must never have to work out which button a sentence belongs to.
|
||||
const [msg, setMsg] = useState({})
|
||||
// The in-session reviews, keyed by which plane raised them.
|
||||
const [review, setReview] = useState({})
|
||||
|
||||
// `quiet` re-reads without flipping `loading`, and that distinction is the
|
||||
// whole difference between a usable panel and a maddening one: `loading`
|
||||
// replaces the page with a spinner, so refreshing that way after an action
|
||||
// unmounts everything, throws the operator back to the top of a long page, and
|
||||
// takes the sentence saying what just happened with it — at the bottom of the
|
||||
// cliloc section, that means pressing Update appears to do nothing at all.
|
||||
const load = useCallback(async ({ quiet = false } = {}) => {
|
||||
if (!quiet) setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
// Both statuses call the shard, and neither one failing should cost the
|
||||
// other its panel: an operator whose cliloc file is missing still needs to
|
||||
// see what the asset import says.
|
||||
const [a, c] = await Promise.all([
|
||||
api.admin.assets.status().catch((err) => ({ error: err.message })),
|
||||
api.admin.clilocs.status().catch((err) => ({ error: err.message })),
|
||||
])
|
||||
setAssets(a)
|
||||
setClilocs(c)
|
||||
setClilocPath(c?.path || '')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load the client-file status.')
|
||||
} finally {
|
||||
if (!quiet) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// One automatic re-read when the shard answered BUSY (§3.2's single slot),
|
||||
// and exactly one per mount.
|
||||
//
|
||||
// BUSY is not a fault and it is not sticky on the shard — it means something
|
||||
// else held the asset slot for longer than the client's own 425 backoff, and
|
||||
// the two things that hold it are both ordinary: an import the operator
|
||||
// started, and the item-art warm pass refilling itself after a client patch.
|
||||
// The panel does not poll, so without this the operator is left reading a
|
||||
// refusal about a shard that was free again seconds later, until they think to
|
||||
// reload. A second read clears the common case; if it is still busy, the
|
||||
// sentence says to come back, because a page that retried forever would be
|
||||
// holding the slot it is waiting for.
|
||||
const retried = useRef(false)
|
||||
useEffect(() => {
|
||||
if (retried.current || busy) return
|
||||
const stillBusy = assets?.code === 'BUSY' || clilocs?.code === 'BUSY'
|
||||
if (!stillBusy) return
|
||||
retried.current = true
|
||||
const t = setTimeout(() => load({ quiet: true }), 4000)
|
||||
return () => clearTimeout(t)
|
||||
}, [assets, clilocs, busy, load])
|
||||
|
||||
// Every action shares this: run it, say what it said, then re-read status so
|
||||
// the panel reflects the world rather than what we assumed happened.
|
||||
async function run(section, table, fn) {
|
||||
setBusy(true)
|
||||
setMsg((m) => ({ ...m, [section]: '' }))
|
||||
setError('')
|
||||
try {
|
||||
const result = await fn()
|
||||
setMsg((m) => ({ ...m, [section]: describe(table, result) }))
|
||||
// Set or cleared from the SAME answer, in one place. Clearing separately
|
||||
// left the review standing after an approve that had already applied — a
|
||||
// banner asking for a decision that was made ten seconds ago, on pictures
|
||||
// that are already gone.
|
||||
setReview((r) => ({
|
||||
...r,
|
||||
[section]: result?.status === 'needsReview' ? { result, run: fn } : null,
|
||||
}))
|
||||
await load({ quiet: true })
|
||||
return result
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
return null
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveClilocPath() {
|
||||
setBusy(true)
|
||||
setMsg((m) => ({ ...m, clilocs: '' }))
|
||||
setError('')
|
||||
try {
|
||||
const fresh = await api.admin.clilocs.setPath(clilocPath.trim())
|
||||
setClilocs(fresh)
|
||||
setClilocPath(fresh.path || '')
|
||||
setMsg((m) => ({
|
||||
...m,
|
||||
clilocs:
|
||||
fresh.source === 'bridge'
|
||||
? 'Saved. The base table still comes from the shard — this selects where custom/ overlay' +
|
||||
' files are read from.'
|
||||
: fresh.path === ''
|
||||
? 'Path cleared. The loaded table keeps serving; nothing new will be read.'
|
||||
: fresh.fileReadable
|
||||
? 'Saved. The file is readable — import when you are ready.'
|
||||
: 'Saved, but the file could not be read from here. Check the mount and permissions.',
|
||||
}))
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save the path.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !assets && !clilocs) return <ErrorState message={error} />
|
||||
|
||||
const loaded = assets?.loaded || null
|
||||
const shard = assets?.shard || null
|
||||
const families = shard?.families || []
|
||||
// Reported by the server rather than inferred from `shard` being null — which
|
||||
// is also what a linked shard that is simply DOWN looks like, and those two
|
||||
// want opposite things from this page: one needs its buttons disabled, the
|
||||
// other needs them available so the operator can retry.
|
||||
const linked = Boolean(assets?.linked)
|
||||
const imagingBroken = shard?.imaging && shard.imaging.ok === false
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<header>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
Client files
|
||||
</h2>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
Creature portraits, item pictures and the names your shard’s items and titles are stored
|
||||
under all come out of the UO client on the shard host. The shard reads and decodes them
|
||||
itself and hands them over uo-link — nothing is converted on a desktop and nothing is
|
||||
uploaded. They change when you patch that client, which is something only you know about,
|
||||
so <strong>these buttons are the only thing that imports them</strong>: nothing here
|
||||
happens on a restart.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{(assets?.error || clilocs?.error) && (
|
||||
<section
|
||||
style={{ border: '1px solid #d98b84', borderRadius: 10, padding: 16 }}
|
||||
className="sans"
|
||||
>
|
||||
<strong style={{ color: 'var(--head)' }}>Part of this page could not be read.</strong>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
{assets?.error || clilocs?.error} — the counts below may be missing. Both status calls
|
||||
are written never to fail for an ordinary problem (a shard that is down is an ANSWER
|
||||
here), so this one is worth the server log.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{assets?.reason && !shard && (
|
||||
<section
|
||||
style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}
|
||||
className="sans"
|
||||
>
|
||||
{/* BUSY is the one code here that is not a fault, and saying "the shard
|
||||
is not answering" about it sends an operator to check a shard that is
|
||||
working. The slot is held by something ordinary — an import running,
|
||||
or the warm pass — and it frees itself. */}
|
||||
<strong style={{ color: 'var(--head)' }}>
|
||||
{assets.code === 'BUSY'
|
||||
? 'The shard is busy with another client-file request.'
|
||||
: 'The shard is not answering for client files.'}
|
||||
</strong>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
{assets.code === 'BUSY'
|
||||
? 'The shard serves one of these at a time, so an import running now — or the' +
|
||||
' item-picture pass refilling itself after a client patch — holds it until it is' +
|
||||
' done. This page re-reads once on its own; if the counts below are still missing' +
|
||||
' after that, reload in a moment.'
|
||||
: assets.reason}
|
||||
{assets.code === 'DISABLED' &&
|
||||
' — set Bridge.AssetsEnabled on the shard to allow it to read its own client files.'}
|
||||
</p>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
What is already imported keeps serving; only new imports are affected.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{imagingBroken && (
|
||||
<section
|
||||
style={{ border: '1px solid #c58f4a', borderRadius: 10, padding: 16, background: 'rgba(197,143,74,0.08)' }}
|
||||
className="sans"
|
||||
>
|
||||
<strong style={{ color: 'var(--head)' }}>The shard host cannot render images.</strong>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
{shard.imaging.reason ||
|
||||
'A Linux shard host needs libgdiplus before it can decode a single sprite.'}{' '}
|
||||
Names (the cliloc table) are unaffected and can still be imported — they have no pixels
|
||||
in them.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{review.assets && (
|
||||
<VanishedReview
|
||||
review={review.assets}
|
||||
busy={busy}
|
||||
onApprove={() => run('assets', ASSET_OUTCOME, () => review.assets.run(true))}
|
||||
onDismiss={() => setReview((r) => ({ ...r, assets: null }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── creature portraits ── */}
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
Creature portraits
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
One picture per creature body, imported as a set and shown on the bestiary. Creatures the
|
||||
client has no artwork for are normal and stay as text — a stock client has none for most
|
||||
ghost and gargoyle bodies. Portraits you drew yourself and named in{' '}
|
||||
<code>spawnAtlas.art.json</code> always win over an imported one.
|
||||
</p>
|
||||
<Row label="Pictures held">{`${num(loaded?.stored)} of ${num(loaded?.assets)} catalogued`}</Row>
|
||||
<Row label="Creatures matched">{`${num(loaded?.resolved)} of ${num(loaded?.creatures)}`}</Row>
|
||||
<LastImport last={loaded?.last} at={loaded?.importedAt} />
|
||||
<Row label="Client files changed since">
|
||||
{assets?.drift == null
|
||||
? '—'
|
||||
: assets.drift
|
||||
? 'Yes — an update would pick it up'
|
||||
: 'No'}
|
||||
</Row>
|
||||
{shard?.hashing && (
|
||||
<Row label="Shard is hashing">
|
||||
Yes — it is still fingerprinting its client files in the background. Drift may read as
|
||||
“yes” until it finishes.
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Extractor version">
|
||||
{/* "—" for a version nobody has imported yet reads as a missing value;
|
||||
it is an answer, and the shard's own version is the useful half of
|
||||
the sentence on exactly that install. */}
|
||||
{(loaded?.extractorVersion == null ? 'None' : num(loaded.extractorVersion)) +
|
||||
' imported' +
|
||||
(shard?.extractorVersion == null ? '' : ` · ${num(shard.extractorVersion)} on the shard`)}
|
||||
</Row>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center', marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy || !linked}
|
||||
onClick={() => run('assets', ASSET_OUTCOME, (approve = false) => api.admin.assets.update(approve))}
|
||||
>
|
||||
{busy ? 'Working…' : 'Update'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy || !linked}
|
||||
onClick={() => run('assets', ASSET_OUTCOME, (approve = false) => api.admin.assets.reimport(approve))}
|
||||
>
|
||||
Re-import everything
|
||||
</button>
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.8rem', lineHeight: 1.6 }}>
|
||||
<strong>Update</strong> checks the shard’s client files first and transfers only the
|
||||
pictures that actually changed — when nothing has, it costs one small round trip.{' '}
|
||||
<strong>Re-import everything</strong> fetches the whole catalogue again; use it after
|
||||
restoring a backup or losing the uploads volume, where the database still remembers
|
||||
pictures that are no longer on disk.
|
||||
</p>
|
||||
{msg.assets && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.85rem', color: '#7fd0a4' }}>{msg.assets}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── item and land pictures ── */}
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
Item and land pictures
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
The pictures beside marketplace listings and on character sheets. These are never imported
|
||||
as a set — there are tens of thousands of item graphics, times every dye colour — so they
|
||||
arrive one at a time, shortly after a page asks for one, and refresh themselves after a
|
||||
client patch. This is here for the two moments waiting is the wrong answer: you have just
|
||||
linked a shard, or you have just patched a client and would rather not wait.
|
||||
</p>
|
||||
<Row label="Item pictures held">{num(loaded?.items)}</Row>
|
||||
<Row label="Land pictures held">{num(loaded?.land)}</Row>
|
||||
<Row label="Shard serves">
|
||||
{families.length > 0 ? families.join(', ') : '—'}
|
||||
{shard && !families.includes('static')
|
||||
? ' — this shard’s plugin predates item pictures; update the overlay to get them'
|
||||
: ''}
|
||||
</Row>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center', marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy || !linked}
|
||||
onClick={() => run('warm', WARM_OUTCOME, () => api.admin.assets.warm(false))}
|
||||
>
|
||||
Fetch waiting pictures
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy || !linked}
|
||||
onClick={() => run('warm', WARM_OUTCOME, () => api.admin.assets.warm(true))}
|
||||
>
|
||||
Refresh the ones I have
|
||||
</button>
|
||||
</div>
|
||||
{msg.warm && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.85rem', color: '#7fd0a4' }}>{msg.warm}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── the cliloc table ── */}
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
Item and title names (clilocs)
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
UO stores most item, title and reward names as numbers, and the words live in the client’s
|
||||
cliloc file. Without this table the marketplace and character sheets show numbers. With a
|
||||
shard linked the shard decompresses and serves it; otherwise the site reads a file you
|
||||
point it at below.
|
||||
</p>
|
||||
<Row label="Names loaded">{num(clilocs?.count)}</Row>
|
||||
<Row label="Imported">{when(clilocs?.importedAt)}</Row>
|
||||
<Row label="Source">
|
||||
{clilocs?.source === 'bridge'
|
||||
? 'The shard, over uo-link'
|
||||
: clilocs?.configured
|
||||
? clilocs.path
|
||||
: 'None configured'}
|
||||
</Row>
|
||||
<Row label="Overlays">
|
||||
{clilocs?.sources?.length ? clilocs.sources.join(', ') : 'None'}
|
||||
</Row>
|
||||
<Row label="Changed since import">
|
||||
{clilocs?.drift == null ? '—' : clilocs.drift ? 'Yes — an import would pick it up' : 'No'}
|
||||
</Row>
|
||||
{clilocs?.problem && (
|
||||
<Row label="Problem">
|
||||
<span style={{ color: '#d98b84' }}>{clilocs.problem}</span>
|
||||
</Row>
|
||||
)}
|
||||
{clilocs?.missingSources?.length > 0 && (
|
||||
<Row label="Missing since last import">
|
||||
<span style={{ color: '#d98b84' }}>{clilocs.missingSources.join(', ')}</span>
|
||||
</Row>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center', marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run('clilocs', CLILOC_OUTCOME, (approve = false) =>
|
||||
api.admin.clilocs.import({ approve }),
|
||||
)
|
||||
}
|
||||
>
|
||||
{busy ? 'Working…' : 'Update'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run('clilocs', CLILOC_OUTCOME, (approve = false) =>
|
||||
api.admin.clilocs.import({ force: true, approve }),
|
||||
)
|
||||
}
|
||||
>
|
||||
Re-import everything
|
||||
</button>
|
||||
</div>
|
||||
{review.clilocs && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
border: '1px solid #c58f4a',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
background: 'rgba(197,143,74,0.08)',
|
||||
}}
|
||||
>
|
||||
<strong className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||
An overlay file that was loaded last time is missing
|
||||
</strong>
|
||||
<p className="sans" style={{ margin: '6px 0 10px', fontSize: '0.85rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
{(review.clilocs.result.missingSources || []).join(', ') || 'One or more overlays'} —
|
||||
the table was left exactly as it is. If you deleted those files on purpose, import
|
||||
anyway; if this is a mount that did not come back, fix it first and the next import
|
||||
picks the names up again.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run('clilocs', CLILOC_OUTCOME, () => review.clilocs.run(true))
|
||||
}
|
||||
>
|
||||
Import without them
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => setReview((r) => ({ ...r, clilocs: null }))}
|
||||
>
|
||||
Keep the names I have
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem', lineHeight: 1.6 }}>
|
||||
{clilocs?.source === 'bridge'
|
||||
? 'Where custom/ overlay files are read from. The base table comes from the shard' +
|
||||
' either way; leave this blank if you have no overlays.'
|
||||
: 'The directory holding the cliloc file. Blank turns cliloc resolution off — the' +
|
||||
' table that is already loaded keeps serving.'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
value={clilocPath}
|
||||
onChange={(e) => setClilocPath(e.target.value)}
|
||||
placeholder="/srv/uo-client"
|
||||
style={{ flex: '1 1 320px', minWidth: 0 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-sq" disabled={busy} onClick={saveClilocPath}>
|
||||
Save path
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{msg.clilocs && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.85rem', color: '#7fd0a4' }}>{msg.clilocs}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading } from '../../core.js'
|
||||
import Row from '../../components/DetailRow.jsx'
|
||||
|
||||
// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -36,26 +37,6 @@ const OUTCOME = {
|
||||
|
||||
const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result)
|
||||
|
||||
function Row({ label, children }) {
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
padding: '7px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span className="dim">{label}</span>
|
||||
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingReview({ pending, busy, onApprove, onReject }) {
|
||||
const declined = pending.status === 'rejected'
|
||||
return (
|
||||
@@ -159,11 +140,14 @@ export default function SpawnAtlas() {
|
||||
setStatus(fresh)
|
||||
setPath(fresh.path || '')
|
||||
setMsg(
|
||||
fresh.path === ''
|
||||
? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
|
||||
: fresh.treeReadable
|
||||
? 'Saved. The tree is readable — import when you are ready.'
|
||||
: 'Saved, but the tree could not be read from here. Check the mount and permissions.',
|
||||
fresh.source === 'bridge'
|
||||
? 'Saved, but not in use: this site reads the atlas from the linked shard. The path takes'
|
||||
+ ' over only if uo-link is disabled.'
|
||||
: fresh.path === ''
|
||||
? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
|
||||
: fresh.treeReadable
|
||||
? 'Saved. The tree is readable — import when you are ready.'
|
||||
: 'Saved, but the tree could not be read from here. Check the mount and permissions.',
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save the path.')
|
||||
@@ -185,9 +169,11 @@ export default function SpawnAtlas() {
|
||||
</h2>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files.
|
||||
It refreshes itself on every server start; everything here is for the times you don’t want
|
||||
to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not
|
||||
shard state, and stays complete while the shard is down.
|
||||
Where those files come from depends on whether a shard is linked: with uo-link configured
|
||||
the shard serves them over the bridge and importing is something you do here, when a map
|
||||
changes. Without one, the site reads a local tree and re-imports itself on every server
|
||||
start. Either way the atlas is shard <em>content</em> rather than shard state, so what is
|
||||
loaded keeps serving in full while the shard is down.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -218,10 +204,23 @@ export default function SpawnAtlas() {
|
||||
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
|
||||
</>
|
||||
)}
|
||||
<Row label="Tree readable">
|
||||
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
|
||||
<Row label="Source">
|
||||
{status?.source === 'bridge'
|
||||
? 'The shard, over uo-link'
|
||||
: status?.configured
|
||||
? status.path
|
||||
: 'None — no shard linked and no path set'}
|
||||
</Row>
|
||||
<Row label="Tree changed since import">
|
||||
<Row label="Source readable">
|
||||
{!status?.configured
|
||||
? 'No source'
|
||||
: status.treeReadable
|
||||
? 'Yes'
|
||||
: status.source === 'bridge'
|
||||
? 'No — the shard did not answer, or Bridge.TreeEnabled is off'
|
||||
: 'No'}
|
||||
</Row>
|
||||
<Row label="Changed since import">
|
||||
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
|
||||
</Row>
|
||||
</section>
|
||||
@@ -231,9 +230,13 @@ export default function SpawnAtlas() {
|
||||
ServUO tree
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
Where the website reads the shard’s spawn files from — the same host, a bind mount or a
|
||||
A local ServUO tree the website can read directly — the same host, a bind mount or a
|
||||
shared volume. This setting wins over the <code>SERVUO_PATH</code> deploy default, so the
|
||||
mount can move without a redeploy. Leave it blank to turn the atlas off.
|
||||
mount can move without a redeploy.
|
||||
{status?.source === 'bridge'
|
||||
? ' It is not in use right now: this site has a shard linked, and the shard serves its' +
|
||||
' own files over the bridge. Unlink or disable uo-link to fall back to a path.'
|
||||
: ' Leave it blank to turn the atlas off.'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
@@ -254,9 +257,11 @@ export default function SpawnAtlas() {
|
||||
Re-import
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
Applies a map change without restarting. An unchanged tree costs nothing — the source files
|
||||
are hashed first and skipped when they match. A refresh that would remove a facet still
|
||||
comes back here for approval rather than being applied.
|
||||
Applies a map change without restarting — and on a linked shard it is the only thing that
|
||||
does, because boot deliberately never calls the shard for this. An unchanged source costs
|
||||
almost nothing: the file list and its hashes are read first (about 32 KB over the bridge)
|
||||
and no file is transferred when they match. A refresh that would remove a facet still comes
|
||||
back here for approval rather than being applied.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button
|
||||
|
||||
@@ -102,6 +102,42 @@ test('admin atlas actions use the right methods and bodies', async () => {
|
||||
assert.deepEqual(calls[1].opts.body, { path: '/srv/servuo' })
|
||||
})
|
||||
|
||||
// ── the Asset Bridge's two stages (docs/link/v8.md §6) ──────────────────────
|
||||
// Update and Re-import are one route and differ only by `force`, and the
|
||||
// difference is not cosmetic: one transfers nothing when the client files are
|
||||
// unchanged, the other fetches the whole catalogue. A binding that sent `force`
|
||||
// on both would make the cheap button the expensive one, and nothing visible
|
||||
// would change — the pictures would be correct either way.
|
||||
test('assets.update asks for the diff and assets.reimport asks for everything', async () => {
|
||||
await admin.assets.update()
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/assets/import')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
assert.deepEqual(calls[0].opts.body, { approve: false })
|
||||
|
||||
await admin.assets.reimport()
|
||||
assert.deepEqual(calls[1].opts.body, { force: true, approve: false })
|
||||
})
|
||||
|
||||
// Approving a vanished key re-runs the SAME operation the operator pressed, so
|
||||
// `approve` has to ride on both. Sending the update's approval as a re-import
|
||||
// would quietly turn "yes, accept those deletions" into a full re-download.
|
||||
test('approve rides on whichever import the operator ran', async () => {
|
||||
await admin.assets.update(true)
|
||||
await admin.assets.reimport(true)
|
||||
assert.deepEqual(calls[0].opts.body, { approve: true })
|
||||
assert.deepEqual(calls[1].opts.body, { force: true, approve: true })
|
||||
})
|
||||
|
||||
test('cliloc admin actions use the right methods and bodies', async () => {
|
||||
await admin.clilocs.import({ force: true })
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/clilocs/import')
|
||||
assert.deepEqual(calls[0].opts.body, { force: true, approve: false })
|
||||
|
||||
await admin.clilocs.setPath('/srv/uo-client')
|
||||
assert.equal(calls[1].opts.method, 'PUT')
|
||||
assert.deepEqual(calls[1].opts.body, { path: '/srv/uo-client' })
|
||||
})
|
||||
|
||||
// ── path encoding ───────────────────────────────────────────────────────────
|
||||
// A city name with an apostrophe and a space is the real case: "Serpent's Hold"
|
||||
// is a governor city, and an unencoded one would break the route match rather
|
||||
|
||||
@@ -120,7 +120,7 @@ const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run np
|
||||
it('registers routes in all three areas, namespaced under the module id', () => {
|
||||
const { routes } = registered
|
||||
assert.equal(routes.public.length, 13)
|
||||
assert.equal(routes.admin.length, 7)
|
||||
assert.equal(routes.admin.length, 8)
|
||||
assert.equal(routes.player.length, 2)
|
||||
for (const area of ['public', 'admin', 'player']) {
|
||||
for (const r of routes[area]) {
|
||||
|
||||
@@ -305,16 +305,54 @@ function webUserId(webId) {
|
||||
}
|
||||
|
||||
/** Resolve a `facet/name` landmark to the point the shard counts around. */
|
||||
function landmarkValue(row) {
|
||||
const group = row.group || ''
|
||||
return group === '' ? `${row.facet}/${row.name}` : `${row.facet}/${group}/${row.name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A place string resolved to a point on a facet.
|
||||
*
|
||||
* **Two forms, and the older one is not deprecated — it is stored.** The current
|
||||
* form is `facet/group/name`, which names exactly one landmark. The older
|
||||
* `facet/name` is what every event published before this fix carries, and those
|
||||
* rows are the authored record: a published version is immutable, so a parse that
|
||||
* stopped understanding them would break runs rather than correct them. So the
|
||||
* three-part form is tried first and the two-part read is the fallback.
|
||||
*
|
||||
* The fallback keeps the old first-match behaviour deliberately. It is wrong in
|
||||
* the same way it always was — that is what the new form exists to fix — but it
|
||||
* is what those runs did last time, and silently relocating a live event's
|
||||
* spawn point is worse than repeating a known imprecision.
|
||||
*
|
||||
* A three-part value whose group no longer exists REFUSES rather than falling
|
||||
* back to the name alone, and that is the point rather than a gap: it asked for
|
||||
* one particular landmark, so the honest answer when that landmark is gone is to
|
||||
* say so — the operator renamed something and an event needs re-pointing. Only a
|
||||
* value that never named a group gets the imprecise read.
|
||||
*/
|
||||
async function landmarkPoint(value) {
|
||||
const raw = String(value == null ? '' : value)
|
||||
const cut = raw.indexOf('/')
|
||||
if (cut < 1) {
|
||||
const parts = raw.split('/')
|
||||
if (parts.length < 2 || parts[0] === '' || parts[parts.length - 1] === '') {
|
||||
return { ok: false, error: `"${raw}" is not a facet/name place` }
|
||||
}
|
||||
|
||||
const facet = raw.slice(0, cut)
|
||||
const name = raw.slice(cut + 1)
|
||||
const facet = parts[0]
|
||||
const rows = await shardAtlas.listLandmarks({ facet })
|
||||
|
||||
// `facet/group/name`. The name is the LAST segment and the group is everything
|
||||
// between, so a group carrying a slash still resolves.
|
||||
if (parts.length >= 3) {
|
||||
const group = parts.slice(1, -1).join('/')
|
||||
const name = parts[parts.length - 1]
|
||||
const hit = rows.find((r) => r.name === name && (r.group || '') === group)
|
||||
if (hit) return { ok: true, map: hit.facet, x: hit.x, y: hit.y }
|
||||
// No fall-through error: a name containing a slash reads as three parts too,
|
||||
// and the two-part read below is the one that resolves it.
|
||||
}
|
||||
|
||||
const name = parts.slice(1).join('/')
|
||||
const hit = rows.find((r) => r.facet === facet && r.name === name)
|
||||
|
||||
if (!hit) {
|
||||
@@ -2125,7 +2163,14 @@ const OPTION_SOURCES = [
|
||||
async resolve() {
|
||||
const rows = await shardAtlas.listLandmarks()
|
||||
return bounded(rows, 'uo.options.landmarks').map((r) => ({
|
||||
value: `${r.facet}/${r.name}`,
|
||||
// **`facet/group/name`, because `facet/name` does not name one place.**
|
||||
// A stock 57.4 tree has 558 landmarks under 320 distinct `facet/name`
|
||||
// pairs: `Trammel/Entrance` is 23 different dungeons, and `landmarkPoint`
|
||||
// resolves with `.find()`, so 22 of them were unreachable — an author who
|
||||
// picked "Entrance — Destard" got Blighted Grove, with a successful run
|
||||
// and no warning. The group was already the disambiguator; it was shown
|
||||
// to the eye and left out of the value. All 558 are distinct with it.
|
||||
value: landmarkValue(r),
|
||||
label: r.name,
|
||||
// The atlas's own grouping where it has one, the facet otherwise — so a
|
||||
// shard whose landmark file carries no groups still gets a usable
|
||||
|
||||
@@ -675,7 +675,7 @@ CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
||||
-- because the manifest reports them before the pixels are fetched and a screen
|
||||
-- that lists what WOULD be imported needs them then.
|
||||
CREATE TABLE IF NOT EXISTS shard_assets (
|
||||
asset_key VARCHAR(191) NOT NULL PRIMARY KEY, -- §5's key: `body/34/a0`
|
||||
asset_key VARCHAR(191) NOT NULL PRIMARY KEY, -- §5's key: `body/34/a0`, `body/820/a23`
|
||||
family VARCHAR(24) NOT NULL DEFAULT 'body',
|
||||
sha256 CHAR(64) NOT NULL,
|
||||
bytes INT NOT NULL DEFAULT 0,
|
||||
@@ -708,6 +708,21 @@ CREATE TABLE IF NOT EXISTS shard_assets (
|
||||
-- same test and costs one re-fetch.
|
||||
ALTER TABLE shard_assets ADD COLUMN IF NOT EXISTS catalog VARCHAR(32) NULL;
|
||||
|
||||
-- Which action a body's thumbnail came from (§11.2, phase 6).
|
||||
--
|
||||
-- The catalogue is still one row per body and still a first frame; what changed
|
||||
-- is that a body with no art at action 0 is catalogued at the first action that
|
||||
-- has any, and the key says so — `body/820/a23` is a horse whose action 0 is
|
||||
-- empty. 73 of a stock client's bodies are in that state, and they rendered as
|
||||
-- text on the bestiary until this phase looked one action further.
|
||||
--
|
||||
-- It is stored rather than parsed back out of the key because the atlas join
|
||||
-- needs it in SQL, and re-deriving it there with SUBSTRING_INDEX would put a
|
||||
-- second, weaker parser of §5's key scheme in the schema. NULL means a row
|
||||
-- written before this column existed, which is action 0 by definition — every
|
||||
-- key the catalogue had then ended in `a0`.
|
||||
ALTER TABLE shard_assets ADD COLUMN IF NOT EXISTS action TINYINT NULL;
|
||||
|
||||
-- Slug → body id, as the shard itself answered it (§8).
|
||||
--
|
||||
-- **Deliberately NOT a column on `shard_spawn_creatures`.** That table is
|
||||
@@ -942,3 +957,20 @@ UPDATE uo_link_config SET protocol = 8
|
||||
WHERE id = 1 AND protocol < 8
|
||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_8_migrated');
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_8_migrated', '1');
|
||||
|
||||
-- `shard_spawn_points.unique_id` for an install that already had the table
|
||||
-- (Asset Bridge phase 9; the column itself is Events phase 12b).
|
||||
--
|
||||
-- The column was added to the CREATE TABLE above and nowhere else, so it reached
|
||||
-- fresh installs and no existing one -- `CREATE TABLE IF NOT EXISTS` does not add
|
||||
-- a column to a table that is already there, which is what every ALTER in this
|
||||
-- file exists to do. `replaceAtlas` inserts `unique_id` unconditionally, so on an
|
||||
-- upgraded install EVERY spawn-atlas import since v1.2.0 has failed outright with
|
||||
-- `Unknown column 'unique_id' in 'INSERT INTO'` -- the bestiary, the spawn map and
|
||||
-- the champion altars all frozen at whatever was last imported.
|
||||
--
|
||||
-- Found by the phase 9 acceptance walk, on a rig whose tables predate 12b: a fresh
|
||||
-- install cannot reproduce it, and neither can a test whose schema is this file
|
||||
-- applied to an empty database. That is the same blind spot the protocol-pin block
|
||||
-- above records, two phases running.
|
||||
ALTER TABLE shard_spawn_points ADD COLUMN IF NOT EXISTS unique_id VARCHAR(64) NULL;
|
||||
|
||||
@@ -30,10 +30,26 @@ async function batched(conn, sql, rows) {
|
||||
|
||||
// ── the manifest side ──────────────────────────────────────────────────────
|
||||
|
||||
/** Every asset row we hold, as a Map of key → row. */
|
||||
async function allAssets() {
|
||||
/**
|
||||
* The asset rows we hold in one family, as a Map of key → row.
|
||||
*
|
||||
* **The family is required, and the reason is a deletion.** The import diffs what
|
||||
* this returns against a manifest, and a manifest is always of ONE family (§14 —
|
||||
* the reply carries a single catalogue id, so it could not be otherwise). Phase 5
|
||||
* put item and land art in this table beside the body catalogue; read whole, the
|
||||
* body import then sees every item picture as a key the shard has stopped
|
||||
* offering and stages all of them for deletion. On a real install that is a few
|
||||
* hundred pictures the operator is asked to approve the loss of, with a sentence
|
||||
* that is entirely wrong about what happened.
|
||||
*
|
||||
* `null` reads every family, which nothing in the import path should ever want.
|
||||
*/
|
||||
async function allAssets(family = null) {
|
||||
const rows = await query(
|
||||
'SELECT asset_key, family, sha256, bytes, width, height, body, direction, file, catalog FROM shard_assets',
|
||||
'SELECT asset_key, family, sha256, bytes, width, height, body, action, direction, file, catalog ' +
|
||||
'FROM shard_assets' +
|
||||
(family ? ' WHERE family = ?' : ''),
|
||||
family ? [family] : [],
|
||||
)
|
||||
|
||||
const map = new Map()
|
||||
@@ -47,6 +63,7 @@ async function allAssets() {
|
||||
width: Number(row.width) || 0,
|
||||
height: Number(row.height) || 0,
|
||||
body: row.body === null ? null : Number(row.body),
|
||||
action: row.action === null ? null : Number(row.action),
|
||||
direction: row.direction === null ? null : Number(row.direction),
|
||||
file: row.file || null,
|
||||
catalog: row.catalog || null,
|
||||
@@ -66,8 +83,16 @@ async function allAssets() {
|
||||
* `ON DUPLICATE KEY UPDATE` rather than delete-and-insert, because an unchanged
|
||||
* key must keep the file it already points at — re-writing the file for every
|
||||
* asset on every Update is exactly the cost the manifest diff exists to avoid.
|
||||
*
|
||||
* `remove` is the keys an operator has APPROVED the loss of (§6). They are
|
||||
* deleted here, inside the same transaction, because a half-applied removal is
|
||||
* the worst of the three outcomes: until phase 8 the import unlinked the sprite
|
||||
* and left the row, so the catalogue still counted a picture that was gone, the
|
||||
* atlas could point a creature at a deleted file, and the very next forced
|
||||
* import staged the same key for review again — telling the operator nothing had
|
||||
* changed, about a file it had already deleted.
|
||||
*/
|
||||
async function saveAssets(rows, meta) {
|
||||
async function saveAssets(rows, meta, remove = []) {
|
||||
const conn = await core.pool.getConnection()
|
||||
|
||||
try {
|
||||
@@ -81,6 +106,7 @@ async function saveAssets(rows, meta) {
|
||||
r.width ?? 0,
|
||||
r.height ?? 0,
|
||||
r.body ?? null,
|
||||
r.action ?? null,
|
||||
r.direction ?? null,
|
||||
r.file ?? null,
|
||||
r.catalog ?? meta?.catalog ?? null,
|
||||
@@ -88,15 +114,26 @@ async function saveAssets(rows, meta) {
|
||||
|
||||
await batched(
|
||||
conn,
|
||||
'INSERT INTO shard_assets (asset_key, family, sha256, bytes, width, height, body, direction, file, catalog) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?) ' +
|
||||
'INSERT INTO shard_assets ' +
|
||||
'(asset_key, family, sha256, bytes, width, height, body, action, direction, file, catalog) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?) ' +
|
||||
'ON DUPLICATE KEY UPDATE family = VALUES(family), sha256 = VALUES(sha256), ' +
|
||||
'bytes = VALUES(bytes), width = VALUES(width), height = VALUES(height), ' +
|
||||
'body = VALUES(body), direction = VALUES(direction), file = VALUES(file), ' +
|
||||
'catalog = VALUES(catalog), imported_at = CURRENT_TIMESTAMP',
|
||||
'body = VALUES(body), action = VALUES(action), direction = VALUES(direction), ' +
|
||||
'file = VALUES(file), catalog = VALUES(catalog), imported_at = CURRENT_TIMESTAMP',
|
||||
values,
|
||||
)
|
||||
|
||||
if (remove.length > 0) {
|
||||
for (let i = 0; i < remove.length; i += BATCH) {
|
||||
const slice = remove.slice(i, i + BATCH)
|
||||
await conn.query(
|
||||
`DELETE FROM shard_assets WHERE asset_key IN (${slice.map(() => '?').join(',')})`,
|
||||
slice,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
await conn.query(
|
||||
'INSERT INTO shard_asset_meta (id, payload) VALUES (1, ?) ' +
|
||||
@@ -184,6 +221,31 @@ async function countByFamily() {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the import that just finished actually did (§6, phase 8).
|
||||
*
|
||||
* **A second write, deliberately.** The interesting half of that summary — how
|
||||
* many atlas creatures resolved to a body id, how many portraits were applied —
|
||||
* does not exist when `saveAssets` commits: producing it takes another round trip
|
||||
* to the shard, and widening the rows-and-meta transaction to cover a network
|
||||
* call is how an import ends up holding a write lock for the length of a timeout.
|
||||
*
|
||||
* `JSON_SET` rather than a read-modify-write for the same reason the rest of this
|
||||
* file is one statement per operation: the payload is the gate an Update compares
|
||||
* against, and re-serialising it from the outside is how a concurrent import
|
||||
* loses a field nobody notices for a month.
|
||||
*
|
||||
* It is cosmetic by design — nothing reads `last` to make a decision, the panel
|
||||
* only renders it — so a failure here is logged and swallowed by the caller
|
||||
* rather than failing an import that has already applied.
|
||||
*/
|
||||
async function recordLastImport(last) {
|
||||
await query('UPDATE shard_asset_meta SET payload = JSON_SET(payload, ?, JSON_COMPACT(?)) WHERE id = 1', [
|
||||
'$.last',
|
||||
JSON.stringify(last),
|
||||
])
|
||||
}
|
||||
|
||||
async function getMeta() {
|
||||
const rows = await query('SELECT payload, imported_at FROM shard_asset_meta WHERE id = 1')
|
||||
if (rows.length === 0) return null
|
||||
@@ -191,9 +253,23 @@ async function getMeta() {
|
||||
return { ...payload, importedAt: rows[0].imported_at }
|
||||
}
|
||||
|
||||
async function countAssets() {
|
||||
/**
|
||||
* How many assets we hold, optionally in one family.
|
||||
*
|
||||
* **The family argument is not optional in spirit.** Phase 5 put item and land
|
||||
* art in this table beside the body catalogue, and they are counted differently
|
||||
* by nature: the catalogue is a SET with a known size, while item art is however
|
||||
* much of an unbounded space the site has happened to ask for. A whole-table
|
||||
* count answers neither question — it reported the creature catalogue as 1,408
|
||||
* rows on an install holding 1,095 portraits and 313 item pictures, which is a
|
||||
* confident wrong number in the one place an operator checks whether the import
|
||||
* worked.
|
||||
*/
|
||||
async function countAssets(family = null) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS n, SUM(file IS NOT NULL) AS stored FROM shard_assets',
|
||||
'SELECT COUNT(*) AS n, SUM(file IS NOT NULL) AS stored FROM shard_assets' +
|
||||
(family ? ' WHERE family = ?' : ''),
|
||||
family ? [family] : [],
|
||||
)
|
||||
return { total: Number(rows[0]?.n) || 0, stored: Number(rows[0]?.stored) || 0 }
|
||||
}
|
||||
@@ -260,17 +336,24 @@ async function countBodies() {
|
||||
* first-class state everywhere it is consumed and the expected one for two thirds
|
||||
* of the player bodies (§5.2).
|
||||
*
|
||||
* **The join is pinned to the catalogue key, not merely to the body id.** Today
|
||||
* one body has exactly one asset, so `a.body = b.body` alone would be correct —
|
||||
* and it would stop being correct the moment phase 6 adds `body/400/a2/f0`, at
|
||||
* which point one slug would match dozens of rows and whichever the engine
|
||||
* returned last would become the portrait. Naming the key here means that phase
|
||||
* adds rows without changing what a creature page shows.
|
||||
* **The join is pinned to the catalogue key, not merely to the body id** — and as
|
||||
* of phase 6 that key is no longer always `a0`. 73 of this client's bodies have
|
||||
* no art at action 0 and are catalogued at the first action that does (§11.2), so
|
||||
* a join hardcoding `a0` would silently drop exactly the creatures this phase
|
||||
* added — a horse among them. It reads the row's own `action` instead, which
|
||||
* still excludes any deeper key a later phase adds (`body/400/a2/f0` does not
|
||||
* equal `body/400/a2`), so one slug still matches at most one row.
|
||||
*
|
||||
* `COALESCE(a.action, 0)` because a row written before this column existed has
|
||||
* NULL there and a NULL inside `CONCAT` makes the whole comparison NULL — which
|
||||
* would have dropped every portrait on the site until the next import, with the
|
||||
* database perfectly correct.
|
||||
*/
|
||||
async function artBySlug() {
|
||||
const rows = await query(
|
||||
'SELECT b.slug, a.file FROM shard_creature_bodies b ' +
|
||||
"JOIN shard_assets a ON a.asset_key = CONCAT('body/', b.body, '/a0') " +
|
||||
"JOIN shard_assets a ON a.body = b.body AND a.family = 'body' " +
|
||||
"AND a.asset_key = CONCAT('body/', b.body, '/a', COALESCE(a.action, 0)) " +
|
||||
"WHERE b.status = 'ok' AND b.body IS NOT NULL AND a.file IS NOT NULL",
|
||||
)
|
||||
|
||||
@@ -284,6 +367,7 @@ async function artBySlug() {
|
||||
module.exports = {
|
||||
allAssets,
|
||||
saveAssets,
|
||||
recordLastImport,
|
||||
getMeta,
|
||||
countAssets,
|
||||
replaceBodies,
|
||||
|
||||
@@ -25,7 +25,13 @@ const log = require('../../core').logger('shardAssets')
|
||||
// **The catalogue** (§4.8, §11) is one thumbnail per creature body: the shard
|
||||
// walks bodies 0–2047, validates each index entry, decodes the ones that are real
|
||||
// and hands back `{ key, sha256 }` first and the PNG second. On a stock client
|
||||
// that is **787 sprites**, not the 1,144 the decoder claims — see below.
|
||||
// that is **1,095 sprites** — 787 out of the legacy anim files, 235 more out of
|
||||
// the UOP packages (phase 4), and 73 more since phase 6, which have no art at
|
||||
// action 0 and real art at a later one. Never the 1,144 the decoder claims.
|
||||
//
|
||||
// A key therefore names its action — `body/820/a23` is a horse whose action 0 is
|
||||
// empty — and the key is still one per body. Nothing here treats `a0` as the
|
||||
// shape of a body key; the atlas join reads the row's own action (§11.2).
|
||||
//
|
||||
// **Body resolution** (§8) is the join. The atlas knows a creature by the class
|
||||
// name in `Spawns/*.xml`; the client knows it by a body id; nothing in the ServUO
|
||||
@@ -170,8 +176,12 @@ function removeSprite(name) {
|
||||
* an operator recovers from a deleted uploads directory — the database still
|
||||
* holds the hashes, but the files behind them are gone). `approve` accepts a
|
||||
* catalogue that no longer offers keys we hold.
|
||||
*
|
||||
* `by` is who pressed the button, carried through only so the panel can say what
|
||||
* the last import did and who ran it without reading the audit log (phase 8). It
|
||||
* decides nothing.
|
||||
*/
|
||||
async function importAssets({ force = false, approve = false } = {}) {
|
||||
async function importAssets({ force = false, approve = false, by = null } = {}) {
|
||||
if (!(await shardLinked())) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
@@ -201,7 +211,7 @@ async function importAssets({ force = false, approve = false } = {}) {
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
|
||||
if (!force && bridge.sameSources(sources, meta?.sources)) {
|
||||
const counts = await db.countAssets()
|
||||
const counts = await db.countAssets(bridge.FAMILY)
|
||||
const bodies = await db.countBodies()
|
||||
|
||||
return {
|
||||
@@ -222,7 +232,10 @@ async function importAssets({ force = false, approve = false } = {}) {
|
||||
return failure(err, 'asset manifest')
|
||||
}
|
||||
|
||||
const held = await db.allAssets()
|
||||
// The body family only. This diff decides what gets DELETED, and the manifest
|
||||
// it is diffed against is of one family by construction — so reading the whole
|
||||
// table here stages every item picture phase 5 warmed as a vanished key.
|
||||
const held = await db.allAssets(bridge.FAMILY)
|
||||
const offered = new Set(manifest.rows.map((r) => r.key))
|
||||
|
||||
// A key we hold that the shard no longer offers. An unmounted client volume and
|
||||
@@ -237,7 +250,11 @@ async function importAssets({ force = false, approve = false } = {}) {
|
||||
reason:
|
||||
`${vanished.length} asset(s) this site holds are no longer offered by the shard; ` +
|
||||
'nothing was changed',
|
||||
vanished: vanished.slice(0, 50),
|
||||
// Each one carries the picture it currently has, because the decision the
|
||||
// operator is being asked for is "is it right that these disappear?" and a
|
||||
// list of keys cannot be looked at. `body/820/a23` names nothing a human
|
||||
// recognises; the horse it is a picture of does.
|
||||
vanished: vanished.slice(0, 50).map((key) => ({ key, file: held.get(key)?.file ?? null })),
|
||||
vanishedCount: vanished.length,
|
||||
}
|
||||
}
|
||||
@@ -296,6 +313,7 @@ async function importAssets({ force = false, approve = false } = {}) {
|
||||
width: got.width || row.width,
|
||||
height: got.height || row.height,
|
||||
body: got.body ?? row.body,
|
||||
action: got.action ?? row.action ?? 0,
|
||||
direction: got.direction ?? row.direction,
|
||||
file: name ?? existing?.file ?? null,
|
||||
})
|
||||
@@ -311,14 +329,21 @@ async function importAssets({ force = false, approve = false } = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
await db.saveAssets(rows, {
|
||||
catalog: manifest.catalog,
|
||||
extractorVersion: manifest.extractorVersion,
|
||||
family: bridge.FAMILY,
|
||||
playerBodies: manifest.playerBodies,
|
||||
sources: { files: sources.files, extractorVersion: sources.extractorVersion },
|
||||
count: rows.length,
|
||||
})
|
||||
await db.saveAssets(
|
||||
rows,
|
||||
{
|
||||
catalog: manifest.catalog,
|
||||
extractorVersion: manifest.extractorVersion,
|
||||
family: bridge.FAMILY,
|
||||
playerBodies: manifest.playerBodies,
|
||||
sources: { files: sources.files, extractorVersion: sources.extractorVersion },
|
||||
count: rows.length,
|
||||
},
|
||||
// The approved removals go in with the write. The sprite is already
|
||||
// unlinked above; leaving the row behind would keep counting a picture
|
||||
// that is gone and re-offer the same key for review on every import.
|
||||
removed,
|
||||
)
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
@@ -326,6 +351,36 @@ async function importAssets({ force = false, approve = false } = {}) {
|
||||
const bodies = await resolveAtlasBodies()
|
||||
const art = await applyArt()
|
||||
|
||||
// What this run did, kept beside the catalogue it produced (phase 8). The admin
|
||||
// panel renders it as "the last import", which is the question an operator has
|
||||
// straight after pressing a button that takes a minute and prints nothing:
|
||||
// what changed, and did the body pass find drift. Core's activity log records
|
||||
// the same action, but it is one unfiltered list of every admin action on the
|
||||
// site, so an import from three client patches ago is not findable there.
|
||||
//
|
||||
// Best-effort on purpose: the import has already applied, and losing a cosmetic
|
||||
// summary must not turn a successful import into a failure.
|
||||
const last = {
|
||||
at: new Date().toISOString(),
|
||||
by,
|
||||
force,
|
||||
approve,
|
||||
assets: rows.length,
|
||||
fetched: fetched.assets.size,
|
||||
written,
|
||||
removed: removed.length,
|
||||
absent: fetched.missing.absent,
|
||||
unsupported: fetched.missing.unsupported,
|
||||
bodies: bodies.tally ?? null,
|
||||
art: art.applied ?? 0,
|
||||
}
|
||||
|
||||
try {
|
||||
await db.recordLastImport(last)
|
||||
} catch (err) {
|
||||
log.warn('could not record the import summary', { error: err.message })
|
||||
}
|
||||
|
||||
log.info('asset import applied', {
|
||||
assets: rows.length,
|
||||
fetched: fetched.assets.size,
|
||||
@@ -453,12 +508,21 @@ async function applyArt() {
|
||||
* state with a reason an operator can act on.
|
||||
*/
|
||||
async function getStatus() {
|
||||
const counts = await db.countAssets().catch(() => ({ total: 0, stored: 0 }))
|
||||
// The BODY family, not the whole table: item and land art live here too and
|
||||
// are reported separately below, because they are a working set rather than a
|
||||
// catalogue with a size (§11).
|
||||
const counts = await db.countAssets(bridge.FAMILY).catch(() => ({ total: 0, stored: 0 }))
|
||||
const bodies = await db.countBodies().catch(() => ({ total: 0, resolved: 0 }))
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
const families = await db.countByFamily().catch(() => ({}))
|
||||
|
||||
const status = {
|
||||
// Is there a shard to ask at all? Stated rather than left to be inferred:
|
||||
// the panel disables its import buttons on it, and the alternative — reading
|
||||
// it out of `reason`'s wording, or out of `shard` being null, which is also
|
||||
// what a shard that is merely DOWN looks like — is a sentence that decides
|
||||
// behaviour.
|
||||
linked: await shardLinked(),
|
||||
loaded: {
|
||||
assets: counts.total,
|
||||
stored: counts.stored,
|
||||
@@ -473,12 +537,18 @@ async function getStatus() {
|
||||
// for and holds, which is the only number that means anything here.
|
||||
items: families.static?.stored ?? 0,
|
||||
land: families.land?.stored ?? 0,
|
||||
// What the last import did, and who ran it (phase 8). Null on an install
|
||||
// that has never imported, and on one whose last import predates this
|
||||
// field — both of which render as "no import recorded" rather than as
|
||||
// zeroes, because an import that fetched nothing is a real and different
|
||||
// answer from one that never happened.
|
||||
last: meta?.last ?? null,
|
||||
},
|
||||
shard: null,
|
||||
drift: null,
|
||||
}
|
||||
|
||||
if (!(await shardLinked())) {
|
||||
if (!status.linked) {
|
||||
status.reason = 'uo-link is not configured'
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const log = require('../../core').logger('shardItemArt')
|
||||
// ── Why this is not the body catalogue with a different prefix ─────────
|
||||
//
|
||||
// The bestiary wants every creature, so phase 3 imports a SET: walk a manifest,
|
||||
// diff the hashes, fetch what moved. That works because the set is 1,022 rows
|
||||
// diff the hashes, fetch what moved. That works because the set is 1,095 rows
|
||||
// and one megabyte.
|
||||
//
|
||||
// This side has no set. The shard's client addresses 49,152 item graphics and
|
||||
|
||||
@@ -5,13 +5,13 @@ const db = require('./shardAtlas.db')
|
||||
const core = require('../../core')
|
||||
const { settings } = core
|
||||
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||
const {
|
||||
AtlasSourceError,
|
||||
PARSER_VERSION,
|
||||
buildAtlas,
|
||||
hashSources,
|
||||
sameSources,
|
||||
} = require('../../utils/spawnAtlasSource')
|
||||
const { AtlasSourceError, PARSER_VERSION, sameSources } = require('../../utils/spawnAtlasSource')
|
||||
// The two readers are reached through the namespace rather than destructured,
|
||||
// because a test stubs them ON the module object and a binding taken at require
|
||||
// time would keep calling the real one — quietly, and while reporting success.
|
||||
const spawnAtlasSource = require('../../utils/spawnAtlasSource')
|
||||
const { TreeBridgeError } = require('../../utils/treeBridge')
|
||||
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
|
||||
const log = require('../../core').logger('shardAtlas')
|
||||
|
||||
// The spawn atlas, refreshed from the shard's own ServUO tree.
|
||||
@@ -37,6 +37,45 @@ const log = require('../../core').logger('shardAtlas')
|
||||
|
||||
const SETTING_KEY = 'spawn_atlas_servuo_path'
|
||||
|
||||
/**
|
||||
* Is there a shard to ask?
|
||||
*
|
||||
* Both halves matter. `baseUrl` alone is an install that has been configured and
|
||||
* then switched off, and calling it would spend a 12 s timeout to learn what the
|
||||
* row already says. Never throws: an unreadable config means "no shard", and a
|
||||
* local tree is a working answer.
|
||||
*/
|
||||
async function shardLinked() {
|
||||
try {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
return Boolean(config?.enabled && config?.baseUrl)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which end this atlas is built from (docs/link/v8.md §10, §17.7).
|
||||
*
|
||||
* **The bridge wins whenever uo-link is configured and enabled**, the same rule
|
||||
* the cliloc table follows and for the same reason: there is no version of "which
|
||||
* source?" an operator benefits from answering, so there is no setting asking it.
|
||||
* A local tree remains the source where there is no shard link — development,
|
||||
* same-host installs — plus the one-off explicit path an admin can type, which is
|
||||
* an instruction rather than a default and therefore overrules this.
|
||||
*/
|
||||
async function sourceFor(pathOverride = '') {
|
||||
const explicit = String(pathOverride || '').trim()
|
||||
if (explicit !== '') return { kind: 'fs', root: explicit }
|
||||
|
||||
if (await shardLinked()) return { kind: 'bridge', root: '' }
|
||||
|
||||
return { kind: 'fs', root: await getServuoPath() }
|
||||
}
|
||||
|
||||
/** How a source reads in a log line or an admin panel. */
|
||||
const describe = (source) => (source.kind === 'bridge' ? 'the shard bridge' : source.root)
|
||||
|
||||
/**
|
||||
* Where the ServUO tree lives.
|
||||
*
|
||||
@@ -175,18 +214,29 @@ 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' }
|
||||
// would be, nor by the bridge.
|
||||
const source = await sourceFor(pathOverride)
|
||||
const root = source.root
|
||||
const where = describe(source)
|
||||
|
||||
if (source.kind === 'fs' && root === '') {
|
||||
return { status: 'skipped', reason: 'no ServUO path configured' }
|
||||
}
|
||||
|
||||
let hashes
|
||||
try {
|
||||
hashes = hashSources(root)
|
||||
hashes = await spawnAtlasSource.hashFrom(source)
|
||||
} catch (err) {
|
||||
if (err instanceof AtlasSourceError) {
|
||||
return { status: 'unavailable', reason: err.message, code: err.code, path: root }
|
||||
if (err instanceof AtlasSourceError || err instanceof TreeBridgeError) {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
source: source.kind,
|
||||
reason: err.message,
|
||||
code: err.code,
|
||||
path: where,
|
||||
}
|
||||
}
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
return { status: 'failed', source: source.kind, reason: err.message, path: where }
|
||||
}
|
||||
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
@@ -199,7 +249,7 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
|
||||
// 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 }
|
||||
return { status: 'unchanged', source: source.kind, path: where }
|
||||
}
|
||||
|
||||
// A rejected refresh must not re-prompt on every boot. It stays rejected until
|
||||
@@ -207,14 +257,28 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
|
||||
// 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' }
|
||||
return {
|
||||
status: 'unchanged',
|
||||
source: source.kind,
|
||||
path: where,
|
||||
reason: 'refresh previously rejected',
|
||||
}
|
||||
}
|
||||
|
||||
let atlas
|
||||
try {
|
||||
atlas = buildAtlas(root)
|
||||
atlas = await spawnAtlasSource.buildFrom(source)
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
if (err instanceof AtlasSourceError || err instanceof TreeBridgeError) {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
source: source.kind,
|
||||
reason: err.message,
|
||||
code: err.code,
|
||||
path: where,
|
||||
}
|
||||
}
|
||||
return { status: 'failed', source: source.kind, reason: err.message, path: where }
|
||||
}
|
||||
|
||||
const currentFacets = await db.getFacets().catch(() => [])
|
||||
@@ -227,7 +291,8 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
|
||||
if (removedFacets.length > 0 && !approve) {
|
||||
const summary = {
|
||||
hashes,
|
||||
path: root,
|
||||
source: source.kind,
|
||||
path: where,
|
||||
currentFacets,
|
||||
incomingFacets,
|
||||
removedFacets,
|
||||
@@ -242,9 +307,16 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
|
||||
|
||||
try {
|
||||
const counts = await applyAtlas(atlas)
|
||||
return { status: 'imported', path: root, counts, addedFacets, removedFacets }
|
||||
return {
|
||||
status: 'imported',
|
||||
source: source.kind,
|
||||
path: where,
|
||||
counts,
|
||||
addedFacets,
|
||||
removedFacets,
|
||||
}
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
return { status: 'failed', source: source.kind, reason: err.message, path: where }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +339,9 @@ async function rejectPending() {
|
||||
|
||||
/** Everything the admin panel needs to describe atlas state. */
|
||||
async function status({ path: pathOverride = '' } = {}) {
|
||||
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
|
||||
const source = await sourceFor(pathOverride)
|
||||
const root = source.root
|
||||
const configured = source.kind === 'bridge' || root !== ''
|
||||
const [meta, pending, facets] = await Promise.all([
|
||||
db.getMeta().catch(() => null),
|
||||
db.getPending().catch(() => null),
|
||||
@@ -276,9 +350,13 @@ async function status({ path: pathOverride = '' } = {}) {
|
||||
|
||||
let treeReadable = false
|
||||
let drift = null
|
||||
if (root !== '') {
|
||||
if (configured) {
|
||||
try {
|
||||
const hashes = hashSources(root)
|
||||
// On the bridge this is the MANIFEST, not the tree: 141 rows and ~32 KB,
|
||||
// with no file bytes crossing the wire to answer "has anything changed".
|
||||
// It is still a shard round trip on an admin page load, which is why it is
|
||||
// here and not on the boot path (§17.7).
|
||||
const hashes = await spawnAtlasSource.hashFrom(source)
|
||||
treeReadable = true
|
||||
const loaded = meta?.source
|
||||
? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256]))
|
||||
@@ -292,8 +370,9 @@ async function status({ path: pathOverride = '' } = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
configured: root !== '',
|
||||
path: root,
|
||||
configured,
|
||||
source: source.kind,
|
||||
path: describe(source),
|
||||
treeReadable,
|
||||
drift,
|
||||
facets,
|
||||
@@ -309,6 +388,18 @@ async function status({ path: pathOverride = '' } = {}) {
|
||||
*/
|
||||
async function refreshOnBoot() {
|
||||
try {
|
||||
// **On the bridge it imports nothing**, deliberately, and by the same
|
||||
// reasoning as the cliloc table (§17.7). A local tree hashes in ~120 ms and
|
||||
// skips; asking the shard would put a sidecar round trip in the boot sequence
|
||||
// to answer a question whose answer is "no" on every restart that did not
|
||||
// follow a map edit — and editing spawn files is an operator action, so
|
||||
// importing became one: Admin → Shard → Import. Whatever atlas is loaded
|
||||
// keeps serving until then.
|
||||
if ((await sourceFor()).kind === 'bridge') {
|
||||
log.info('spawn atlas comes from the shard; import is admin-triggered (Admin → Shard)')
|
||||
return { status: 'skipped', source: 'bridge', reason: 'the shard is the atlas source' }
|
||||
}
|
||||
|
||||
const result = await refresh()
|
||||
switch (result.status) {
|
||||
case 'imported':
|
||||
|
||||
@@ -251,8 +251,8 @@ shardRouter.get(
|
||||
shardRouter.get(
|
||||
'/atlas',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)'
|
||||
// #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.'
|
||||
// #swagger.summary = 'Spawn atlas status: source, drift, counts, pending review (admin only)'
|
||||
// #swagger.description = 'Which source the atlas is built from — the linked shard over uo-link, or a local ServUO tree — whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. On the bridge, reading drift costs one shard round trip for the file manifest (hashes, no bytes). The public /atlas/meta route reports the game world only; this detail is here.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasStatus" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
@@ -262,8 +262,8 @@ shardRouter.get(
|
||||
shardRouter.post(
|
||||
'/atlas/import',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Re-import the spawn atlas from the ServUO tree (admin only)'
|
||||
// #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.'
|
||||
// #swagger.summary = 'Re-import the spawn atlas from its source (admin only)'
|
||||
// #swagger.description = 'Applies a map change without a restart — and on a linked shard it is the only thing that does, because boot never calls the shard for this. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable source answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
|
||||
|
||||
@@ -19,8 +19,13 @@
|
||||
// operator patches their client, which is an event they know about and the site
|
||||
// does not. So this endpoint is what an operator presses afterwards.
|
||||
//
|
||||
// The full panel — per-key review, the activity view, approve/reject as buttons —
|
||||
// is phase 8. This pair is what makes phase 3 reachable at all.
|
||||
// Phase 8 built the panel these serve (`Admin → Client Files`) and added one
|
||||
// thing to this pair: the import records a summary of what it did, and the
|
||||
// vanished keys it refuses to apply come back with the pictures they currently
|
||||
// have. Both exist because an operator pressing Update needs to see an answer,
|
||||
// and the audit log — which still receives every action here — is one unfiltered
|
||||
// list of every admin action on the site, so an import from three client patches
|
||||
// ago cannot be found in it (org lead, 2026-09-14).
|
||||
|
||||
const assets = require('../../model/shardAssets/shardAssets.model')
|
||||
const itemArt = require('../../model/shardAssets/shardItemArt.model')
|
||||
@@ -56,7 +61,9 @@ async function importAssets(req, res) {
|
||||
try {
|
||||
const force = !!req.body?.force
|
||||
const approve = !!req.body?.approve
|
||||
const result = await assets.importAssets({ force, approve })
|
||||
// From the session, never the body — the same rule the in-game ops routes
|
||||
// apply, and for the same reason: this is recorded as who did it.
|
||||
const result = await assets.importAssets({ force, approve, by: req.user?.username ?? null })
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
|
||||
@@ -49,11 +49,14 @@ function describe(result) {
|
||||
switch (result.status) {
|
||||
case 'skipped':
|
||||
return (
|
||||
'No ServUO path configured — nothing to import.\n' +
|
||||
'Set one with SERVUO_PATH, the admin panel, or --servuo <path>.\n'
|
||||
'No atlas source — nothing to import.\n' +
|
||||
'Either link a shard (Admin → Shard) or set a tree path with SERVUO_PATH, ' +
|
||||
'the admin panel, or --servuo <path>.\n'
|
||||
)
|
||||
case 'unavailable':
|
||||
return `ServUO tree unavailable: ${result.reason}\n`
|
||||
return result.source === 'bridge'
|
||||
? `The shard could not serve its configuration tree: ${result.reason}\n`
|
||||
: `ServUO tree unavailable: ${result.reason}\n`
|
||||
case 'unchanged':
|
||||
return `Atlas is already up to date${result.reason ? ` (${result.reason})` : ''}.\n`
|
||||
case 'needsReview': {
|
||||
|
||||
@@ -454,12 +454,18 @@ module.exports = {
|
||||
},
|
||||
UoAtlasStatus: {
|
||||
type: 'object',
|
||||
description: 'Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review.',
|
||||
description: 'Admin view of atlas state: which source the tree comes from, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review.',
|
||||
properties: {
|
||||
configured: { type: 'boolean', example: true },
|
||||
path: { type: 'string', example: '/srv/servuo' },
|
||||
source: {
|
||||
type: 'string',
|
||||
enum: ['bridge', 'fs'],
|
||||
description: '`bridge`: the shard serves its own configuration files over uo-link (protocol 8 phase 7, the normal case once a shard is linked). `fs`: a ServUO tree the website can read directly — development and same-host installs, and the only source where boot re-imports by itself.',
|
||||
example: 'bridge',
|
||||
},
|
||||
path: { type: 'string', description: 'The local tree path, or `the shard bridge` when that is the source.', example: 'the shard bridge' },
|
||||
treeReadable: { type: 'boolean', example: true },
|
||||
drift: { type: 'boolean', nullable: true, description: 'True when the tree\'s source hashes differ from the loaded atlas. NULL when the tree could not be read.', example: false },
|
||||
drift: { type: 'boolean', nullable: true, description: 'True when the source file hashes differ from the loaded atlas. NULL when the source could not be read. On the bridge this is answered from the shard\'s file MANIFEST — hashes only, no file bytes.', example: false },
|
||||
facets: { type: 'array', items: { type: 'string' } },
|
||||
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
counts: { type: 'object', nullable: true, additionalProperties: true },
|
||||
@@ -481,7 +487,15 @@ module.exports = {
|
||||
example: 'imported',
|
||||
},
|
||||
reason: { type: 'string', nullable: true },
|
||||
path: { type: 'string', nullable: true },
|
||||
source: {
|
||||
type: 'string',
|
||||
enum: ['bridge', 'fs'],
|
||||
nullable: true,
|
||||
description: 'Which end this attempt read from. Absent only on `skipped`, where there was no source at all.',
|
||||
example: 'bridge',
|
||||
},
|
||||
path: { type: 'string', nullable: true, description: 'The local tree path, or `the shard bridge`.' },
|
||||
code: { type: 'string', nullable: true, description: 'On `unavailable`: NO_PATH, NOT_FOUND, NO_REGIONS or NO_SPAWNS from a local tree; DISABLED, SOURCE_CHANGED, INCOMPLETE, MALFORMED, BUSY, SHARD_DOWN or TOO_LARGE from the bridge.' },
|
||||
counts: { type: 'object', nullable: true, additionalProperties: true },
|
||||
addedFacets: { type: 'array', items: { type: 'string' } },
|
||||
removedFacets: { type: 'array', items: { type: 'string' } },
|
||||
@@ -612,6 +626,11 @@ module.exports = {
|
||||
description:
|
||||
'Admin view of the client-asset import (docs/link/v8.md §6, §8). What the site holds beside what the shard’s UO client currently is. Holding nothing at all is a supported state — creature pages simply render without pictures, which is what every install did before this pipeline existed.',
|
||||
properties: {
|
||||
linked: {
|
||||
type: 'boolean',
|
||||
description: 'Whether a shard is configured and enabled at all. Stated rather than inferred: `shard: null` is also what a linked shard that is merely DOWN looks like, and the two want opposite things from an admin surface — one disables its import buttons, the other keeps them available so the operator can retry.',
|
||||
example: true,
|
||||
},
|
||||
loaded: {
|
||||
type: 'object',
|
||||
description: 'What this site currently holds.',
|
||||
@@ -625,6 +644,35 @@ module.exports = {
|
||||
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
items: { type: 'integer', description: 'Item pictures held. Unlike the catalogue this has no total to compare against: item art is fetched because something on the site names it, so this is the working set rather than a fraction of one.', example: 1840 },
|
||||
land: { type: 'integer', description: 'Land tile pictures held. Zero on every install until something asks for one.', example: 0 },
|
||||
last: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'What the last import actually did. NULL on an install that has never imported, and on one whose last import predates this field — both of which mean "no import recorded", which is a different answer from an import that fetched nothing. The admin activity log records the same action, but it is one unfiltered list of every admin action on the site, so an import from three client patches ago is not findable there.',
|
||||
properties: {
|
||||
at: { type: 'string', format: 'date-time' },
|
||||
by: { type: 'string', nullable: true, description: 'The admin who pressed it, from their session.' },
|
||||
force: { type: 'boolean', description: 'True when it was a full re-import rather than an update.' },
|
||||
approve: { type: 'boolean', description: 'True when it accepted assets the shard had stopped offering.' },
|
||||
assets: { type: 'integer', example: 1095 },
|
||||
fetched: { type: 'integer', example: 12 },
|
||||
written: { type: 'integer', example: 12 },
|
||||
removed: { type: 'integer', example: 0 },
|
||||
absent: { type: 'integer', example: 0 },
|
||||
unsupported: { type: 'integer', example: 0 },
|
||||
bodies: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'The body pass, as a tally rather than one number: `unknown` is real drift — a spawn file naming a type this shard’s scripts do not define — and reads identically to a failure if both are summed into "not resolved".',
|
||||
properties: {
|
||||
ok: { type: 'integer', example: 780 },
|
||||
unknown: { type: 'integer', example: 20 },
|
||||
notCreature: { type: 'integer', example: 12 },
|
||||
failed: { type: 'integer', example: 0 },
|
||||
},
|
||||
},
|
||||
art: { type: 'integer', description: 'Creatures pointing at a picture afterwards.', example: 763 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
shard: {
|
||||
@@ -709,7 +757,18 @@ module.exports = {
|
||||
description: 'The body ids the shard reports as player-character bodies — every registered race’s male, female and ghost bodies, asked of the shard rather than hardcoded. These render head-on; everything else renders three-quarter.',
|
||||
example: [400, 401, 402, 403, 605, 606, 607, 608, 666, 667, 694, 695],
|
||||
},
|
||||
vanished: { type: 'array', nullable: true, items: { type: 'string' }, description: 'On `needsReview`: up to fifty of the keys that disappeared.' },
|
||||
vanished: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'On `needsReview`: up to fifty of the keys that disappeared, each with the picture this site currently serves for it. The filename is there because the decision being asked for is "is it right that these disappear?", and an asset key names nothing a human recognises — `body/820/a23` is a horse.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', example: 'body/820/a23' },
|
||||
file: { type: 'string', nullable: true, description: 'Filename under uploads/atlas/, or null if this site never stored a picture for it.', example: 'uo-body-820-a23-9f3c1a77.png' },
|
||||
},
|
||||
},
|
||||
},
|
||||
vanishedCount: { type: 'integer', nullable: true },
|
||||
bodies: {
|
||||
type: 'object',
|
||||
|
||||
@@ -205,7 +205,7 @@ test('a short page that did not end the catalogue is refused', async (t) => {
|
||||
stub({ manifest: [manifestPage([row(12)], { more: false, cut: 'limit' })] })
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(() => bridge.readManifest(), /stopped sending assets/)
|
||||
await assert.rejects(() => bridge.readManifest(), /stopped sending asset rows/)
|
||||
})
|
||||
|
||||
test('a cursor that does not advance is refused rather than looped on', async (t) => {
|
||||
@@ -250,6 +250,45 @@ test('a fetch passes the catalogue id and decodes the PNG', async (t) => {
|
||||
assert.equal(assets.get('body/12/a0').width, 24)
|
||||
})
|
||||
|
||||
test('a body catalogued at a later action keeps that action in its row', async (t) => {
|
||||
// §11.2, phase 6. 73 of a stock client's bodies have no art at action 0 and are
|
||||
// catalogued at the first action that does — body 820's is 23, and it is a
|
||||
// horse. The action travels with the row because the atlas join needs it in
|
||||
// SQL; re-deriving it from the key would put a second parser of §5's scheme in
|
||||
// the schema.
|
||||
stub({
|
||||
manifest: [
|
||||
manifestPage([
|
||||
{ ...row(12), action: 0 },
|
||||
{ key: 'body/820/a23', sha256: 'bb', bytes: 900, width: 68, height: 69, body: 820, action: 23, direction: 1 },
|
||||
]),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const { rows } = await bridge.readManifest({})
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((r) => [r.key, r.action]),
|
||||
[
|
||||
['body/12/a0', 0],
|
||||
['body/820/a23', 23],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('an overlay older than phase 6 reads as action 0 rather than as unknown', async (t) => {
|
||||
// A phase-3 through phase-5 overlay omits `action` entirely, and every key it
|
||||
// ever produced ended in `a0`. Reading that as null would make the atlas join
|
||||
// COALESCE it back to 0 anyway; reading it as 0 here says so once.
|
||||
stub({ manifest: [manifestPage([row(12)])] })
|
||||
t.after(restore)
|
||||
|
||||
const { rows } = await bridge.readManifest({})
|
||||
|
||||
assert.equal(rows[0].action, 0)
|
||||
})
|
||||
|
||||
test('an absent asset is a counted row, not a failed fetch', async (t) => {
|
||||
// The whole reason this is not an error: two thirds of the playable ghost and
|
||||
// gargoyle bodies have no art on a stock client (§5.2), and an import that
|
||||
|
||||
156
server/test/atlasSourceSelection.test.js
Normal file
156
server/test/atlasSourceSelection.test.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// Which atlas source runs, and what boot does with the answer
|
||||
// (docs/link/v8.md §10, §17.7; docs/website/SPAWN_ATLAS.md).
|
||||
//
|
||||
// The model is the only place that decides between a local ServUO tree and the
|
||||
// shard bridge, so these drive it with the shard, the database and the
|
||||
// filesystem all stubbed. Nothing here reaches a real sidecar or a real tree.
|
||||
//
|
||||
// The rule under test is the one the cliloc pipeline settled first and this
|
||||
// inherits: the bridge wins whenever uo-link is configured and enabled, a local
|
||||
// path is what a site with no shard link uses, and an explicit path is an
|
||||
// instruction that overrules both.
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const atlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
const db = require('../model/shardAtlas/shardAtlas.db')
|
||||
const source = require('../utils/spawnAtlasSource')
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const { ctx } = require('./_setup')
|
||||
|
||||
const saved = {
|
||||
getMeta: db.getMeta,
|
||||
getPending: db.getPending,
|
||||
getFacets: db.getFacets,
|
||||
hashFrom: source.hashFrom,
|
||||
buildFrom: source.buildFrom,
|
||||
getSafe: uoLinkConfig.getSafe,
|
||||
settingsGet: ctx.settings.get,
|
||||
}
|
||||
|
||||
function restore() {
|
||||
db.getMeta = saved.getMeta
|
||||
db.getPending = saved.getPending
|
||||
db.getFacets = saved.getFacets
|
||||
source.hashFrom = saved.hashFrom
|
||||
source.buildFrom = saved.buildFrom
|
||||
uoLinkConfig.getSafe = saved.getSafe
|
||||
ctx.settings.get = saved.settingsGet
|
||||
}
|
||||
|
||||
/** Whatever source the model chose, captured rather than read. */
|
||||
function rig({ linked = true, treePath = '', meta = null } = {}) {
|
||||
const asked = { hash: [], build: [] }
|
||||
|
||||
uoLinkConfig.getSafe = async () => ({
|
||||
enabled: linked,
|
||||
baseUrl: linked ? 'http://127.0.0.1:8099' : null,
|
||||
})
|
||||
ctx.settings.get = async () => treePath
|
||||
|
||||
db.getMeta = async () => meta
|
||||
db.getPending = async () => null
|
||||
db.getFacets = async () => []
|
||||
|
||||
source.hashFrom = async (descriptor) => {
|
||||
asked.hash.push(descriptor)
|
||||
return { 'Data/Regions.xml': 'aa' }
|
||||
}
|
||||
source.buildFrom = async (descriptor) => {
|
||||
asked.build.push(descriptor)
|
||||
throw new Error('the test stops before a build')
|
||||
}
|
||||
|
||||
return asked
|
||||
}
|
||||
|
||||
test('a linked shard is the atlas source, and the configured path is not consulted', async () => {
|
||||
const asked = rig({ linked: true, treePath: '/srv/servuo' })
|
||||
|
||||
const status = await atlas.status()
|
||||
|
||||
assert.equal(status.source, 'bridge')
|
||||
assert.equal(status.path, 'the shard bridge')
|
||||
assert.equal(status.configured, true)
|
||||
assert.deepEqual(asked.hash[0], { kind: 'bridge', root: '' })
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('with no shard linked the configured tree is the source', async () => {
|
||||
const asked = rig({ linked: false, treePath: '/srv/servuo' })
|
||||
|
||||
const status = await atlas.status()
|
||||
|
||||
assert.equal(status.source, 'fs')
|
||||
assert.equal(status.path, '/srv/servuo')
|
||||
assert.deepEqual(asked.hash[0], { kind: 'fs', root: '/srv/servuo' })
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('an explicit path overrules the bridge — it is an instruction, not a default', async () => {
|
||||
const asked = rig({ linked: true, treePath: '/srv/servuo' })
|
||||
|
||||
await atlas.status({ path: '/tmp/other-tree' })
|
||||
|
||||
assert.deepEqual(asked.hash[0], { kind: 'fs', root: '/tmp/other-tree' })
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('no shard and no path is "nothing configured", not an error', async () => {
|
||||
rig({ linked: false, treePath: '' })
|
||||
|
||||
const status = await atlas.status()
|
||||
assert.equal(status.configured, false)
|
||||
|
||||
const result = await atlas.refresh()
|
||||
assert.equal(result.status, 'skipped')
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('boot does not call the shard; it says where the import lives instead', async () => {
|
||||
// §17.7's rule, and the reason it is not free: an install whose atlas comes
|
||||
// over the bridge has NO automatic refresh at all, so the skip has to be
|
||||
// deliberate and visible rather than a path that quietly does nothing.
|
||||
const asked = rig({ linked: true, treePath: '/srv/servuo' })
|
||||
|
||||
const result = await atlas.refreshOnBoot()
|
||||
|
||||
assert.equal(result.status, 'skipped')
|
||||
assert.equal(result.source, 'bridge')
|
||||
assert.equal(asked.hash.length, 0, 'boot made no shard call at all')
|
||||
assert.equal(asked.build.length, 0)
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('boot still refreshes by itself from a local tree', async () => {
|
||||
const asked = rig({ linked: false, treePath: '/srv/servuo' })
|
||||
|
||||
await atlas.refreshOnBoot()
|
||||
|
||||
assert.deepEqual(asked.hash[0], { kind: 'fs', root: '/srv/servuo' })
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a source that cannot be read is reported, with which end could not read it', async () => {
|
||||
rig({ linked: true, treePath: '' })
|
||||
|
||||
source.hashFrom = async () => {
|
||||
const { TreeBridgeError } = require('../utils/treeBridge')
|
||||
throw new TreeBridgeError('the shard is not serving its tree', 'DISABLED')
|
||||
}
|
||||
|
||||
const result = await atlas.refresh()
|
||||
|
||||
assert.equal(result.status, 'unavailable')
|
||||
assert.equal(result.source, 'bridge')
|
||||
assert.equal(result.code, 'DISABLED')
|
||||
|
||||
restore()
|
||||
})
|
||||
@@ -40,6 +40,7 @@ function stubEverything({ manifest, fetched, held = new Map(), meta = null, sour
|
||||
saved.resolveBodies = bridge.resolveBodies
|
||||
saved.allAssets = db.allAssets
|
||||
saved.saveAssets = db.saveAssets
|
||||
saved.recordLastImport = db.recordLastImport
|
||||
saved.getMeta = db.getMeta
|
||||
saved.countAssets = db.countAssets
|
||||
saved.countBodies = db.countBodies
|
||||
@@ -50,7 +51,7 @@ function stubEverything({ manifest, fetched, held = new Map(), meta = null, sour
|
||||
saved.loadArtMap = atlasModel.loadArtMap
|
||||
saved.getSafe = uoLinkConfig.getSafe
|
||||
|
||||
const seen = { saved: null, fetchedKeys: null, art: null }
|
||||
const seen = { saved: null, fetchedKeys: null, art: null, last: null }
|
||||
|
||||
uoLinkConfig.getSafe = async () => ({ enabled: true, baseUrl: 'http://127.0.0.1:8080' })
|
||||
|
||||
@@ -78,6 +79,9 @@ function stubEverything({ manifest, fetched, held = new Map(), meta = null, sour
|
||||
seen.saved = rows
|
||||
return rows.length
|
||||
}
|
||||
db.recordLastImport = async (last) => {
|
||||
seen.last = last
|
||||
}
|
||||
db.replaceBodies = async () => 0
|
||||
db.artBySlug = async () => ({})
|
||||
|
||||
@@ -221,6 +225,37 @@ test('only the keys whose hash moved are fetched', async (t) => {
|
||||
assert.equal(result.written, 1)
|
||||
})
|
||||
|
||||
test('a body catalogued at a later action is imported under that key', async (t) => {
|
||||
// §11.2, phase 6. Body 820 has no art at action 0 and a horse at action 23, so
|
||||
// its key is `body/820/a23` — and the filename, the stored row and the atlas
|
||||
// join all have to agree on that. A name built as `uo-body-820-a0-…` would be
|
||||
// a file nothing ever asks for, with the creature page still showing text.
|
||||
const dir = useTempUploads(t)
|
||||
|
||||
const seen = stubEverything({
|
||||
manifest: manifestOf([
|
||||
{ ...row(820, 'new'), key: 'body/820/a23', action: 23 },
|
||||
]),
|
||||
fetched: {
|
||||
assets: new Map([['body/820/a23', { ...sprite('new'), action: 23 }]]),
|
||||
missing: { absent: 0, unsupported: 0 },
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.deepEqual(seen.fetchedKeys, ['body/820/a23'])
|
||||
|
||||
const saved = seen.saved[0]
|
||||
assert.equal(saved.key, 'body/820/a23')
|
||||
assert.equal(saved.action, 23)
|
||||
// Content-addressed, and the stem is the key: the action is IN the filename.
|
||||
assert.equal(saved.file, 'uo-body-820-a23-new.png')
|
||||
assert.ok(fs.existsSync(path.join(dir, model.ART_SUBDIR, saved.file)))
|
||||
})
|
||||
|
||||
test('an unchanged key whose file is missing from disk is fetched again', async (t) => {
|
||||
// The row and the file can disagree — a wiped uploads volume, a restore from a
|
||||
// database dump. Trusting the row alone leaves a broken image on a creature
|
||||
@@ -332,3 +367,193 @@ test('a sprite filename carries its hash so a changed picture is a changed URL',
|
||||
assert.notEqual(before, after)
|
||||
assert.match(before, /^uo-body-34-a0-[0-9a-f]{8}\.png$/)
|
||||
})
|
||||
|
||||
// ── what the panel reads (phase 8) ────────────────────────────────────────
|
||||
//
|
||||
// The admin surface is the only thing that imports — boot never calls the shard
|
||||
// — so everything an operator can learn about an import, they learn from what
|
||||
// these two return. Each of these is a way the panel would render a confident
|
||||
// sentence that is not true.
|
||||
|
||||
test('the vanished keys come back with the pictures they currently have', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const held = new Map([
|
||||
['body/820/a23', { key: 'body/820/a23', sha256: 'a', file: 'uo-body-820-a23-aabbccdd.png' }],
|
||||
])
|
||||
|
||||
stubEverything({ held, manifest: manifestOf([row(12, 'a')]) })
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
// The decision being asked for is "is it right that these disappear?", and a
|
||||
// key names nothing a human recognises. Without the filename the panel has
|
||||
// nothing to show but `body/820/a23`, which is a horse.
|
||||
assert.equal(result.status, 'needsReview')
|
||||
assert.deepEqual(result.vanished, [
|
||||
{ key: 'body/820/a23', file: 'uo-body-820-a23-aabbccdd.png' },
|
||||
])
|
||||
})
|
||||
|
||||
test('an import records what it did, including the body tally and who ran it', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const seen = stubEverything({
|
||||
manifest: manifestOf([row(12, 'new')]),
|
||||
fetched: {
|
||||
assets: new Map([['body/12/a0', sprite('new')]]),
|
||||
missing: { absent: 3, unsupported: 0 },
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
atlasDb.allCreatureTypes = async () => [{ slug: 'wolf', name: 'Wolf' }]
|
||||
bridge.resolveBodies = async () => [
|
||||
{ slug: 'wolf', typeName: 'Wolf', body: 34, status: 'ok' },
|
||||
{ slug: 'ghost-of-something', typeName: 'GhostOfSomething', body: null, status: 'unknown' },
|
||||
]
|
||||
|
||||
await model.importAssets({ force: true, by: 'colby' })
|
||||
|
||||
assert.equal(seen.last.by, 'colby')
|
||||
assert.equal(seen.last.force, true)
|
||||
assert.equal(seen.last.written, 1)
|
||||
assert.equal(seen.last.absent, 3)
|
||||
// The body pass is kept as a TALLY rather than a single "resolved" number:
|
||||
// `unknown` means the spawn files name a type this shard's scripts do not
|
||||
// define, which is real drift, and it reads identically to a failure if both
|
||||
// are summed into "not resolved".
|
||||
assert.deepEqual(seen.last.bodies, { ok: 1, unknown: 1, notCreature: 0, failed: 0 })
|
||||
})
|
||||
|
||||
test('a summary that cannot be written does not fail an import that applied', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
stubEverything({
|
||||
manifest: manifestOf([row(12, 'new')]),
|
||||
fetched: {
|
||||
assets: new Map([['body/12/a0', sprite('new')]]),
|
||||
missing: { absent: 0, unsupported: 0 },
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
db.recordLastImport = async () => {
|
||||
throw new Error('the meta row is locked')
|
||||
}
|
||||
|
||||
// The pictures are already on disk and the rows are already committed. Failing
|
||||
// here would report a failure for an import that succeeded, and the operator's
|
||||
// next move — press it again — would re-fetch the whole catalogue for nothing.
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.equal(result.written, 1)
|
||||
})
|
||||
|
||||
test('status says whether a shard is linked rather than leaving it to be inferred', async (t) => {
|
||||
stubEverything({ manifest: manifestOf([]) })
|
||||
t.after(restore)
|
||||
|
||||
db.getMeta = async () => ({ catalog: 'cat1', last: { by: 'colby', written: 4 } })
|
||||
|
||||
const linked = await model.getStatus()
|
||||
|
||||
assert.equal(linked.linked, true)
|
||||
assert.deepEqual(linked.loaded.last, { by: 'colby', written: 4 })
|
||||
|
||||
// A shard that is linked but DOWN also reports `shard: null`, which is why the
|
||||
// panel cannot read this off that: one wants its buttons disabled and the
|
||||
// other wants them available so the operator can retry.
|
||||
uoLinkConfig.getSafe = async () => ({ enabled: false, baseUrl: '' })
|
||||
|
||||
const unlinked = await model.getStatus()
|
||||
|
||||
assert.equal(unlinked.linked, false)
|
||||
assert.equal(unlinked.reason, 'uo-link is not configured')
|
||||
})
|
||||
|
||||
test('the catalogue count is the body family, not every asset in the table', async (t) => {
|
||||
stubEverything({ manifest: manifestOf([]) })
|
||||
t.after(restore)
|
||||
|
||||
let askedFor = 'never called'
|
||||
|
||||
// Item and land art live in the same table as the body catalogue (phase 5) and
|
||||
// are counted separately on purpose: one is a set with a size, the other is
|
||||
// however much of an unbounded space the site has happened to ask for. A
|
||||
// whole-table count reported 1,095 portraits plus 313 item pictures as a
|
||||
// "1,408-row catalogue" on the one screen that answers "did the import work".
|
||||
db.countAssets = async (family) => {
|
||||
askedFor = family
|
||||
return { total: 1095, stored: 1095 }
|
||||
}
|
||||
|
||||
const status = await model.getStatus()
|
||||
|
||||
assert.equal(askedFor, 'body')
|
||||
assert.equal(status.loaded.assets, 1095)
|
||||
})
|
||||
|
||||
test('item pictures are not "vanished" just because the body manifest never listed them', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
// The state every install reaches within a day of its first import: a body
|
||||
// catalogue, plus whatever item art the warm pass has fetched because a
|
||||
// marketplace page asked for it. Both live in `shard_assets`.
|
||||
const held = new Map([
|
||||
['body/12/a0', { key: 'body/12/a0', family: 'body', sha256: 'a', file: 'wolf.png' }],
|
||||
['static/3934/h1801', { key: 'static/3934/h1801', family: 'static', sha256: 'b', file: 'robe.png' }],
|
||||
])
|
||||
|
||||
const seen = stubEverything({ held, manifest: manifestOf([row(12, 'a')]) })
|
||||
t.after(restore)
|
||||
|
||||
// The family filter is the fix, so the stub has to honour it or the test
|
||||
// passes against a whole-table read.
|
||||
db.allAssets = async (family) =>
|
||||
new Map([...held].filter(([, r]) => !family || r.family === family))
|
||||
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
// Before the filter this was `needsReview` naming the item picture, and
|
||||
// approving it would have deleted every picture the warm pass had fetched —
|
||||
// with a sentence saying the shard had stopped offering them, which it had
|
||||
// not: a body manifest never mentions item art at all.
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.equal(result.removed, 0)
|
||||
assert.ok(seen.saved)
|
||||
})
|
||||
|
||||
test('an approved vanish deletes the row, not just the picture', async (t) => {
|
||||
const dir = useTempUploads(t)
|
||||
fs.mkdirSync(path.join(dir, model.ART_SUBDIR), { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, model.ART_SUBDIR, 'gone.png'), 'x')
|
||||
|
||||
const held = new Map([
|
||||
['body/99/a0', { key: 'body/99/a0', family: 'body', sha256: 'a', file: 'gone.png' }],
|
||||
])
|
||||
|
||||
let removedKeys = null
|
||||
|
||||
const seen = stubEverything({ held, manifest: manifestOf([row(12, 'a')]) })
|
||||
t.after(restore)
|
||||
|
||||
db.saveAssets = async (rows, meta, remove) => {
|
||||
seen.saved = rows
|
||||
removedKeys = remove
|
||||
return rows.length
|
||||
}
|
||||
|
||||
const result = await model.importAssets({ force: true, approve: true })
|
||||
|
||||
assert.equal(result.removed, 1)
|
||||
// The file was already unlinked before this fix; the ROW was not. A row whose
|
||||
// picture is gone keeps being counted, keeps being offered for review on every
|
||||
// forced import, and can still point a creature page at a file that is not
|
||||
// there — with the import reporting "nothing was changed" about a deletion it
|
||||
// had already performed.
|
||||
assert.deepEqual(removedKeys, ['body/99/a0'])
|
||||
assert.equal(fs.existsSync(path.join(dir, model.ART_SUBDIR, 'gone.png')), false)
|
||||
})
|
||||
|
||||
@@ -58,7 +58,8 @@ function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {})
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Spawns', `${facet}.xml`),
|
||||
`<Spawns>
|
||||
<Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
|
||||
<Points><Name>${facet}A</Name><UniqueId>uid-${facet}-A</UniqueId>
|
||||
<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>
|
||||
@@ -112,6 +113,29 @@ function tempTree(options) {
|
||||
|
||||
// ── buildAtlas against a custom-facet tree ─────────────────────────────────
|
||||
|
||||
test('buildAtlas: a point keeps the UniqueId a property lease targets', () => {
|
||||
// The field is asserted on the AGGREGATOR's output, not the parser's, which is
|
||||
// the whole point of this test. `parsePoints` produced it from Phase 12b
|
||||
// onwards and `PARSER_VERSION`'s own note said a point kept it, while the
|
||||
// mapping in `buildAtlas` rebuilt each point from an explicit field list that
|
||||
// omitted it — so `shard_spawn_points.unique_id` was NULL on every row, and
|
||||
// `listSpawners`, whose WHERE is `unique_id IS NOT NULL`, answered empty. That
|
||||
// left `uo.options.spawners` an empty dropdown and every Phase 12b
|
||||
// object-property lease unauthorable. Found by the Phase 16b released-artefact
|
||||
// walk, against a real tree whose files carry ~6,400 of these.
|
||||
//
|
||||
// The fixture above had no <UniqueId> at all until this test, which is exactly
|
||||
// why a green suite said nothing about it.
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
const atlas = buildAtlas(root)
|
||||
const named = atlas.points.find((p) => p.name === 'SosariaA')
|
||||
assert.equal(named.uniqueId, 'uid-Sosaria-A')
|
||||
// And a point whose file names none is absent rather than empty-string, so the
|
||||
// DB layer's `unique_id IS NOT NULL AND <> ''` reads it the same way either way.
|
||||
const unnamed = atlas.points.find((p) => p.name === 'SosariaB')
|
||||
assert.ok(!unnamed.uniqueId)
|
||||
})
|
||||
|
||||
test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
|
||||
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||||
const atlas = buildAtlas(root)
|
||||
|
||||
413
server/test/treeBridge.test.js
Normal file
413
server/test/treeBridge.test.js
Normal file
@@ -0,0 +1,413 @@
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const zlib = require('zlib')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Installs the `ctx` core would have handed over — treeBridge takes a logger
|
||||
// from it at call time, so a test that skips this dies on the first log line.
|
||||
require('./_setup')
|
||||
|
||||
const uoLinkClient = require('../utils/uoLinkClient')
|
||||
const treeBridge = require('../utils/treeBridge')
|
||||
const { buildFrom, readFrom } = require('../utils/spawnAtlasSource')
|
||||
|
||||
// The atlas source walk over the bridge (docs/link/v8.md §10 — protocol 8,
|
||||
// phase 7), driven against a stub that behaves the way `BridgeTree.cs` does.
|
||||
//
|
||||
// The test that matters most is the LAST one: the same synthetic tree, read off
|
||||
// a disk and read over the bridge, must produce a byte-identical atlas. Every
|
||||
// other test here is one specific way a walk can end in something that LOOKS
|
||||
// imported — which is the failure mode this whole family is shaped around, since
|
||||
// XML is forgiving enough that a tree reassembled wrong still parses and simply
|
||||
// has fewer spawns in it.
|
||||
|
||||
const saved = {}
|
||||
|
||||
function restore() {
|
||||
for (const [name, fn] of Object.entries(saved)) {
|
||||
if (fn) uoLinkClient[name] = fn
|
||||
}
|
||||
}
|
||||
|
||||
after(restore)
|
||||
|
||||
const ok = (data) => ({ ok: true, status: 200, data })
|
||||
const fail = (status, data) => ({ ok: false, status, data })
|
||||
const sha = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
|
||||
|
||||
// ── A stub shard ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Chunks and gzips exactly as the overlay does, so the reader under test is
|
||||
// exercised against the wire shape rather than against a convenience.
|
||||
|
||||
function serveTree(files, { chunkBytes = 64, catalog = 'cafebabe12345678', tweak = {} } = {}) {
|
||||
saved.getAssetManifest = saved.getAssetManifest ?? uoLinkClient.getAssetManifest
|
||||
saved.fetchAssets = saved.fetchAssets ?? uoLinkClient.fetchAssets
|
||||
|
||||
const chunksOf = (bytes) => Math.max(1, Math.ceil(bytes.length / chunkBytes))
|
||||
|
||||
const rows = files.map(([label, bytes]) => ({
|
||||
key: `tree/${label}`,
|
||||
label,
|
||||
bytes: bytes.length,
|
||||
mtime: 1700000000000,
|
||||
chunks: chunksOf(bytes),
|
||||
sha256: sha(bytes),
|
||||
}))
|
||||
|
||||
const byLabel = new Map(files)
|
||||
const calls = { manifest: 0, fetch: 0 }
|
||||
|
||||
uoLinkClient.getAssetManifest = async ({ family, cursor } = {}) => {
|
||||
calls.manifest++
|
||||
assert.equal(family, 'tree', 'the walk must name its family')
|
||||
assert.equal(cursor ?? null, null, 'this stub answers in one page')
|
||||
if (tweak.manifestReply) return tweak.manifestReply(rows, catalog)
|
||||
return ok({
|
||||
kind: 'assets.manifest.ok',
|
||||
family: 'tree',
|
||||
catalog,
|
||||
chunkBytes,
|
||||
total: rows.length,
|
||||
rows,
|
||||
more: false,
|
||||
cut: 'end',
|
||||
})
|
||||
}
|
||||
|
||||
uoLinkClient.fetchAssets = async ({ keys, catalog: asked } = {}) => {
|
||||
calls.fetch++
|
||||
assert.equal(asked, catalog, 'a fetch must assert the catalog it was listed under')
|
||||
|
||||
const out = []
|
||||
|
||||
for (const key of keys) {
|
||||
const slash = key.lastIndexOf('/')
|
||||
const label = key.slice('tree/'.length, slash)
|
||||
const chunk = Number(key.slice(slash + 2))
|
||||
const bytes = byLabel.get(label)
|
||||
|
||||
if (!bytes) {
|
||||
out.push({ key, status: 'absent', reason: 'no such file' })
|
||||
continue
|
||||
}
|
||||
|
||||
const raw = bytes.subarray(chunk * chunkBytes, (chunk + 1) * chunkBytes)
|
||||
|
||||
out.push({
|
||||
key,
|
||||
status: 'ok',
|
||||
label,
|
||||
chunk,
|
||||
chunks: chunksOf(bytes),
|
||||
offset: chunk * chunkBytes,
|
||||
bytes: raw.length,
|
||||
sha256: sha(raw),
|
||||
gzip: zlib.gzipSync(raw).toString('base64'),
|
||||
})
|
||||
}
|
||||
|
||||
if (tweak.fetchRows) tweak.fetchRows(out)
|
||||
|
||||
return ok({
|
||||
kind: 'assets.fetch.ok',
|
||||
family: 'tree',
|
||||
catalog,
|
||||
rows: out,
|
||||
more: false,
|
||||
cut: 'end',
|
||||
...(tweak.fetchEnvelope || {}),
|
||||
})
|
||||
}
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
const FILES = [
|
||||
['Data/Regions.xml', Buffer.from('<ServerRegions><Region /></ServerRegions>', 'utf8')],
|
||||
['Spawns/Sosaria.xml', Buffer.from('<Spawns>' + 'x'.repeat(400) + '</Spawns>', 'utf8')],
|
||||
]
|
||||
|
||||
// ── The walk ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('a chunked, gzipped tree reassembles to the exact bytes the shard holds', async () => {
|
||||
const calls = serveTree(FILES)
|
||||
|
||||
const { files } = await treeBridge.readSources()
|
||||
|
||||
assert.equal(files.length, 2)
|
||||
assert.equal(calls.manifest, 1, 'one manifest call')
|
||||
|
||||
for (const [label, bytes] of FILES) {
|
||||
const got = files.find((f) => f.label === label)
|
||||
assert.ok(got, `${label} came back`)
|
||||
assert.equal(got.text, bytes.toString('utf8'))
|
||||
assert.equal(got.bytes, bytes.length)
|
||||
assert.equal(got.sha256, sha(bytes))
|
||||
}
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('chunks are placed by their declared index, not by the order they arrive in', async () => {
|
||||
// The rows come back in the order they were asked for today. A reader that
|
||||
// appended them would agree with this test until the day something reorders a
|
||||
// page — and then produce a file that still parses and is quietly wrong.
|
||||
serveTree(FILES, { tweak: { fetchRows: (rows) => rows.reverse() } })
|
||||
|
||||
const { files } = await treeBridge.readSources()
|
||||
const spawns = files.find((f) => f.label === 'Spawns/Sosaria.xml')
|
||||
|
||||
assert.equal(spawns.text, FILES[1][1].toString('utf8'))
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a chunk the shard refuses fails the import rather than shortening a file', async () => {
|
||||
serveTree(FILES, {
|
||||
tweak: {
|
||||
fetchRows: (rows) => {
|
||||
rows[rows.length - 1] = { key: rows[rows.length - 1].key, status: 'absent', reason: 'gone' }
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), /refused .*absent: gone/)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a missing chunk is named, with which one and out of how many', async () => {
|
||||
serveTree(FILES, { tweak: { fetchRows: (rows) => rows.splice(2, 1) } })
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), /missing chunk 1 of/)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a chunk that does not match its own hash is refused', async () => {
|
||||
serveTree(FILES, {
|
||||
tweak: {
|
||||
fetchRows: (rows) => {
|
||||
rows[1].gzip = zlib.gzipSync(Buffer.from('not what was hashed')).toString('base64')
|
||||
rows[1].bytes = 19
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), /does not match its own hash/)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a file whose reassembly does not match its manifest hash is refused', async () => {
|
||||
// Every chunk is individually honest and the whole is not — which is what a
|
||||
// dropped or duplicated chunk looks like from here.
|
||||
serveTree(FILES, {
|
||||
tweak: {
|
||||
manifestReply: (rows, catalog) =>
|
||||
ok({
|
||||
kind: 'assets.manifest.ok',
|
||||
family: 'tree',
|
||||
catalog,
|
||||
chunkBytes: 64,
|
||||
total: rows.length,
|
||||
rows: rows.map((r) => ({ ...r, sha256: r.sha256.replace(/^./, '0') })),
|
||||
more: false,
|
||||
cut: 'end',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), /does not match the hash its manifest row carried/)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a tree that moves mid-read is refused rather than stitched together', async () => {
|
||||
serveTree(FILES, { tweak: { fetchEnvelope: { catalog: 'deadbeefdeadbeef' } } })
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), {
|
||||
code: 'SOURCE_CHANGED',
|
||||
})
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a short page that did not end the walk is refused', async () => {
|
||||
serveTree(FILES, { tweak: { fetchEnvelope: { more: false, cut: 'budget' } } })
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), { code: 'INCOMPLETE' })
|
||||
restore()
|
||||
})
|
||||
|
||||
test('403 names the tree switch, not the asset switch', async () => {
|
||||
// The two consents are different settings with different fixes, and sending an
|
||||
// operator to Bridge.AssetsEnabled when the answer is Bridge.TreeEnabled costs
|
||||
// them an afternoon.
|
||||
saved.getAssetManifest = saved.getAssetManifest ?? uoLinkClient.getAssetManifest
|
||||
uoLinkClient.getAssetManifest = async () => fail(403, { reason: 'not served' })
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), {
|
||||
code: 'DISABLED',
|
||||
message: /Bridge\.TreeEnabled/,
|
||||
})
|
||||
restore()
|
||||
})
|
||||
|
||||
test('the manifest alone answers the drift gate, with no file bytes at all', async () => {
|
||||
const calls = serveTree(FILES)
|
||||
|
||||
const listing = await treeBridge.manifest()
|
||||
const fingerprint = treeBridge.fingerprintOf(listing.files)
|
||||
|
||||
assert.equal(calls.fetch, 0, 'nothing was fetched to answer "has anything changed"')
|
||||
assert.deepEqual(Object.keys(fingerprint).sort(), [
|
||||
'Data/Regions.xml',
|
||||
'Spawns/Sosaria.xml',
|
||||
])
|
||||
assert.equal(fingerprint['Spawns/Sosaria.xml'], sha(FILES[1][1]))
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a zero-byte file crosses as one chunk carrying a real gzip stream', async () => {
|
||||
// Stock ServUO 57.4 ships TWO empty decoration files, so this is the ordinary
|
||||
// case rather than an edge one — and it is the case .NET gets wrong on its own:
|
||||
// `GZipStream` writes the gzip header lazily, so zero bytes in produces zero
|
||||
// bytes out, which is not a gzip stream at all. The overlay answers with a
|
||||
// literal empty member; a reader that accepted an empty payload instead would
|
||||
// have hidden the bug rather than caught it.
|
||||
const empty = Buffer.alloc(0)
|
||||
|
||||
serveTree([
|
||||
['Data/Regions.xml', Buffer.from('<ServerRegions />', 'utf8')],
|
||||
['Data/Decoration/nothing.cfg', empty],
|
||||
])
|
||||
|
||||
const { files } = await treeBridge.readSources()
|
||||
const blank = files.find((f) => f.label === 'Data/Decoration/nothing.cfg')
|
||||
|
||||
assert.equal(blank.bytes, 0)
|
||||
assert.equal(blank.text, '')
|
||||
assert.equal(blank.sha256, sha(empty))
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('an empty payload for a chunk is refused, whatever the row declares', async () => {
|
||||
serveTree(FILES, { tweak: { fetchRows: (rows) => { rows[0].gzip = '' } } })
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), /Could not decompress/)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a tree-only shard tells the client-file readers so, rather than looking empty', async () => {
|
||||
// Phase 7 opened `assets.sources` to a shard that serves ONLY its configuration
|
||||
// tree, so a 200 from it stopped meaning "the client files are on offer". Both
|
||||
// client-file readers have to say DISABLED rather than read the empty file list
|
||||
// as "your UO client has no cliloc.enu", which sends an operator to their client
|
||||
// install for a setting that lives on their shard.
|
||||
const clilocBridge = require('../utils/clilocBridge')
|
||||
const assetBridge2 = require('../utils/assetBridge')
|
||||
|
||||
saved.getAssetSources = saved.getAssetSources ?? uoLinkClient.getAssetSources
|
||||
uoLinkClient.getAssetSources = async () =>
|
||||
ok({
|
||||
kind: 'assets.sources.ok',
|
||||
extractorVersion: 3,
|
||||
assetsEnabled: false,
|
||||
treeEnabled: true,
|
||||
imaging: { ok: true },
|
||||
families: ['tree'],
|
||||
files: [],
|
||||
more: false,
|
||||
cut: 'end',
|
||||
complete: true,
|
||||
})
|
||||
|
||||
await assert.rejects(() => clilocBridge.fingerprint(), {
|
||||
code: 'DISABLED',
|
||||
message: /Bridge\.AssetsEnabled/,
|
||||
})
|
||||
await assert.rejects(() => assetBridge2.sourceFingerprint(), {
|
||||
code: 'DISABLED',
|
||||
message: /Bridge\.AssetsEnabled/,
|
||||
})
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
// ── The parity test ────────────────────────────────────────────────────────
|
||||
|
||||
test('the same tree read off a disk and read over the bridge builds the same atlas', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-parity-'))
|
||||
|
||||
const tree = [
|
||||
[
|
||||
'Data/Regions.xml',
|
||||
'<?xml version="1.0"?><ServerRegions>'
|
||||
+ '<region type="Region"><name>Yew</name><map>Sosaria</map>'
|
||||
+ '<rect x="100" y="100" width="200" height="200" /></region>'
|
||||
+ '</ServerRegions>',
|
||||
],
|
||||
[
|
||||
'Data/Locations/Sosaria.xml',
|
||||
'<?xml version="1.0"?><locations><location><name>Yew Bank</name>'
|
||||
+ '<x>150</x><y>150</y><z>0</z></location></locations>',
|
||||
],
|
||||
[
|
||||
'Spawns/Sosaria.xml',
|
||||
'<?xml version="1.0"?><Spawns>'
|
||||
+ Array.from({ length: 40 }, (_, i) =>
|
||||
`<Spawn Name="s${i}" X="${120 + i}" Y="${130 + i}" Map="Sosaria" Count="3" `
|
||||
+ 'Running="True" MinDelay="00:05:00" MaxDelay="00:10:00" SpawnRange="5" '
|
||||
+ 'HomeRange="5"><Object>Lizardman</Object></Spawn>').join('')
|
||||
+ '</Spawns>',
|
||||
],
|
||||
['Config/ChampionSpawns.xml', '<?xml version="1.0"?><champions />'],
|
||||
['Data/Decoration/top.cfg', 'Brazier 0x0E31\n100 100 0\n'],
|
||||
['Data/Decoration/Deep/nested.cfg', 'LargeCrate 0x0E3C\n500 500 0\n'],
|
||||
]
|
||||
|
||||
for (const [label, text] of tree) {
|
||||
const file = path.join(root, label.replace(/\//g, path.sep))
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, text, 'utf8')
|
||||
}
|
||||
|
||||
const fromDisk = await buildFrom({ kind: 'fs', root })
|
||||
|
||||
// A chunk size small enough that the spawn file alone is dozens of chunks,
|
||||
// because a one-chunk-per-file test proves nothing about reassembly.
|
||||
serveTree(tree.map(([label, text]) => [label, Buffer.from(text, 'utf8')]), { chunkBytes: 37 })
|
||||
|
||||
const fromBridge = await buildFrom({ kind: 'bridge' })
|
||||
|
||||
// `generatedAt` is a timestamp and the only field that legitimately differs.
|
||||
delete fromDisk.meta.generatedAt
|
||||
delete fromBridge.meta.generatedAt
|
||||
|
||||
// **Serialised, not deepEqual.** `deepEqual` ignores object key order, and key
|
||||
// order is precisely what differed between the two readers on a real tree —
|
||||
// which a live walk caught and this test, written first, did not.
|
||||
assert.equal(JSON.stringify(fromBridge), JSON.stringify(fromDisk))
|
||||
assert.deepEqual(fromBridge, fromDisk)
|
||||
|
||||
// And the source fingerprints agree, which is what makes switching backends on
|
||||
// an existing install NOT look like a change to the drift gate.
|
||||
assert.deepEqual(fromBridge.meta.source, fromDisk.meta.source)
|
||||
|
||||
restore()
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('readFrom hands both backends back in one shape', async () => {
|
||||
serveTree(FILES)
|
||||
|
||||
const bridged = await readFrom({ kind: 'bridge' })
|
||||
|
||||
assert.deepEqual(Object.keys(bridged), ['files'])
|
||||
assert.deepEqual(Object.keys(bridged.files[0]).sort(), ['bytes', 'label', 'sha256', 'text'])
|
||||
|
||||
restore()
|
||||
})
|
||||
@@ -563,6 +563,78 @@ test('an atlas larger than the dropdown bound is truncated and said so', async (
|
||||
assert.ok(warned, 'a truncated source must leave a log line naming itself')
|
||||
})
|
||||
|
||||
// ── A landmark option value names ONE landmark (Phase 16b) ────────────────
|
||||
|
||||
test('two landmarks sharing a name are two different options, and both resolve', async () => {
|
||||
// A stock 57.4 tree has 558 landmarks under 320 distinct `facet/name` pairs:
|
||||
// `Trammel/Entrance` is 23 different dungeons. The source emitted `facet/name`
|
||||
// and `landmarkPoint` resolved with `.find()`, so 22 of the 23 were unreachable
|
||||
// — an author who picked "Entrance — Destard" got Blighted Grove, with a
|
||||
// successful run and no warning. The group was already the disambiguator and it
|
||||
// was shown to the eye while being left out of the value.
|
||||
//
|
||||
// Asserted as an INEQUALITY between two resolved points rather than against a
|
||||
// literal value string, so it survives someone changing the value's format
|
||||
// again as long as the two options still address two places.
|
||||
shardAtlas.listLandmarks = async () => [
|
||||
{ facet: 'Felucca', name: 'Entrance', group: 'Blighted Grove', x: 586, y: 1643, z: 0 },
|
||||
{ facet: 'Felucca', name: 'Entrance', group: 'Destard', x: 1176, y: 2637, z: 0 },
|
||||
]
|
||||
|
||||
const source = actions.OPTION_SOURCES.find((s) => s.id === 'uo.options.landmarks')
|
||||
const options = await source.resolve({})
|
||||
assert.equal(options.length, 2)
|
||||
assert.equal(new Set(options.map((o) => o.value)).size, 2, 'both options must be addressable')
|
||||
|
||||
const points = []
|
||||
for (const option of options) {
|
||||
const result = await byId('uo.creature.spawn').perform({
|
||||
runId: 41,
|
||||
idempotencyKey: `L${option.value}`.padEnd(40, 'x'),
|
||||
params: { place: option.value, creature: 'Orc', count: 1 },
|
||||
verify: true,
|
||||
})
|
||||
assert.equal(result.ok, true, `${option.value} must resolve`)
|
||||
points.push(option.value)
|
||||
}
|
||||
assert.notEqual(points[0], points[1])
|
||||
})
|
||||
|
||||
test('a place published before the group was carried still resolves', async () => {
|
||||
// Every event published before the fix stores `facet/name`, and a published
|
||||
// version is immutable — so a parse that stopped understanding the two-part
|
||||
// form would break those runs rather than correct them. It keeps the old
|
||||
// first-match read, which is imprecise in exactly the way it always was.
|
||||
shardAtlas.listLandmarks = async () => [
|
||||
{ facet: 'Felucca', name: 'Entrance', group: 'Blighted Grove', x: 586, y: 1643, z: 0 },
|
||||
{ facet: 'Felucca', name: 'Entrance', group: 'Destard', x: 1176, y: 2637, z: 0 },
|
||||
// A name carrying a slash reads as three parts too; the two-part read is what
|
||||
// resolves it, which is why the three-part attempt must not answer for it.
|
||||
{ facet: 'Felucca', name: 'Odd/Name', group: null, x: 10, y: 20, z: 0 },
|
||||
]
|
||||
|
||||
for (const place of ['Felucca/Entrance', 'Felucca/Odd/Name']) {
|
||||
const result = await byId('uo.creature.spawn').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: `P${place}`.padEnd(40, 'x'),
|
||||
params: { place, creature: 'Orc', count: 1 },
|
||||
verify: true,
|
||||
})
|
||||
assert.equal(result.ok, true, `${place} must still resolve`)
|
||||
}
|
||||
|
||||
// And a three-part value whose group is gone REFUSES rather than silently
|
||||
// landing somewhere else. That is the honest answer: it asked for one place.
|
||||
const gone = await byId('uo.creature.spawn').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'G'.repeat(40),
|
||||
params: { place: 'Felucca/Renamed/Entrance', creature: 'Orc', count: 1 },
|
||||
verify: true,
|
||||
})
|
||||
assert.equal(gone.ok, false)
|
||||
assert.match(gone.error, /no landmark called/)
|
||||
})
|
||||
|
||||
// ── The world verbs (Phase 12a) ───────────────────────────────
|
||||
|
||||
test('a spawn files one ledger row per serial, not one per call', async () => {
|
||||
|
||||
@@ -153,10 +153,10 @@ async function withBusyRetry(send, what) {
|
||||
* warnings: a truncated catalogue is indistinguishable downstream from a client
|
||||
* that simply has fewer creatures.
|
||||
*/
|
||||
function checkPage(page, { arrayName, cursor, pages }) {
|
||||
function checkPage(page, { arrayName, cursor, pages, noun = 'asset' }) {
|
||||
if (!page || !Array.isArray(page[arrayName])) {
|
||||
throw new AssetBridgeError(
|
||||
`The shard sent an asset page with no ${arrayName} array`,
|
||||
`The shard sent a ${noun} page with no ${arrayName} array`,
|
||||
'MALFORMED',
|
||||
)
|
||||
}
|
||||
@@ -164,7 +164,7 @@ function checkPage(page, { arrayName, cursor, pages }) {
|
||||
if (!page.more) {
|
||||
if (page.cut !== 'end') {
|
||||
throw new AssetBridgeError(
|
||||
`The shard stopped sending assets after ${pages} page(s) (cut: ${page.cut || 'unknown'})`,
|
||||
`The shard stopped sending ${noun} rows after ${pages} page(s) (cut: ${page.cut || 'unknown'})`,
|
||||
'INCOMPLETE',
|
||||
)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ function checkPage(page, { arrayName, cursor, pages }) {
|
||||
|
||||
if (!page.cursor || page.cursor === cursor) {
|
||||
throw new AssetBridgeError(
|
||||
`The shard asked for another asset page without advancing its cursor (${page.cursor || 'none'})`,
|
||||
`The shard asked for another ${noun} page without advancing its cursor (${page.cursor || 'none'})`,
|
||||
'STUCK',
|
||||
)
|
||||
}
|
||||
@@ -181,6 +181,17 @@ function checkPage(page, { arrayName, cursor, pages }) {
|
||||
return { done: false, cursor: page.cursor }
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 of a buffer, lowercase hex.
|
||||
*
|
||||
* Here rather than in each caller because the shard's `BridgeAssets.Sha256Hex`
|
||||
* is one function on its side too, and a hash that has to match across a wire
|
||||
* should have exactly one spelling at each end.
|
||||
*/
|
||||
function sha256Of(buffer) {
|
||||
return require('crypto').createHash('sha256').update(buffer).digest('hex')
|
||||
}
|
||||
|
||||
// The client files the body catalogue is derived from. `assets.sources` reports
|
||||
// every file the shard can see; these are the ones that decide a sprite.
|
||||
//
|
||||
@@ -218,6 +229,18 @@ async function sourceFingerprint() {
|
||||
const res = await uoLinkClient.getAssetSources()
|
||||
if (!res.ok) throw describeFailure(res, 'client file manifest')
|
||||
|
||||
// A 200 from this call stopped meaning "client files are on offer" in phase 7:
|
||||
// it now answers whenever either plane is enabled, so a shard serving only its
|
||||
// configuration tree reports an empty file list rather than a 403. Read as-is
|
||||
// that becomes "your client has no animation files", which sends an operator to
|
||||
// the wrong place entirely.
|
||||
if (res.data?.assetsEnabled === false) {
|
||||
throw new AssetBridgeError(
|
||||
'The shard is refusing to serve client assets (Bridge.AssetsEnabled is off)',
|
||||
'DISABLED',
|
||||
)
|
||||
}
|
||||
|
||||
const wanted = new Set(SOURCE_FILES)
|
||||
const files = {}
|
||||
|
||||
@@ -332,6 +355,11 @@ async function readManifest({ family = FAMILY } = {}) {
|
||||
width: Number(row?.width) || 0,
|
||||
height: Number(row?.height) || 0,
|
||||
body: Number.isFinite(Number(row?.body)) ? Number(row.body) : null,
|
||||
// Which action the thumbnail came from (§11.2, phase 6). All but 73 of
|
||||
// this client's bodies answer 0; the rest have no art there and are
|
||||
// catalogued deeper, with the key naming the action. An overlay older
|
||||
// than phase 6 omits it, and 0 is the right reading of that.
|
||||
action: Number.isFinite(Number(row?.action)) ? Number(row.action) : 0,
|
||||
direction: Number.isFinite(Number(row?.direction)) ? Number(row.direction) : null,
|
||||
})
|
||||
}
|
||||
@@ -374,7 +402,7 @@ async function readManifest({ family = FAMILY } = {}) {
|
||||
/**
|
||||
* The bytes for an explicit list of keys.
|
||||
*
|
||||
* Returns a Map of key → `{ sha256, bytes, width, height, body, direction, png }`
|
||||
* Returns a Map of key → `{ sha256, bytes, width, height, body, action, direction, png }`
|
||||
* where `png` is a Buffer. A key the shard could not serve is **absent from the
|
||||
* map** rather than present with a null — the caller then decides what that means
|
||||
* for its own row, and the two ways it happens (`absent`, `unsupported`) are
|
||||
@@ -449,6 +477,7 @@ async function fetchAssets({ keys, catalog } = {}) {
|
||||
width: Number(row.width) || 0,
|
||||
height: Number(row.height) || 0,
|
||||
body: Number.isFinite(Number(row.body)) ? Number(row.body) : null,
|
||||
action: Number.isFinite(Number(row.action)) ? Number(row.action) : null,
|
||||
direction: Number.isFinite(Number(row.direction)) ? Number(row.direction) : null,
|
||||
// Phase 5's art families carry these; the body catalogue does not, and a
|
||||
// consumer that wants neither is unaffected by either.
|
||||
@@ -558,6 +587,12 @@ async function resolveBodies({ creatures } = {}) {
|
||||
|
||||
module.exports = {
|
||||
AssetBridgeError,
|
||||
// Shared with `treeBridge.js` (phase 7): the 425 backoff, the page-envelope
|
||||
// checks and the hash are properties of this PLANE, not of the body family, and
|
||||
// a second copy of any of them is a second place for the envelope to drift.
|
||||
withBusyRetry,
|
||||
checkPage,
|
||||
sha256Of,
|
||||
FAMILY,
|
||||
BODY_CHUNK,
|
||||
FETCH_CHUNK,
|
||||
|
||||
@@ -127,6 +127,18 @@ async function fingerprint() {
|
||||
const res = await uoLinkClient.getAssetSources()
|
||||
if (!res.ok) throw describeFailure(res, 'client file manifest')
|
||||
|
||||
// Since protocol 8 phase 7 this call answers when EITHER plane is enabled, so
|
||||
// a 200 no longer means the client files are on offer. Without this check an
|
||||
// operator who switched client-file extraction off would read "your UO client
|
||||
// has no cliloc.enu" and go looking at their client install for a setting that
|
||||
// lives on their shard.
|
||||
if (res.data?.assetsEnabled === false) {
|
||||
throw new ClilocBridgeError(
|
||||
'The shard is refusing to serve client assets (Bridge.AssetsEnabled is off)',
|
||||
'DISABLED',
|
||||
)
|
||||
}
|
||||
|
||||
const files = Array.isArray(res.data?.files) ? res.data.files : []
|
||||
const entry = files.find((f) => String(f?.name || '').toLowerCase() === SOURCE_FILE)
|
||||
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
// - the server, which refreshes the atlas on boot (`shardAtlas.model.js`)
|
||||
// - the CLI (`scripts/importSpawnAtlas.js`)
|
||||
//
|
||||
// **As of protocol 8 phase 7 it is no longer the only way in** (docs/link/v8.md
|
||||
// §10). `treeBridge.js` reads the same five labelled groups off the SHARD, over
|
||||
// the sidecar, and hands back files in exactly the shape `readSources` produces
|
||||
// here — which is why `buildFromFiles` below is where the parse actually starts
|
||||
// and both readers feed it. That closes the one place the platform's rule (only
|
||||
// the sidecar bridges the shard) was broken, and broken by the component that
|
||||
// faces the internet: this file's `SERVUO_PATH` required the WEBSITE to be able
|
||||
// to read the shard's directories.
|
||||
//
|
||||
// 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
|
||||
@@ -48,8 +57,20 @@ class AtlasSourceError extends Error {
|
||||
|
||||
// ── Reading ────────────────────────────────────────────────────────────────
|
||||
|
||||
function sha256(text) {
|
||||
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
|
||||
/**
|
||||
* The fingerprint of one source file, over its RAW BYTES.
|
||||
*
|
||||
* Bytes rather than the decoded string, so that this reader and the bridge
|
||||
* reader cannot disagree. The shard hashes what it sends; a hash taken here over
|
||||
* `text` would be a hash of a UTF-8 RE-ENCODING of what was read — identical for
|
||||
* every valid UTF-8 file, and different for one that is not, because Node's utf8
|
||||
* decode replaces each undecodable byte with U+FFFD and the re-encode never gets
|
||||
* them back. A spawn file with one Latin-1 character in a creature name would
|
||||
* then fingerprint differently depending on which end read it, and the drift gate
|
||||
* would report a change on every single import, forever, with the tree untouched.
|
||||
*/
|
||||
function sha256(bytes) {
|
||||
return crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
function listXml(dir) {
|
||||
@@ -93,7 +114,7 @@ function listCfgTree(dir, prefix = '') {
|
||||
|
||||
function readIfPresent(file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
return fs.readFileSync(file)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null
|
||||
throw err
|
||||
@@ -118,9 +139,14 @@ function readSources(root) {
|
||||
|
||||
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') })
|
||||
const bytes = readIfPresent(file)
|
||||
if (bytes === null) return false
|
||||
files.push({
|
||||
label,
|
||||
text: bytes.toString('utf8'),
|
||||
sha256: sha256(bytes),
|
||||
bytes: bytes.length,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -180,8 +206,25 @@ function hashSources(root) {
|
||||
* targets (Phase 12b). The bump is what re-reads a tree the boot path
|
||||
* would otherwise skip on an unchanged hash — the source files have not
|
||||
* changed, only what is kept from them.
|
||||
* 5 — and it did NOT keep it: version 4 bumped the parser and the aggregator
|
||||
* below still discarded the field, so the intent above shipped as a
|
||||
* comment. This bump is what makes an already-imported tree re-read now
|
||||
* that the mapping keeps it; without it `sameSources` sees an unchanged
|
||||
* tree and every existing install stays empty. Released in v1.2.2.
|
||||
* 6 — source files are parsed in one canonical label order (protocol 8 phase
|
||||
* 7). The decoration index keeps the first item id it sees for a type, so
|
||||
* the read order decided a preview graphic; it now cannot differ between a
|
||||
* tree read off a disk and the same tree read over the bridge. Identical
|
||||
* sources, and for a handful of types a different answer, which is exactly
|
||||
* what this number exists to make reach an install.
|
||||
*
|
||||
* This was written as 5 on `edge` while 5 was being released from `main`
|
||||
* meaning something else, so the cutover renumbered it: an install that
|
||||
* imported under v1.2.2 already stores 5, and had the number not moved,
|
||||
* `sameSources` would have called that tree current and this change would
|
||||
* have reached nobody who was already running.
|
||||
*/
|
||||
const PARSER_VERSION = 4
|
||||
const PARSER_VERSION = 6
|
||||
|
||||
/** True when two source fingerprints describe the same tree. */
|
||||
function sameSources(a, b) {
|
||||
@@ -251,8 +294,45 @@ function aggregateCreatures(points) {
|
||||
* here writes. `shardAtlas.model.js` decides what to do with the result.
|
||||
*/
|
||||
function buildAtlas(root, options = {}) {
|
||||
const { files } = readSources(root)
|
||||
return buildFromFiles(readSources(root).files, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* The parse itself, over files that have already been read.
|
||||
*
|
||||
* Split out in phase 7 so that a tree which arrived over the sidecar and a tree
|
||||
* read off a local disk go through the SAME code from here on. The alternative —
|
||||
* a second build for the bridge — would have been a second place for the facet
|
||||
* reconciliation, the decoration case-folding and the disabled-spawner filter to
|
||||
* be subtly different, and the difference would only ever show up as one install
|
||||
* having a slightly wrong atlas.
|
||||
*/
|
||||
function buildFromFiles(files, options = {}) {
|
||||
// **Sorted here, once, whatever order the reader handed them over in.**
|
||||
//
|
||||
// Order is not cosmetic in this parse: the decoration index keeps the FIRST
|
||||
// item id it sees for a type and the first spelling of it, and `meta.source` is
|
||||
// written in iteration order. Both readers happen to agree on a stock tree, and
|
||||
// "happen to" is the problem — the filesystem reader walks each decoration
|
||||
// directory with `localeCompare` while the shard sorts whole relative paths,
|
||||
// and those two disagree the moment a directory mixes cases. A tree read over
|
||||
// the bridge would then produce a subtly different atlas from the same tree read
|
||||
// off a disk, in a way nothing reports and only a side-by-side diff would find.
|
||||
//
|
||||
// A plain ordinal comparison rather than `localeCompare`, because the answer
|
||||
// must not depend on the host's ICU data either.
|
||||
files = [...files].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))
|
||||
|
||||
const byLabel = new Map(files.map((file) => [file.label, file]))
|
||||
|
||||
if (!byLabel.has('Data/Regions.xml')) {
|
||||
throw new AtlasSourceError('Missing required file: Data/Regions.xml', 'NO_REGIONS')
|
||||
}
|
||||
|
||||
if (!files.some((file) => file.label.startsWith('Spawns/'))) {
|
||||
throw new AtlasSourceError('No spawn files found in Spawns', 'NO_SPAWNS')
|
||||
}
|
||||
|
||||
const source = {}
|
||||
for (const file of files) source[file.label] = { bytes: file.bytes, sha256: file.sha256 }
|
||||
|
||||
@@ -304,6 +384,16 @@ function buildAtlas(root, options = {}) {
|
||||
const place = resolveRegion(point.x, point.y, point.facet, placement, resolveOpts)
|
||||
return {
|
||||
name: point.name,
|
||||
// **The field this whole `PARSER_VERSION` note was about, and it was
|
||||
// dropped right here.** The parser has produced it since Phase 12b and
|
||||
// the column and the query have both been waiting for it, but this
|
||||
// mapping rebuilds each point from an explicit field list and `uniqueId`
|
||||
// was not on it — so every row landed with `unique_id` NULL, and
|
||||
// `listSpawners`, whose WHERE is `unique_id IS NOT NULL`, could only ever
|
||||
// answer empty. That made `uo.options.spawners` an empty dropdown and
|
||||
// every Phase 12b object-property lease unauthorable, with nothing on the
|
||||
// form to say why. Found by the Phase 16b walk against a released bundle.
|
||||
uniqueId: point.uniqueId,
|
||||
facet: point.facet,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
@@ -403,6 +493,49 @@ function buildAtlas(root, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backends ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// One descriptor, two readers (§10, phase 7). A source is `{ kind: 'fs', root }`
|
||||
// or `{ kind: 'bridge' }`, and everything above this line belongs to the first.
|
||||
//
|
||||
// `treeBridge` is required lazily and INSIDE the functions rather than at the top
|
||||
// of the file, because this module is also loaded by `scripts/importSpawnAtlas.js`
|
||||
// and by tests that have no sidecar, no core logger and no intention of touching
|
||||
// either. A top-level require would drag the whole client stack into both.
|
||||
|
||||
/** `{ files }` from whichever end this source names. */
|
||||
async function readFrom(source) {
|
||||
if (source?.kind === 'bridge') {
|
||||
const { files } = await require('./treeBridge').readSources()
|
||||
return { files }
|
||||
}
|
||||
|
||||
return readSources(source?.root)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `{ label: sha256 }` fingerprint, from whichever end.
|
||||
*
|
||||
* The bridge answers this from the MANIFEST alone — no file bytes cross the wire
|
||||
* to answer "has anything changed", which is the whole reason the manifest is a
|
||||
* separate call. A stock tree is one page and about 32 KB.
|
||||
*/
|
||||
async function hashFrom(source) {
|
||||
if (source?.kind === 'bridge') {
|
||||
const treeBridge = require('./treeBridge')
|
||||
const listing = await treeBridge.manifest()
|
||||
return treeBridge.fingerprintOf(listing.files)
|
||||
}
|
||||
|
||||
return hashSources(source?.root)
|
||||
}
|
||||
|
||||
/** The full atlas, from whichever end. */
|
||||
async function buildFrom(source, options = {}) {
|
||||
const { files } = await readFrom(source)
|
||||
return buildFromFiles(files, options)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AtlasSourceError,
|
||||
PARSER_VERSION,
|
||||
@@ -410,6 +543,10 @@ module.exports = {
|
||||
hashSources,
|
||||
sameSources,
|
||||
buildAtlas,
|
||||
buildFromFiles,
|
||||
readFrom,
|
||||
hashFrom,
|
||||
buildFrom,
|
||||
aggregateCreatures,
|
||||
displayName,
|
||||
}
|
||||
|
||||
418
server/utils/treeBridge.js
Normal file
418
server/utils/treeBridge.js
Normal file
@@ -0,0 +1,418 @@
|
||||
// Spawn atlas sources — the SHARD half (docs/link/v8.md §10, protocol 8 phase 7).
|
||||
//
|
||||
// `spawnAtlasSource.js` reads a ServUO tree off a filesystem and has done since
|
||||
// the atlas existed. That is the half this replaces, and it is worth being blunt
|
||||
// about what was wrong with it: `SPAWN_ATLAS.md` required the WEBSITE to be able
|
||||
// to read the shard's directories — "the same host, a bind mount, or a shared
|
||||
// volume". Everything else about this platform holds that only the sidecar
|
||||
// bridges the shard, and that one requirement broke the rule using the component
|
||||
// that faces the internet.
|
||||
//
|
||||
// So the shard now serves its own files over the same request/reply path as
|
||||
// every other shard read, and `SERVUO_PATH` becomes what it should always have
|
||||
// been: the development and same-host convenience, not the design.
|
||||
//
|
||||
// **The parsers do not move.** `spawnAtlasParse.js` is pure, fs-free and covered
|
||||
// by CI without a ServUO tree anywhere near it, and every quirk it handles — the
|
||||
// two respawn delay units, `:OBJ=` splitting, facet-name reconciliation, the
|
||||
// XmlSpawner directive stripping — stays exactly where it is. The shard sends
|
||||
// bytes; the website still decides what they mean.
|
||||
//
|
||||
// ── Why a file arrives in pieces ──────────────────────────────────────────
|
||||
//
|
||||
// §10 said the shard would serve `tree/<label>` → bytes, and phase 7 measured
|
||||
// that it cannot. A stock `Spawns/trammel.xml` is 4.03 MB; the sidecar discards
|
||||
// any inbound line over 1 MiB; that file as a single base64 row is 5.4 MiB. It
|
||||
// would never arrive — the reply would be dropped, the request would time out,
|
||||
// and the import would retry forever with no error in it anywhere. Two files on
|
||||
// a STOCK tree are in that state.
|
||||
//
|
||||
// A file therefore crosses as chunks, each gzipped:
|
||||
//
|
||||
// tree/Spawns/trammel.xml the manifest row — size, hash, chunk count
|
||||
// tree/Spawns/trammel.xml/c0 the first 512 KiB of it, gzipped
|
||||
//
|
||||
// Measured on the stock 57.4 tree: 141 files, 11.34 MB, 158 chunks, three pages,
|
||||
// 1.33 MB actually on the wire.
|
||||
//
|
||||
// ── The three checks below that are not decoration ────────────────────────
|
||||
//
|
||||
// Every one of them catches a way this can end in a tree that LOOKS imported:
|
||||
//
|
||||
// - **Each chunk re-declares its own address** and carries the hash of its own
|
||||
// uncompressed bytes. A reassembly that put chunk 3 where chunk 4 belongs
|
||||
// would produce XML that still parses — XML is forgiving about what it skips
|
||||
// — and an atlas quietly missing spawns.
|
||||
// - **The whole file is hashed after reassembly** against what the manifest
|
||||
// said, which is also the fingerprint the drift gate stores.
|
||||
// - **The catalog must not move mid-walk.** An operator editing a spawn file
|
||||
// while this runs would otherwise produce one atlas stitched out of two
|
||||
// trees, with nothing anywhere reporting a problem.
|
||||
|
||||
// Required as a namespace, not destructured: a test that stubs the sidecar
|
||||
// replaces these on the module object, and a destructured copy taken at load
|
||||
// time would keep calling the real one.
|
||||
const zlib = require('zlib')
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const assetBridge = require('./assetBridge')
|
||||
const log = require('../core').logger('tree-bridge')
|
||||
|
||||
/** The §5 key family the shard serves these under. */
|
||||
const FAMILY = 'tree'
|
||||
|
||||
// How many chunk keys go in one `assets.fetch`. The shard cuts the PAGE by byte
|
||||
// budget within whatever it is asked for, so this only bounds the request; a
|
||||
// stock tree's 158 chunks fit in a single one.
|
||||
const FETCH_CHUNK = 200
|
||||
|
||||
// Bounds on the walk. Neither is expected to be reached on any real tree — the
|
||||
// stock one is 141 files and 158 chunks — and both exist so that a shard
|
||||
// answering nonsense costs a bounded amount of memory rather than all of it.
|
||||
const MAX_FILES = 20000
|
||||
const MAX_BYTES = 256 * 1024 * 1024
|
||||
|
||||
class TreeBridgeError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message)
|
||||
this.name = 'TreeBridgeError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recast an asset-plane failure as one of ours.
|
||||
*
|
||||
* The distinction worth keeping is 403: on this family it does NOT mean the
|
||||
* operator declined to serve their client files, it means they declined to serve
|
||||
* their own configuration tree — a different switch (`Bridge.TreeEnabled`) with
|
||||
* a different fix, and telling them to look at the wrong one costs them an
|
||||
* afternoon.
|
||||
*/
|
||||
function rethrow(err, what) {
|
||||
if (!(err instanceof assetBridge.AssetBridgeError)) return err
|
||||
|
||||
if (err.code === 'DISABLED') {
|
||||
return new TreeBridgeError(
|
||||
'The shard is refusing to serve its configuration tree (Bridge.TreeEnabled is off): '
|
||||
+ err.message,
|
||||
'DISABLED',
|
||||
)
|
||||
}
|
||||
|
||||
return new TreeBridgeError(`${what}: ${err.message}`, err.code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 1: every atlas source file the shard has, with its hash — and no bytes.
|
||||
*
|
||||
* Returns `{ catalog, chunkBytes, files: [{ key, label, bytes, mtime, chunks,
|
||||
* sha256 }] }`.
|
||||
*
|
||||
* This is the whole of the drift gate. The website stores these hashes; the next
|
||||
* import asks for this list again and fetches nothing at all when nothing moved,
|
||||
* which on a shard whose maps are not being edited is every import. One page and
|
||||
* about 32 KB on a stock tree.
|
||||
*/
|
||||
async function manifest() {
|
||||
const started = Date.now()
|
||||
const files = []
|
||||
|
||||
let cursor = null
|
||||
let pages = 0
|
||||
let catalog = null
|
||||
let chunkBytes = 0
|
||||
let finished = false
|
||||
|
||||
while (pages < assetBridge.MAX_PAGES) {
|
||||
let page
|
||||
|
||||
try {
|
||||
page = await assetBridge.withBusyRetry(
|
||||
() => uoLinkClient.getAssetManifest({ family: FAMILY, cursor }),
|
||||
'the shard configuration tree',
|
||||
)
|
||||
} catch (err) {
|
||||
throw rethrow(err, 'reading the tree manifest')
|
||||
}
|
||||
|
||||
pages++
|
||||
|
||||
if (catalog === null) {
|
||||
catalog = page.catalog ?? null
|
||||
chunkBytes = Number(page.chunkBytes) || 0
|
||||
} else if (page.catalog !== catalog) {
|
||||
throw new TreeBridgeError(
|
||||
"The shard's configuration tree changed while it was being listed; nothing was imported",
|
||||
'SOURCE_CHANGED',
|
||||
)
|
||||
}
|
||||
|
||||
for (const row of page.rows ?? []) {
|
||||
const label = String(row?.label ?? '')
|
||||
if (label === '') continue
|
||||
|
||||
files.push({
|
||||
key: String(row?.key ?? `${FAMILY}/${label}`),
|
||||
label,
|
||||
bytes: Number(row?.bytes) || 0,
|
||||
mtime: Number(row?.mtime) || 0,
|
||||
chunks: Number(row?.chunks) || 0,
|
||||
sha256: row?.sha256 ? String(row.sha256) : null,
|
||||
})
|
||||
}
|
||||
|
||||
if (files.length > MAX_FILES) {
|
||||
throw new TreeBridgeError(
|
||||
`The shard listed more than ${MAX_FILES} tree files; refusing to keep reading`,
|
||||
'TOO_LARGE',
|
||||
)
|
||||
}
|
||||
|
||||
let state
|
||||
|
||||
try {
|
||||
state = assetBridge.checkPage(page, { arrayName: 'rows', cursor, pages, noun: 'tree' })
|
||||
} catch (err) {
|
||||
throw rethrow(err, 'reading the tree manifest')
|
||||
}
|
||||
|
||||
if (state.done) {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
|
||||
cursor = state.cursor
|
||||
}
|
||||
|
||||
if (!finished) {
|
||||
throw new TreeBridgeError(
|
||||
`The tree manifest did not end within ${assetBridge.MAX_PAGES} pages; nothing was imported`,
|
||||
'TOO_LARGE',
|
||||
)
|
||||
}
|
||||
|
||||
log.info('tree manifest read from the shard', {
|
||||
files: files.length,
|
||||
catalog,
|
||||
pages,
|
||||
ms: Date.now() - started,
|
||||
})
|
||||
|
||||
return { catalog, chunkBytes, files, pages }
|
||||
}
|
||||
|
||||
/** A manifest as the `{ label: sha256 }` fingerprint the atlas model stores. */
|
||||
function fingerprintOf(list) {
|
||||
const hashes = {}
|
||||
for (const file of list) hashes[file.label] = file.sha256
|
||||
return hashes
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 2: the bytes.
|
||||
*
|
||||
* Returns `{ files: [{ label, text, sha256, bytes }] }` — deliberately the exact
|
||||
* shape `spawnAtlasSource.readSources` returns from a filesystem, so that
|
||||
* `buildAtlas` cannot tell which end a tree arrived from and nothing downstream
|
||||
* has a second code path to be wrong in.
|
||||
*/
|
||||
async function readSources() {
|
||||
const started = Date.now()
|
||||
const listing = await manifest()
|
||||
|
||||
if (listing.files.length === 0) {
|
||||
throw new TreeBridgeError(
|
||||
'The shard served no atlas source files at all (is this a ServUO tree?)',
|
||||
'NO_SOURCE',
|
||||
)
|
||||
}
|
||||
|
||||
const expected = listing.files.reduce((sum, file) => sum + file.bytes, 0)
|
||||
|
||||
if (expected > MAX_BYTES) {
|
||||
throw new TreeBridgeError(
|
||||
`The shard's tree is ${expected} bytes, over the ${MAX_BYTES} this will read`,
|
||||
'TOO_LARGE',
|
||||
)
|
||||
}
|
||||
|
||||
const keys = []
|
||||
for (const file of listing.files) {
|
||||
// A zero-length file is still ONE chunk. An overlay that said zero would
|
||||
// leave a manifest row nothing could ever fetch, and the walk below would
|
||||
// report the import incomplete forever.
|
||||
const chunks = Math.max(1, file.chunks)
|
||||
for (let i = 0; i < chunks; i++) keys.push(`${file.key}/c${i}`)
|
||||
}
|
||||
|
||||
const parts = new Map()
|
||||
let pages = 0
|
||||
let wire = 0
|
||||
|
||||
for (let i = 0; i < keys.length; i += FETCH_CHUNK) {
|
||||
const batch = keys.slice(i, i + FETCH_CHUNK)
|
||||
|
||||
let cursor = null
|
||||
let finished = false
|
||||
let walked = 0
|
||||
|
||||
while (walked < assetBridge.MAX_PAGES) {
|
||||
let page
|
||||
|
||||
try {
|
||||
page = await assetBridge.withBusyRetry(
|
||||
() => uoLinkClient.fetchAssets({ keys: batch, catalog: listing.catalog, cursor }),
|
||||
'the shard configuration tree',
|
||||
)
|
||||
} catch (err) {
|
||||
throw rethrow(err, 'reading the tree')
|
||||
}
|
||||
|
||||
pages++
|
||||
walked++
|
||||
|
||||
if (typeof page.catalog === 'string' && page.catalog !== '' && page.catalog !== listing.catalog) {
|
||||
throw new TreeBridgeError(
|
||||
`The shard's configuration tree changed mid-read (catalog ${listing.catalog} became ${page.catalog})`,
|
||||
'SOURCE_CHANGED',
|
||||
)
|
||||
}
|
||||
|
||||
for (const row of page.rows ?? []) {
|
||||
const key = String(row?.key ?? '')
|
||||
|
||||
if (row?.status !== 'ok') {
|
||||
// Unlike an asset key, a tree key comes straight off a manifest this
|
||||
// same walk just read. There is no such thing as an expected gap here:
|
||||
// the shard listed the file, so a refusal means the tree moved or the
|
||||
// two ends disagree about the key scheme, and importing the rest would
|
||||
// silently drop whatever that file held.
|
||||
throw new TreeBridgeError(
|
||||
`The shard refused ${key || 'a tree chunk'} (${row?.status || 'unknown'}: `
|
||||
+ `${row?.reason || 'no reason given'})`,
|
||||
'INCOMPLETE',
|
||||
)
|
||||
}
|
||||
|
||||
const label = String(row.label ?? '')
|
||||
const chunk = Number(row.chunk)
|
||||
|
||||
if (label === '' || !Number.isInteger(chunk) || chunk < 0) {
|
||||
throw new TreeBridgeError(`The shard sent a tree chunk with no address (${key})`, 'MALFORMED')
|
||||
}
|
||||
|
||||
let raw
|
||||
|
||||
try {
|
||||
raw = zlib.gunzipSync(Buffer.from(String(row.gzip ?? ''), 'base64'))
|
||||
} catch (err) {
|
||||
throw new TreeBridgeError(`Could not decompress ${key}: ${err.message}`, 'MALFORMED')
|
||||
}
|
||||
|
||||
const declared = Number(row.bytes)
|
||||
|
||||
if (Number.isFinite(declared) && declared !== raw.length) {
|
||||
throw new TreeBridgeError(
|
||||
`${key} declared ${declared} bytes and decompressed to ${raw.length}`,
|
||||
'MALFORMED',
|
||||
)
|
||||
}
|
||||
|
||||
if (row.sha256 && assetBridge.sha256Of(raw) !== String(row.sha256)) {
|
||||
throw new TreeBridgeError(`${key} does not match its own hash`, 'MALFORMED')
|
||||
}
|
||||
|
||||
if (!parts.has(label)) parts.set(label, new Map())
|
||||
parts.get(label).set(chunk, raw)
|
||||
}
|
||||
|
||||
let state
|
||||
|
||||
try {
|
||||
state = assetBridge.checkPage(page, { arrayName: 'rows', cursor, pages: walked, noun: 'tree' })
|
||||
} catch (err) {
|
||||
throw rethrow(err, 'reading the tree')
|
||||
}
|
||||
|
||||
if (state.done) {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
|
||||
cursor = state.cursor
|
||||
}
|
||||
|
||||
if (!finished) {
|
||||
throw new TreeBridgeError(
|
||||
`A tree fetch did not end within ${assetBridge.MAX_PAGES} pages; nothing was imported`,
|
||||
'TOO_LARGE',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const files = []
|
||||
|
||||
for (const file of listing.files) {
|
||||
const chunks = Math.max(1, file.chunks)
|
||||
const held = parts.get(file.label)
|
||||
|
||||
if (!held) {
|
||||
throw new TreeBridgeError(`The shard sent nothing for ${file.label}`, 'INCOMPLETE')
|
||||
}
|
||||
|
||||
const ordered = []
|
||||
|
||||
for (let i = 0; i < chunks; i++) {
|
||||
const part = held.get(i)
|
||||
|
||||
// Indexed rather than appended in arrival order. The rows come back in the
|
||||
// order they were asked for today, and a design that depends on that is one
|
||||
// reordering away from an atlas that is wrong in a way nothing reports.
|
||||
if (!part) {
|
||||
throw new TreeBridgeError(`${file.label} is missing chunk ${i} of ${chunks}`, 'INCOMPLETE')
|
||||
}
|
||||
|
||||
ordered.push(part)
|
||||
}
|
||||
|
||||
const whole = Buffer.concat(ordered)
|
||||
const sha256 = assetBridge.sha256Of(whole)
|
||||
|
||||
if (file.sha256 && sha256 !== file.sha256) {
|
||||
throw new TreeBridgeError(
|
||||
`${file.label} does not match the hash its manifest row carried`,
|
||||
'MALFORMED',
|
||||
)
|
||||
}
|
||||
|
||||
files.push({
|
||||
label: file.label,
|
||||
text: whole.toString('utf8'),
|
||||
sha256,
|
||||
bytes: whole.length,
|
||||
})
|
||||
|
||||
wire += whole.length
|
||||
}
|
||||
|
||||
log.info('tree read from the shard', {
|
||||
files: files.length,
|
||||
bytes: wire,
|
||||
chunks: keys.length,
|
||||
pages,
|
||||
ms: Date.now() - started,
|
||||
})
|
||||
|
||||
return { files, catalog: listing.catalog, pages, chunks: keys.length }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TreeBridgeError,
|
||||
FAMILY,
|
||||
FETCH_CHUNK,
|
||||
MAX_FILES,
|
||||
MAX_BYTES,
|
||||
manifest,
|
||||
fingerprintOf,
|
||||
readSources,
|
||||
}
|
||||
@@ -271,8 +271,8 @@
|
||||
"tags": [
|
||||
"Admin · Shard"
|
||||
],
|
||||
"summary": "Spawn atlas status: path, drift, counts, pending review (admin only)",
|
||||
"description": "Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.",
|
||||
"summary": "Spawn atlas status: source, drift, counts, pending review (admin only)",
|
||||
"description": "Which source the atlas is built from — the linked shard over uo-link, or a local ServUO tree — whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. On the bridge, reading drift costs one shard round trip for the file manifest (hashes, no bytes). The public /atlas/meta route reports the game world only; this detail is here.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Atlas status",
|
||||
@@ -345,8 +345,8 @@
|
||||
"tags": [
|
||||
"Admin · Shard"
|
||||
],
|
||||
"summary": "Re-import the spawn atlas from the ServUO tree (admin only)",
|
||||
"description": "Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status \"unavailable\" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.",
|
||||
"summary": "Re-import the spawn atlas from its source (admin only)",
|
||||
"description": "Applies a map change without a restart — and on a linked shard it is the only thing that does, because boot never calls the shard for this. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable source answers 200 with status \"unavailable\" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "What happened",
|
||||
@@ -7152,7 +7152,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review."
|
||||
"example": "Admin view of atlas state: which source the tree comes from, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
@@ -7170,6 +7170,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"bridge",
|
||||
"fs"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "`bridge`: the shard serves its own configuration files over uo-link (protocol 8 phase 7, the normal case once a shard is linked). `fs`: a ServUO tree the website can read directly — development and same-host installs, and the only source where boot re-imports by itself."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "bridge"
|
||||
}
|
||||
}
|
||||
},
|
||||
"path": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7177,9 +7204,13 @@
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The local tree path, or `the shard bridge` when that is the source."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "/srv/servuo"
|
||||
"example": "the shard bridge"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7209,7 +7240,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "True when the tree's source hashes differ from the loaded atlas. NULL when the tree could not be read."
|
||||
"example": "True when the source file hashes differ from the loaded atlas. NULL when the source could not be read. On the bridge this is answered from the shard's file MANIFEST — hashes only, no file bytes."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
@@ -7350,6 +7381,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"bridge",
|
||||
"fs"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Which end this attempt read from. Absent only on `skipped`, where there was no source at all."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "bridge"
|
||||
}
|
||||
}
|
||||
},
|
||||
"path": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7360,6 +7422,27 @@
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The local tree path, or `the shard bridge`."
|
||||
}
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "On `unavailable`: NO_PATH, NOT_FOUND, NO_REGIONS or NO_SPAWNS from a local tree; DISABLED, SOURCE_CHANGED, INCOMPLETE, MALFORMED, BUSY, SHARD_DOWN or TOO_LARGE from the bridge."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -8399,6 +8482,23 @@
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"linked": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Whether a shard is configured and enabled at all. Stated rather than inferred: `shard: null` is also what a linked shard that is merely DOWN looks like, and the two want opposite things from an admin surface — one disables its import buttons, the other keeps them available so the operator can retry."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"loaded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8573,6 +8673,253 @@
|
||||
"example": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"last": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "What the last import actually did. NULL on an install that has never imported, and on one whose last import predates this field — both of which mean \"no import recorded\", which is a different answer from an import that fetched nothing. The admin activity log records the same action, but it is one unfiltered list of every admin action on the site, so an import from three client patches ago is not findable there."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"at": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"by": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The admin who pressed it, from their session."
|
||||
}
|
||||
}
|
||||
},
|
||||
"force": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "True when it was a full re-import rather than an update."
|
||||
}
|
||||
}
|
||||
},
|
||||
"approve": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "True when it accepted assets the shard had stopped offering."
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 1095
|
||||
}
|
||||
}
|
||||
},
|
||||
"fetched": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 12
|
||||
}
|
||||
}
|
||||
},
|
||||
"written": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 12
|
||||
}
|
||||
}
|
||||
},
|
||||
"removed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"absent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"unsupported": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"bodies": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The body pass, as a tally rather than one number: `unknown` is real drift — a spawn file naming a type this shard’s scripts do not define — and reads identically to a failure if both are summed into \"not resolved\"."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 780
|
||||
}
|
||||
}
|
||||
},
|
||||
"unknown": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"notCreature": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 12
|
||||
}
|
||||
}
|
||||
},
|
||||
"failed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"art": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Creatures pointing at a picture afterwards."
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 763
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9188,18 +9535,57 @@
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "On `needsReview`: up to fifty of the keys that disappeared, each with the picture this site currently serves for it. The filename is there because the decision being asked for is \"is it right that these disappear?\", and an asset key names nothing a human recognises — `body/820/a23` is a horse."
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "body/820/a23"
|
||||
}
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Filename under uploads/atlas/, or null if this site never stored a picture for it."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "uo-body-820-a23-9f3c1a77.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "On `needsReview`: up to fifty of the keys that disappeared."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user