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:
parent
73cae3defd
commit
64d92bf848
3 changed files with 768 additions and 165 deletions
440
src/lib/arma-import/catalog.ts
Normal file
440
src/lib/arma-import/catalog.ts
Normal 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) };
|
||||||
|
}
|
||||||
|
|
@ -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); });
|
||||||
|
|
|
||||||
286
tests/int/arma-catalog-import.int.spec.ts
Normal file
286
tests/int/arma-catalog-import.int.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue