fix(assets): serve furniture icons from the gamedata tree, add stack-height SQL generator
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m52s
CI / tests-unit (push) Successful in 2m3s
CI / tests-ui (push) Successful in 2m40s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m47s

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.
This commit is contained in:
openhands committed 2026-10-11 17:01:53 +02:00
1 parent 43742e8d99
commit 2978483ea9
2 files changed
+489

No files matched your search

+103
View File
@@ -78,6 +78,23 @@ map "$cms_cc_class|$cms_skip_cache" $cms_public_cc {
"~^3\|0" "public, max-age=300, s-maxage=300, stale-while-revalidate=600";
}
# Bestandsnaam van een furni-icon, opgehaald uit de URL. De locatie voor
# /swf/dcr/hof_furni/icons/ heeft die nodig om hetzelfde bestand bij de
# gamedata-boom op te kunnen vragen. Leeg laten voor elke andere URL, zodat
# niets anders deze waarde per ongeluk gebruikt.
map $uri $furni_icon_file {
~^/swf/dcr/hof_furni/icons/(?<icon_file>.+)$ $icon_file;
default "";
}
# Cache-Control per status voor het CMS-valrimpeltje. Een 404 mag nooit
# gecacht worden (zie @gamedata_missing hieronder), dus die krijgt `no-store`
# in plaats van de icon-TTL.
map $upstream_status $furni_icon_cc {
200 "public, max-age=604800, stale-while-revalidate=2592000";
default "no-store";
}
# ─── Mime fix ───
types {
application/json jsonc;
@@ -376,6 +393,92 @@ server {
return 404;
}
# ─── Furni-icons: gamedata-boom eerst, CMS-boom als valrimpeltje ───
#
# De CMS bouwde zijn icon-URL's altijd vanaf /swf/dcr/hof_furni/icons/,
# maar de catalogus komt uit /var/www/Gamedata/icons:
# - van de 16.263 classnames in items_base staat er 15.338 hier onder de
# "veilige" naam (`highscore_perteam_1_icon.png`), tegen 11.267 in
# public/swf/dcr/hof_furni/icons;
# - de swf-boom houdt kleurvarianten bovendien vast met een letterlijke
# `*` in de bestandsnaam (`highscore_perteam*1_icon.png`), dus elke
# aanvraag met de veilige naam mist;
# - en `hof.furni.url` wijst de Nitro-client al naar deze boom.
# Resultaat was een 404 voor `highscore_perteam_1_icon.png` en duizenden
# andere, terwijl `/gamedata/icons/` ze wél heeft.
#
# Deze locatie leest de gamedata-boom rechtstreeks van schijf — nginx heeft
# er leesrechten op en het is de boom die de client ook gebruikt. Wat daar
# niet staat wordt doorgestuurd naar de CMS, die uit public/ serveert,
# zodat niets wat nu al laadde stopt met laden.
#
# Een echte miss is een no-store 404, nooit een gecachte: `add_header`
# zonder `always` zegt niets over een 404, en zonder de expliciete handler
# hieronder neemt Cloudflare de zone-TTL (1 jaar) over. Zelfde les als bij
# @gamedata_missing hierboven.
location ^~ /swf/dcr/hof_furni/icons/ {
alias /var/www/Gamedata/icons/;
error_page 404 = @furni_icon_from_cms;
# 403 = het pad valt op een map, dus `alias` eindigt op een directory.
# Dat is geen icon, dus dezelfde route als een miss.
error_page 403 = @furni_icon_missing;
# Geen limit_req: zelfde reden als /gamedata/icons/. Een kamerladen
# vuurt honderden icons in één burst af.
add_header Cache-Control "public, max-age=604800, stale-while-revalidate=2592000";
add_header Cache-Tag "cms-furni-icons";
access_log off;
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
if ($cors_headers) {
add_header Access-Control-Max-Age 1728000;
add_header Content-Type "text/plain; charset=utf-8";
return 204;
}
}
# Niet in de gamedata-boom: val terug op public/swf/dcr/hof_furni/icons/,
# waar de CMS naartoe importeert. Het pad wordt hier opnieuw opgebouwd
# omdat een named location geen prefix van `$uri` afstropt.
#
# De Cache-Control komt uit `$furni_icon_cc` (status-afhankelijk) in plaats
# van uit een tweede `error_page`: nginx negeert `error_page` die binnen
# een named location staat die zelf via `error_page` bereikt is, dus een
# geketende 404-handler bestaat hier niet.
location @furni_icon_from_cms {
internal;
proxy_pass http://cms_app/swf/dcr/hof_furni/icons/$furni_icon_file;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_intercept_errors on;
# `=404` (geen URI) vervangt de body door nginx' eigen foutpagina. Next
# stuurt voor een miss een volledige HTML-pagina van ~300 KB mee, en een
# gebroken icon hoeft geen 300 KB aan markup op te halen. Dit is geen
# interne redirect, dus de `add_header` hieronder blijven gelden.
error_page 404 =404;
# `proxy_hide_header` haalt de Cache-Control van de CMS weg, anders
# staan er twee in één antwoord (Next stuurt voor /swf/** een
# `public, max-age=604800`, `location /` elders nog een
# `private, no-cache`). Eén header per antwoord.
proxy_hide_header Cache-Control;
add_header Cache-Control $furni_icon_cc always;
add_header Cache-Tag "cms-furni-icons";
access_log off;
}
location @furni_icon_missing {
add_header Cache-Control "no-store" always;
add_header Cache-Tag "cms-furni-icons" always;
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
access_log off;
return 404;
}
location /camera/ {
alias /var/www/Camera/;
add_header Cache-Control "public, max-age=31536000, immutable";
+386
View File
@@ -0,0 +1,386 @@
import "./load-env";
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
autoDetectInteraction,
nitroAnimationStatesCount,
} from "@/lib/furni/auto-interaction";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
/**
* Generate a plain SQL file that repairs every furniture row in `items_base`
* from the visual logic inside each `.hab` / `.nitro` bundle:
*
* width / length / stack_height ← logic.model.dimensions x/y/z
* allow_stack ← z > 0
* allow_sit / allow_lay / allow_walk ← furnidata flags (cansiton/canlayon/canstandon)
* interaction_modes_count ← real nitro animation states / mechanic
* interaction_type ← logicType + classname mechanic (only fills 'default')
*
* The dimensions are NOT in furnidata — they live in the bundle JSON at
* `logic.model.dimensions` (or top-level `dimensions` for official bundles).
* The interaction columns mirror `verifyAndFixInteractionModesCount`
* (src/lib/services/furni-import.ts:1341): real furnidata flags + .nitro states,
* and `interaction_type` is only touched when it is still `default`.
*
* Usage:
* pnpm exec tsx scripts/generate-stack-height-sql.ts [options]
*
* Options:
* --dir=<path> bundle directory (default: /var/www/Gamedata/bundled/furniture)
* --furnidata=<path> local FurnitureData.json (default: /var/www/Gamedata/config/FurnitureData.json)
* --out=<path> SQL output file (default: stack-heights.sql)
* --types=<list> items_base types (default: s,i → floor + wall furniture)
* --all emit every matched row instead of only changed rows
* --no-interaction dimensions/allow_stack only, skip interaction columns
*
* Nothing in the database is modified here; you run the generated file yourself.
*/
const args = new Map<string, string | true>();
for (const raw of process.argv.slice(2)) {
const eq = raw.indexOf("=");
if (eq === -1) args.set(raw.replace(/^--/, ""), true);
else args.set(raw.slice(2, eq), raw.slice(eq + 1));
}
const DIR = String(args.get("dir") ?? "/var/www/Gamedata/bundled/furniture");
const FURNIDATA = String(
args.get("furnidata") ?? "/var/www/Gamedata/config/FurnitureData.json",
);
const OUT = String(args.get("out") ?? "stack-heights.sql");
const TYPES = String(args.get("types") ?? "s,i")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const EMIT_ALL = args.has("all");
const WITH_INTERACTION = !args.has("no-interaction");
const EXTENSIONS = [".hab", ".nitro"] as const;
interface Dims {
x: number;
y: number;
z: number;
}
interface Flags {
cansiton: boolean;
canlayon: boolean;
canstandon: boolean;
}
interface BundleInfo {
dims: Dims | null;
animationStates: number | undefined;
logicType: string | undefined;
}
interface Row {
item_name: string;
public_name: string;
width: number;
length: number;
stack_height: number;
allow_stack: number | string;
allow_sit: number | string;
allow_lay: number | string;
allow_walk: number | string;
interaction_type: string;
interaction_modes_count: number;
}
/** Escape a value for a single-quoted MySQL string literal. */
function q(value: string): string {
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
}
/** `enum('0','1')` values must be quoted — an unquoted 0 is read as index 0 (''). */
function qbit(value: boolean): string {
return value ? "'1'" : "'0'";
}
function isTruthyBit(value: number | string): boolean {
return Number(value) === 1;
}
/** Pull dimensions out of a parsed bundle JSON, tolerating both layouts. */
function extractDims(json: unknown): Dims | null {
const root = json as Record<string, unknown> | null;
if (!root) return null;
const logic = root.logic as Record<string, unknown> | undefined;
const model = logic?.model as Record<string, unknown> | undefined;
const candidate =
(model?.dimensions as Dims | undefined) ??
(logic?.dimensions as Dims | undefined) ??
(root.dimensions as Dims | undefined);
if (!candidate) return null;
const x = Number(candidate.x);
const y = Number(candidate.y);
const z = Number(candidate.z);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null;
}
return { x, y, z };
}
/** Parse a bundle once and cache everything we need from it. */
function readBundleInfo(path: string): BundleInfo {
try {
const json = parseNitroBundle(readFileSync(path)).json as Record<
string,
unknown
>;
return {
dims: extractDims(json),
animationStates: nitroAnimationStatesCount(json),
logicType:
typeof json.logicType === "string" ? json.logicType : undefined,
};
} catch {
return { dims: null, animationStates: undefined, logicType: undefined };
}
}
/**
* Read the local FurnitureData.json into a classname → flags map. Duplicate
* classnames are merged so a true flag in any entry wins (same rule as the
* app's `lookupRealFurniFlags`).
*/
function loadFurniFlags(path: string): Map<string, Flags> {
const map = new Map<string, Flags>();
if (!existsSync(path)) return map;
try {
const json = JSON.parse(readFileSync(path, "utf-8")) as Record<
string,
{ furnitype?: Array<Record<string, unknown>> }
>;
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const item of json[section]?.furnitype ?? []) {
const cn = item.classname;
if (typeof cn !== "string" || !cn) continue;
const candidate: Flags = {
cansiton: !!item.cansiton,
canlayon: !!item.canlayon,
canstandon: !!item.canstandon,
};
const existing = map.get(cn);
if (!existing) {
map.set(cn, candidate);
continue;
}
existing.cansiton = existing.cansiton || candidate.cansiton;
existing.canlayon = existing.canlayon || candidate.canlayon;
existing.canstandon = existing.canstandon || candidate.canstandon;
}
}
} catch {
// unreadable furnidata → interaction columns are skipped
}
return map;
}
async function main(): Promise<void> {
const t0 = Date.now();
const dir = resolve(DIR);
const entries = new Set(readdirSync(dir));
const infoCache = new Map<string, BundleInfo>();
const flagsMap = WITH_INTERACTION
? loadFurniFlags(resolve(FURNIDATA))
: new Map<string, Flags>();
const [rows] = (await db.execute(
sql`SELECT item_name, public_name, width, length, stack_height, allow_stack,
allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count
FROM items_base
WHERE type IN (${sql.join(
TYPES.map((t) => sql`${t}`),
sql`, `,
)})`,
)) as unknown as [Row[], unknown];
console.log(
`[stack-sql] ${rows.length} furniture rows | ${entries.size} bundles | ${flagsMap.size} furnidata flags`,
);
const updates: string[] = [];
let matched = 0;
let changed = 0;
let resolvedBase = 0;
const counts = {
dims: 0,
allowFlags: 0,
modes: 0,
interactionType: 0,
};
const missing: string[] = [];
const unparsed: string[] = [];
for (const row of rows) {
const name = row.item_name;
const base = name.includes("*") ? name.slice(0, name.indexOf("*")) : name;
const variants = [name, name.trim(), base, base.trim()];
let chosen: string | null = null;
for (const variant of variants) {
for (const ext of EXTENSIONS) {
const candidate = `${variant}${ext}`;
if (entries.has(candidate)) {
chosen = candidate;
break;
}
}
if (chosen) break;
}
if (!chosen) {
missing.push(name);
continue;
}
if (!chosen.startsWith(`${name}.`)) resolvedBase++;
let info = infoCache.get(chosen);
if (!info) {
info = readBundleInfo(resolve(dir, chosen));
infoCache.set(chosen, info);
}
if (!info.dims) {
unparsed.push(`${name} (${chosen})`);
continue;
}
matched++;
const width = Math.trunc(info.dims.x);
const length = Math.trunc(info.dims.y);
const stackHeight = Number(info.dims.z.toFixed(2));
const allowStack = info.dims.z > 0;
const setParts: string[] = [];
const dimsChanged =
row.width !== width ||
row.length !== length ||
Number(row.stack_height) !== stackHeight ||
isTruthyBit(row.allow_stack) !== allowStack;
if (dimsChanged) counts.dims++;
setParts.push(
`width=${width}`,
`length=${length}`,
`stack_height=${stackHeight}`,
`allow_stack=${qbit(allowStack)}`,
);
// Interaction columns — only when the classname exists in furnidata
// (mirrors the app sweep: authoritative flags, never keyword guesses).
const flags =
flagsMap.get(name) ??
flagsMap.get(base) ??
flagsMap.get(name.trim()) ??
flagsMap.get(base.trim());
if (WITH_INTERACTION && flags) {
const auto = autoDetectInteraction(name, row.public_name || undefined, {
cansiton: flags.cansiton,
canlayon: flags.canlayon,
canstandon: flags.canstandon,
hasActionData: true,
animationStates: info.animationStates,
logicType: info.logicType,
});
if (
Number(row.allow_sit) !== 2 &&
isTruthyBit(row.allow_sit) !== auto.canSit
) {
setParts.push(`allow_sit=${qbit(auto.canSit)}`);
counts.allowFlags++;
}
if (
Number(row.allow_lay) !== 2 &&
isTruthyBit(row.allow_lay) !== auto.canLay
) {
setParts.push(`allow_lay=${qbit(auto.canLay)}`);
counts.allowFlags++;
}
if (
Number(row.allow_walk) !== 2 &&
isTruthyBit(row.allow_walk) !== auto.canStand
) {
setParts.push(`allow_walk=${qbit(auto.canStand)}`);
counts.allowFlags++;
}
if (
Number(row.interaction_modes_count ?? 0) !== auto.interactionModesCount
) {
setParts.push(`interaction_modes_count=${auto.interactionModesCount}`);
counts.modes++;
}
if (
(row.interaction_type === "default" || !row.interaction_type) &&
auto.interactionType !== "default"
) {
setParts.push(`interaction_type=${q(auto.interactionType)}`);
counts.interactionType++;
}
}
const isChanged =
dimsChanged ||
setParts.some(
(p) =>
!p.startsWith("width=") &&
!p.startsWith("length=") &&
!p.startsWith("stack_height=") &&
!p.startsWith("allow_stack="),
);
if (isChanged) changed++;
if (!EMIT_ALL && !isChanged) continue;
updates.push(
`UPDATE items_base SET ${setParts.join(", ")} WHERE item_name=${q(name)} AND type IN (${TYPES.map(q).join(", ")});`,
);
}
const header = [
"-- Auto-generated by scripts/generate-stack-height-sql.ts",
`-- Source bundles: ${dir}`,
`-- Furnidata: ${WITH_INTERACTION ? resolve(FURNIDATA) : "(disabled)"}`,
`-- Generated: ${new Date().toISOString()}`,
`-- Furniture rows: ${rows.length} | matched: ${matched} | changed: ${changed} | missing bundle: ${missing.length} | unparsable: ${unparsed.length}`,
`-- Changes: dimensions ${counts.dims} | allow_sit/lay/walk ${counts.allowFlags} | modes_count ${counts.modes} | interaction_type ${counts.interactionType}`,
`-- Mode: ${EMIT_ALL ? "all matched rows" : "changed rows only"}`,
"",
"START TRANSACTION;",
"",
].join("\n");
const footer = "\n\nCOMMIT;\n";
writeFileSync(
resolve(OUT),
`${header}${updates.join("\n")}${footer}`,
"utf-8",
);
console.log(`[stack-sql] matched ${matched}, changed ${changed}`);
console.log(
`[stack-sql] changes -> dims ${counts.dims}, allow flags ${counts.allowFlags}, modes ${counts.modes}, interaction_type ${counts.interactionType}`,
);
if (resolvedBase > 0)
console.log(`[stack-sql] resolved via base classname: ${resolvedBase}`);
console.log(`[stack-sql] updates written: ${updates.length}`);
if (missing.length) {
console.log(
`[stack-sql] no bundle (${missing.length}): ${missing.slice(0, 20).join(", ")}${missing.length > 20 ? ", …" : ""}`,
);
}
if (unparsed.length) {
console.log(
`[stack-sql] unparsable (${unparsed.length}): ${unparsed.slice(0, 20).join(", ")}${unparsed.length > 20 ? ", …" : ""}`,
);
}
console.log(`[stack-sql] wrote ${resolve(OUT)} in ${Date.now() - t0}ms`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});