1
0
Fork 0

refactor(arma): extract catalog import logic into lib/arma-import

Move the record contract, planning, and upsert logic out of the CLI script
and into src/lib/arma-import/catalog.ts so it can be unit-tested and reused.
The CLI (importArmaLibrary.ts) becomes a thin wrapper. Records are upserted
into assets (keyed by className) and vehicles (by classname); thumbnails are
materialized from .paa inventory icons, and fuel config is resolved per
record type. Adds integration coverage for the catalog import path.
This commit is contained in:
Jason Fraley 2026-10-01 10:04:38 -04:00
parent 73cae3defd
commit 64d92bf848
3 changed files with 768 additions and 165 deletions

View file

@ -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<string>(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<string, unknown>;
}
function lookupIcon(icons: Record<string, unknown> | 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<number> {
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<UpsertResult> {
return plan.kind === "vehicle" ? upsertVehicle(payload, plan, fuelId) : upsertAsset(payload, plan);
}
const IMAGE_MIMETYPES: Record<string, string> = {
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<string, unknown> {
const metadata: Record<string, unknown> = { mod: plan.mod };
if (plan.description) metadata.gameDescription = plan.description;
return metadata;
}
async function upsertAsset(payload: Payload, plan: ImportPlan): Promise<UpsertResult> {
// 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<string, unknown> = {
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<string, unknown>).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<string, unknown> = {
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<UpsertResult> {
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<string, unknown> = {
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<string, unknown> = {
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) };
}

View file

@ -2,10 +2,10 @@
/** /**
* Import an Arma inventory export into the Polaris Task Force Payload CMS. * 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 * Thin CLI wrapper around src/lib/arma-import/catalog.ts (record contract,
* carrying classname, display name, type, description, picture) plus an * planning and upsert logic live there). Records are UPSERTED into the
* optional icon_map, and upserts rows into the `assets` / `vehicles` * `assets` / `vehicles` collections: assets keyed by `className`, vehicles by
* collections. Thumbnails are materialized from .paa inventory icons found * `classname`. Thumbnails are materialized from .paa inventory icons found
* under --paa-root (see extractArmaIcons.ts) and converted to PNG uploads via * under --paa-root (see extractArmaIcons.ts) and converted to PNG uploads via
* `hemtt utils paa convert`; vehicle 3D-rendered screenshots can be supplied * `hemtt utils paa convert`; vehicle 3D-rendered screenshots can be supplied
* via --screenshots (matched by classname). * via --screenshots (matched by classname).
@ -22,10 +22,18 @@
* [--limit <n>] import at most n records * [--limit <n>] import at most n records
*/ */
import { spawnSync } from "child_process"; import { spawnSync } from "child_process";
import { existsSync, mkdirSync, readFileSync } from "fs"; import { existsSync, readFileSync } from "fs";
import { dirname, join } from "path"; import { dirname } from "path";
import { getPayload } from "payload"; import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import {
enrichPlanMedia,
planRecord,
resolveFuelConfig,
upsertPlan,
type CatalogRecord,
type ImportPlan,
} from "@/lib/arma-import/catalog";
interface CliOpts { interface CliOpts {
catalog?: string[]; catalog?: string[];
@ -59,150 +67,6 @@ function resolveHemtt(explicit?: string): string {
throw new Error("hemtt not found; pass --hemtt <path>"); throw new Error("hemtt not found; pass --hemtt <path>");
} }
// 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<string, unknown>, 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<string, unknown>,
lookups: { icons: Record<string, unknown>; 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<string> {
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<any> }, filePath: string): Promise<number> {
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() { async function main() {
const opts = parseCli(process.argv.slice(2)); const opts = parseCli(process.argv.slice(2));
const hemtt = resolveHemtt(opts.hemtt?.[0]); const hemtt = resolveHemtt(opts.hemtt?.[0]);
@ -210,10 +74,10 @@ async function main() {
if (!catalogPath || !existsSync(catalogPath)) throw new Error("--catalog <file> is required and must exist"); if (!catalogPath || !existsSync(catalogPath)) throw new Error("--catalog <file> is required and must exist");
const dryRun = !!opts["dry-run"]; const dryRun = !!opts["dry-run"];
const approve = !!opts.approve || dryRun; 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 outDir = opts.out?.[0] ?? dirname(catalogPath);
const paaRoot = opts["paa-root"]?.[0] ?? ""; const paaRoot = opts["paa-root"]?.[0] ?? "";
const screenshots = opts.screenshots?.[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 catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
const records = Array.isArray(catalog) ? catalog : [catalog]; const records = Array.isArray(catalog) ? catalog : [catalog];
@ -222,34 +86,47 @@ async function main() {
icons = JSON.parse(readFileSync(opts.icons[0], "utf8")); icons = JSON.parse(readFileSync(opts.icons[0], "utf8"));
} }
const limit = opts.limit ? parseInt(opts.limit[0], 10) : records.length; 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}):`); console.log(`Import plan (dry-run=${dryRun}, approve=${approve}):`);
const plans: ImportPlan[] = []; const plans: ImportPlan[] = [];
for (const rec of records.slice(0, limit)) { for (const rec of records.slice(0, limit)) {
const plan = planImport(rec, lookups); const plan = planRecord(rec as CatalogRecord, { icons });
if (!plan) { console.log(` SKIP ${String((rec as any).classname ?? "")} (not an importable record)`); continue; } 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); plans.push(plan);
const img = plan.pngPath ? "[img: item-png]" : plan.screenshotPath ? "[img: screenshot]" : "[no img]"; const img = plan.media.pngPath || plan.media.paaPath
console.log(` -> ${plan.kind}\t${plan.clazz}\t${plan.displayName}\t${img}`); ? "[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 payload = await getPayload({ config });
const fuel = await resolveFuelConfig(payload); // Fuel is only needed for vehicle records; resolve lazily so asset-only
let ok = 0, err = 0; // 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) { for (const plan of plans) {
try { try {
const id = await writeEntry(payload, plan, { outDir }, fuel.id); const res = await upsertPlan(payload, plan, fuelId);
console.log(`Applied ${id}`); console.log(`Applied ${res.action} ${res.collection}:${res.id}`);
ok++; if (res.action === "created") created++; else updated++;
} catch (e) { } catch (e) {
console.error(`ERR ${plan.clazz}: ${(e as Error).message}`); console.error(`ERR ${plan.className}: ${(e as Error).message}`);
err++; 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); }); main().catch((e) => { console.error(e); process.exit(1); });

View file

@ -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");
});
});
});