Add SCUM current-service map asset contract
This commit is contained in:
@@ -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.
|
- 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)`.
|
- 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.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.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.
|
- [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.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.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.
|
- [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.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.
|
- [ ] 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.
|
- [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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
+49
@@ -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 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -558,6 +558,28 @@
|
|||||||
"timeoutMs": 2000,
|
"timeoutMs": 2000,
|
||||||
"maxResultBytes": 262144
|
"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": [
|
"permissions": [
|
||||||
@@ -653,6 +675,14 @@
|
|||||||
{
|
{
|
||||||
"path": "assets/scum-live/queries/positions-read.json",
|
"path": "assets/scum-live/queries/positions-read.json",
|
||||||
"mode": 384
|
"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": {
|
"productionLifecycle": {
|
||||||
|
|||||||
@@ -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}$" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
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 requireUniqueAssetKeys = (assets: Asset[] | undefined, collection: string): void => {
|
||||||
const seenKeys = new Set<string>();
|
const seenKeys = new Set<string>();
|
||||||
for (const [index, asset] of (assets ?? []).entries()) {
|
for (const [index, asset] of (assets ?? []).entries()) {
|
||||||
@@ -1255,6 +1326,7 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st
|
|||||||
const bounds = mapAsset.worldBounds;
|
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 (!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`);
|
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");
|
requireUniqueAssetKeys(liveData.giftCatalogs, "giftCatalogs");
|
||||||
for (const [index, catalog] of (liveData.giftCatalogs ?? []).entries()) {
|
for (const [index, catalog] of (liveData.giftCatalogs ?? []).entries()) {
|
||||||
|
|||||||
@@ -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 JSONSchemaObject = {
|
||||||
type?: unknown;
|
type?: unknown;
|
||||||
additionalProperties?: boolean;
|
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")}`;
|
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[] {
|
function listProductionGoFiles(directory: string): string[] {
|
||||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
const target = path.join(directory, entry.name);
|
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);
|
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", () => {
|
it("requires SCUM schema probe targets to have generated Run workspace data targets", () => {
|
||||||
const errors = validateTemporaryScumCompanionManifest((manifest) => {
|
const errors = validateTemporaryScumCompanionManifest((manifest) => {
|
||||||
manifest.runtimeProfiles.dataTargets = [];
|
manifest.runtimeProfiles.dataTargets = [];
|
||||||
@@ -413,7 +490,11 @@ describe("plugin manifest validation", () => {
|
|||||||
];
|
];
|
||||||
const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => {
|
const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => {
|
||||||
manifest.assetFiles = [...manifest.assetFiles, ...assetPaths.map((assetPath) => ({ path: assetPath, mode: 384 }))];
|
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);
|
const digest = (assetPath: string) => sha256FixtureDigest(fixtureDir, assetPath);
|
||||||
manifest.scumLiveData = {
|
manifest.scumLiveData = {
|
||||||
...manifest.scumLiveData,
|
...manifest.scumLiveData,
|
||||||
|
|||||||
Reference in New Issue
Block a user