diff --git a/src/lib/arma-import/catalog.ts b/src/lib/arma-import/catalog.ts new file mode 100644 index 0000000..3316a1f --- /dev/null +++ b/src/lib/arma-import/catalog.ts @@ -0,0 +1,440 @@ +/** + * Arma catalog import: planning + upsert logic for the in-game export catalog. + * + * Record contract (the in-game ptf_export addon emits a JSON array of these): + * + * { classname: string, type: AssetType | "vehicle", displayName: string, + * picture: string (.paa ref, may be ""), mod: string, description: string, + * mass: number, extra: number | string } + * + * - `type` is trusted when it is a known Assets.assetType value or "vehicle"; + * unknown/missing types fall back to classname heuristics. + * - For "vehicle" records `extra` is the transport mode ("air" | "sea" | + * "ground"; anything else -> "ground"). + * - For "magazine" records `extra` is the round count (magazine roundCapacity). + * - `mass` is accepted for contract completeness; no collection field maps to + * it yet, so it is not imported. + * + * Legacy field aliases (`class`, `category`, `name`) are still accepted. + * + * planRecord() is pure (safe to unit test); enrichPlanMedia() does the + * filesystem work (.paa lookup, hemtt PAA->PNG conversion, screenshot + * matching); upsertPlan() writes to Payload and is an idempotent upsert — + * assets keyed by `className`, vehicles by `classname`. Updates refresh only + * in-game data (name, assetType/transportMode, mod, description) and attach a + * new thumbnail/image only when a fresh media doc was produced, so + * approvalStatus and human-edited "Virtual Data" are never reset. + */ +import { spawnSync } from "child_process"; +import { existsSync, mkdirSync, readFileSync } from "fs"; +import { join } from "path"; +import type { Payload } from "payload"; +import { plaintextToLexical } from "@/lib/tickets/lexical"; + +/** Mirrors the Assets collection's assetType enum (src/collections/logistics/Assets.ts). */ +export const ASSET_TYPES = [ + "weapon", "magazine", "optic", "muzzle", "bipod", "uniform", "vest", + "backpack", "helmet", "facewear", "nvg", "radio", "medical", "tool", + "explosive", "skin", "miscellaneous", +] as const; +export type AssetType = (typeof ASSET_TYPES)[number]; +export type TransportMode = "ground" | "air" | "sea"; + +const ASSET_TYPE_SET = new Set(ASSET_TYPES); +const TRANSPORT_MODES: readonly string[] = ["ground", "air", "sea"]; + +export interface CatalogRecord { + classname?: string; + /** Legacy alias for classname. */ + class?: string; + type?: string; + /** Legacy alias for type. */ + category?: string; + displayName?: string; + /** Legacy alias for displayName. */ + name?: string; + picture?: string; + mod?: string; + description?: string; + /** Accepted by the contract; no collection field maps to it (not imported). */ + mass?: number; + /** Transport mode for vehicles, round count for magazines, otherwise "". */ + extra?: number | string; +} + +export interface PlanMedia { + /** Resolved .paa path under --paa-root (exists check only, no conversion). */ + paaPath: string; + /** Converted PNG (only produced in approve mode by enrichPlanMedia). */ + pngPath: string; + /** Matched --screenshots PNG/WebP/JPG preview for the classname. */ + screenshotPath: string; +} + +export interface ImportPlan { + kind: "asset" | "vehicle"; + className: string; + displayName: string; + description: string; + /** The Assets.assetType value; "vehicle" for vehicle plans. */ + assetType: AssetType | "vehicle"; + /** Vehicles only. */ + transportMode: TransportMode; + /** Magazines only: round count from `extra`. */ + roundCapacity?: number; + mod: string; + /** Icon-map ref or rec.picture, used to resolve the .paa thumbnail. */ + pictureRef: string; + media: PlanMedia; +} + +export interface PlanLookups { + /** icon_map JSON: classname -> .paa ref (takes priority over rec.picture). */ + icons?: Record; +} + +function lookupIcon(icons: Record | undefined, className: string): string { + if (!icons || typeof icons !== "object") return ""; + for (const [key, value] of Object.entries(icons)) { + if (key.toLowerCase() === className.toLowerCase() && typeof value === "string") return value; + } + return ""; +} + +/** + * Classify an asset record. Known types win; unknown types fall back to + * classname heuristics, ending at "miscellaneous" (a valid enum value — the + * old "item" fallback was not). + */ +function classifyAssetType(className: string, type: string): AssetType { + const t = (type || "").toLowerCase(); + if (ASSET_TYPE_SET.has(t)) return t as AssetType; + const c = className.toLowerCase(); + if (c.includes("mag") || c.startsWith("30rnd") || c.startsWith("20rnd")) return "magazine"; + if (/^(arifle|smg|hgun|srifle|lm|mmg|rifle|launch)/.test(c)) return "weapon"; + if (/^(30|40|45|9|7|5|6|12|mag_|rhs_mag)/.test(c)) return "magazine"; + return "miscellaneous"; +} + +/** Legacy vehicle heuristic, used only when `type` is not a known value. */ +function looksLikeVehicle(className: string, type: string): boolean { + if (type && /vehicle|car|tank|uav|ship|heli|truck/.test(type.toLowerCase())) return true; + const c = className.toLowerCase(); + return ( + /^(b_|o_|i_|c_)(mr|tru|car|heli|tank|uav|boat|apc|lt|soldier)/.test(c) && + !/backpack|vest|helmet/.test(c) + ); +} + +/** Numeric `extra` (round count for magazines); tolerates numeric strings. */ +function numericExtra(extra: number | string | undefined): number | undefined { + if (extra === undefined || extra === "") return undefined; + const n = typeof extra === "number" ? extra : Number(String(extra).trim()); + return Number.isFinite(n) && n > 0 ? n : undefined; +} + +/** Transport mode from a vehicle record's `extra`, defaulting to "ground". */ +function transportModeFromExtra(extra: number | string | undefined): TransportMode { + if (typeof extra === "string" && TRANSPORT_MODES.includes(extra.trim().toLowerCase())) { + return extra.trim().toLowerCase() as TransportMode; + } + return "ground"; +} + +/** + * Plan one catalog record (pure — no filesystem or database access). + * Returns undefined for records without a usable classname. + */ +export function planRecord(rec: CatalogRecord, lookups: PlanLookups = {}): ImportPlan | undefined { + const className = String(rec.classname ?? rec.class ?? "").trim(); + if (!className) return undefined; + + const rawType = String(rec.type ?? rec.category ?? "").trim().toLowerCase(); + const displayName = String(rec.displayName ?? rec.name ?? "").trim() || className; + const description = String(rec.description ?? "").trim(); + const mod = String(rec.mod ?? "").trim() || "Arma 3"; + const pictureRef = lookupIcon(lookups.icons, className) || String(rec.picture ?? "").trim(); + + if (rawType === "vehicle" || (!ASSET_TYPE_SET.has(rawType) && looksLikeVehicle(className, rawType))) { + return { + kind: "vehicle", + className, + displayName, + description, + assetType: "vehicle", + transportMode: transportModeFromExtra(rec.extra), + mod, + pictureRef, + media: { paaPath: "", pngPath: "", screenshotPath: "" }, + }; + } + + const assetType = classifyAssetType(className, rawType); + const roundCapacity = assetType === "magazine" ? numericExtra(rec.extra) : undefined; + + return { + kind: "asset", + className, + displayName, + description, + assetType, + transportMode: "ground", + ...(roundCapacity !== undefined ? { roundCapacity } : {}), + mod, + pictureRef, + media: { paaPath: "", pngPath: "", screenshotPath: "" }, + }; +} + +/* ------------------------------------------------------------------------- + * Filesystem media resolution (CLI only — never runs in planRecord/tests) + * ----------------------------------------------------------------------- */ + +function basename(p: string): string { + const seg = p.split(/[\\/]/); + return seg[seg.length - 1]; +} + +function resolvePaaPath(ref: string, paaRoot: string): string { + if (!ref || !paaRoot) return ""; + const clean = ref.replace(/^[\\/]+/, "").replace(/\\/g, "/"); + const cand = join(paaRoot, clean); + return existsSync(cand) ? cand : ""; +} + +function convertPaaToPng(hemtt: string, paaPath: string, outDir: string): string | undefined { + const dest = join(outDir, basename(paaPath).replace(/\.paa$/i, "") + ".png"); + if (existsSync(dest)) return dest; + mkdirSync(outDir, { recursive: true }); + const r = spawnSync(hemtt, ["utils", "paa", "convert", paaPath, dest], { encoding: "utf8" }); + return r.status === 0 && existsSync(dest) ? dest : undefined; +} + +export interface MediaEnv { + /** Root mirroring ref paths where extracted .paa files live. */ + paaRoot: string; + /** PNG/WebP/JPG per-classname 3D-rendered previews ("" to disable). */ + screenshots: string; + /** Destination for converted PNGs. */ + outDir: string; + /** hemtt binary used for the PAA->PNG conversion. */ + hemtt: string; +} + +/** + * Resolve media for a plan on the filesystem: .paa ref -> existing file, + * converted PNG, and screenshot match. With `convert = false` (dry-run) this + * is read-only: the .paa existence check runs but no PNG is produced. + */ +export function enrichPlanMedia(plan: ImportPlan, env: MediaEnv, convert = true): void { + const paaPath = resolvePaaPath(plan.pictureRef, env.paaRoot); + plan.media.paaPath = paaPath; + if (paaPath && convert) { + plan.media.pngPath = convertPaaToPng(env.hemtt, paaPath, env.outDir) ?? ""; + } + plan.media.screenshotPath = env.screenshots + ? (["png", "webp", "jpg"] + .map((ext) => join(env.screenshots, plan.className + "." + ext)) + .find(existsSync) ?? "") + : ""; +} + +/* ------------------------------------------------------------------------- + * 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({ + collection: "resources", + limit: 50, + where: { name: { equals: "Fuel" } }, + 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"); + return Number(fuel.id); +} + +export interface UpsertResult { + action: "created" | "updated"; + collection: "assets" | "vehicles"; + id: number; +} + +/** + * Upsert one planned record into `assets` (keyed by `className`) or + * `vehicles` (keyed by `classname`), exact match. `fuelId` (from + * resolveFuelConfig) is required for vehicle plans. + */ +export async function upsertPlan(payload: Payload, plan: ImportPlan, fuelId?: number): Promise { + return plan.kind === "vehicle" ? upsertVehicle(payload, plan, fuelId) : upsertAsset(payload, plan); +} + +const IMAGE_MIMETYPES: Record = { + png: "image/png", + webp: "image/webp", + jpg: "image/jpeg", + jpeg: "image/jpeg", +}; + +/** + * Find-or-create a media doc for the file, deduped by filename so re-runs + * reuse the same media row instead of piling up duplicates. `created` tells + * callers whether this run actually produced a new image; only then is an + * existing doc's thumbnail/image refreshed (a human-chosen image is kept). + * Failures resolve to undefined — the record is still imported, just without + * an image. + */ +async function resolveMedia(payload: Payload, filePath: string): Promise<{ id: number; created: boolean } | undefined> { + try { + const fileName = basename(filePath); + const buffer = readFileSync(filePath); + const ext = (fileName.split(".").pop() ?? "").toLowerCase(); + const mimetype = IMAGE_MIMETYPES[ext] ?? "application/octet-stream"; + + const existing = await payload.find({ + collection: "media", + where: { filename: { equals: fileName } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + if (existing.docs.length > 0) return { id: Number(existing.docs[0].id), created: false }; + + const created = await payload.create({ + collection: "media", + data: { alt: fileName }, + file: { data: buffer, mimetype, name: fileName, size: buffer.length }, + overrideAccess: true, + }); + return { id: Number(created.id), created: true }; + } catch { + return undefined; + } +} + +/** First available image path for the plan, or "" when none was produced. */ +function planImagePath(plan: ImportPlan): string { + return plan.media.pngPath || plan.media.screenshotPath || ""; +} + +/** + * Asset metadata group written on every import (mod) — gameDescription only + * when the record carries a description, so an empty export can never wipe a + * human-entered description. thumbnail is appended by the caller when a new + * image exists. + */ +function assetMetadata(plan: ImportPlan): Record { + const metadata: Record = { mod: plan.mod }; + if (plan.description) metadata.gameDescription = plan.description; + return metadata; +} + +async function upsertAsset(payload: Payload, plan: ImportPlan): Promise { + // planRecord guarantees a real Assets.assetType value for kind "asset". + const assetType = plan.assetType as AssetType; + + const existing = await payload.find({ + collection: "assets", + where: { className: { equals: plan.className } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + + if (existing.docs.length > 0) { + const id = Number(existing.docs[0].id); + const data: Record = { + name: plan.displayName, + assetType, + "Asset Metadata": assetMetadata(plan), + }; + const imagePath = planImagePath(plan); + if (imagePath) { + 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).thumbnail = media.id; + } + await payload.update({ collection: "assets", id, data, overrideAccess: true }); + return { action: "updated", collection: "assets", id }; + } + + const imagePath = planImagePath(plan); + const mediaId = imagePath ? (await resolveMedia(payload, imagePath))?.id : undefined; + const data: Record = { + name: plan.displayName, + className: plan.className, + assetType, + "Asset Metadata": { + ...assetMetadata(plan), + ...(mediaId !== undefined ? { thumbnail: mediaId } : {}), + }, + ...(assetType === "magazine" && plan.roundCapacity !== undefined + ? { "Magazine Configuration": { roundCapacity: plan.roundCapacity } } + : {}), + }; + + const created = await payload.create({ + collection: "assets", + // draft: false pins the non-draft create overload; `as never` satisfies + // the generated create type, which marks defaultValue-backed fields + // (approvalStatus, crafting, storageDimensions) required even though + // Payload fills them at runtime (repo precedent: reservationOptions.ts). + draft: false, + data: data as never, + overrideAccess: true, + }); + return { action: "created", collection: "assets", id: Number(created.id) }; +} + +async function upsertVehicle(payload: Payload, plan: ImportPlan, fuelId: number | undefined): Promise { + if (!fuelId) throw new Error("No resource named 'Fuel' found. Cannot set vehicle fuel fields"); + + const existing = await payload.find({ + collection: "vehicles", + where: { classname: { equals: plan.className } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + + if (existing.docs.length > 0) { + const id = Number(existing.docs[0].id); + const data: Record = { + name: plan.displayName, + transportMode: plan.transportMode, + }; + if (plan.description) data.description = plaintextToLexical(plan.description); + const imagePath = planImagePath(plan); + if (imagePath) { + const media = await resolveMedia(payload, imagePath); + if (media?.created) data.image = media.id; + } + await payload.update({ collection: "vehicles", id, data, overrideAccess: true }); + return { action: "updated", collection: "vehicles", id }; + } + + const imagePath = planImagePath(plan); + const mediaId = imagePath ? (await resolveMedia(payload, imagePath))?.id : undefined; + const data: Record = { + name: plan.displayName, + classname: plan.className, + fuel: { fuelType: fuelId, fuelCapacity: 100, fuelConsumptionRate: 1 }, + transportMode: plan.transportMode, + ...(plan.description ? { description: plaintextToLexical(plan.description) } : {}), + ...(mediaId !== undefined ? { image: mediaId } : {}), + }; + + const created = await payload.create({ + collection: "vehicles", + // draft: false pins the non-draft create overload; `as never` satisfies + // the generated create type (approvalStatus is marked required though + // Payload fills the default at runtime). + draft: false, + data: data as never, + overrideAccess: true, + }); + return { action: "created", collection: "vehicles", id: Number(created.id) }; +} diff --git a/src/tools/import/importArmaLibrary.ts b/src/tools/import/importArmaLibrary.ts index ca9dc16..651bfcc 100644 --- a/src/tools/import/importArmaLibrary.ts +++ b/src/tools/import/importArmaLibrary.ts @@ -2,10 +2,10 @@ /** * Import an Arma inventory export into the Polaris Task Force Payload CMS. * - * Consumes a catalog JSON produced by the in-game ptf_export addon (records - * carrying classname, display name, type, description, picture) plus an - * optional icon_map, and upserts rows into the `assets` / `vehicles` - * collections. Thumbnails are materialized from .paa inventory icons found + * Thin CLI wrapper around src/lib/arma-import/catalog.ts (record contract, + * planning and upsert logic live there). Records are UPSERTED into the + * `assets` / `vehicles` collections: assets keyed by `className`, vehicles by + * `classname`. Thumbnails are materialized from .paa inventory icons found * under --paa-root (see extractArmaIcons.ts) and converted to PNG uploads via * `hemtt utils paa convert`; vehicle 3D-rendered screenshots can be supplied * via --screenshots (matched by classname). @@ -22,10 +22,18 @@ * [--limit ] import at most n records */ import { spawnSync } from "child_process"; -import { existsSync, mkdirSync, readFileSync } from "fs"; -import { dirname, join } from "path"; +import { existsSync, readFileSync } from "fs"; +import { dirname } from "path"; import { getPayload } from "payload"; import config from "@payload-config"; +import { + enrichPlanMedia, + planRecord, + resolveFuelConfig, + upsertPlan, + type CatalogRecord, + type ImportPlan, +} from "@/lib/arma-import/catalog"; interface CliOpts { catalog?: string[]; @@ -59,150 +67,6 @@ function resolveHemtt(explicit?: string): string { throw new Error("hemtt not found; pass --hemtt "); } -// Mirrors the Assets collection's assetType enum (see payload-types.ts). -// The in-game export (ptf_export addon) emits exactly these values. -const KNOWN_ASSET_TYPES = new Set([ - "weapon", "magazine", "optic", "muzzle", "bipod", "uniform", "vest", - "backpack", "helmet", "facewear", "nvg", "radio", "medical", "tool", - "explosive", "skin", "miscellaneous", -]); -function classifyAssetType(clazz: string, type: string): string { - const t = (type || "").toLowerCase(); - if (KNOWN_ASSET_TYPES.has(t)) return t; - const c = clazz.toLowerCase(); - if (c.includes("mag") || c.startsWith("30rnd") || c.startsWith("20rnd")) return "magazine"; - if (/^(arifle|smg|hgun|srifle|lm|mmg|rifle|launch)/.test(c)) return "weapon"; - if (/^(30|40|45|9|7|5|6|12|mag_|rhs_mag)/.test(c)) return "magazine"; - return "item"; -} - -function isVehicle(clazz: string, type: string): boolean { - if (type && /vehicle|car|tank|uav|ship|heli|truck/.test(type.toLowerCase())) return true; - const c = clazz.toLowerCase(); - return /^(b_|o_|i_|c_)(mr|tru|car|heli|tank|uav|boat|apc|lt|soldier)/.test(c) && - !/backpack|vest|helmet/.test(c); -} - -function lookupIcon(icons: Record, clazz: string): string { - if (icons && typeof icons === "object") { - for (const [k, v] of Object.entries(icons)) { - if (k.toLowerCase() === clazz.toLowerCase() && typeof v === "string") return v; - } - } - return ""; -} - -function resolvePaaPath(ref: string, paaRoot: string): string { - if (!ref) return ""; - const clean = ref.replace(/^[\\/]+/, "").replace(/\\/g, "/"); - const cand = join(paaRoot, clean); - return existsSync(cand) ? cand : ""; -} - -function convertPaaToPng(hemtt: string, paaPath: string, outDir: string): string | undefined { - const base = basename(paaPath).replace(/\.paa$/i, "") + ".png"; - const dest = join(outDir, base); - if (existsSync(dest)) return dest; - mkdirSync(outDir, { recursive: true }); - const r = spawnSync(hemtt, ["utils", "paa", "convert", paaPath, dest], { encoding: "utf8" }); - return r.status === 0 && existsSync(dest) ? dest : undefined; -} -function basename(p: string): string { - const seg = p.split(/[\\/]/); - return seg[seg.length - 1]; -} - -interface ImportPlan { - kind: "asset" | "vehicle"; - clazz: string; - displayName: string; - description: string; - assetType: string; - mod: string; - pictureRef: string; - paaPath: string; - pngPath: string; - screenshotPath: string; -} - -function planImport( - rec: Record, - lookups: { icons: Record; paaRoot: string; screenshots: string; outDir: string; hemtt: string }, -): ImportPlan | undefined { - const clazz = String(rec.classname ?? rec.class ?? "").trim(); - const type = String(rec.type ?? rec.category ?? "").trim(); - if (!clazz) return undefined; - const vehicle = isVehicle(clazz, type); - const assetType = vehicle ? "vehicle" : classifyAssetType(clazz, type); - const displayName = String(rec.displayName ?? rec.name ?? clazz).trim() || clazz; - const description = String(rec.description ?? "").trim(); - const pictureRef = String(rec.picture ?? "").trim(); - const mod = String(rec.mod ?? "").trim() || "Arma 3"; - const iconRef = lookups.icons ? lookupIcon(lookups.icons, clazz) : ""; - const ref = iconRef || pictureRef; - const paaPath = ref ? resolvePaaPath(ref, lookups.paaRoot) : ""; - const pngPath = paaPath - ? (convertPaaToPng(lookups.hemtt, paaPath, join(lookups.outDir)) ?? "") - : ""; - const screenshotPath = lookups.screenshots - ? (["png", "webp", "jpg"].map((e) => join(lookups.screenshots, clazz + "." + e)).find(existsSync) ?? "") - : ""; - return { kind: vehicle ? "vehicle" : "asset", clazz, displayName, description, assetType, mod, pictureRef: ref, paaPath, pngPath, screenshotPath }; -} - -async function resolveFuelConfig(payload: { find: (args: any) => Promise<{ docs: any[] }> }): Promise<{ id: number }> { - const r = await payload.find({ collection: "resources", limit: 50, where: { name: { equals: "Fuel" } } }); - const fuel = r.docs.find((d: any) => String(d.name).toLowerCase() === "fuel"); - if (!fuel) throw new Error("No resource named 'Fuel' found. Cannot set vehicle fuel fields"); - return { id: fuel.id }; -} - -async function writeEntry( - payload: any, - plan: ImportPlan, - opts: { outDir: string }, - fuelId: number, -): Promise { - const common = { - class_name: plan.clazz, - display_name: plan.displayName, - description: plan.description || null, - }; - const mediaId = plan.pngPath || plan.screenshotPath ? await uploadMedia(payload, plan.pngPath || plan.screenshotPath).catch(() => undefined) : undefined; - - if (plan.kind === "vehicle") { - return payload.create({ - collection: "vehicles", - data: { - ...common, - vehicleName: plan.displayName, - ...(mediaId ? { vehicle_metadata_thumbnail: mediaId } : {}), - fuel: { fuelType: fuelId, fuelCapacity: 100, fuelConsumptionRate: 1 }, - }, - }).then((d: any) => `vehicle:${d.id}`); - } - return payload.create({ - collection: "assets", - data: { - ...common, - assetType: plan.assetType, - ...(plan.pngPath ? { "Asset Metadata": { mod: plan.mod, thumbnail: mediaId, gameDescription: plan.description || null } } : {}), - }, - }).then((d: any) => `asset:${d.id}`); -} - -async function uploadMedia(payload: { create: (args: { collection: string; data: any; file: any }) => Promise }, filePath: string): Promise { - const { readFileSync } = await import("fs"); - const fileName = basename(filePath); - const buffer = readFileSync(filePath); - const res = await payload.create({ - collection: "media", - data: { alt: fileName, filename: fileName }, - file: { data: buffer, mimetype: "image/png", name: fileName, size: buffer.length }, - }); - return Number(res.id); -} - async function main() { const opts = parseCli(process.argv.slice(2)); const hemtt = resolveHemtt(opts.hemtt?.[0]); @@ -210,10 +74,10 @@ async function main() { if (!catalogPath || !existsSync(catalogPath)) throw new Error("--catalog is required and must exist"); const dryRun = !!opts["dry-run"]; const approve = !!opts.approve || dryRun; + if (!dryRun && !approve) throw new Error("--approve or --dry-run required to run without a real write"); const outDir = opts.out?.[0] ?? dirname(catalogPath); const paaRoot = opts["paa-root"]?.[0] ?? ""; const screenshots = opts.screenshots?.[0] ?? ""; - if (!dryRun && !approve) throw new Error("--approve or --dry-run required to run without a real write"); const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); const records = Array.isArray(catalog) ? catalog : [catalog]; @@ -222,34 +86,47 @@ async function main() { icons = JSON.parse(readFileSync(opts.icons[0], "utf8")); } const limit = opts.limit ? parseInt(opts.limit[0], 10) : records.length; - const lookups = { icons, paaRoot, screenshots, outDir, hemtt }; console.log(`Import plan (dry-run=${dryRun}, approve=${approve}):`); const plans: ImportPlan[] = []; for (const rec of records.slice(0, limit)) { - const plan = planImport(rec, lookups); - if (!plan) { console.log(` SKIP ${String((rec as any).classname ?? "")} (not an importable record)`); continue; } + const plan = planRecord(rec as CatalogRecord, { icons }); + if (!plan) { + console.log(` SKIP ${String((rec as CatalogRecord).classname ?? "")} (not an importable record)`); + continue; + } + // Media resolution is filesystem work: read-only during dry-run (no + // PAA->PNG conversion), full conversion in approve mode. + enrichPlanMedia(plan, { paaRoot, screenshots, outDir, hemtt }, !dryRun); plans.push(plan); - const img = plan.pngPath ? "[img: item-png]" : plan.screenshotPath ? "[img: screenshot]" : "[no img]"; - console.log(` -> ${plan.kind}\t${plan.clazz}\t${plan.displayName}\t${img}`); + const img = plan.media.pngPath || plan.media.paaPath + ? "[img: item-png]" + : plan.media.screenshotPath ? "[img: screenshot]" : "[no img]"; + console.log(` -> ${plan.kind}\t${plan.className}\t${plan.displayName}\t${img}`); } - if (dryRun) { console.log(`\nDry run: ${plans.length} would import, 0 applied.`); return; } + if (dryRun) { + console.log(`\nDry run: ${plans.length} would import, 0 applied.`); + return; + } const payload = await getPayload({ config }); - const fuel = await resolveFuelConfig(payload); - let ok = 0, err = 0; + // 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; + + let created = 0, updated = 0, errors = 0; for (const plan of plans) { try { - const id = await writeEntry(payload, plan, { outDir }, fuel.id); - console.log(`Applied ${id}`); - ok++; + const res = await upsertPlan(payload, plan, fuelId); + console.log(`Applied ${res.action} ${res.collection}:${res.id}`); + if (res.action === "created") created++; else updated++; } catch (e) { - console.error(`ERR ${plan.clazz}: ${(e as Error).message}`); - err++; + console.error(`ERR ${plan.className}: ${(e as Error).message}`); + errors++; } } - console.log(`Done: ${ok} ok, ${err} errors.`); + console.log(`Done: ${created} created, ${updated} updated, ${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 new file mode 100644 index 0000000..b59450c --- /dev/null +++ b/tests/int/arma-catalog-import.int.spec.ts @@ -0,0 +1,286 @@ +import { getPayload, Payload } from "payload"; +import config from "@/payload.config"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + ASSET_TYPES, + planRecord, + upsertPlan, + type CatalogRecord, +} from "@/lib/arma-import/catalog"; + +let payload: Payload; +const RUN = `armacat-${Date.now().toString(36)}`; + +let fuelId: number; +let createdFuel = false; +const assetIds: number[] = []; +const vehicleIds: number[] = []; + +describe("Arma catalog import", () => { + beforeAll(async () => { + const payloadConfig = await config; + payload = await getPayload({ config: payloadConfig }); + + // Vehicles need a fuel resource for the fuel group; reuse one if a prior + // fixture left it, otherwise create it (and only then clean it up). + const existingFuel = await payload.find({ + collection: "resources", + where: { name: { equals: "Fuel" } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + if (existingFuel.docs[0]) { + fuelId = existingFuel.docs[0].id; + } else { + const fuel = await payload.create({ + collection: "resources", + data: { + name: "Fuel", + codeName: `res_fuel_${RUN}`, + unitOfMeasure: "liter", + approvalStatus: "in_progress", + type: "fluid", + baseValue: 1, + rarity: "common", + massPerUnit: 0, + gridWidth: 1, + gridHeight: 1, + tradeable: false, + }, + depth: 0, + overrideAccess: true, + }); + fuelId = fuel.id; + createdFuel = true; + } + }); + + afterAll(async () => { + for (const id of vehicleIds) { + await payload.delete({ collection: "vehicles", id, overrideAccess: true }).catch(() => {}); + } + for (const id of assetIds) { + await payload.delete({ collection: "assets", id, overrideAccess: true }).catch(() => {}); + } + if (createdFuel && fuelId) { + await payload.delete({ collection: "resources", id: fuelId, overrideAccess: true }).catch(() => {}); + } + }); + + describe("planRecord classification", () => { + it("trusts type for every contract asset type", () => { + for (const t of ASSET_TYPES) { + const rec: CatalogRecord = { + classname: `PTF_Test_Thing_${t}`, + type: t, + displayName: "Thing", + picture: "", + mod: "CBA_3den", + description: "", + mass: 1, + extra: "", + }; + const plan = planRecord(rec); + expect(plan).toBeDefined(); + expect(plan!.kind).toBe("asset"); + expect(plan!.assetType).toBe(t); + } + }); + + it("routes vehicle records to the vehicles collection with transportMode from extra", () => { + const air = planRecord({ + classname: "B_Heli_Light_01_F", + type: "vehicle", + displayName: "Ghost Hawk", + picture: "", + mod: "Arma 3", + description: "", + mass: 3000, + extra: "air", + }); + expect(air!.kind).toBe("vehicle"); + expect(air!.transportMode).toBe("air"); + + const sea = planRecord({ classname: "C_Boat_Civil_01_F", type: "vehicle", extra: "sea" }); + expect(sea!.transportMode).toBe("sea"); + }); + + it("defaults transportMode to ground when extra is not a valid mode", () => { + const plan = planRecord({ classname: "B_MRAP_01_F", type: "vehicle", extra: "submarine" }); + expect(plan!.kind).toBe("vehicle"); + expect(plan!.transportMode).toBe("ground"); + }); + + it("falls back to heuristics for unknown types (miscellaneous, not item)", () => { + const plan = planRecord({ classname: "PTF_Test_Odd_Gadget", type: "qrd_mysterious_device" }); + expect(plan!.kind).toBe("asset"); + expect(plan!.assetType).toBe("miscellaneous"); + }); + + it("falls back to the legacy vehicle heuristic for unknown types", () => { + const plan = planRecord({ classname: "B_Truck_01_transport_F", type: "who_knows" }); + expect(plan!.kind).toBe("vehicle"); + expect(plan!.transportMode).toBe("ground"); + }); + + it("captures magazine round counts from extra", () => { + const plan = planRecord({ classname: "30rnd_556x45_Mag", type: "magazine", extra: 30 }); + expect(plan!.kind).toBe("asset"); + expect(plan!.roundCapacity).toBe(30); + expect(plan!.assetType).toBe("magazine"); + }); + + it("ignores numeric extra for non-magazine assets", () => { + const plan = planRecord({ classname: "PTF_Test_Rifle", type: "weapon", extra: 30 }); + expect(plan!.assetType).toBe("weapon"); + expect(plan!.roundCapacity).toBeUndefined(); + }); + + it("supports legacy class/category/name aliases and icon-map overrides", () => { + const plan = planRecord( + { class: "Legacy_Thing", category: "optic", name: "Legacy Optic" }, + { icons: { legacy_thing: "\\a3\\data\\ui\\icon.paa" } }, + ); + expect(plan!.className).toBe("Legacy_Thing"); + expect(plan!.assetType).toBe("optic"); + expect(plan!.displayName).toBe("Legacy Optic"); + expect(plan!.pictureRef).toBe("\\a3\\data\\ui\\icon.paa"); + }); + + it("defaults mod to Arma 3 and rejects records without a classname", () => { + const plan = planRecord({ classname: "PTF_Test_Thing", type: "weapon" }); + expect(plan).toBeDefined(); + expect(plan!.mod).toBe("Arma 3"); + expect(planRecord({ type: "weapon" })).toBeUndefined(); + }); + }); + + describe("upsertPlan assets", () => { + const assetClassName = `${RUN}_asset_rifle`; + + it("creates an asset with correct className/assetType and Asset Metadata.mod", async () => { + const plan = planRecord({ + classname: assetClassName, + type: "weapon", + displayName: `${RUN} Rifle`, + picture: "", + mod: "PTF_Test_Mod", + description: "An import test rifle.", + mass: 2, + extra: "", + })!; + + const res = await upsertPlan(payload, plan); + expect(res.action).toBe("created"); + expect(res.collection).toBe("assets"); + assetIds.push(res.id); + + const doc = await payload.findByID({ collection: "assets", id: res.id, depth: 0, overrideAccess: true }); + expect(doc.className).toBe(assetClassName); + expect(doc.assetType).toBe("weapon"); + expect(doc.name).toBe(`${RUN} Rifle`); + expect(doc["Asset Metadata"]?.mod).toBe("PTF_Test_Mod"); + expect(doc["Asset Metadata"]?.gameDescription).toBe("An import test rifle."); + expect(doc.approvalStatus).toBe("in_progress"); + }); + + it("updates on re-run without duplicating and preserves approvalStatus and Virtual Data", async () => { + const assetId = assetIds[0]; + // Simulate human edits before the second import run. + await payload.update({ + collection: "assets", + id: assetId, + data: { approvalStatus: "approved", tradeable: true }, + overrideAccess: true, + }); + + const res = await upsertPlan( + payload, + planRecord({ + classname: assetClassName, + type: "weapon", + displayName: `${RUN} Rifle MkII`, + picture: "", + mod: "PTF_Test_Mod_2", + description: "Updated in-game description.", + mass: 2, + extra: "", + })!, + undefined, // assets never need fuel + ); + expect(res.action).toBe("updated"); + expect(res.collection).toBe("assets"); + expect(res.id).toBe(assetId); + + const count = await payload.count({ + collection: "assets", + where: { className: { equals: assetClassName } }, + }); + expect(count.totalDocs).toBe(1); + + const doc = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true }); + expect(doc.name).toBe(`${RUN} Rifle MkII`); + expect(doc["Asset Metadata"]?.mod).toBe("PTF_Test_Mod_2"); + expect(doc["Asset Metadata"]?.gameDescription).toBe("Updated in-game description."); + expect(doc.approvalStatus).toBe("approved"); // never reset by re-import + expect(doc.tradeable).toBe(true); // Virtual Data untouched + }); + }); + + describe("upsertPlan vehicles", () => { + const vehicleClassname = `${RUN}_heli_ghost`; + + it("creates a vehicle with classname and transportMode air", async () => { + const plan = planRecord({ + classname: vehicleClassname, + type: "vehicle", + displayName: `${RUN} Ghost Hawk`, + picture: "", + mod: "PTF_Test_Mod", + description: "An import test helicopter.", + mass: 3000, + extra: "air", + })!; + + const res = await upsertPlan(payload, plan, fuelId); + expect(res.action).toBe("created"); + expect(res.collection).toBe("vehicles"); + vehicleIds.push(res.id); + + const doc = await payload.findByID({ collection: "vehicles", id: res.id, depth: 0, overrideAccess: true }); + expect(doc.classname).toBe(vehicleClassname); + expect(doc.name).toBe(`${RUN} Ghost Hawk`); + expect(doc.transportMode).toBe("air"); + expect(doc.fuel.fuelType).toBe(fuelId); + expect(doc.fuel.fuelCapacity).toBe(100); + expect(doc.approvalStatus).toBe("in_progress"); + }); + + it("updates an existing vehicle without duplicating", async () => { + const vehicleId = vehicleIds[0]; + const res = await upsertPlan( + payload, + planRecord({ + classname: vehicleClassname, + type: "vehicle", + displayName: `${RUN} Ghost Hawk II`, + extra: "air", + })!, + fuelId, + ); + expect(res.action).toBe("updated"); + expect(res.id).toBe(vehicleId); + + const count = await payload.count({ + collection: "vehicles", + where: { classname: { equals: vehicleClassname } }, + }); + expect(count.totalDocs).toBe(1); + + const doc = await payload.findByID({ collection: "vehicles", id: vehicleId, depth: 0, overrideAccess: true }); + expect(doc.name).toBe(`${RUN} Ghost Hawk II`); + expect(doc.transportMode).toBe("air"); + }); + }); +});