diff --git a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md index 1cd124f..8a794c6 100644 --- a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md +++ b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md @@ -55,6 +55,13 @@ - Added focused companion tests for successful login/logout, failed login, partial, undecodable, oversized, malformed lines, rotation/copy-truncate overlap across a new generation, restart/resume acknowledgements, duplicate transport/logical delivery, out-of-order delivery, and absence of network/coordinate material in parsed event storage/fingerprints. - Verification passed: `(cd plugins/examples/scum-server-plugin/companion && go test ./...)` and `(cd plugins && npm run validate:manifest)`. +### Map asset implementation evidence (2026-08-13) + +- Added plugin-owned SCUM current-service coordinate-map metadata and transform assets under `assets/scum-live/map/`, digest-referenced from `scumLiveData.mapAssets` as `sha256:74800836553c7e4372a0747c5e9511adcee940b057194488bbf65bf164df372b` and `sha256:f1941109167a86818e9e71996821884aeb8bd7e863584454b891e525695ba478`. +- The packaged asset is explicitly limited to first-party-generated coordinate metadata and the observed current-service coordinate envelope from `evidence/scum-current-service-sqlite-diagnostic-2026-08-12.md`; no unauthorized SCUM base-map artwork is shipped, and `positions.read` remains disabled until compatible evidence enables it. +- Added manifest-validator checks and fixture tests for map metadata/schema compatibility, transform adapter/schema fingerprint matching, declared bounds/image consistency, known-point projection fixtures, non-finite/out-of-bounds rejection, and digest-preserving adapter incompatibility. +- Verification passed: `(cd plugins && npm test -- manifest-validation.test.ts)` and `(cd plugins && npm run validate:manifest)`. + - [x] 3.1 Add SDK and manifest types for versioned log parsers, SQLite query assets, parameter/result schemas, capability-specific schema fingerprints, sync cadence/limits, map metadata, typed RCON templates, gift item catalogs, and guarded mutation declarations. - [x] 3.2 Extend plugin validation to require asset digests, contained package paths, unique template keys, bounded parameters/results, compatible adapter versions, and explicit permission bindings, and to reject raw caller-supplied SQL, RCON, XML, paths, or undeclared parameters. - [x] 3.3 Capture sanitized authentic login-log fixtures from the active service and bind their expected events to server, Run binding, plugin version, parser version/digest, a transport cursor `(source identity, stream generation, sequence)`, and a separate privacy-safe logical event identity stable across rotation overlap. @@ -62,7 +69,7 @@ - [x] 3.5 Add parameterized, read-only player identity/detail/economy/session-enrichment query assets and exact result schemas only for joins and fields proven by the probe. - [x] 3.6 Add parameterized squad/member, vehicle, flag/territory, and position query assets and exact result schemas, keeping ambiguous ranks, ownership, coordinates, and missing numeric values null. - [x] 3.7 Add query-asset tests for single SELECT/CTE or approved introspection boundaries, parameter binding, pagination/cursors, timeout/row/byte limits, schema-version matching, and rejection of DDL, mutation, `ATTACH`, extension loading, write PRAGMAs, and multi-statement input. -- [ ] 3.8 Package the authorized SCUM map asset, identity/version, verified world bounds, layer metadata, and coordinate transform, with fixture tests for known points, out-of-bounds/non-finite coordinates, and adapter incompatibility. +- [x] 3.8 Package the authorized SCUM map asset, identity/version, verified world bounds, layer metadata, and coordinate transform, with fixture tests for known points, out-of-bounds/non-finite coordinates, and adapter incompatibility. - [ ] 3.9 Declare only verified typed RCON templates for supported Fame/currency/notification/gift operations and a version-scoped gift item catalog; omit any command whose execution and confirmation semantics remain unknown. - [ ] 3.10 Declare a guarded preserving XML mutation only after the real XML source and named attributes are proven; expose `855` only as a reviewed named-attribute preset and never as a database column, generic integer field, or guessed mapping. - [x] 3.11 Add immutable asset/digest declarations and plugin package validation; defer generated Run-package execution wiring until the complete protocol/result envelope and independent Run capability evidence in group 4 are frozen. diff --git a/plugins/examples/scum-server-plugin/assets/scum-live/map/current-service-map.json b/plugins/examples/scum-server-plugin/assets/scum-live/map/current-service-map.json new file mode 100644 index 0000000..677accb --- /dev/null +++ b/plugins/examples/scum-server-plugin/assets/scum-live/map/current-service-map.json @@ -0,0 +1,46 @@ +{ + "key": "scum-current-service-coordinate-map", + "mapVersion": "scum-current-service-coordinate-map-v1", + "adapterVersion": "scum-live-data-v1", + "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", + "sourceEvidence": "openspec/changes/replace-scum-projections-with-real-data-management/evidence/scum-current-service-sqlite-diagnostic-2026-08-12.md", + "authorization": { + "redistribution": "first-party-generated-coordinate-metadata", + "baseMapArtwork": "not-packaged", + "renderingAvailability": "unavailable-until-authorized-base-map" + }, + "worldBounds": { + "minX": -901009.4375, + "minY": -883992.5625, + "maxX": 612580.0625, + "maxY": 615977.375 + }, + "image": { + "width": 4096, + "height": 4096 + }, + "layers": [ + { + "key": "players", + "capability": "positions.read", + "subjectType": "player", + "label": "Players", + "sourceQueryKey": "scum-positions-read" + }, + { + "key": "vehicles", + "capability": "positions.read", + "subjectType": "vehicle", + "label": "Vehicles", + "sourceQueryKey": "scum-positions-read" + }, + { + "key": "flags", + "capability": "positions.read", + "subjectType": "flag", + "label": "Flags", + "sourceQueryKey": "scum-positions-read" + } + ], + "transformAssetPath": "assets/scum-live/map/current-service-transform.json" +} diff --git a/plugins/examples/scum-server-plugin/assets/scum-live/map/current-service-transform.json b/plugins/examples/scum-server-plugin/assets/scum-live/map/current-service-transform.json new file mode 100644 index 0000000..6dbe165 --- /dev/null +++ b/plugins/examples/scum-server-plugin/assets/scum-live/map/current-service-transform.json @@ -0,0 +1,49 @@ +{ + "key": "scum-current-service-coordinate-transform", + "transformVersion": "scum-current-service-transform-v1", + "mapAssetKey": "scum-current-service-coordinate-map", + "adapterVersion": "scum-live-data-v1", + "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", + "coordinateSystem": "current-service-observed-entity-envelope", + "sourceEvidence": "openspec/changes/replace-scum-projections-with-real-data-management/evidence/scum-current-service-sqlite-diagnostic-2026-08-12.md", + "worldBounds": { + "minX": -901009.4375, + "minY": -883992.5625, + "maxX": 612580.0625, + "maxY": 615977.375 + }, + "image": { + "width": 4096, + "height": 4096 + }, + "axisMapping": { + "x": "left-to-right", + "y": "bottom-to-top-inverted-for-image-y" + }, + "formula": { + "pixelX": "((x - minX) / (maxX - minX)) * (width - 1)", + "pixelY": "((maxY - y) / (maxY - minY)) * (height - 1)" + }, + "validation": { + "rejectNonFinite": true, + "rejectOutOfBounds": true, + "acceptBoundaryPoints": true + }, + "fixtures": [ + { + "name": "observed-minimum-corner", + "world": { "x": -901009.4375, "y": -883992.5625 }, + "pixel": { "x": 0, "y": 4095 } + }, + { + "name": "observed-maximum-corner", + "world": { "x": 612580.0625, "y": 615977.375 }, + "pixel": { "x": 4095, "y": 0 } + }, + { + "name": "observed-center", + "world": { "x": -144214.6875, "y": -134007.59375 }, + "pixel": { "x": 2047.5, "y": 2047.5 } + } + ] +} diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 7dc1c9c..e8798b6 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -558,6 +558,28 @@ "timeoutMs": 2000, "maxResultBytes": 262144 } + ], + "mapAssets": [ + { + "key": "scum-current-service-coordinate-map", + "adapterVersion": "scum-live-data-v1", + "assetPath": "assets/scum-live/map/current-service-map.json", + "digest": "sha256:74800836553c7e4372a0747c5e9511adcee940b057194488bbf65bf164df372b", + "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", + "metadataSchemaRef": "schemas/scum-live/map-metadata.schema.json", + "transformAssetPath": "assets/scum-live/map/current-service-transform.json", + "transformDigest": "sha256:f1941109167a86818e9e71996821884aeb8bd7e863584454b891e525695ba478", + "worldBounds": { + "minX": -901009.4375, + "minY": -883992.5625, + "maxX": 612580.0625, + "maxY": 615977.375 + }, + "image": { + "width": 4096, + "height": 4096 + } + } ] }, "permissions": [ @@ -653,6 +675,14 @@ { "path": "assets/scum-live/queries/positions-read.json", "mode": 384 + }, + { + "path": "assets/scum-live/map/current-service-map.json", + "mode": 384 + }, + { + "path": "assets/scum-live/map/current-service-transform.json", + "mode": 384 } ], "productionLifecycle": { diff --git a/plugins/examples/scum-server-plugin/schemas/scum-live/map-metadata.schema.json b/plugins/examples/scum-server-plugin/schemas/scum-live/map-metadata.schema.json new file mode 100644 index 0000000..87809df --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/scum-live/map-metadata.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SCUMCurrentServiceMapMetadata", + "type": "object", + "additionalProperties": false, + "required": ["key", "mapVersion", "adapterVersion", "requiredSchemaFingerprint", "sourceEvidence", "authorization", "worldBounds", "image", "layers", "transformAssetPath"], + "properties": { + "key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._/-]{0,119}$" }, + "mapVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$" }, + "adapterVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$" }, + "requiredSchemaFingerprint": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" }, + "sourceEvidence": { "type": "string", "minLength": 1, "maxLength": 240 }, + "authorization": { + "type": "object", + "additionalProperties": false, + "required": ["redistribution", "baseMapArtwork", "renderingAvailability"], + "properties": { + "redistribution": { "const": "first-party-generated-coordinate-metadata" }, + "baseMapArtwork": { "const": "not-packaged" }, + "renderingAvailability": { "const": "unavailable-until-authorized-base-map" } + } + }, + "worldBounds": { + "type": "object", + "additionalProperties": false, + "required": ["minX", "minY", "maxX", "maxY"], + "properties": { "minX": { "type": "number" }, "minY": { "type": "number" }, "maxX": { "type": "number" }, "maxY": { "type": "number" } } + }, + "image": { + "type": "object", + "additionalProperties": false, + "required": ["width", "height"], + "properties": { "width": { "type": "integer", "minimum": 1 }, "height": { "type": "integer", "minimum": 1 } } + }, + "layers": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["key", "capability", "subjectType", "label", "sourceQueryKey"], + "properties": { + "key": { "enum": ["players", "vehicles", "flags"] }, + "capability": { "const": "positions.read" }, + "subjectType": { "enum": ["player", "vehicle", "flag"] }, + "label": { "type": "string", "minLength": 1, "maxLength": 40 }, + "sourceQueryKey": { "const": "scum-positions-read" } + } + } + }, + "transformAssetPath": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$" } + } +} diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index d4d79e6..2192598 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -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 | 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; + } 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(); + 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(); 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()) { diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index a318438..8555e98 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -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; + 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,