feat(arma): extract catalog icons and extend the import library
- Add an `icons:arma` tool (extractArmaIcons.ts) that pulls inventory icons out of the game's PBOs and materializes the .paa files the catalog needs. - Extend the catalog import library with the matching planning and upsert support, and cover it with integration tests.
This commit is contained in:
parent
74564afb2b
commit
d4f9051b17
4 changed files with 488 additions and 63 deletions
|
|
@ -26,7 +26,7 @@
|
|||
* approvalStatus and human-edited "Virtual Data" are never reset.
|
||||
*/
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync } from "fs";
|
||||
import { existsSync, mkdirSync, readFileSync, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
import type { Payload } from "payload";
|
||||
import { plaintextToLexical } from "@/lib/tickets/lexical";
|
||||
|
|
@ -35,7 +35,7 @@ import { plaintextToLexical } from "@/lib/tickets/lexical";
|
|||
export const ASSET_TYPES = [
|
||||
"weapon", "magazine", "optic", "muzzle", "bipod", "uniform", "vest",
|
||||
"backpack", "helmet", "facewear", "nvg", "radio", "medical", "tool",
|
||||
"explosive", "skin", "miscellaneous",
|
||||
"explosive", "skin", "accessory", "miscellaneous",
|
||||
] as const;
|
||||
export type AssetType = (typeof ASSET_TYPES)[number];
|
||||
export type TransportMode = "ground" | "air" | "sea";
|
||||
|
|
@ -60,6 +60,8 @@ export interface CatalogRecord {
|
|||
mass?: number;
|
||||
/** Transport mode for vehicles, round count for magazines, otherwise "". */
|
||||
extra?: number | string;
|
||||
/** Weapons only: slot plus classnames of compatible records in the same catalog. */
|
||||
compat?: { slot?: string; magazines?: unknown; attachments?: unknown };
|
||||
}
|
||||
|
||||
export interface PlanMedia {
|
||||
|
|
@ -82,12 +84,50 @@ export interface ImportPlan {
|
|||
transportMode: TransportMode;
|
||||
/** Magazines only: round count from `extra`. */
|
||||
roundCapacity?: number;
|
||||
/** Weapons only: compatibility references (classnames), linked in a second pass. */
|
||||
compat?: WeaponCompat;
|
||||
mod: string;
|
||||
/** Icon-map ref or rec.picture, used to resolve the .paa thumbnail. */
|
||||
pictureRef: string;
|
||||
media: PlanMedia;
|
||||
}
|
||||
|
||||
export type WeaponSlot = "primary" | "secondary" | "launcher";
|
||||
|
||||
export interface WeaponCompat {
|
||||
slot?: WeaponSlot;
|
||||
/** Deduplicated (case-insensitive) classnames. */
|
||||
magazines: string[];
|
||||
attachments: string[];
|
||||
}
|
||||
|
||||
/** Classnames from an untrusted JSON list: strings only, trimmed, case-insensitive unique. */
|
||||
function classList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== "string") continue;
|
||||
const name = item.trim();
|
||||
const key = name.toLowerCase();
|
||||
if (!name || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseCompat(raw: CatalogRecord["compat"]): WeaponCompat | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const slot = ["primary", "secondary", "launcher"].includes(String(raw.slot))
|
||||
? (raw.slot as WeaponSlot)
|
||||
: undefined;
|
||||
const magazines = classList(raw.magazines);
|
||||
const attachments = classList(raw.attachments);
|
||||
if (!slot && magazines.length === 0 && attachments.length === 0) return undefined;
|
||||
return { ...(slot ? { slot } : {}), magazines, attachments };
|
||||
}
|
||||
|
||||
export interface PlanLookups {
|
||||
/** icon_map JSON: classname -> .paa ref (takes priority over rec.picture). */
|
||||
icons?: Record<string, unknown>;
|
||||
|
|
@ -171,6 +211,7 @@ export function planRecord(rec: CatalogRecord, lookups: PlanLookups = {}): Impor
|
|||
|
||||
const assetType = classifyAssetType(className, rawType);
|
||||
const roundCapacity = assetType === "magazine" ? numericExtra(rec.extra) : undefined;
|
||||
const compat = assetType === "weapon" ? parseCompat(rec.compat) : undefined;
|
||||
|
||||
return {
|
||||
kind: "asset",
|
||||
|
|
@ -180,6 +221,7 @@ export function planRecord(rec: CatalogRecord, lookups: PlanLookups = {}): Impor
|
|||
assetType,
|
||||
transportMode: "ground",
|
||||
...(roundCapacity !== undefined ? { roundCapacity } : {}),
|
||||
...(compat ? { compat } : {}),
|
||||
mod,
|
||||
pictureRef,
|
||||
media: { paaPath: "", pngPath: "", screenshotPath: "" },
|
||||
|
|
@ -232,10 +274,12 @@ export function enrichPlanMedia(plan: ImportPlan, env: MediaEnv, convert = true)
|
|||
if (paaPath && convert) {
|
||||
plan.media.pngPath = convertPaaToPng(env.hemtt, paaPath, env.outDir) ?? "";
|
||||
}
|
||||
// Skip 0-byte images: Arma's screenshot command writes empty files when the
|
||||
// game is misconfigured (HDR below 16-bit), and uploading one fails.
|
||||
plan.media.screenshotPath = env.screenshots
|
||||
? (["png", "webp", "jpg"]
|
||||
.map((ext) => join(env.screenshots, plan.className + "." + ext))
|
||||
.find(existsSync) ?? "")
|
||||
.find(hasContent) ?? "")
|
||||
: "";
|
||||
}
|
||||
|
||||
|
|
@ -243,16 +287,45 @@ export function enrichPlanMedia(plan: ImportPlan, env: MediaEnv, convert = true)
|
|||
* Database writes (Payload local API)
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
/** Resolve the "Fuel" resource id required by Vehicles.fuel.fuelType. */
|
||||
export async function resolveFuelConfig(payload: Payload): Promise<number> {
|
||||
const r = await payload.find({
|
||||
/** A file that exists and actually has bytes in it. */
|
||||
function hasContent(path: string): boolean {
|
||||
if (!existsSync(path)) return false;
|
||||
try {
|
||||
return statSync(path).size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the fuel resource id required by Vehicles.fuel.fuelType.
|
||||
* Looked up by code name first (`res_fuel` by default, which is stable even
|
||||
* when the display name is something like "Gasoline"), then by a name equal
|
||||
* to "Fuel" in any casing. Pass a different code name to use another resource.
|
||||
*/
|
||||
export async function resolveFuelConfig(payload: Payload, codeName = "res_fuel"): Promise<number> {
|
||||
const byCode = await payload.find({
|
||||
collection: "resources",
|
||||
limit: 50,
|
||||
where: { name: { equals: "Fuel" } },
|
||||
where: { codeName: { equals: codeName } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const fuel = r.docs.find((d) => String(d.name).toLowerCase() === "fuel");
|
||||
if (!fuel) throw new Error("No resource named 'Fuel' found. Cannot set vehicle fuel fields");
|
||||
if (byCode.docs[0]) return Number(byCode.docs[0].id);
|
||||
|
||||
const byName = await payload.find({
|
||||
collection: "resources",
|
||||
where: { name: { like: "fuel" } },
|
||||
limit: 50,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const fuel = byName.docs.find((d) => String(d.name).trim().toLowerCase() === "fuel");
|
||||
if (!fuel) {
|
||||
throw new Error(
|
||||
`No fuel resource found (code name '${codeName}' or name 'Fuel'). Cannot set vehicle fuel fields; pass --fuel <codeName>.`,
|
||||
);
|
||||
}
|
||||
return Number(fuel.id);
|
||||
}
|
||||
|
||||
|
|
@ -351,11 +424,15 @@ async function upsertAsset(payload: Payload, plan: ImportPlan): Promise<UpsertRe
|
|||
"Asset Metadata": assetMetadata(plan),
|
||||
};
|
||||
const imagePath = planImagePath(plan);
|
||||
if (imagePath) {
|
||||
// Attach an image whenever the record has none. That covers importing the
|
||||
// catalog first and adding screenshots in a later run, and repairs records
|
||||
// whose image never landed. An image that is already set is never replaced
|
||||
// (it may be a human's choice), and resolveMedia reuses an existing media
|
||||
// doc, so repeated runs do not pile up duplicates.
|
||||
const current = existing.docs[0] as unknown as { "Asset Metadata"?: { thumbnail?: unknown } };
|
||||
if (imagePath && !current["Asset Metadata"]?.thumbnail) {
|
||||
const media = await resolveMedia(payload, imagePath);
|
||||
// Only attach when this run produced a new media doc — never stomp a
|
||||
// human-chosen thumbnail with a re-discovered one.
|
||||
if (media?.created) (data["Asset Metadata"] as Record<string, unknown>).thumbnail = media.id;
|
||||
if (media) (data["Asset Metadata"] as Record<string, unknown>).thumbnail = media.id;
|
||||
}
|
||||
await payload.update({ collection: "assets", id, data, overrideAccess: true });
|
||||
return { action: "updated", collection: "assets", id };
|
||||
|
|
@ -408,9 +485,11 @@ async function upsertVehicle(payload: Payload, plan: ImportPlan, fuelId: number
|
|||
};
|
||||
if (plan.description) data.description = plaintextToLexical(plan.description);
|
||||
const imagePath = planImagePath(plan);
|
||||
if (imagePath) {
|
||||
// Same rule as assets: fill an empty image, never replace one that is set.
|
||||
const current = existing.docs[0] as unknown as { image?: unknown };
|
||||
if (imagePath && !current.image) {
|
||||
const media = await resolveMedia(payload, imagePath);
|
||||
if (media?.created) data.image = media.id;
|
||||
if (media) data.image = media.id;
|
||||
}
|
||||
await payload.update({ collection: "vehicles", id, data, overrideAccess: true });
|
||||
return { action: "updated", collection: "vehicles", id };
|
||||
|
|
@ -438,3 +517,125 @@ async function upsertVehicle(payload: Payload, plan: ImportPlan, fuelId: number
|
|||
});
|
||||
return { action: "created", collection: "vehicles", id: Number(created.id) };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Weapon compatibility (second pass, after every record has been upserted)
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
/** Asset types each Weapon Configuration relationship accepts (its filterOptions). */
|
||||
const MAGAZINE_TYPES = new Set<string>(["magazine"]);
|
||||
const ATTACHMENT_TYPES = new Set<string>(["optic", "muzzle", "bipod", "accessory"]);
|
||||
|
||||
export interface CompatLinkResult {
|
||||
/** Weapons whose relationships or slot changed. */
|
||||
updated: number;
|
||||
/** Weapons already up to date. */
|
||||
unchanged: number;
|
||||
/** Weapon classnames not found in the assets collection. */
|
||||
missingWeapons: number;
|
||||
/** References to classnames that are not imported assets. */
|
||||
unresolved: number;
|
||||
/** References to assets of a type the relationship does not accept
|
||||
* (e.g. rail lights/lasers, which import as "miscellaneous"). */
|
||||
skippedType: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
function relationIds(value: unknown): number[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((v) => (typeof v === "number" ? v : typeof v === "object" && v && "id" in v ? Number((v as { id: unknown }).id) : NaN))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Links each weapon to its compatible magazines and attachments. These are
|
||||
* relationships to the asset rows that were imported, so nothing is copied
|
||||
* or duplicated. Links are MERGED with what is already on the weapon (links
|
||||
* added by hand are kept) and deduplicated; the slot is only filled when it
|
||||
* is empty. Run after upserting the whole catalog, so references to records
|
||||
* later in the file resolve.
|
||||
*/
|
||||
export async function linkWeaponCompatibility(
|
||||
payload: Payload,
|
||||
plans: ImportPlan[],
|
||||
log: (line: string) => void = () => {},
|
||||
): Promise<CompatLinkResult> {
|
||||
const result: CompatLinkResult = { updated: 0, unchanged: 0, missingWeapons: 0, unresolved: 0, skippedType: 0, errors: 0 };
|
||||
const weapons = plans.filter((p) => p.kind === "asset" && p.compat);
|
||||
if (weapons.length === 0) return result;
|
||||
|
||||
// One lookup table for every classname involved: weapons + their references.
|
||||
const wanted = new Set<string>();
|
||||
for (const w of weapons) {
|
||||
wanted.add(w.className);
|
||||
for (const c of [...w.compat!.magazines, ...w.compat!.attachments]) wanted.add(c);
|
||||
}
|
||||
const byLower = new Map<string, { id: number; assetType: string }>();
|
||||
const names = [...wanted];
|
||||
for (let i = 0; i < names.length; i += 500) {
|
||||
const chunk = names.slice(i, i + 500);
|
||||
const found = await payload.find({
|
||||
collection: "assets",
|
||||
where: { className: { in: chunk } },
|
||||
limit: chunk.length * 2,
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
});
|
||||
for (const doc of found.docs) {
|
||||
const key = String(doc.className).toLowerCase();
|
||||
if (!byLower.has(key)) byLower.set(key, { id: Number(doc.id), assetType: String(doc.assetType) });
|
||||
}
|
||||
}
|
||||
// The exporter writes each reference with the referenced record's own
|
||||
// classname, so the exact-match query above finds it; the map is keyed in
|
||||
// lower case only so duplicates that differ in case collapse to one id.
|
||||
const resolve = (name: string, allowed: Set<string>, into: number[]) => {
|
||||
const hit = byLower.get(name.toLowerCase());
|
||||
if (!hit) { result.unresolved++; return; }
|
||||
if (!allowed.has(hit.assetType)) { result.skippedType++; return; }
|
||||
if (!into.includes(hit.id)) into.push(hit.id);
|
||||
};
|
||||
|
||||
for (const weapon of weapons) {
|
||||
const self = byLower.get(weapon.className.toLowerCase());
|
||||
if (!self) { result.missingWeapons++; continue; }
|
||||
try {
|
||||
const doc = await payload.findByID({ collection: "assets", id: self.id, depth: 0, overrideAccess: true });
|
||||
const config = (doc as unknown as { "Weapon Configuration"?: Record<string, unknown> })["Weapon Configuration"] ?? {};
|
||||
const currentMags = relationIds(config.compatibleMagazines);
|
||||
const currentAtts = relationIds(config.compatibleAttachments);
|
||||
|
||||
const mags = [...currentMags];
|
||||
const atts = [...currentAtts];
|
||||
for (const m of weapon.compat!.magazines) resolve(m, MAGAZINE_TYPES, mags);
|
||||
for (const a of weapon.compat!.attachments) resolve(a, ATTACHMENT_TYPES, atts);
|
||||
const slot = config.slotType || weapon.compat!.slot || undefined;
|
||||
|
||||
const changed = mags.length !== currentMags.length
|
||||
|| atts.length !== currentAtts.length
|
||||
|| (slot ?? null) !== (config.slotType ?? null);
|
||||
if (!changed) { result.unchanged++; continue; }
|
||||
|
||||
await payload.update({
|
||||
collection: "assets",
|
||||
id: self.id,
|
||||
data: {
|
||||
"Weapon Configuration": {
|
||||
...(slot ? { slotType: slot } : {}),
|
||||
compatibleMagazines: mags,
|
||||
compatibleAttachments: atts,
|
||||
},
|
||||
} as never,
|
||||
overrideAccess: true,
|
||||
});
|
||||
result.updated++;
|
||||
log(`Linked ${weapon.className}: ${mags.length} magazines, ${atts.length} attachments`);
|
||||
} catch (e) {
|
||||
result.errors++;
|
||||
log(`ERR linking ${weapon.className}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@
|
|||
* game's packed PBOs into a --paa-root tree shaped like the reference paths,
|
||||
* so importArmaLibrary.ts's resolvePaaPath() can find them for PAA->PNG.
|
||||
*
|
||||
* Resolution is ref-driven: the second path segment names the PBO (vanilla
|
||||
* refs look like "\a3\weapons_f\Rifles\MX\data\UI\gear_mx_cqc_M_CA.paa"), so we
|
||||
* locate that PBO by basename and inspect it on demand (cached), then map the
|
||||
* ref to a file inside it. A fabricated/unresolvable ref reports MISS rather
|
||||
* Resolution is prefix-driven: every PBO's "prefix" header is read up front
|
||||
* (cheap: first few KB of each file), and each ref is matched to the PBO with
|
||||
* the longest prefix it starts with (e.g. "\a3\weapons_f\..." -> prefix
|
||||
* a3\weapons_f; CUP refs map to their per-weapon PBOs). That PBO's file list
|
||||
* is then read once via `hemtt utils pbo inspect` (cached). Vanilla DLC PBOs in
|
||||
* <game>/<dlc>/addons are scanned too. An unresolvable ref reports MISS rather
|
||||
* than failing the run.
|
||||
*
|
||||
* CLI:
|
||||
|
|
@ -20,7 +22,7 @@
|
|||
* [--hemtt <path>] hemtt binary (default: resolve from PATH)
|
||||
*/
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync } from "fs";
|
||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
interface CliOpts {
|
||||
|
|
@ -61,6 +63,14 @@ interface PboInfo {
|
|||
|
||||
function discoverPbos(game: string, mods: string[]): string[] {
|
||||
const dirs = [join(game, "Addons")];
|
||||
// Vanilla DLC/expansion PBOs live in <game>/<dlc>/addons (enoch, expansion, jets, ...).
|
||||
for (const entry of readdirSync(game, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
for (const sub of ["addons", "Addons"]) {
|
||||
const cand = join(game, entry.name, sub);
|
||||
if (existsSync(cand) && !dirs.includes(cand)) dirs.push(cand);
|
||||
}
|
||||
}
|
||||
for (const m of mods) {
|
||||
const cand = join(m, "addons");
|
||||
if (existsSync(cand)) dirs.push(cand);
|
||||
|
|
@ -78,15 +88,6 @@ function discoverPbos(game: string, mods: string[]): string[] {
|
|||
return out;
|
||||
}
|
||||
|
||||
function pbosByName(pbos: string[]): Map<string, string> {
|
||||
const byName = new Map<string, string>();
|
||||
for (const p of pbos) {
|
||||
const base = p.split(/[\\/]/).pop()!.replace(/\.pbo$/i, "").toLowerCase();
|
||||
if (!byName.has(base)) byName.set(base, p);
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
function inspectPbo(hemtt: string, pboPath: string): { prefix: string; files: string[] } | undefined {
|
||||
const r = spawnSync(
|
||||
hemtt,
|
||||
|
|
@ -94,18 +95,60 @@ function inspectPbo(hemtt: string, pboPath: string): { prefix: string; files: st
|
|||
{ encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
|
||||
);
|
||||
if (r.status !== 0 || !r.stdout) return undefined;
|
||||
let data: { files?: { name?: string }[]; header?: { properties?: Record<string, string> } };
|
||||
// hemtt prints a text summary ("- prefix: a3\weapons_f", ...) followed by
|
||||
// the file list as a JSON array; it is not a single JSON document.
|
||||
const prefixLine = r.stdout.match(/^\s*-\s*prefix:\s*(.+)$/im);
|
||||
const prefix = (prefixLine?.[1] ?? readPboPrefix(pboPath)).trim().toLowerCase().replace(/\\/g, "/");
|
||||
const arrayStart = r.stdout.indexOf("[{");
|
||||
if (arrayStart < 0) return { prefix, files: [] };
|
||||
let entries: { filename?: string; name?: string }[];
|
||||
try {
|
||||
data = JSON.parse(r.stdout);
|
||||
entries = JSON.parse(r.stdout.slice(arrayStart, r.stdout.lastIndexOf("]") + 1));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const files = (data.files ?? []).map((f) => f.name ?? "").filter(Boolean);
|
||||
const props = data.header?.properties ?? {};
|
||||
const prefix = (props.prefix ?? "").trim().toLowerCase().replace(/\\/g, "/");
|
||||
const files = entries.map((f) => f.filename ?? f.name ?? "").filter(Boolean);
|
||||
return { prefix, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the "prefix" header property straight from the PBO file (first entry
|
||||
* is a "Vers" product entry followed by key/value C strings). Cheap enough to
|
||||
* run on every PBO up front, which lets refs be matched to PBOs by prefix
|
||||
* instead of guessing from the PBO file name.
|
||||
*/
|
||||
function readPboPrefix(pboPath: string): string {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(pboPath, "r");
|
||||
const buf = Buffer.alloc(64 * 1024);
|
||||
const len = readSync(fd, buf, 0, buf.length, 0);
|
||||
let pos = 0;
|
||||
const cstr = (): string => {
|
||||
const end = buf.indexOf(0, pos);
|
||||
if (end < 0 || end >= len) throw new Error("truncated");
|
||||
const v = buf.toString("latin1", pos, end);
|
||||
pos = end + 1;
|
||||
return v;
|
||||
};
|
||||
cstr(); // empty entry name
|
||||
const mime = buf.readUInt32LE(pos);
|
||||
pos += 20;
|
||||
if (mime !== 0x56657273) return "";
|
||||
for (;;) {
|
||||
const key = cstr();
|
||||
if (!key) break;
|
||||
const value = cstr();
|
||||
if (key.toLowerCase() === "prefix") return value;
|
||||
}
|
||||
} catch {
|
||||
// not a readable PBO header
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function ensurePbo(
|
||||
hemtt: string,
|
||||
pboPath: string,
|
||||
|
|
@ -125,37 +168,31 @@ function normalizeRef(src: string): string {
|
|||
return src.replace(/\\/g, "/").replace(/^\/+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function buildPrefixIndex(pbos: string[]): { prefix: string; pboPath: string }[] {
|
||||
const index: { prefix: string; pboPath: string }[] = [];
|
||||
for (const pboPath of pbos) {
|
||||
const prefix = readPboPrefix(pboPath).trim().toLowerCase().replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (prefix) index.push({ prefix, pboPath });
|
||||
}
|
||||
// Longest prefix first, so "cup/weapons/cup_weapons_m16" beats "cup/weapons".
|
||||
return index.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||
}
|
||||
|
||||
function resolveRef(
|
||||
norm: string,
|
||||
pbos: string[],
|
||||
byName: Map<string, string>,
|
||||
prefixIndex: { prefix: string; pboPath: string }[],
|
||||
inspected: Map<string, PboInfo>,
|
||||
hemtt: string,
|
||||
): { pbo: PboInfo; relFile: string } | undefined {
|
||||
const seg = norm.split("/").filter(Boolean);
|
||||
const pboName = seg[1]?.toLowerCase();
|
||||
if (pboName && byName.has(pboName)) {
|
||||
const pbo = ensurePbo(hemtt, byName.get(pboName)!, inspected);
|
||||
if (pbo) {
|
||||
const prefixSlash = pbo.prefix + "/";
|
||||
if (norm.startsWith(prefixSlash)) {
|
||||
const relLower = norm.slice(pbo.prefix.length + 1).replace(/\//g, "\\");
|
||||
for (const entry of prefixIndex) {
|
||||
if (!norm.startsWith(entry.prefix + "/")) continue;
|
||||
const pbo = ensurePbo(hemtt, entry.pboPath, inspected);
|
||||
if (!pbo) continue;
|
||||
const relLower = norm.slice(entry.prefix.length + 1).replace(/\//g, "\\");
|
||||
const exact = pbo.files.get(relLower);
|
||||
if (exact) return { pbo, relFile: exact };
|
||||
}
|
||||
}
|
||||
}
|
||||
let best: { prefix: string; pbo: PboInfo } | undefined;
|
||||
for (const pbo of inspected.values()) {
|
||||
const prefixSlash = pbo.prefix + "/";
|
||||
if (norm.startsWith(prefixSlash) && (!best || pbo.prefix.length > best.prefix.length)) {
|
||||
best = { prefix: pbo.prefix, pbo };
|
||||
}
|
||||
}
|
||||
if (!best) return undefined;
|
||||
const relLower = norm.slice(best.prefix.length + 1).replace(/\//g, "\\");
|
||||
const exact = best.pbo.files.get(relLower);
|
||||
return exact ? { pbo: best.pbo, relFile: exact } : undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
|
@ -170,7 +207,8 @@ async function main() {
|
|||
|
||||
const pbos = discoverPbos(game, opts.mod ?? []);
|
||||
console.log(`Discovered ${pbos.length} PBOs across ${1 + (opts.mod?.length ?? 0)} roots.`);
|
||||
const byName = pbosByName(pbos);
|
||||
const prefixIndex = buildPrefixIndex(pbos);
|
||||
console.log(`Indexed ${prefixIndex.length} PBO prefixes.`);
|
||||
const inspected = new Map<string, PboInfo>();
|
||||
|
||||
interface Ref { src: string; origin: string; }
|
||||
|
|
@ -202,7 +240,7 @@ async function main() {
|
|||
ok++;
|
||||
continue;
|
||||
}
|
||||
const res = resolveRef(norm, pbos, byName, inspected, hemtt);
|
||||
const res = resolveRef(norm, prefixIndex, inspected, hemtt);
|
||||
if (!res) {
|
||||
console.log(` MISS ${ref.origin}\t${ref.src} (not in any indexed PBO)`);
|
||||
report.push(`MISS\t${ref.origin}\t${ref.src}`);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
* [--approve] apply to the DB (default: proposal only)
|
||||
* [--dry-run] plan + print, never touch the DB or filesystem
|
||||
* [--limit <n>] import at most n records
|
||||
* [--fuel <codeName>] fuel resource for vehicles (default: res_fuel)
|
||||
*/
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
|
|
@ -29,6 +30,7 @@ import config from "@payload-config";
|
|||
import {
|
||||
enrichPlanMedia,
|
||||
planRecord,
|
||||
linkWeaponCompatibility,
|
||||
resolveFuelConfig,
|
||||
upsertPlan,
|
||||
type CatalogRecord,
|
||||
|
|
@ -45,6 +47,7 @@ interface CliOpts {
|
|||
approve?: boolean;
|
||||
"dry-run"?: boolean;
|
||||
limit?: string[];
|
||||
fuel?: string[];
|
||||
}
|
||||
function parseCli(argv: string[]): CliOpts {
|
||||
const opts: CliOpts = {};
|
||||
|
|
@ -106,14 +109,17 @@ async function main() {
|
|||
}
|
||||
|
||||
if (dryRun) {
|
||||
const withCompat = plans.filter((p) => p.compat);
|
||||
const refs = withCompat.reduce((n, p) => n + p.compat!.magazines.length + p.compat!.attachments.length, 0);
|
||||
console.log(`\nDry run: ${plans.length} would import, 0 applied.`);
|
||||
console.log(`Compatibility: ${withCompat.length} weapons carry ${refs} magazine/attachment references.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config });
|
||||
// Fuel is only needed for vehicle records; resolve lazily so asset-only
|
||||
// catalogs import even without a Fuel resource.
|
||||
const fuelId = plans.some((p) => p.kind === "vehicle") ? await resolveFuelConfig(payload) : undefined;
|
||||
const fuelId = plans.some((p) => p.kind === "vehicle") ? await resolveFuelConfig(payload, opts.fuel?.[0] ?? "res_fuel") : undefined;
|
||||
|
||||
let created = 0, updated = 0, errors = 0;
|
||||
for (const plan of plans) {
|
||||
|
|
@ -127,6 +133,15 @@ async function main() {
|
|||
}
|
||||
}
|
||||
console.log(`Done: ${created} created, ${updated} updated, ${errors} errors.`);
|
||||
|
||||
// Second pass: link weapons to their magazines/attachments (references to
|
||||
// the rows imported above, never copies).
|
||||
const link = await linkWeaponCompatibility(payload, plans, (line) => console.log(line));
|
||||
console.log(
|
||||
`Compatibility: ${link.updated} weapons linked, ${link.unchanged} already up to date, ` +
|
||||
`${link.unresolved} references not in the app, ${link.skippedType} of an unsupported type ` +
|
||||
`(e.g. rail lights/lasers), ${link.missingWeapons} weapons missing, ${link.errors} errors.`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import {
|
||||
ASSET_TYPES,
|
||||
enrichPlanMedia,
|
||||
linkWeaponCompatibility,
|
||||
planRecord,
|
||||
upsertPlan,
|
||||
type CatalogRecord,
|
||||
type MediaEnv,
|
||||
} from "@/lib/arma-import/catalog";
|
||||
|
||||
let payload: Payload;
|
||||
|
|
@ -15,6 +21,16 @@ let fuelId: number;
|
|||
let createdFuel = false;
|
||||
const assetIds: number[] = [];
|
||||
const vehicleIds: number[] = [];
|
||||
const mediaIds: number[] = [];
|
||||
const SHOT_DIR = join(tmpdir(), `ptf-shots-${RUN}`);
|
||||
// 1x1 PNG: enough for the upload path, tiny enough to inline.
|
||||
const PNG_1PX = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
function mediaEnv(screenshots = ""): MediaEnv {
|
||||
return { paaRoot: "", screenshots, outDir: SHOT_DIR, hemtt: "" };
|
||||
}
|
||||
|
||||
describe("Arma catalog import", () => {
|
||||
beforeAll(async () => {
|
||||
|
|
@ -63,6 +79,10 @@ describe("Arma catalog import", () => {
|
|||
for (const id of assetIds) {
|
||||
await payload.delete({ collection: "assets", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of mediaIds) {
|
||||
await payload.delete({ collection: "media", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
rmSync(SHOT_DIR, { recursive: true, force: true });
|
||||
if (createdFuel && fuelId) {
|
||||
await payload.delete({ collection: "resources", id: fuelId, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
|
|
@ -228,6 +248,157 @@ describe("Arma catalog import", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("adding images in a later import pass", () => {
|
||||
const lateClassName = `${RUN}_late_image_rifle`;
|
||||
|
||||
function planFor(screenshot: string) {
|
||||
const plan = planRecord({
|
||||
classname: lateClassName,
|
||||
type: "weapon",
|
||||
displayName: `${RUN} Late Image Rifle`,
|
||||
picture: "",
|
||||
mod: "PTF_Test_Mod",
|
||||
description: "",
|
||||
mass: 1,
|
||||
extra: "",
|
||||
})!;
|
||||
enrichPlanMedia(plan, mediaEnv(screenshot));
|
||||
return plan;
|
||||
}
|
||||
|
||||
it("imports the catalog with no image, then attaches one on a later run", async () => {
|
||||
// Pass 1: data only (no --screenshots).
|
||||
const first = await upsertPlan(payload, planFor(""));
|
||||
expect(first.action).toBe("created");
|
||||
assetIds.push(first.id);
|
||||
let doc = await payload.findByID({ collection: "assets", id: first.id, depth: 0, overrideAccess: true });
|
||||
expect(doc["Asset Metadata"]?.thumbnail).toBeFalsy();
|
||||
|
||||
// Pass 2: the screenshot now exists on disk.
|
||||
mkdirSync(SHOT_DIR, { recursive: true });
|
||||
writeFileSync(join(SHOT_DIR, `${lateClassName}.png`), PNG_1PX);
|
||||
|
||||
const second = await upsertPlan(payload, planFor(SHOT_DIR));
|
||||
expect(second.action).toBe("updated");
|
||||
expect(second.id).toBe(first.id); // upsert, not a duplicate
|
||||
|
||||
doc = await payload.findByID({ collection: "assets", id: first.id, depth: 0, overrideAccess: true });
|
||||
const thumbnail = doc["Asset Metadata"]?.thumbnail;
|
||||
expect(thumbnail).toBeTruthy();
|
||||
if (typeof thumbnail === "number") mediaIds.push(thumbnail);
|
||||
else if (thumbnail && typeof thumbnail === "object") mediaIds.push(thumbnail.id);
|
||||
|
||||
const count = await payload.count({
|
||||
collection: "assets",
|
||||
where: { className: { equals: lateClassName } },
|
||||
});
|
||||
expect(count.totalDocs).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the image when re-run, and repairs a cleared one", async () => {
|
||||
const assetId = assetIds[assetIds.length - 1];
|
||||
const before = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
|
||||
const originalId = typeof before["Asset Metadata"]?.thumbnail === "number"
|
||||
? (before["Asset Metadata"]!.thumbnail as number)
|
||||
: (before["Asset Metadata"]!.thumbnail as { id: number }).id;
|
||||
|
||||
// Re-run: the media doc already exists, so it must not be duplicated.
|
||||
await upsertPlan(payload, planFor(SHOT_DIR));
|
||||
const after = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
|
||||
const afterId = typeof after["Asset Metadata"]?.thumbnail === "number"
|
||||
? (after["Asset Metadata"]!.thumbnail as number)
|
||||
: (after["Asset Metadata"]!.thumbnail as { id: number }).id;
|
||||
expect(afterId).toBe(originalId);
|
||||
|
||||
// A cleared thumbnail is repaired by the next run (media already exists).
|
||||
await payload.update({
|
||||
collection: "assets",
|
||||
id: assetId,
|
||||
data: { "Asset Metadata": { thumbnail: null } },
|
||||
overrideAccess: true,
|
||||
});
|
||||
await upsertPlan(payload, planFor(SHOT_DIR));
|
||||
const repaired = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
|
||||
expect(repaired["Asset Metadata"]?.thumbnail).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("weapon compatibility", () => {
|
||||
const rifle = `${RUN}_compat_rifle`;
|
||||
const mag = `${RUN}_compat_mag`;
|
||||
const optic = `${RUN}_compat_optic`;
|
||||
const light = `${RUN}_compat_light`;
|
||||
|
||||
function catalog() {
|
||||
return [
|
||||
{ classname: rifle, type: "weapon", displayName: "Compat Rifle", compat: {
|
||||
slot: "primary",
|
||||
// duplicate + different case + a ref that was never imported
|
||||
magazines: [mag, mag.toUpperCase(), `${RUN}_not_in_app`],
|
||||
attachments: [optic, light],
|
||||
} },
|
||||
{ classname: mag, type: "magazine", displayName: "Compat Mag", extra: 30 },
|
||||
{ classname: optic, type: "optic", displayName: "Compat Optic" },
|
||||
{ classname: light, type: "miscellaneous", displayName: "Compat Light" },
|
||||
].map((r) => planRecord(r as CatalogRecord)!);
|
||||
}
|
||||
|
||||
it("parses compat on weapons only, deduplicating classnames", () => {
|
||||
const plans = catalog();
|
||||
expect(plans[0].compat).toEqual({
|
||||
slot: "primary",
|
||||
magazines: [mag, `${RUN}_not_in_app`],
|
||||
attachments: [optic, light],
|
||||
});
|
||||
expect(plans[1].compat).toBeUndefined();
|
||||
expect(planRecord({ classname: "x", type: "magazine", compat: { magazines: ["y"] } })!.compat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("links magazines/attachments as references, merges on re-run, never duplicates", async () => {
|
||||
const plans = catalog();
|
||||
const ids: Record<string, number> = {};
|
||||
for (const plan of plans) {
|
||||
const res = await upsertPlan(payload, plan);
|
||||
assetIds.push(res.id);
|
||||
ids[plan.className] = res.id;
|
||||
}
|
||||
|
||||
const first = await linkWeaponCompatibility(payload, plans);
|
||||
expect(first.updated).toBe(1);
|
||||
expect(first.unresolved).toBe(1); // the never-imported magazine
|
||||
expect(first.skippedType).toBe(1); // rail light is "miscellaneous"
|
||||
|
||||
let doc = await payload.findByID({ collection: "assets", id: ids[rifle], depth: 0, overrideAccess: true });
|
||||
const wc = () => (doc as unknown as { "Weapon Configuration": Record<string, unknown> })["Weapon Configuration"];
|
||||
expect(wc().slotType).toBe("primary");
|
||||
expect(wc().compatibleMagazines).toEqual([ids[mag]]);
|
||||
expect(wc().compatibleAttachments).toEqual([ids[optic]]);
|
||||
|
||||
// A link added by hand survives, and re-running adds nothing twice.
|
||||
const extraOptic = await upsertPlan(payload, planRecord({ classname: `${RUN}_hand_optic`, type: "optic" })!);
|
||||
assetIds.push(extraOptic.id);
|
||||
await payload.update({
|
||||
collection: "assets",
|
||||
id: ids[rifle],
|
||||
data: { "Weapon Configuration": { compatibleAttachments: [ids[optic], extraOptic.id] } } as never,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const second = await linkWeaponCompatibility(payload, plans);
|
||||
expect(second.updated).toBe(0);
|
||||
expect(second.unchanged).toBe(1);
|
||||
|
||||
doc = await payload.findByID({ collection: "assets", id: ids[rifle], depth: 0, overrideAccess: true });
|
||||
expect(wc().compatibleMagazines).toEqual([ids[mag]]);
|
||||
expect(wc().compatibleAttachments).toEqual([ids[optic], extraOptic.id]);
|
||||
|
||||
// Each referenced item still exists exactly once.
|
||||
for (const name of [mag, optic]) {
|
||||
const count = await payload.count({ collection: "assets", where: { className: { equals: name } } });
|
||||
expect(count.totalDocs).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertPlan vehicles", () => {
|
||||
const vehicleClassname = `${RUN}_heli_ghost`;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue