The project lints with Biome only, so none of these 78 directives across 57
files ever suppressed anything. Directives that carried the rationale for a
flagged dynamic index access keep that rationale as a plain comment.
Coverage was enabled by default, which forced v8 instrumentation on every run
and made a focused `vitest run <file>` fail the global thresholds that a
single file can never satisfy. `pnpm test:coverage` now opts in explicitly
while `pnpm test` stays fast.
Thresholds ratcheted from 36/29/30/37 to the measured full-suite floor of
37/30/31/38, so a drop in coverage fails again. Also drops a stale comment
about a parent Laravel repo and a dead .next.prev exclude from the config.
The example .env suggested DATABASE_POOL_SIZE=150 and connection_limit=150.
MariaDB defaults max_connections to 151, so a single CMS instance could claim
the server's entire connection budget, starving the emulator and any second
instance. The README already documents a pool size of 25; the example now
matches and notes that DATABASE_POOL_SIZE is the value that actually applies.
/ is the highest-traffic route but was missing from both the public-route
instrumentation and the JS size budgets, so a payload regression there could
never fail the build. It is now wrapped in withPublicPagePerformance and
budgeted alongside the other public routes.
optimizePackageImports listed date-fns, which is not a dependency (a no-op),
while omitting the barrel-heavy packages that are actually imported: motion,
@dnd-kit/core, @dnd-kit/sortable and @tanstack/react-virtual.
The root layout fed the entire ~254 KB message catalogue to
NextIntlClientProvider, which serializes it into the RSC payload of every
page. A public page only reads ~59 KB of it; the ~195 KB difference is staff
tool copy that only /admin, /admin-next and /mod ever render.
The root provider now installs the reduced catalogue and each staff route
group re-installs the full one, since a nested NextIntlClientProvider replaces
the parent message set rather than merging into it. pages.admin.actions stays
public because the shared useServerAction hook runs on public pages too.
Measured: 253 KB -> 59 KB of messages per public page (raw JSON).
Guard tests cover all three halves: the root keeps using the reduced
catalogue, every staff layout installs its own full provider, and no public
tree reads a staff-only namespace.
A single authenticated render called auth() 4-5 times (access guard, site
layout, both permission loaders, the page itself), and every call re-parsed
the session cookie, decoded and HMAC-verified the JWT and ran the jwt/session
callbacks. Wrapping NextAuth's handler in React's cache scopes that work to
one verification per request.
Outside a render (scripts, jobs worker) React's cache is a pass-through, so
those call sites behave exactly as before.
Adds the header.* keys introduced by the header rewrite (enter, search
users, staff, mod/admin panel, command center, logs, logo generator,
dashboard/profile/settings links, credits/duckets/diamonds, ...) to all
25 message files. Values are translated where available and fall back to
English otherwise.
The working tree files had been re-serialized with escaped unicode
(\uXXXX); this commit restores literal UTF-8 so the diff shows only the
content change instead of a full-file rewrite.
The production webpack build failed with UnhandledSchemeError: client
components imported @/lib/logger, which binds pino, AsyncLocalStorage
(node:async_hooks), node:crypto and node:fs — and validates server env on
import. The UI fixture aliased those modules, so the harness and tests
never hit it.
- add src/lib/browser-log.ts: browser-safe logger with the same interface;
errors are forwarded to /api/diagnostics/errors via reportBrowserError
- guard src/lib/logger.ts with server-only and drop its now-dead browser
branches
- switch the 11 client components/hooks to @/lib/browser-log
Verified with the Dockerfile's production build (pnpm run build) and the
full vitest suite.
- Run visual snapshots on Chromium only; Firefox/WebKit rendering does
not match Chromium pixel output. Functional and accessibility
assertions still run on all four projects (baselines were renamed to
the new project names in the previous commit).
- Poll axe audits until clean: WebKit can report stale color-contrast
violations for a moment after interactions while styles settle.
- Poll the Tab-focus assertion: Firefox can swallow a Tab while the
sandboxed srcdoc preview frame is still loading.
- Fix the recovery clock race: install the fake clock before navigation
at a fixed time and pause at a fixed target instead of wall-clock
values, which under load could already be in the fake clock's past
('Cannot fast-forward to the past').
The UI fixture bundle crashed at runtime, so every test waited out its
45s timeout:
- server-only modules leaked into the bundle and referenced node
built-ins, the process global, and validated env at import time
- the previous platform: 'node' fix kept esbuild happy but emitted
require() calls that cannot run in the browser
Fix it at the bundle boundary:
- alias node:* imports to e2e/ui/node-stubs.mjs (shape-compatible stubs,
no real I/O)
- inject e2e/ui/process-shim.mjs for the process global
- define process.env.SKIP_ENV_VALIDATION=1 so src/env.ts skips parsing
in the fixture (same escape hatch vitest uses)
Also make the fixture server fast to start:
- build JS and Tailwind CSS in parallel
- cache built assets in node_modules/.cache/nexst-ui-fixture keyed on a
content hash of src/, fixtures, stubs and the server file
- startup drops from ~1.7s to ~220ms on cache hits
Biome flagged TypeScript syntax in the .mjs stub module. Remove type
annotations and prefix the unused run() store parameter with underscore
to satisfy lint rules.
Replace platform: 'node' (incompatible with iife browser bundle) with
explicit aliases for node:async_hooks, node:crypto, node:fs/promises,
node:path pointing at e2e/ui/node-stubs.mjs. Provides shape-compatible
exports so esbuild can bundle the UI fixture without failing on Node
built-ins. Increase webServer timeout to 120s for reliable startup.
Add firefox and webkit projects alongside chromium (desktop/mobile),
and enable reuseExistingServer so the fixture server isn't restarted
per run. Chromium/Firefox/WebKit browsers installed via
npx playwright install.
Add platform: 'node' to esbuild build options so Node.js built-ins
(node:async_hooks, node:crypto, node:fs/promises, node:path) are
treated as external instead of failing to resolve during browser
bundle generation in e2e/ui/fixture-server.mjs.
- Replace all 39 console.* calls with universal logger (pino server, console browser)
- Set server-only marker on import-badge.ts
- Disable DB query logging in production
- Add bulkInsertCatalogItems: one transaction instead of N per-item inserts
- Add reserveCatalogItemIds helper for bulk ID allocation
- Optimize translateCatalogItems: single inArray batch query instead of N per-item selects
- Test mock updated for logger.debug
- biome clean, tsc clean, 3569 tests passing
Enable osvVulnerabilityAlerts (Gitea has no advisory feed of its own) with the
OSV summary on the dependency dashboard, and let security fixes skip the 3-day
minimumReleaseAge in their own labelled PR. Set automergeStrategy to squash so
automerged dependency updates match the repository history.
Renovate bumped .nvmrc and the packageManager pin in isolation, so the
check-node-toolchain contract failed (engines.node, @types/node and the Docker
node image are intentionally upgraded together) and the grouped PR could never
pass check. Disable the nvm manager, the Dockerfile node image and the pnpm
packageManager field so those rollouts stay manual and coordinated.
Allow mode: enforce in the report config, fail the command on over-budget and
unavailable routes, and make the markdown wording follow the configured mode.
Recalibrate the budgets against the webpack build (baseline x1.15, rounded up to
the next 10 KiB) now that the old Turbopack numbers no longer apply, so the
Docker image build gate blocks genuine bundle regressions instead of only
warning. Raise the Vitest coverage thresholds to the measured floor
(37.17/30.1/31.79/38.17 -> 36/29/30/37).
Run the pinned renovate/renovate image on the host runner every weekday at
04:17 (plus workflow_dispatch) against the gitea platform, authenticating with
the renovate-bot RENOVATE_TOKEN Actions secret. Minor and patch updates are
grouped and automerged only after CI passes; majors wait for dashboard
approval. Document the bot, secret rotation and the minimumReleaseAge policy in
the error-centre runbook.
The cross-browser imaging job runs against the public origin, so every request
goes through Cloudflare. One chromium run of "rejects malformed figures ›
missing figure" came back 525 (Cloudflare: TLS handshake to the origin failed)
after a 40s stall — the request never reached the CMS, so a 400 was impossible
to observe and the assertion was effectively testing the network. It passed on
both firefox and webkit, which is the signature of an edge hiccup rather than a
regression.
`e2e/imaging/imaging-target.ts` now owns `getThroughEdge` / `fetchThroughEdge`,
which retry on the statuses that mean "the app was never reached"
(502/503/504 plus Cloudflare's 520-527) with a short backoff, and both
edge-fronted specs use them. A genuinely broken edge still exhausts the retries
and fails the run, so nothing is masked.
Verified: 84 passed / 21 skipped / 0 failed against epicnabbo.nl with
IMAGING_EDGE=1 across chromium, firefox and webkit.
The Habbo presets (and the other furnidata-only sources) carry no
`nitroBaseUrl`, so the clone importer's `${nitroBaseUrl}/${classname}.nitro`
download can never work against them — an `--import-new` run would have failed
for every single item instead of saying why. The SWF path that does work against
`images.habbo.com` lives in `furni-import.ts`, not in the clone importer this
CLI uses.
`stepImportNew` now skips those sources up-front, lists them under
"compared only" in the summary, and the menu prints the same warning when an
import step is chosen with such a source, pointing at the sources that do serve
bundles. Verified: Habbo (COM) reports compare-only, while Habboon and
VirtualCity list real candidates.
The CLI could audit and repair what already existed, but adding furniture
present at a source was still only possible from the admin UI item by item. The
repo already ships every piece of that pipeline, so wire them together as
further steps of the same run:
- `--import-new` imports furniture the compared source has and this hotel lacks.
It reuses `getClonableClassnames` for the diff and `cloneSingleFurni` per item,
which downloads the icon and the bundle (sources serve `.nitro`, stored as
`.hab`), appends the furnidata entry with `deferFurniData` and creates the
catalog page plus the offer itself. Deferred entries are written in one
`appendFurniEntriesBulk` call, so 26000 items cost one JSON write, not one per
item. `--only`, `--limit` and `--concurrency` narrow/tune it, failures are
isolated per item and never abort the run.
- `--reconcile`, `--fix-sprite-ids` and `--rebuild-offers` run the existing
`reconcileFurniDataWithItemsBase`, `verifyAndFixSpriteIds` and
`rebuildCatalogOfferIds` passes — the post-import consistency work.
- `--align-ids` and `--rename-bundles` delegate to the existing standalone
scripts; both only report until `--apply` is given, matching their own
contracts.
- The menu grows options 6-8 plus a dry run, each with its own confirmation.
`--everything` turns on every repair + import step but deliberately leaves the
destructive ones off. A live dry run against Wibbo listed 26593 items on the
source that this hotel does not have, and Habbo (COM) reported 2871.
`.hab` has been the stored bundle extension since the `FURNI_BUNDLE_EXT`
switch, so the repair path already writes `.hab` (the sources serve `.nitro`),
but nothing in the output said so. Print the resolved extension in the run
header and correct the header comment: generating the catalog SQL also creates
the `catalog_pages` sub-pages it references, so a "generate only" run is not
entirely read-only — only the `catalog_items` rows wait for `--apply-sql`.
Updating the catalog was only possible from the admin "Catalog audit" page,
which streams `runCatalogAudit` over SSE and always compares against every
enabled clone source. This adds the same engine to the terminal so it can be run
by hand or from cron:
- `scripts/catalog-sync.ts` — CLI driver around `runCatalogAudit`. Prints the
live progress, the per-source "on the source but not in this hotel" counts and
a summary block; generated catalog SQL is saved to
`storage/catalog-sync/*.sql` (gitignored). Supports `--list-sources`,
`--sources=<a,b>`, `--icons`, `--nitros`, `--furnidata`, `--structure`,
`--check-ids`, `--sql`, `--apply-sql`, `--organize-sql`, `--all`, `--json` and
`--fail-on-errors`.
- `scripts/catalog-sync.sh` — interactive menu (`pnpm catalog:sync`). Lists the
configured sources (so the menu always matches `clone_sources`), lets Habbo
(COM) be taken with Enter, and offers audit-only through full repair +
applying the catalog SQL, each with a distinct confirmation. With no arguments
it needs a terminal; with any flag it passes straight through to the CLI.
- `runCatalogAudit` gains `sourceIds` to restrict which sites are compared;
omitting it keeps the previous "compare against everything enabled" behaviour.
An audit writes nothing, and `--apply-sql` is the only flag that changes the
catalog. Verified against the live DB: 16370 items_base rows, 16479 catalog
items, 2871 Habbo (COM) entries missing from this hotel.
Legacy accounts can carry a malformed stored look — the old register default
`hr-100-.hd-180-1...` has an empty colour slot — which the imager rejects with
400 "Invalid figure format", so the avatar renders broken everywhere the look
appears. Normalise the figure in the shared URL builder (`getAvatarUrl`, used by
every avatar on the site): an unrenderable look becomes `DEFAULT_AVATAR_FIGURE`
while the proxy route keeps rejecting malformed input with 400.
The rule set and default now live in one client-safe module
(`src/lib/avatar-figure.ts`), shared by the URL builder and the server parser.
`src/lib/furni/classname.ts` is imported by client components for its pure
string helpers, but it read `process.env.FURNI_BUNDLE_EXT` at module load.
In any client bundle without a `process` shim — including the UI fixture's
esbuild build — that threw `ReferenceError: process is not defined`, so the
lazily-loaded organize-imports dialog never mounted and `pnpm test:ui`
failed (studio.spec.ts). Fall back to the default extension when `process`
is absent; server behaviour is unchanged.
The deploy gate ran e2e specs that depend on live content and data, so a missing
upload (the /help article images) or a legacy malformed avatar figure could turn
a safe code deploy red. Split the suites by what they actually guarantee:
- Blocking gate (`pnpm test:e2e`, `playwright.config.ts`): only checks the CMS
can guarantee itself — anonymous redirects, register form fields, the captcha
DOM contract and the cache-control headers (deterministic origin-direct).
- Advisory suite (`pnpm test:e2e:quality`, `playwright.quality.config.ts`):
broken images, layout overflow and warm-page latency, which depend on live
content/upload state and the host. It runs as a `continue-on-error` CI job
against the public site so a real content problem stays visible without
blocking the pipeline.
Also add `scripts/sync-readme-versions.mjs` (+ tests, docs and a CI check) so
the pinned versions in README.md can never drift from package.json / .nvmrc.
The starter look for a new account was `hr-100-.hd-180-1...`, whose empty
hair colour slot is rejected by the imager with 400 "Invalid figure format".
Every player who registered without a look therefore got a broken avatar.
Add the missing colour slot (`hr-100-1`), which the imager renders.
Match the versions table and core-stack line to package.json after the
dependency bump: pnpm 12.11.2, Next.js 16.4.1, React 19.3.1,
Drizzle ORM 0.45.4, Zod 4.6.7, Vitest 5.0.4, Playwright 1.64.1.
Explain where the captcha is configured, how the admin panel and env
vars resolve (with a precedence table), step-by-step Turnstile setup,
that the site key is public while the secret must never be committed,
and how to verify. Also link it from Optional Integrations.
Document that CAPTCHA_PROVIDER accepts a provider name (e.g. turnstile)
and spell out that the site key is public while the secret must stay on
the host / in CI and never be committed.
- catalog: apply bulk offer edits in a single transaction with one RCON
refresh instead of N round-trips, virtualize the items table past 150
rows and cap the shop preview at 200 tiles
- cache: lift the public cache TTLs and the boot warm-up into env-tunable
constants (src/lib/perf-ttls.ts; CMS_*_TTL_SEC, CMS_WARMUP_ENABLED)
- auth: resolve the captcha provider, site key and secret from
CAPTCHA_PROVIDER, TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY when the
admin settings are unset; an explicit DB value (including "none") still
wins, so Turnstile can be enabled per-deploy without touching the DB.
Commit 3c8abb6f made docker-update.sh exit 0 (SKIP) on a host owned by CI so
the cron does not report a failed deployment. The tests still expected a
nonzero exit for the ci-active/ci-green/ci-upstream scenarios, so five cases
failed.
- docker-update.test.ts: move the CI scenarios out of the non-zero group and
assert the skip: exit 0, 'This host is managed by CI' in the output and no
'compose up'. All three CI scenarios (both slot containers and the nginx
upstream) now share one test.
- README.md: document the exit-0 skip.
Bundle the remaining imaging hardening and the host/CI fixes from the
"Doe alles" pass into one release.
Furniture:
- Resolve the local bundle extension from FURNI_BUNDLE_EXT (".hab" default,
".nitro" for a stock Nitro client). The configured extension is written by
imports/repairs and preferred on lookup; the other stays a legacy fallback.
Imaging:
- Avatar proxy sends s-maxage=86400 + stale-while-revalidate (300/3600 for the
fallback path) so the edge can hold a render a day.
- Cloudflare cache rule now covers /api/imaging/ (verified MISS->HIT live).
- Document that bare /imaging is shadowed by the edge proxy to the renderer.
Ops/CI:
- docker-update.sh skips a CI-managed host (exit 0) instead of failing cron.
- ci-deploy.sh runs the cross-browser imaging gate (chromium+firefox+webkit)
against the candidate before cutover; DEPLOY_SKIP_CROSS_BROWSER=1 as escape.
- scripts/imager-health-alert.sh alerts (Discord) on imager up/down transitions;
runs from cron every 5 minutes.
Tests:
- Furniture bundle-ext resolver + FURNI_BUNDLE_EXT wiring.
- Imaging performance budget (bytes + warm latency).
- classifyDdos: /api/imaging/* stays in the api bucket.
Environment-independent contract tests (cache headers + source, CORS, OPTIONS
preflight, negative figure/badge tables, concurrency burst) run in both the
deploy gate and the cross-browser job.
CMS-only /imaging assertions (webp/avif transcode, animated-APNG passthrough,
s-maxage edge caching) live in cms-imaging.spec.ts and are skipped behind the
edge proxy; the public topology has its own public-routing.spec.ts smoke that
fails if the edge ever stops fronting the imager or starts shadowing the CMS
endpoints. A committed per-engine visual baseline pins the static render.
The cross-browser imaging job is now blocking (IMAGING_EDGE=1), so avatar
imaging on chromium/firefox/webkit is a required signal.
In production the edge proxy fronts /imaging to the imager container, so the
CMS route only answers when reached directly. Asserting it in the shared spec
failed against the public site in all three engines. Its behaviour is covered
by src/lib/imaging-route.test.ts; the e2e spec now only checks the endpoints
that are identical in both topologies (/api/imaging/avatar and the badge).
The deploy gate only installs chromium, so imaging had no browser-level
coverage. Add two Playwright specs:
- e2e/imaging/avatar-endpoint.spec.ts: HTTP contract for the avatar
(/api/imaging/avatar), default (/imaging) and badge endpoints — animated
APNG for a figure with an effect, the trailing-`.0` regression figure,
ETag/304 revalidation, and a 400 for a malformed figure. Runs in the
deploy gate automatically via the default Playwright config.
- e2e/imaging/avatar-render.spec.ts: loads the avatar in a real browser and
asserts it decodes at a non-zero size and animates. Navigates to the image
URL directly so production CSP `upgrade-insecure-requests` cannot rewrite
the request over a plain-HTTP host.
Add playwright.imaging.config.ts running both across chromium, firefox and
webkit, a `test:imaging` script, and a non-blocking CI job that installs all
three engines and checks the live site.
The hotel moved from .nitro to .hab bundles, so the imager must treat .hab
like .nitro and the public avatar endpoints must accept the figure strings
the client sends (including extra trailing segments such as `.0`, which the
old FIGURE_RE rejected with a 400).
- imager-upstream: relax FIGURE_RE, extend parseAvatarQuery test coverage
- api/imaging/avatar + /imaging: shared validation, placeholder and ETag/304
- api/imaging/badge: single-flight upstream fetch and conditional responses
- imager-http/imager-png: strong ETag helpers and APNG passthrough guard
- csp + next/image: unoptimized external images, tightened CSP
- deps: override postcss-selector-parser to ^7.1.6
- also fold in the pending drizzle draft snapshot and stray test cleanup
Route all Catalog Studio strings through the admin.studio namespace
(workspace, filters, review, inspector, quality, sync, scale32, organize
imports and page titles/subtitles) with English fallback, and add shared
StudioPage chrome plus a loading skeleton. Tighten mobile padding on the
header and sub-bars.
The badge GIF files exist at /var/www/Gamedata/c_images/album1584/ but the
/api/imaging/badge endpoint only looked in /swf/c_images/album1584/ (relative
to process.cwd()). Added GEDATA_IMAGES_BASE candidate and fetchCandidate
handler so badges render correctly in the admin UI and on the site.
The Studio furniture pane was present in the DOM the whole time and still
looked empty. Every row existed, had real dimensions, and its image loaded
with a 200 — and none of it was visible.
The pane was wrapped in a `motion/react-m` element declared with
`initial={{ opacity: 0 }}` and `animate={{ opacity: 1 }}`. That minimal entry
renders the element but never runs the animation, so the inline style stayed at
opacity 0 and the content was painted transparently forever. Measured on the
live release: the table sat at opacity 1 directly inside a wrapper pinned at
`style="opacity: 0"`.
It went unnoticed because playwright.ui.config.ts sets reducedMotion to
"reduce", under which the animation is skipped and the element lands straight
on its final value. The existing Studio specs therefore passed while the real
browser showed nothing. That also means the perf win from the minimal entry
was never actually delivering a working fade — it only hid the breakage.
The decorative 150ms fade is now a CSS animation (.studio-list-fade-in). A CSS
animation cannot strand content this way: if it never runs, the element is
simply opaque. motion/react-m had exactly one usage in the app and is gone;
the four files that use the full motion/react are untouched and unaffected.
Adds e2e/ui/studio-visibility.spec.ts, which opts out of reduced motion and
asserts no ancestor of a furniture row is faded below 0.9. Verified it fails
on the old code with `Received: 0` and passes on the new, so this cannot
regress silently again.
The previous commit stopped the worker from caching build output, which
removed the cause of the blank Catalog Studio but left the worker itself
serving a purpose worth one offline fallback: three public endpoints
(/api/home, /api/online, /api/radio/config). Behind Cloudflare, for a site
whose visitors are online, that fallback rarely fires and goes stale exactly
where it is most likely to matter.
So the worker goes away rather than staying as a mostly-inert layer that every
future release still has to keep correct.
public/sw.js is not deleted, because deleting it would leave every existing
registration alive and still in control of the page, caching as it did before.
It becomes the opposite of what it was: on activate it deletes every cache,
unregisters itself and reloads open clients so they stop being controlled.
Browsers that already installed a worker therefore uninstall it on their next
visit; browsers that never had one are unaffected.
src/components/pwa-register.tsx is removed and unmounted from the root
layout, so nothing registers a worker any more and the kill switch only ever
runs for the visitors who need it.
The manifest is deliberately untouched. src/app/manifest.ts is an ordinary
Next.js route, independent of any worker, and Chrome installs from a manifest
alone, so the site stays installable on a phone.
Verified nothing depended on it: no other navigator.serviceWorker or caches.*
reference exists in the app, the theme runs from the plain /scripts/
theme-init.js script, and no Studio, catalog or import module touches a
worker. Typecheck and lint clean, 2326 unit tests and 72 UI tests green,
including every Studio and catalog spec.
The Catalog Studio rendered as a blank white page after a deploy, while the
server was serving it correctly: /admin/studio/furni answered 200 with 110KB
of HTML and every API it calls answered 200 with data. No script chunk 404ed
during the session and no JavaScript threw, so the failure was entirely in
what the browser chose to execute.
The service worker wrapped /assets/ and /_next/static/ in a Cache Storage
entry served cache-first, under a hardcoded name (atom-v3) with no build id
and no revalidation. A release therefore could not invalidate it: the browser
kept replaying the previous release's chunks against the new HTML, and React
never hydrated. The chunk branch also had no .catch(), unlike the API branch
below it, so a rejected fetch silently dropped the <script> instead of
surfacing a network error the browser could retry.
That cache also bought nothing. nginx already sends /_next/static/ and
/assets/ as `max-age=31536000, immutable`, and those filenames are
content-hashed, so the HTTP cache is both sufficient and safe — a changed
file gets a new name. The worker was the only layer able to go stale across a
deploy, and it was the one doing it.
Both prefixes now fall through to the network and are left to the HTTP cache.
The API offline fallback and network-first navigations are unchanged, and the
cache names move to v4 so an existing worker is replaced. SW_VERSION moves
with them: it is part of the registration URL, so without the bump the browser
never refetches sw.js and the old worker keeps running.
Unrelated but confirmed while tracing this: /assets/images/themes/arctic-ice.png
is requested by the browser and 404s, but that string exists nowhere in the
code, the database or the build. It was a stale reference served out of this
same cache, not a missing asset.
Commit the two changes that were live in the working tree but never
recorded.
The nginx change adds a location for /swf/dcr/hof_furni/icons/ that reads
/var/www/Gamedata/icons/ off disk and falls back to the CMS bundle for
anything missing. The CMS built its icon URLs from that path, but the icons
actually live in the gamedata tree: of the 16,263 classnames in items_base,
15,338 are present there under the safe name, against 11,267 under
public/swf/dcr/hof_furni/icons. The swf tree also keeps colour variants
behind a literal "*" in the filename, so every request using the safe name
missed, and hof.furni.url already points the Nitro client at the gamedata
tree. A miss is a no-store 404, never a cached one — same reasoning as
@gamedata_missing, since add_header without "always" says nothing about a
404 and Cloudflare would otherwise apply the zone TTL.
scripts/generate-stack-height-sql.ts emits a SQL file that repairs
items_base dimensions and interaction columns from the logic inside each
bundle, which is where they actually live rather than in furnidata. It
writes a file and touches no database, so the result is reviewed before it
is run.