1
0
Fork 0

feat(arma): extract catalog icons and extend the import library

- Add an `icons:arma` tool (extractArmaIcons.ts) that pulls inventory icons
  out of the game's PBOs and materializes the .paa files the catalog needs.
- Extend the catalog import library with the matching planning and upsert
  support, and cover it with integration tests.
This commit is contained in:
Jason Fraley 2026-10-03 03:29:58 -04:00
parent 74564afb2b
commit d4f9051b17
4 changed files with 488 additions and 63 deletions

View file

@ -26,7 +26,7 @@
* approvalStatus and human-edited "Virtual Data" are never reset.
*/
import { spawnSync } from "child_process";
import { existsSync, mkdirSync, readFileSync } from "fs";
import { existsSync, mkdirSync, readFileSync, statSync } from "fs";
import { join } from "path";
import type { Payload } from "payload";
import { plaintextToLexical } from "@/lib/tickets/lexical";
@ -35,7 +35,7 @@ import { plaintextToLexical } from "@/lib/tickets/lexical";
export const ASSET_TYPES = [
"weapon", "magazine", "optic", "muzzle", "bipod", "uniform", "vest",
"backpack", "helmet", "facewear", "nvg", "radio", "medical", "tool",
"explosive", "skin", "miscellaneous",
"explosive", "skin", "accessory", "miscellaneous",
] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
export type TransportMode = "ground" | "air" | "sea";
@ -60,6 +60,8 @@ export interface CatalogRecord {
mass?: number;
/** Transport mode for vehicles, round count for magazines, otherwise "". */
extra?: number | string;
/** Weapons only: slot plus classnames of compatible records in the same catalog. */
compat?: { slot?: string; magazines?: unknown; attachments?: unknown };
}
export interface PlanMedia {
@ -82,12 +84,50 @@ export interface ImportPlan {
transportMode: TransportMode;
/** Magazines only: round count from `extra`. */
roundCapacity?: number;
/** Weapons only: compatibility references (classnames), linked in a second pass. */
compat?: WeaponCompat;
mod: string;
/** Icon-map ref or rec.picture, used to resolve the .paa thumbnail. */
pictureRef: string;
media: PlanMedia;
}
export type WeaponSlot = "primary" | "secondary" | "launcher";
export interface WeaponCompat {
slot?: WeaponSlot;
/** Deduplicated (case-insensitive) classnames. */
magazines: string[];
attachments: string[];
}
/** Classnames from an untrusted JSON list: strings only, trimmed, case-insensitive unique. */
function classList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const item of value) {
if (typeof item !== "string") continue;
const name = item.trim();
const key = name.toLowerCase();
if (!name || seen.has(key)) continue;
seen.add(key);
out.push(name);
}
return out;
}
function parseCompat(raw: CatalogRecord["compat"]): WeaponCompat | undefined {
if (!raw || typeof raw !== "object") return undefined;
const slot = ["primary", "secondary", "launcher"].includes(String(raw.slot))
? (raw.slot as WeaponSlot)
: undefined;
const magazines = classList(raw.magazines);
const attachments = classList(raw.attachments);
if (!slot && magazines.length === 0 && attachments.length === 0) return undefined;
return { ...(slot ? { slot } : {}), magazines, attachments };
}
export interface PlanLookups {
/** icon_map JSON: classname -> .paa ref (takes priority over rec.picture). */
icons?: Record<string, unknown>;
@ -171,6 +211,7 @@ export function planRecord(rec: CatalogRecord, lookups: PlanLookups = {}): Impor
const assetType = classifyAssetType(className, rawType);
const roundCapacity = assetType === "magazine" ? numericExtra(rec.extra) : undefined;
const compat = assetType === "weapon" ? parseCompat(rec.compat) : undefined;
return {
kind: "asset",
@ -180,6 +221,7 @@ export function planRecord(rec: CatalogRecord, lookups: PlanLookups = {}): Impor
assetType,
transportMode: "ground",
...(roundCapacity !== undefined ? { roundCapacity } : {}),
...(compat ? { compat } : {}),
mod,
pictureRef,
media: { paaPath: "", pngPath: "", screenshotPath: "" },
@ -232,10 +274,12 @@ export function enrichPlanMedia(plan: ImportPlan, env: MediaEnv, convert = true)
if (paaPath && convert) {
plan.media.pngPath = convertPaaToPng(env.hemtt, paaPath, env.outDir) ?? "";
}
// Skip 0-byte images: Arma's screenshot command writes empty files when the
// game is misconfigured (HDR below 16-bit), and uploading one fails.
plan.media.screenshotPath = env.screenshots
? (["png", "webp", "jpg"]
.map((ext) => join(env.screenshots, plan.className + "." + ext))
.find(existsSync) ?? "")
.find(hasContent) ?? "")
: "";
}
@ -243,16 +287,45 @@ export function enrichPlanMedia(plan: ImportPlan, env: MediaEnv, convert = true)
* Database writes (Payload local API)
* ----------------------------------------------------------------------- */
/** Resolve the "Fuel" resource id required by Vehicles.fuel.fuelType. */
export async function resolveFuelConfig(payload: Payload): Promise<number> {
const r = await payload.find({
/** A file that exists and actually has bytes in it. */
function hasContent(path: string): boolean {
if (!existsSync(path)) return false;
try {
return statSync(path).size > 0;
} catch {
return false;
}
}
/**
* Resolve the fuel resource id required by Vehicles.fuel.fuelType.
* Looked up by code name first (`res_fuel` by default, which is stable even
* when the display name is something like "Gasoline"), then by a name equal
* to "Fuel" in any casing. Pass a different code name to use another resource.
*/
export async function resolveFuelConfig(payload: Payload, codeName = "res_fuel"): Promise<number> {
const byCode = await payload.find({
collection: "resources",
limit: 50,
where: { name: { equals: "Fuel" } },
where: { codeName: { equals: codeName } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const fuel = r.docs.find((d) => String(d.name).toLowerCase() === "fuel");
if (!fuel) throw new Error("No resource named 'Fuel' found. Cannot set vehicle fuel fields");
if (byCode.docs[0]) return Number(byCode.docs[0].id);
const byName = await payload.find({
collection: "resources",
where: { name: { like: "fuel" } },
limit: 50,
depth: 0,
overrideAccess: true,
});
const fuel = byName.docs.find((d) => String(d.name).trim().toLowerCase() === "fuel");
if (!fuel) {
throw new Error(
`No fuel resource found (code name '${codeName}' or name 'Fuel'). Cannot set vehicle fuel fields; pass --fuel <codeName>.`,
);
}
return Number(fuel.id);
}
@ -351,11 +424,15 @@ async function upsertAsset(payload: Payload, plan: ImportPlan): Promise<UpsertRe
"Asset Metadata": assetMetadata(plan),
};
const imagePath = planImagePath(plan);
if (imagePath) {
// Attach an image whenever the record has none. That covers importing the
// catalog first and adding screenshots in a later run, and repairs records
// whose image never landed. An image that is already set is never replaced
// (it may be a human's choice), and resolveMedia reuses an existing media
// doc, so repeated runs do not pile up duplicates.
const current = existing.docs[0] as unknown as { "Asset Metadata"?: { thumbnail?: unknown } };
if (imagePath && !current["Asset Metadata"]?.thumbnail) {
const media = await resolveMedia(payload, imagePath);
// Only attach when this run produced a new media doc — never stomp a
// human-chosen thumbnail with a re-discovered one.
if (media?.created) (data["Asset Metadata"] as Record<string, unknown>).thumbnail = media.id;
if (media) (data["Asset Metadata"] as Record<string, unknown>).thumbnail = media.id;
}
await payload.update({ collection: "assets", id, data, overrideAccess: true });
return { action: "updated", collection: "assets", id };
@ -408,9 +485,11 @@ async function upsertVehicle(payload: Payload, plan: ImportPlan, fuelId: number
};
if (plan.description) data.description = plaintextToLexical(plan.description);
const imagePath = planImagePath(plan);
if (imagePath) {
// Same rule as assets: fill an empty image, never replace one that is set.
const current = existing.docs[0] as unknown as { image?: unknown };
if (imagePath && !current.image) {
const media = await resolveMedia(payload, imagePath);
if (media?.created) data.image = media.id;
if (media) data.image = media.id;
}
await payload.update({ collection: "vehicles", id, data, overrideAccess: true });
return { action: "updated", collection: "vehicles", id };
@ -438,3 +517,125 @@ async function upsertVehicle(payload: Payload, plan: ImportPlan, fuelId: number
});
return { action: "created", collection: "vehicles", id: Number(created.id) };
}
/* -------------------------------------------------------------------------
* Weapon compatibility (second pass, after every record has been upserted)
* ----------------------------------------------------------------------- */
/** Asset types each Weapon Configuration relationship accepts (its filterOptions). */
const MAGAZINE_TYPES = new Set<string>(["magazine"]);
const ATTACHMENT_TYPES = new Set<string>(["optic", "muzzle", "bipod", "accessory"]);
export interface CompatLinkResult {
/** Weapons whose relationships or slot changed. */
updated: number;
/** Weapons already up to date. */
unchanged: number;
/** Weapon classnames not found in the assets collection. */
missingWeapons: number;
/** References to classnames that are not imported assets. */
unresolved: number;
/** References to assets of a type the relationship does not accept
* (e.g. rail lights/lasers, which import as "miscellaneous"). */
skippedType: number;
errors: number;
}
function relationIds(value: unknown): number[] {
if (!Array.isArray(value)) return [];
return value
.map((v) => (typeof v === "number" ? v : typeof v === "object" && v && "id" in v ? Number((v as { id: unknown }).id) : NaN))
.filter((n) => Number.isFinite(n));
}
/**
* Links each weapon to its compatible magazines and attachments. These are
* relationships to the asset rows that were imported, so nothing is copied
* or duplicated. Links are MERGED with what is already on the weapon (links
* added by hand are kept) and deduplicated; the slot is only filled when it
* is empty. Run after upserting the whole catalog, so references to records
* later in the file resolve.
*/
export async function linkWeaponCompatibility(
payload: Payload,
plans: ImportPlan[],
log: (line: string) => void = () => {},
): Promise<CompatLinkResult> {
const result: CompatLinkResult = { updated: 0, unchanged: 0, missingWeapons: 0, unresolved: 0, skippedType: 0, errors: 0 };
const weapons = plans.filter((p) => p.kind === "asset" && p.compat);
if (weapons.length === 0) return result;
// One lookup table for every classname involved: weapons + their references.
const wanted = new Set<string>();
for (const w of weapons) {
wanted.add(w.className);
for (const c of [...w.compat!.magazines, ...w.compat!.attachments]) wanted.add(c);
}
const byLower = new Map<string, { id: number; assetType: string }>();
const names = [...wanted];
for (let i = 0; i < names.length; i += 500) {
const chunk = names.slice(i, i + 500);
const found = await payload.find({
collection: "assets",
where: { className: { in: chunk } },
limit: chunk.length * 2,
depth: 0,
pagination: false,
overrideAccess: true,
});
for (const doc of found.docs) {
const key = String(doc.className).toLowerCase();
if (!byLower.has(key)) byLower.set(key, { id: Number(doc.id), assetType: String(doc.assetType) });
}
}
// The exporter writes each reference with the referenced record's own
// classname, so the exact-match query above finds it; the map is keyed in
// lower case only so duplicates that differ in case collapse to one id.
const resolve = (name: string, allowed: Set<string>, into: number[]) => {
const hit = byLower.get(name.toLowerCase());
if (!hit) { result.unresolved++; return; }
if (!allowed.has(hit.assetType)) { result.skippedType++; return; }
if (!into.includes(hit.id)) into.push(hit.id);
};
for (const weapon of weapons) {
const self = byLower.get(weapon.className.toLowerCase());
if (!self) { result.missingWeapons++; continue; }
try {
const doc = await payload.findByID({ collection: "assets", id: self.id, depth: 0, overrideAccess: true });
const config = (doc as unknown as { "Weapon Configuration"?: Record<string, unknown> })["Weapon Configuration"] ?? {};
const currentMags = relationIds(config.compatibleMagazines);
const currentAtts = relationIds(config.compatibleAttachments);
const mags = [...currentMags];
const atts = [...currentAtts];
for (const m of weapon.compat!.magazines) resolve(m, MAGAZINE_TYPES, mags);
for (const a of weapon.compat!.attachments) resolve(a, ATTACHMENT_TYPES, atts);
const slot = config.slotType || weapon.compat!.slot || undefined;
const changed = mags.length !== currentMags.length
|| atts.length !== currentAtts.length
|| (slot ?? null) !== (config.slotType ?? null);
if (!changed) { result.unchanged++; continue; }
await payload.update({
collection: "assets",
id: self.id,
data: {
"Weapon Configuration": {
...(slot ? { slotType: slot } : {}),
compatibleMagazines: mags,
compatibleAttachments: atts,
},
} as never,
overrideAccess: true,
});
result.updated++;
log(`Linked ${weapon.className}: ${mags.length} magazines, ${atts.length} attachments`);
} catch (e) {
result.errors++;
log(`ERR linking ${weapon.className}: ${(e as Error).message}`);
}
}
return result;
}

View file

@ -4,10 +4,12 @@
* game's packed PBOs into a --paa-root tree shaped like the reference paths,
* so importArmaLibrary.ts's resolvePaaPath() can find them for PAA->PNG.
*
* Resolution is ref-driven: the second path segment names the PBO (vanilla
* refs look like "\a3\weapons_f\Rifles\MX\data\UI\gear_mx_cqc_M_CA.paa"), so we
* locate that PBO by basename and inspect it on demand (cached), then map the
* ref to a file inside it. A fabricated/unresolvable ref reports MISS rather
* Resolution is prefix-driven: every PBO's "prefix" header is read up front
* (cheap: first few KB of each file), and each ref is matched to the PBO with
* the longest prefix it starts with (e.g. "\a3\weapons_f\..." -> prefix
* a3\weapons_f; CUP refs map to their per-weapon PBOs). That PBO's file list
* is then read once via `hemtt utils pbo inspect` (cached). Vanilla DLC PBOs in
* <game>/<dlc>/addons are scanned too. An unresolvable ref reports MISS rather
* than failing the run.
*
* CLI:
@ -20,7 +22,7 @@
* [--hemtt <path>] hemtt binary (default: resolve from PATH)
*/
import { spawnSync } from "child_process";
import { existsSync, mkdirSync, readFileSync } from "fs";
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync } from "fs";
import { dirname, join } from "path";
interface CliOpts {
@ -61,6 +63,14 @@ interface PboInfo {
function discoverPbos(game: string, mods: string[]): string[] {
const dirs = [join(game, "Addons")];
// Vanilla DLC/expansion PBOs live in <game>/<dlc>/addons (enoch, expansion, jets, ...).
for (const entry of readdirSync(game, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
for (const sub of ["addons", "Addons"]) {
const cand = join(game, entry.name, sub);
if (existsSync(cand) && !dirs.includes(cand)) dirs.push(cand);
}
}
for (const m of mods) {
const cand = join(m, "addons");
if (existsSync(cand)) dirs.push(cand);
@ -78,15 +88,6 @@ function discoverPbos(game: string, mods: string[]): string[] {
return out;
}
function pbosByName(pbos: string[]): Map<string, string> {
const byName = new Map<string, string>();
for (const p of pbos) {
const base = p.split(/[\\/]/).pop()!.replace(/\.pbo$/i, "").toLowerCase();
if (!byName.has(base)) byName.set(base, p);
}
return byName;
}
function inspectPbo(hemtt: string, pboPath: string): { prefix: string; files: string[] } | undefined {
const r = spawnSync(
hemtt,
@ -94,18 +95,60 @@ function inspectPbo(hemtt: string, pboPath: string): { prefix: string; files: st
{ encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
);
if (r.status !== 0 || !r.stdout) return undefined;
let data: { files?: { name?: string }[]; header?: { properties?: Record<string, string> } };
// hemtt prints a text summary ("- prefix: a3\weapons_f", ...) followed by
// the file list as a JSON array; it is not a single JSON document.
const prefixLine = r.stdout.match(/^\s*-\s*prefix:\s*(.+)$/im);
const prefix = (prefixLine?.[1] ?? readPboPrefix(pboPath)).trim().toLowerCase().replace(/\\/g, "/");
const arrayStart = r.stdout.indexOf("[{");
if (arrayStart < 0) return { prefix, files: [] };
let entries: { filename?: string; name?: string }[];
try {
data = JSON.parse(r.stdout);
entries = JSON.parse(r.stdout.slice(arrayStart, r.stdout.lastIndexOf("]") + 1));
} catch {
return undefined;
}
const files = (data.files ?? []).map((f) => f.name ?? "").filter(Boolean);
const props = data.header?.properties ?? {};
const prefix = (props.prefix ?? "").trim().toLowerCase().replace(/\\/g, "/");
const files = entries.map((f) => f.filename ?? f.name ?? "").filter(Boolean);
return { prefix, files };
}
/**
* Reads the "prefix" header property straight from the PBO file (first entry
* is a "Vers" product entry followed by key/value C strings). Cheap enough to
* run on every PBO up front, which lets refs be matched to PBOs by prefix
* instead of guessing from the PBO file name.
*/
function readPboPrefix(pboPath: string): string {
let fd: number | undefined;
try {
fd = openSync(pboPath, "r");
const buf = Buffer.alloc(64 * 1024);
const len = readSync(fd, buf, 0, buf.length, 0);
let pos = 0;
const cstr = (): string => {
const end = buf.indexOf(0, pos);
if (end < 0 || end >= len) throw new Error("truncated");
const v = buf.toString("latin1", pos, end);
pos = end + 1;
return v;
};
cstr(); // empty entry name
const mime = buf.readUInt32LE(pos);
pos += 20;
if (mime !== 0x56657273) return "";
for (;;) {
const key = cstr();
if (!key) break;
const value = cstr();
if (key.toLowerCase() === "prefix") return value;
}
} catch {
// not a readable PBO header
} finally {
if (fd !== undefined) closeSync(fd);
}
return "";
}
function ensurePbo(
hemtt: string,
pboPath: string,
@ -125,37 +168,31 @@ function normalizeRef(src: string): string {
return src.replace(/\\/g, "/").replace(/^\/+/, "").toLowerCase();
}
function buildPrefixIndex(pbos: string[]): { prefix: string; pboPath: string }[] {
const index: { prefix: string; pboPath: string }[] = [];
for (const pboPath of pbos) {
const prefix = readPboPrefix(pboPath).trim().toLowerCase().replace(/\\/g, "/").replace(/\/+$/, "");
if (prefix) index.push({ prefix, pboPath });
}
// Longest prefix first, so "cup/weapons/cup_weapons_m16" beats "cup/weapons".
return index.sort((a, b) => b.prefix.length - a.prefix.length);
}
function resolveRef(
norm: string,
pbos: string[],
byName: Map<string, string>,
prefixIndex: { prefix: string; pboPath: string }[],
inspected: Map<string, PboInfo>,
hemtt: string,
): { pbo: PboInfo; relFile: string } | undefined {
const seg = norm.split("/").filter(Boolean);
const pboName = seg[1]?.toLowerCase();
if (pboName && byName.has(pboName)) {
const pbo = ensurePbo(hemtt, byName.get(pboName)!, inspected);
if (pbo) {
const prefixSlash = pbo.prefix + "/";
if (norm.startsWith(prefixSlash)) {
const relLower = norm.slice(pbo.prefix.length + 1).replace(/\//g, "\\");
for (const entry of prefixIndex) {
if (!norm.startsWith(entry.prefix + "/")) continue;
const pbo = ensurePbo(hemtt, entry.pboPath, inspected);
if (!pbo) continue;
const relLower = norm.slice(entry.prefix.length + 1).replace(/\//g, "\\");
const exact = pbo.files.get(relLower);
if (exact) return { pbo, relFile: exact };
}
}
}
let best: { prefix: string; pbo: PboInfo } | undefined;
for (const pbo of inspected.values()) {
const prefixSlash = pbo.prefix + "/";
if (norm.startsWith(prefixSlash) && (!best || pbo.prefix.length > best.prefix.length)) {
best = { prefix: pbo.prefix, pbo };
}
}
if (!best) return undefined;
const relLower = norm.slice(best.prefix.length + 1).replace(/\//g, "\\");
const exact = best.pbo.files.get(relLower);
return exact ? { pbo: best.pbo, relFile: exact } : undefined;
return undefined;
}
async function main() {
@ -170,7 +207,8 @@ async function main() {
const pbos = discoverPbos(game, opts.mod ?? []);
console.log(`Discovered ${pbos.length} PBOs across ${1 + (opts.mod?.length ?? 0)} roots.`);
const byName = pbosByName(pbos);
const prefixIndex = buildPrefixIndex(pbos);
console.log(`Indexed ${prefixIndex.length} PBO prefixes.`);
const inspected = new Map<string, PboInfo>();
interface Ref { src: string; origin: string; }
@ -202,7 +240,7 @@ async function main() {
ok++;
continue;
}
const res = resolveRef(norm, pbos, byName, inspected, hemtt);
const res = resolveRef(norm, prefixIndex, inspected, hemtt);
if (!res) {
console.log(` MISS ${ref.origin}\t${ref.src} (not in any indexed PBO)`);
report.push(`MISS\t${ref.origin}\t${ref.src}`);

View file

@ -20,6 +20,7 @@
* [--approve] apply to the DB (default: proposal only)
* [--dry-run] plan + print, never touch the DB or filesystem
* [--limit <n>] import at most n records
* [--fuel <codeName>] fuel resource for vehicles (default: res_fuel)
*/
import { spawnSync } from "child_process";
import { existsSync, readFileSync } from "fs";
@ -29,6 +30,7 @@ import config from "@payload-config";
import {
enrichPlanMedia,
planRecord,
linkWeaponCompatibility,
resolveFuelConfig,
upsertPlan,
type CatalogRecord,
@ -45,6 +47,7 @@ interface CliOpts {
approve?: boolean;
"dry-run"?: boolean;
limit?: string[];
fuel?: string[];
}
function parseCli(argv: string[]): CliOpts {
const opts: CliOpts = {};
@ -106,14 +109,17 @@ async function main() {
}
if (dryRun) {
const withCompat = plans.filter((p) => p.compat);
const refs = withCompat.reduce((n, p) => n + p.compat!.magazines.length + p.compat!.attachments.length, 0);
console.log(`\nDry run: ${plans.length} would import, 0 applied.`);
console.log(`Compatibility: ${withCompat.length} weapons carry ${refs} magazine/attachment references.`);
return;
}
const payload = await getPayload({ config });
// Fuel is only needed for vehicle records; resolve lazily so asset-only
// catalogs import even without a Fuel resource.
const fuelId = plans.some((p) => p.kind === "vehicle") ? await resolveFuelConfig(payload) : undefined;
const fuelId = plans.some((p) => p.kind === "vehicle") ? await resolveFuelConfig(payload, opts.fuel?.[0] ?? "res_fuel") : undefined;
let created = 0, updated = 0, errors = 0;
for (const plan of plans) {
@ -127,6 +133,15 @@ async function main() {
}
}
console.log(`Done: ${created} created, ${updated} updated, ${errors} errors.`);
// Second pass: link weapons to their magazines/attachments (references to
// the rows imported above, never copies).
const link = await linkWeaponCompatibility(payload, plans, (line) => console.log(line));
console.log(
`Compatibility: ${link.updated} weapons linked, ${link.unchanged} already up to date, ` +
`${link.unresolved} references not in the app, ${link.skippedType} of an unsupported type ` +
`(e.g. rail lights/lasers), ${link.missingWeapons} weapons missing, ${link.errors} errors.`,
);
}
main().catch((e) => { console.error(e); process.exit(1); });

View file

@ -1,11 +1,17 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import {
ASSET_TYPES,
enrichPlanMedia,
linkWeaponCompatibility,
planRecord,
upsertPlan,
type CatalogRecord,
type MediaEnv,
} from "@/lib/arma-import/catalog";
let payload: Payload;
@ -15,6 +21,16 @@ let fuelId: number;
let createdFuel = false;
const assetIds: number[] = [];
const vehicleIds: number[] = [];
const mediaIds: number[] = [];
const SHOT_DIR = join(tmpdir(), `ptf-shots-${RUN}`);
// 1x1 PNG: enough for the upload path, tiny enough to inline.
const PNG_1PX = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
function mediaEnv(screenshots = ""): MediaEnv {
return { paaRoot: "", screenshots, outDir: SHOT_DIR, hemtt: "" };
}
describe("Arma catalog import", () => {
beforeAll(async () => {
@ -63,6 +79,10 @@ describe("Arma catalog import", () => {
for (const id of assetIds) {
await payload.delete({ collection: "assets", id, overrideAccess: true }).catch(() => {});
}
for (const id of mediaIds) {
await payload.delete({ collection: "media", id, overrideAccess: true }).catch(() => {});
}
rmSync(SHOT_DIR, { recursive: true, force: true });
if (createdFuel && fuelId) {
await payload.delete({ collection: "resources", id: fuelId, overrideAccess: true }).catch(() => {});
}
@ -228,6 +248,157 @@ describe("Arma catalog import", () => {
});
});
describe("adding images in a later import pass", () => {
const lateClassName = `${RUN}_late_image_rifle`;
function planFor(screenshot: string) {
const plan = planRecord({
classname: lateClassName,
type: "weapon",
displayName: `${RUN} Late Image Rifle`,
picture: "",
mod: "PTF_Test_Mod",
description: "",
mass: 1,
extra: "",
})!;
enrichPlanMedia(plan, mediaEnv(screenshot));
return plan;
}
it("imports the catalog with no image, then attaches one on a later run", async () => {
// Pass 1: data only (no --screenshots).
const first = await upsertPlan(payload, planFor(""));
expect(first.action).toBe("created");
assetIds.push(first.id);
let doc = await payload.findByID({ collection: "assets", id: first.id, depth: 0, overrideAccess: true });
expect(doc["Asset Metadata"]?.thumbnail).toBeFalsy();
// Pass 2: the screenshot now exists on disk.
mkdirSync(SHOT_DIR, { recursive: true });
writeFileSync(join(SHOT_DIR, `${lateClassName}.png`), PNG_1PX);
const second = await upsertPlan(payload, planFor(SHOT_DIR));
expect(second.action).toBe("updated");
expect(second.id).toBe(first.id); // upsert, not a duplicate
doc = await payload.findByID({ collection: "assets", id: first.id, depth: 0, overrideAccess: true });
const thumbnail = doc["Asset Metadata"]?.thumbnail;
expect(thumbnail).toBeTruthy();
if (typeof thumbnail === "number") mediaIds.push(thumbnail);
else if (thumbnail && typeof thumbnail === "object") mediaIds.push(thumbnail.id);
const count = await payload.count({
collection: "assets",
where: { className: { equals: lateClassName } },
});
expect(count.totalDocs).toBe(1);
});
it("keeps the image when re-run, and repairs a cleared one", async () => {
const assetId = assetIds[assetIds.length - 1];
const before = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
const originalId = typeof before["Asset Metadata"]?.thumbnail === "number"
? (before["Asset Metadata"]!.thumbnail as number)
: (before["Asset Metadata"]!.thumbnail as { id: number }).id;
// Re-run: the media doc already exists, so it must not be duplicated.
await upsertPlan(payload, planFor(SHOT_DIR));
const after = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
const afterId = typeof after["Asset Metadata"]?.thumbnail === "number"
? (after["Asset Metadata"]!.thumbnail as number)
: (after["Asset Metadata"]!.thumbnail as { id: number }).id;
expect(afterId).toBe(originalId);
// A cleared thumbnail is repaired by the next run (media already exists).
await payload.update({
collection: "assets",
id: assetId,
data: { "Asset Metadata": { thumbnail: null } },
overrideAccess: true,
});
await upsertPlan(payload, planFor(SHOT_DIR));
const repaired = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
expect(repaired["Asset Metadata"]?.thumbnail).toBeTruthy();
});
});
describe("weapon compatibility", () => {
const rifle = `${RUN}_compat_rifle`;
const mag = `${RUN}_compat_mag`;
const optic = `${RUN}_compat_optic`;
const light = `${RUN}_compat_light`;
function catalog() {
return [
{ classname: rifle, type: "weapon", displayName: "Compat Rifle", compat: {
slot: "primary",
// duplicate + different case + a ref that was never imported
magazines: [mag, mag.toUpperCase(), `${RUN}_not_in_app`],
attachments: [optic, light],
} },
{ classname: mag, type: "magazine", displayName: "Compat Mag", extra: 30 },
{ classname: optic, type: "optic", displayName: "Compat Optic" },
{ classname: light, type: "miscellaneous", displayName: "Compat Light" },
].map((r) => planRecord(r as CatalogRecord)!);
}
it("parses compat on weapons only, deduplicating classnames", () => {
const plans = catalog();
expect(plans[0].compat).toEqual({
slot: "primary",
magazines: [mag, `${RUN}_not_in_app`],
attachments: [optic, light],
});
expect(plans[1].compat).toBeUndefined();
expect(planRecord({ classname: "x", type: "magazine", compat: { magazines: ["y"] } })!.compat).toBeUndefined();
});
it("links magazines/attachments as references, merges on re-run, never duplicates", async () => {
const plans = catalog();
const ids: Record<string, number> = {};
for (const plan of plans) {
const res = await upsertPlan(payload, plan);
assetIds.push(res.id);
ids[plan.className] = res.id;
}
const first = await linkWeaponCompatibility(payload, plans);
expect(first.updated).toBe(1);
expect(first.unresolved).toBe(1); // the never-imported magazine
expect(first.skippedType).toBe(1); // rail light is "miscellaneous"
let doc = await payload.findByID({ collection: "assets", id: ids[rifle], depth: 0, overrideAccess: true });
const wc = () => (doc as unknown as { "Weapon Configuration": Record<string, unknown> })["Weapon Configuration"];
expect(wc().slotType).toBe("primary");
expect(wc().compatibleMagazines).toEqual([ids[mag]]);
expect(wc().compatibleAttachments).toEqual([ids[optic]]);
// A link added by hand survives, and re-running adds nothing twice.
const extraOptic = await upsertPlan(payload, planRecord({ classname: `${RUN}_hand_optic`, type: "optic" })!);
assetIds.push(extraOptic.id);
await payload.update({
collection: "assets",
id: ids[rifle],
data: { "Weapon Configuration": { compatibleAttachments: [ids[optic], extraOptic.id] } } as never,
overrideAccess: true,
});
const second = await linkWeaponCompatibility(payload, plans);
expect(second.updated).toBe(0);
expect(second.unchanged).toBe(1);
doc = await payload.findByID({ collection: "assets", id: ids[rifle], depth: 0, overrideAccess: true });
expect(wc().compatibleMagazines).toEqual([ids[mag]]);
expect(wc().compatibleAttachments).toEqual([ids[optic], extraOptic.id]);
// Each referenced item still exists exactly once.
for (const name of [mag, optic]) {
const count = await payload.count({ collection: "assets", where: { className: { equals: name } } });
expect(count.totalDocs).toBe(1);
}
});
});
describe("upsertPlan vehicles", () => {
const vehicleClassname = `${RUN}_heli_ghost`;