diff --git a/README.md b/README.md index 0f47d6c..6dded11 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/client/src/api.js b/client/src/api.js index 2746aab..4802986 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -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: { diff --git a/client/src/components/CharacterSheet.jsx b/client/src/components/CharacterSheet.jsx index 58c8fb4..c192f2d 100644 --- a/client/src/components/CharacterSheet.jsx +++ b/client/src/components/CharacterSheet.jsx @@ -7,6 +7,7 @@ // player can reach is safe. import ShardAccountActions from './ShardAccountActions.jsx' +import ItemIcon from './ItemIcon' const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' } @@ -270,7 +271,16 @@ export default function CharacterSheet({ char, moderation = false }) { const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null] return (
- + {/* The sheet has always drawn an empty swatch here to hold the + row's alignment. As of phase 5 the shard can hand over the + item's real picture, hued the way the client would draw it — + so the swatch becomes the fallback rather than the only + state, and a row with no picture looks exactly as it did. */} + {it.art ? ( + + ) : ( + + )}
{label}
{detail.filter(Boolean).join(' · ')}
diff --git a/client/src/components/DetailRow.jsx b/client/src/components/DetailRow.jsx new file mode 100644 index 0000000..4c06cc5 --- /dev/null +++ b/client/src/components/DetailRow.jsx @@ -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 ( +
+ {label} + {children} +
+ ) +} diff --git a/client/src/components/ItemIcon.jsx b/client/src/components/ItemIcon.jsx new file mode 100644 index 0000000..b30ab3f --- /dev/null +++ b/client/src/components/ItemIcon.jsx @@ -0,0 +1,47 @@ +// One item's picture, when this site holds one (docs/link/v8.md §5, §11 — phase 5). +// +// `art` is a FILENAME under uploads/items/, never a path or a URL — the same +// shape `CreaturePortrait` takes, so there is one place in this module that knows +// where uploads are mounted rather than one per surface. +// +// **NULL is ordinary and permanent for some items, and this renders nothing for +// it.** Three separate reasons an item has no picture, and none of them is a +// fault: the site has no shard link and never fetched one; the warm pass has not +// reached this key yet (pictures are fetched behind the page, never by it, so a +// new listing shows text first and gains its icon a few minutes later); or the +// operator's own client simply has no art at that id — 9,963 of a stock client's +// static ids have an empty index entry. Every layout using this is written to sit +// correctly with the icon absent, because that is the state all of them were +// built in. +// +// A hued item is a DIFFERENT picture, not a tinted one: the shard applies the hue +// out of `hues.mul` before it sends anything, because whether a hue repaints the +// whole sprite or only its grey pixels is decided by a flag in `tiledata.mul` +// that this browser has no way to read. So there is nothing to style here — the +// bytes already are the right colour. +// +// `imageRendering: 'pixelated'` for the same reason the creature portraits use +// it: UO art is pixel art, and a browser's default smoothing turns a 22×26 +// item into a smear at any size above its own. +export default function ItemIcon({ art, name, size = 32 }) { + if (!art) return null + + return ( + + ) +} diff --git a/client/src/entry.jsx b/client/src/entry.jsx index 3a9b142..4b7434f 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -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: }, ], 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: }, { path: 'visibility', element: }, { path: 'atlas', element: }, + { path: 'files', element: }, { path: 'ops', element: , gate: STAFF }, { path: 'houses', element: , 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. diff --git a/client/src/routes/admin/ClientFiles.jsx b/client/src/routes/admin/ClientFiles.jsx new file mode 100644 index 0000000..0ab8bc9 --- /dev/null +++ b/client/src/routes/admin/ClientFiles.jsx @@ -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 ( +
+

+ An import is waiting for you +

+

+ The shard no longer offers {num(total)} 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. +

+
+ {rows.map((row) => ( +
+ +
+ {row.key} +
+
+ ))} +
+ {total > rows.length && ( +

+ Showing the first {num(rows.length)} of {num(total)}. +

+ )} +
+ + +
+
+ ) +} + +// 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 {at ? when(at) : 'No import recorded yet'} + } + + 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 ( + <> + + {`${when(last.at || at)}${last.by ? ` · ${last.by}` : ''}${last.force ? ' · full re-import' : ''}`} + + + {`${num(last.written)} written, ${num(last.fetched)} fetched`} + {last.removed ? `, ${num(last.removed)} removed` : ''} + + {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. + {unmatched.join(', ')} + )} + + ) +} + +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 + if (error && !assets && !clilocs) return + + 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 ( +
+
+

+ Client files +

+

+ 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 these buttons are the only thing that imports them: nothing here + happens on a restart. +

+
+ + {(assets?.error || clilocs?.error) && ( +
+ Part of this page could not be read. +

+ {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. +

+
+ )} + + {assets?.reason && !shard && ( +
+ {/* 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. */} + + {assets.code === 'BUSY' + ? 'The shard is busy with another client-file request.' + : 'The shard is not answering for client files.'} + +

+ {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.'} +

+

+ What is already imported keeps serving; only new imports are affected. +

+
+ )} + + {imagingBroken && ( +
+ The shard host cannot render images. +

+ {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. +

+
+ )} + + {review.assets && ( + run('assets', ASSET_OUTCOME, () => review.assets.run(true))} + onDismiss={() => setReview((r) => ({ ...r, assets: null }))} + /> + )} + + {/* ── creature portraits ── */} +
+

+ Creature portraits +

+

+ 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{' '} + spawnAtlas.art.json always win over an imported one. +

+ {`${num(loaded?.stored)} of ${num(loaded?.assets)} catalogued`} + {`${num(loaded?.resolved)} of ${num(loaded?.creatures)}`} + + + {assets?.drift == null + ? '—' + : assets.drift + ? 'Yes — an update would pick it up' + : 'No'} + + {shard?.hashing && ( + + Yes — it is still fingerprinting its client files in the background. Drift may read as + “yes” until it finishes. + + )} + + {/* "—" 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`)} + +
+ + +
+

+ Update checks the shard’s client files first and transfers only the + pictures that actually changed — when nothing has, it costs one small round trip.{' '} + Re-import everything 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. +

+ {msg.assets && ( +

{msg.assets}

+ )} +
+ + {/* ── item and land pictures ── */} +
+

+ Item and land pictures +

+

+ 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. +

+ {num(loaded?.items)} + {num(loaded?.land)} + + {families.length > 0 ? families.join(', ') : '—'} + {shard && !families.includes('static') + ? ' — this shard’s plugin predates item pictures; update the overlay to get them' + : ''} + +
+ + +
+ {msg.warm && ( +

{msg.warm}

+ )} +
+ + {/* ── the cliloc table ── */} +
+

+ Item and title names (clilocs) +

+

+ 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. +

+ {num(clilocs?.count)} + {when(clilocs?.importedAt)} + + {clilocs?.source === 'bridge' + ? 'The shard, over uo-link' + : clilocs?.configured + ? clilocs.path + : 'None configured'} + + + {clilocs?.sources?.length ? clilocs.sources.join(', ') : 'None'} + + + {clilocs?.drift == null ? '—' : clilocs.drift ? 'Yes — an import would pick it up' : 'No'} + + {clilocs?.problem && ( + + {clilocs.problem} + + )} + {clilocs?.missingSources?.length > 0 && ( + + {clilocs.missingSources.join(', ')} + + )} +
+ + +
+ {review.clilocs && ( +
+ + An overlay file that was loaded last time is missing + +

+ {(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. +

+
+ + +
+
+ )} +
+

+ {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.'} +

+
+ setClilocPath(e.target.value)} + placeholder="/srv/uo-client" + style={{ flex: '1 1 320px', minWidth: 0 }} + /> + +
+
+ {msg.clilocs && ( +

{msg.clilocs}

+ )} +
+ + {error && ( + {error} + )} +
+ ) +} diff --git a/client/src/routes/admin/SpawnAtlas.jsx b/client/src/routes/admin/SpawnAtlas.jsx index 10f084e..347e192 100644 --- a/client/src/routes/admin/SpawnAtlas.jsx +++ b/client/src/routes/admin/SpawnAtlas.jsx @@ -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 ( -
- {label} - {children} -
- ) -} - 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() {

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 content rather than shard state, so what is + loaded keeps serving in full while the shard is down.

@@ -218,10 +204,23 @@ export default function SpawnAtlas() { {counts.champions?.toLocaleString() ?? '—'} )} - - {!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'} + + {status?.source === 'bridge' + ? 'The shard, over uo-link' + : status?.configured + ? status.path + : 'None — no shard linked and no path set'} - + + {!status?.configured + ? 'No source' + : status.treeReadable + ? 'Yes' + : status.source === 'bridge' + ? 'No — the shard did not answer, or Bridge.TreeEnabled is off' + : 'No'} + + {status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'} @@ -231,9 +230,13 @@ export default function SpawnAtlas() { ServUO tree

- 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 SERVUO_PATH 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.'}

- 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.