diff --git a/src/lib/arma-import/catalog.ts b/src/lib/arma-import/catalog.ts index 3316a1f..efe643c 100644 --- a/src/lib/arma-import/catalog.ts +++ b/src/lib/arma-import/catalog.ts @@ -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(); + 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; @@ -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 { - 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 { + 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 .`, + ); + } return Number(fuel.id); } @@ -351,11 +424,15 @@ async function upsertAsset(payload: Payload, plan: ImportPlan): Promise).thumbnail = media.id; + if (media) (data["Asset Metadata"] as Record).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(["magazine"]); +const ATTACHMENT_TYPES = new Set(["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 { + 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(); + 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(); + 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, 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 })["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; +} diff --git a/src/tools/import/extractArmaIcons.ts b/src/tools/import/extractArmaIcons.ts index 039d190..cc8032c 100644 --- a/src/tools/import/extractArmaIcons.ts +++ b/src/tools/import/extractArmaIcons.ts @@ -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 + * //addons are scanned too. An unresolvable ref reports MISS rather * than failing the run. * * CLI: @@ -20,7 +22,7 @@ * [--hemtt ] 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 //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 { - const byName = new Map(); - 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 } }; + // 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, + prefixIndex: { prefix: string; pboPath: string }[], inspected: Map, 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, "\\"); - const exact = pbo.files.get(relLower); - if (exact) return { pbo, relFile: exact }; - } - } + 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(); 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}`); diff --git a/src/tools/import/importArmaLibrary.ts b/src/tools/import/importArmaLibrary.ts index 651bfcc..e071677 100644 --- a/src/tools/import/importArmaLibrary.ts +++ b/src/tools/import/importArmaLibrary.ts @@ -20,6 +20,7 @@ * [--approve] apply to the DB (default: proposal only) * [--dry-run] plan + print, never touch the DB or filesystem * [--limit ] import at most n records + * [--fuel ] 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); }); diff --git a/tests/int/arma-catalog-import.int.spec.ts b/tests/int/arma-catalog-import.int.spec.ts index b59450c..8cc24b2 100644 --- a/tests/int/arma-catalog-import.int.spec.ts +++ b/tests/int/arma-catalog-import.int.spec.ts @@ -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 = {}; + 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 })["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`;