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
+82 -1
View File
@@ -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,