Add SCUM current-service map asset contract

This commit is contained in:
npc0-hue
2026-08-13 10:19:39 +08:00
parent 5331143806
commit 25a823f298
7 changed files with 341 additions and 2 deletions
+72
View File
@@ -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()) {