Add SCUM current-service map asset contract
This commit is contained in:
@@ -120,6 +120,23 @@ type SCUMSQLiteQueryAsset = {
|
||||
};
|
||||
};
|
||||
|
||||
type SCUMMapBounds = { minX: number; minY: number; maxX: number; maxY: number };
|
||||
type SCUMMapImage = { width: number; height: number };
|
||||
type SCUMMapTransformFixture = { name: string; world: { x: number; y: number }; pixel: { x: number; y: number } };
|
||||
type SCUMMapTransformAsset = {
|
||||
key?: string;
|
||||
transformVersion?: string;
|
||||
mapAssetKey?: string;
|
||||
adapterVersion?: string;
|
||||
requiredSchemaFingerprint?: string;
|
||||
worldBounds: SCUMMapBounds;
|
||||
image: SCUMMapImage;
|
||||
validation?: { rejectNonFinite?: boolean; rejectOutOfBounds?: boolean; acceptBoundaryPoints?: boolean };
|
||||
fixtures?: SCUMMapTransformFixture[];
|
||||
};
|
||||
|
||||
type SCUMMapProjection = { ok: true; pixel: { x: number; y: number } } | { ok: false; reason: "non-finite" | "out-of-bounds" };
|
||||
|
||||
type JSONSchemaObject = {
|
||||
type?: unknown;
|
||||
additionalProperties?: boolean;
|
||||
@@ -146,6 +163,14 @@ function sha256FixtureDigest(fixtureDir: string, relativePath: string): string {
|
||||
return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(path.join(fixtureDir, relativePath))).digest("hex")}`;
|
||||
}
|
||||
|
||||
function projectSCUMMapCoordinate(transform: SCUMMapTransformAsset, world: { x: number; y: number }): SCUMMapProjection {
|
||||
const { minX, minY, maxX, maxY } = transform.worldBounds;
|
||||
const { width, height } = transform.image;
|
||||
if (![world.x, world.y, minX, minY, maxX, maxY, width, height].every(Number.isFinite)) return { ok: false, reason: "non-finite" };
|
||||
if (world.x < minX || world.x > maxX || world.y < minY || world.y > maxY) return { ok: false, reason: "out-of-bounds" };
|
||||
return { ok: true, pixel: { x: ((world.x - minX) / (maxX - minX)) * (width - 1), y: ((maxY - world.y) / (maxY - minY)) * (height - 1) } };
|
||||
}
|
||||
|
||||
function listProductionGoFiles(directory: string): string[] {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
@@ -384,6 +409,58 @@ describe("plugin manifest validation", () => {
|
||||
for (const statement of allowedReadBoundaries) expect(statement).not.toMatch(unsafeSCUMSQLiteReadStatementPattern);
|
||||
});
|
||||
|
||||
it("packages SCUM map metadata and tested coordinate transform assets", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as GamePluginManifest & { scumLiveData: SCUMLiveDataManifestDeclaration };
|
||||
const assetPaths = new Set(manifest.assetFiles?.map((file) => file.path) ?? []);
|
||||
const mapAssets = manifest.scumLiveData.mapAssets ?? [];
|
||||
expect(mapAssets.map((asset) => asset.key)).toEqual(["scum-current-service-coordinate-map"]);
|
||||
const mapAsset = mapAssets[0];
|
||||
expect(mapAsset).toMatchObject({ adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: currentSCUMSchemaFingerprint, metadataSchemaRef: "schemas/scum-live/map-metadata.schema.json", image: { width: 4096, height: 4096 } });
|
||||
expect([...assetPaths]).toEqual(expect.arrayContaining([mapAsset.assetPath, mapAsset.transformAssetPath]));
|
||||
|
||||
const mapAssetPath = path.join(pluginDir, mapAsset.assetPath);
|
||||
const transformAssetPath = path.join(pluginDir, mapAsset.transformAssetPath);
|
||||
expect(`sha256:${crypto.createHash("sha256").update(fs.readFileSync(mapAssetPath)).digest("hex")}`).toBe(mapAsset.digest);
|
||||
expect(`sha256:${crypto.createHash("sha256").update(fs.readFileSync(transformAssetPath)).digest("hex")}`).toBe(mapAsset.transformDigest);
|
||||
|
||||
const metadata = JSON.parse(fs.readFileSync(mapAssetPath, "utf8")) as Record<string, any>;
|
||||
const metadataSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, mapAsset.metadataSchemaRef), "utf8"));
|
||||
const validateMetadata = new Ajv2020({ allErrors: true }).compile(metadataSchema);
|
||||
if (!validateMetadata(metadata)) throw new Error(`SCUM map metadata schema failed: ${JSON.stringify(validateMetadata.errors)}`);
|
||||
expect(metadata).toMatchObject({ key: mapAsset.key, adapterVersion: mapAsset.adapterVersion, requiredSchemaFingerprint: currentSCUMSchemaFingerprint, transformAssetPath: mapAsset.transformAssetPath, worldBounds: mapAsset.worldBounds, image: mapAsset.image });
|
||||
expect(metadata.authorization).toMatchObject({ redistribution: "first-party-generated-coordinate-metadata", baseMapArtwork: "not-packaged", renderingAvailability: "unavailable-until-authorized-base-map" });
|
||||
expect(metadata.layers.map((layer: { key: string; subjectType: string }) => `${layer.key}:${layer.subjectType}`)).toEqual(["players:player", "vehicles:vehicle", "flags:flag"]);
|
||||
|
||||
const transform = JSON.parse(fs.readFileSync(transformAssetPath, "utf8")) as SCUMMapTransformAsset;
|
||||
expect(transform).toMatchObject({ mapAssetKey: mapAsset.key, adapterVersion: mapAsset.adapterVersion, requiredSchemaFingerprint: currentSCUMSchemaFingerprint, worldBounds: mapAsset.worldBounds, image: mapAsset.image, validation: { rejectNonFinite: true, rejectOutOfBounds: true, acceptBoundaryPoints: true } });
|
||||
expect(transform.fixtures?.map((fixture) => fixture.name)).toEqual(["observed-minimum-corner", "observed-maximum-corner", "observed-center"]);
|
||||
for (const fixture of transform.fixtures ?? []) {
|
||||
const projection = projectSCUMMapCoordinate(transform, fixture.world);
|
||||
expect(projection.ok).toBe(true);
|
||||
if (projection.ok) {
|
||||
expect(projection.pixel.x).toBeCloseTo(fixture.pixel.x, 6);
|
||||
expect(projection.pixel.y).toBeCloseTo(fixture.pixel.y, 6);
|
||||
}
|
||||
}
|
||||
for (const world of [{ x: Number.NaN, y: 0 }, { x: Number.POSITIVE_INFINITY, y: 0 }, { x: 0, y: Number.NEGATIVE_INFINITY }]) expect(projectSCUMMapCoordinate(transform, world)).toEqual({ ok: false, reason: "non-finite" });
|
||||
for (const world of [{ x: mapAsset.worldBounds.minX - 1, y: mapAsset.worldBounds.minY }, { x: mapAsset.worldBounds.maxX + 1, y: mapAsset.worldBounds.maxY }, { x: mapAsset.worldBounds.minX, y: mapAsset.worldBounds.minY - 1 }, { x: mapAsset.worldBounds.maxX, y: mapAsset.worldBounds.maxY + 1 }]) expect(projectSCUMMapCoordinate(transform, world)).toEqual({ ok: false, reason: "out-of-bounds" });
|
||||
});
|
||||
|
||||
it("rejects SCUM map transform adapter and schema incompatibility", () => {
|
||||
const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => {
|
||||
const mapAsset = manifest.scumLiveData.mapAssets[0];
|
||||
const transformPath = mapAsset.transformAssetPath;
|
||||
const transform = JSON.parse(fs.readFileSync(path.join(fixtureDir, transformPath), "utf8"));
|
||||
transform.adapterVersion = "scum-live-data-v2";
|
||||
transform.requiredSchemaFingerprint = `sha256:${"0".repeat(64)}`;
|
||||
writeFixtureJSON(fixtureDir, transformPath, transform);
|
||||
mapAsset.transformDigest = sha256FixtureDigest(fixtureDir, transformPath);
|
||||
});
|
||||
expect(errors.some((error) => error.includes("transform adapterVersion must match the map asset declaration"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("transform schema fingerprint must match the map asset declaration"))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires SCUM schema probe targets to have generated Run workspace data targets", () => {
|
||||
const errors = validateTemporaryScumCompanionManifest((manifest) => {
|
||||
manifest.runtimeProfiles.dataTargets = [];
|
||||
@@ -413,7 +490,11 @@ describe("plugin manifest validation", () => {
|
||||
];
|
||||
const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => {
|
||||
manifest.assetFiles = [...manifest.assetFiles, ...assetPaths.map((assetPath) => ({ path: assetPath, mode: 384 }))];
|
||||
for (const assetPath of assetPaths) writeFixtureJSON(fixtureDir, assetPath, { packaged: true });
|
||||
for (const assetPath of [assetPaths[0], assetPaths[1], assetPaths[2], assetPaths[3], assetPaths[6]]) writeFixtureJSON(fixtureDir, assetPath, { packaged: true });
|
||||
const mapBounds = { minX: -100000, minY: -100000, maxX: 100000, maxY: 100000 };
|
||||
const mapImage = { width: 4096, height: 4096 };
|
||||
writeFixtureJSON(fixtureDir, assetPaths[4], { key: "island-map", mapVersion: "island-map-v1", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, sourceEvidence: "fixture", authorization: { redistribution: "first-party-generated-coordinate-metadata", baseMapArtwork: "not-packaged", renderingAvailability: "unavailable-until-authorized-base-map" }, worldBounds: mapBounds, image: mapImage, layers: [{ key: "players", capability: "positions.read", subjectType: "player", label: "Players", sourceQueryKey: "scum-positions-read" }], transformAssetPath: assetPaths[5] });
|
||||
writeFixtureJSON(fixtureDir, assetPaths[5], { key: "island-transform", transformVersion: "island-transform-v1", mapAssetKey: "island-map", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, worldBounds: mapBounds, image: mapImage, validation: { rejectNonFinite: true, rejectOutOfBounds: true, acceptBoundaryPoints: true }, fixtures: [{ name: "origin", world: { x: 0, y: 0 }, pixel: { x: 2047.5, y: 2047.5 } }] });
|
||||
const digest = (assetPath: string) => sha256FixtureDigest(fixtureDir, assetPath);
|
||||
manifest.scumLiveData = {
|
||||
...manifest.scumLiveData,
|
||||
|
||||
Reference in New Issue
Block a user