Add SCUM current-service map asset contract
This commit is contained in:
@@ -1150,6 +1150,77 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st
|
||||
}
|
||||
if (sha256FileDigest(absoluteAssetPath) !== expectedDigest) errors.push(`${digestLocation}: digest does not match packaged asset content`);
|
||||
};
|
||||
const readSCUMJSONAsset = (assetPath: string | undefined, assetLocation: string): Record<string, unknown> | undefined => {
|
||||
if (!manifestDir || !assetPath || !isSafeRelativePathRef(assetPath)) return undefined;
|
||||
const absoluteAssetPath = path.resolve(manifestDir, assetPath);
|
||||
if (!fs.existsSync(absoluteAssetPath) || !fs.statSync(absoluteAssetPath).isFile()) return undefined;
|
||||
try {
|
||||
const value = readJson(absoluteAssetPath);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
errors.push(`${assetLocation}: asset content must be a JSON object`);
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
} catch {
|
||||
errors.push(`${assetLocation}: asset content must be valid JSON`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const mapBoundsMatch = (candidate: unknown, bounds: MapAsset["worldBounds"]): boolean => {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate) || !bounds) return false;
|
||||
const value = candidate as { minX?: unknown; minY?: unknown; maxX?: unknown; maxY?: unknown };
|
||||
return value.minX === bounds.minX && value.minY === bounds.minY && value.maxX === bounds.maxX && value.maxY === bounds.maxY;
|
||||
};
|
||||
const mapImageMatch = (candidate: unknown, image: MapAsset["image"]): boolean => {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate) || !image) return false;
|
||||
const value = candidate as { width?: unknown; height?: unknown };
|
||||
return value.width === image.width && value.height === image.height;
|
||||
};
|
||||
const requireSCUMMapAssetJSONCompatibility = (mapAsset: MapAsset, itemLocation: string): void => {
|
||||
const metadata = readSCUMJSONAsset(mapAsset.assetPath, `${itemLocation}.assetPath`);
|
||||
if (metadata) {
|
||||
if (metadata.key !== mapAsset.key) errors.push(`${itemLocation}.assetPath: metadata key must match the map asset declaration`);
|
||||
if (!adapterPattern.test(String(metadata.mapVersion ?? ""))) errors.push(`${itemLocation}.assetPath: metadata mapVersion must be a bounded version`);
|
||||
if (metadata.adapterVersion !== mapAsset.adapterVersion) errors.push(`${itemLocation}.assetPath: metadata adapterVersion must match the map asset declaration`);
|
||||
if (metadata.requiredSchemaFingerprint !== mapAsset.requiredSchemaFingerprint) errors.push(`${itemLocation}.assetPath: metadata schema fingerprint must match the map asset declaration`);
|
||||
if (metadata.transformAssetPath !== mapAsset.transformAssetPath) errors.push(`${itemLocation}.assetPath: metadata transformAssetPath must match the map asset declaration`);
|
||||
if (!mapBoundsMatch(metadata.worldBounds, mapAsset.worldBounds)) errors.push(`${itemLocation}.assetPath: metadata worldBounds must match the map asset declaration`);
|
||||
if (!mapImageMatch(metadata.image, mapAsset.image)) errors.push(`${itemLocation}.assetPath: metadata image dimensions must match the map asset declaration`);
|
||||
const layers = metadata.layers;
|
||||
if (!Array.isArray(layers) || layers.length === 0) {
|
||||
errors.push(`${itemLocation}.assetPath: metadata must declare at least one map layer`);
|
||||
} else {
|
||||
const layerKeys = new Set<string>();
|
||||
for (const [layerIndex, layer] of layers.entries()) {
|
||||
const layerLocation = `${itemLocation}.assetPath.layers[${layerIndex}]`;
|
||||
if (!layer || typeof layer !== "object" || Array.isArray(layer)) {
|
||||
errors.push(`${layerLocation}: layer metadata must be an object`);
|
||||
continue;
|
||||
}
|
||||
const candidate = layer as { key?: unknown; capability?: unknown; subjectType?: unknown; sourceQueryKey?: unknown };
|
||||
const layerKey = String(candidate.key ?? "");
|
||||
if (layerKeys.has(layerKey)) errors.push(`${layerLocation}.key: duplicate layer key ${layerKey}`);
|
||||
layerKeys.add(layerKey);
|
||||
if (!logicalKeyPattern.test(layerKey)) errors.push(`${layerLocation}.key: must be a safe logical key`);
|
||||
if (candidate.capability !== "positions.read") errors.push(`${layerLocation}.capability: map layers must use positions.read`);
|
||||
if (!["player", "vehicle", "flag"].includes(String(candidate.subjectType ?? ""))) errors.push(`${layerLocation}.subjectType: must be player, vehicle, or flag`);
|
||||
if (candidate.sourceQueryKey !== "scum-positions-read") errors.push(`${layerLocation}.sourceQueryKey: must reference the packaged positions query`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const transform = readSCUMJSONAsset(mapAsset.transformAssetPath, `${itemLocation}.transformAssetPath`);
|
||||
if (transform) {
|
||||
if (transform.mapAssetKey !== mapAsset.key) errors.push(`${itemLocation}.transformAssetPath: transform mapAssetKey must match the map asset declaration`);
|
||||
if (!adapterPattern.test(String(transform.transformVersion ?? ""))) errors.push(`${itemLocation}.transformAssetPath: transformVersion must be a bounded version`);
|
||||
if (transform.adapterVersion !== mapAsset.adapterVersion) errors.push(`${itemLocation}.transformAssetPath: transform adapterVersion must match the map asset declaration`);
|
||||
if (transform.requiredSchemaFingerprint !== mapAsset.requiredSchemaFingerprint) errors.push(`${itemLocation}.transformAssetPath: transform schema fingerprint must match the map asset declaration`);
|
||||
if (!mapBoundsMatch(transform.worldBounds, mapAsset.worldBounds)) errors.push(`${itemLocation}.transformAssetPath: transform worldBounds must match the map asset declaration`);
|
||||
if (!mapImageMatch(transform.image, mapAsset.image)) errors.push(`${itemLocation}.transformAssetPath: transform image dimensions must match the map asset declaration`);
|
||||
const validation = transform.validation as { rejectNonFinite?: unknown; rejectOutOfBounds?: unknown; acceptBoundaryPoints?: unknown } | undefined;
|
||||
if (validation?.rejectNonFinite !== true || validation?.rejectOutOfBounds !== true || validation?.acceptBoundaryPoints !== true) errors.push(`${itemLocation}.transformAssetPath.validation: must reject non-finite/out-of-bounds coordinates and accept boundary points`);
|
||||
if (!Array.isArray(transform.fixtures) || transform.fixtures.length === 0) errors.push(`${itemLocation}.transformAssetPath.fixtures: transform must include known-point fixtures`);
|
||||
}
|
||||
};
|
||||
const requireUniqueAssetKeys = (assets: Asset[] | undefined, collection: string): void => {
|
||||
const seenKeys = new Set<string>();
|
||||
for (const [index, asset] of (assets ?? []).entries()) {
|
||||
@@ -1255,6 +1326,7 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st
|
||||
const bounds = mapAsset.worldBounds;
|
||||
if (!bounds || !Number.isFinite(bounds.minX) || !Number.isFinite(bounds.minY) || !Number.isFinite(bounds.maxX) || !Number.isFinite(bounds.maxY) || (bounds.minX ?? 0) >= (bounds.maxX ?? 0) || (bounds.minY ?? 0) >= (bounds.maxY ?? 0)) errors.push(`${itemLocation}.worldBounds: must define finite increasing bounds`);
|
||||
if (!Number.isInteger(mapAsset.image?.width) || (mapAsset.image?.width ?? 0) < 1 || !Number.isInteger(mapAsset.image?.height) || (mapAsset.image?.height ?? 0) < 1) errors.push(`${itemLocation}.image: width and height must be positive integers`);
|
||||
requireSCUMMapAssetJSONCompatibility(mapAsset, itemLocation);
|
||||
}
|
||||
requireUniqueAssetKeys(liveData.giftCatalogs, "giftCatalogs");
|
||||
for (const [index, catalog] of (liveData.giftCatalogs ?? []).entries()) {
|
||||
|
||||
Reference in New Issue
Block a user