Revert SCUM real data management change

This commit is contained in:
npc0-hue
2026-08-13 15:33:34 +08:00
parent d831e4ade9
commit b07a792784
163 changed files with 5443 additions and 9174 deletions
@@ -1,20 +0,0 @@
{
"key": "scum-login-log-parser-v1",
"parserVersion": "scum-login-log-v1",
"lineEncoding": "utf-16le",
"maxLineBytes": 4096,
"acceptedTemplate": "{timestamp}: '{network} {external_player_id}:{display_name}({profile_local_id})' logged {in|out} at: X={coordinate} Y={coordinate} Z={coordinate}",
"eventTypes": ["scum.login", "scum.logout"],
"transportCursor": ["sourceIdentity", "streamGeneration", "sequence"],
"logicalEventIdentity": {
"algorithm": "sha256",
"fields": ["parserVersion", "serverId", "eventType", "occurredAtSourceText", "externalPlayerId", "displayName", "profileLocalId"],
"excludedFields": ["network", "x", "y", "z", "sourceIdentity", "streamGeneration", "sequence"]
},
"privacy": {
"stripNetworkIdentifiers": true,
"stripCoordinatesFromIdentity": true,
"rawLineStorage": "forbidden"
},
"diagnostics": ["invalid-transport-cursor", "out-of-order-transport", "duplicate-transport-cursor", "partial-line", "oversized-line", "undecodable-line", "failed-login", "malformed-line", "duplicate-logical-event"]
}
@@ -1,46 +0,0 @@
{
"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"
}
@@ -1,49 +0,0 @@
{
"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 }
}
]
}
@@ -1,29 +0,0 @@
{
"key": "scum-flags-read-v1",
"adapterVersion": "scum-live-data-v1",
"queryVersion": "scum-sqlite-query-v1",
"capability": "flags.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"statementType": "single-select-or-cte",
"statement": "SELECT bef.element_id AS flagElementId, be.element_id AS baseElementId, b.id AS baseId, b.owner_profile_id AS ownerProfileId, up.user_id AS ownerExternalPlayerId, up.name AS ownerDisplayName, CAST(NULL AS INTEGER) AS ownerSquadId, CAST(NULL AS TEXT) AS ownerSquadName, be.location_x AS flagX, be.location_y AS flagY, be.location_z AS flagZ, b.location_x AS territoryX, b.location_y AS territoryY, CAST(NULL AS REAL) AS territoryZ FROM base_element_flag bef INNER JOIN base_element be ON be.element_id = bef.element_id INNER JOIN base b ON b.id = be.element_id LEFT JOIN user_profile up ON up.id = b.owner_profile_id ORDER BY bef.element_id ASC LIMIT :limit OFFSET :offset",
"parameters": [
{ "name": "limit", "binding": ":limit", "type": "integer", "required": true, "minimum": 1, "maximum": 200 },
{ "name": "offset", "binding": ":offset", "type": "integer", "required": true, "minimum": 0, "maximum": 1000000 }
],
"safety": {
"queryOnly": true,
"readOnlyConnection": true,
"forbidMultipleStatements": true,
"forbidAttach": true,
"forbidWritePragmas": true,
"forbidExtensionLoading": true,
"maxRows": 200,
"timeoutMs": 2000,
"maxResultBytes": 65536
},
"evidence": {
"source": "openspec/changes/replace-scum-projections-with-real-data-management/evidence/scum-current-service-sqlite-diagnostic-2026-08-12.md",
"provenJoins": ["base_element_flag.element_id -> base_element.element_id", "base_element.element_id -> base.id", "base.owner_profile_id -> user_profile.id"],
"ambiguousFieldsKeptNull": ["ownerSquadId", "ownerSquadName", "territoryZ"]
}
}
@@ -1,27 +0,0 @@
{
"key": "scum-player-details-read-v1",
"adapterVersion": "scum-live-data-v1",
"queryVersion": "scum-sqlite-query-v1",
"capability": "player-details.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"statementType": "single-select-or-cte",
"statement": "SELECT u.id AS externalPlayerId, u.id_type AS idType, u.provider AS provider, u.creation_time AS userCreationTime, u.last_login_time AS userLastLoginTime, u.is_banned AS isBanned, up.id AS profileId, up.name AS displayName, up.type AS profileType, p.id AS prisonerId, p.user_profile_id AS prisonerUserProfileId, p.last_save_time AS prisonerLastSaveTime, pe.entity_id AS entityId, CASE WHEN up.template_xml IS NULL THEN NULL ELSE length(up.template_xml) END AS profileXmlLength FROM \"user\" u LEFT JOIN user_profile up ON up.user_id = u.id AND up.type = 1 LEFT JOIN prisoner p ON p.id = up.prisoner_id AND p.user_profile_id = up.id LEFT JOIN prisoner_entity pe ON pe.prisoner_id = p.id WHERE u.id = :externalPlayerId LIMIT 1",
"parameters": [
{ "name": "externalPlayerId", "binding": ":externalPlayerId", "type": "string", "required": true, "minLength": 1, "maxLength": 96 }
],
"safety": {
"queryOnly": true,
"readOnlyConnection": true,
"forbidMultipleStatements": true,
"forbidAttach": true,
"forbidWritePragmas": true,
"forbidExtensionLoading": true,
"maxRows": 1,
"timeoutMs": 2000,
"maxResultBytes": 32768
},
"evidence": {
"source": "openspec/changes/replace-scum-projections-with-real-data-management/evidence/scum-current-service-sqlite-diagnostic-2026-08-12.md",
"provenFields": ["user.id", "user.id_type", "user.provider", "user.creation_time", "user.last_login_time", "user.is_banned", "user_profile.id", "user_profile.name", "user_profile.type", "user_profile.template_xml", "prisoner.id", "prisoner.user_profile_id", "prisoner.last_save_time", "prisoner_entity.entity_id"]
}
}
@@ -1,29 +0,0 @@
{
"key": "scum-player-economy-read-v1",
"adapterVersion": "scum-live-data-v1",
"queryVersion": "scum-sqlite-query-v1",
"capability": "player-details.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"statementType": "single-select-or-cte",
"statement": "SELECT u.id AS externalPlayerId, up.id AS profileId, bar.id AS bankAccountId, bac.id AS currencyRowId, bac.currency_type AS currencyType, bac.account_balance AS accountBalance FROM \"user\" u JOIN user_profile up ON up.user_id = u.id AND up.type = 1 JOIN bank_account_registry bar ON bar.account_owner_user_profile_id = up.id JOIN bank_account_registry_currencies bac ON bac.bank_account_id = bar.id WHERE u.id = :externalPlayerId ORDER BY bac.currency_type ASC, bac.id ASC LIMIT :limit",
"parameters": [
{ "name": "externalPlayerId", "binding": ":externalPlayerId", "type": "string", "required": true, "minLength": 1, "maxLength": 96 },
{ "name": "limit", "binding": ":limit", "type": "integer", "required": true, "minimum": 1, "maximum": 16 }
],
"safety": {
"queryOnly": true,
"readOnlyConnection": true,
"forbidMultipleStatements": true,
"forbidAttach": true,
"forbidWritePragmas": true,
"forbidExtensionLoading": true,
"maxRows": 16,
"timeoutMs": 2000,
"maxResultBytes": 32768
},
"evidence": {
"source": "openspec/changes/replace-scum-projections-with-real-data-management/evidence/scum-current-service-sqlite-diagnostic-2026-08-12.md",
"provenJoins": ["bank_account_registry.account_owner_user_profile_id -> user_profile.id", "bank_account_registry_currencies.bank_account_id -> bank_account_registry.id"],
"ambiguity": "Currency labels, units, command execution, and readback confirmation are unverified; currencyType remains numeric and version-scoped."
}
}
@@ -1,27 +0,0 @@
{
"key": "scum-player-session-enrichment-read-v1",
"adapterVersion": "scum-live-data-v1",
"queryVersion": "scum-sqlite-query-v1",
"capability": "players.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"statementType": "single-select-or-cte",
"statement": "SELECT u.id AS externalPlayerId, u.id_type AS idType, u.provider AS provider, u.creation_time AS userCreationTime, u.last_login_time AS userLastLoginTime, u.is_banned AS isBanned, up.id AS profileId, up.name AS displayName, up.type AS profileType, p.id AS prisonerId FROM \"user\" u LEFT JOIN user_profile up ON up.user_id = u.id AND up.type = 1 LEFT JOIN prisoner p ON p.id = up.prisoner_id AND p.user_profile_id = up.id WHERE u.id = :externalPlayerId LIMIT 1",
"parameters": [
{ "name": "externalPlayerId", "binding": ":externalPlayerId", "type": "string", "required": true, "minLength": 1, "maxLength": 96 }
],
"safety": {
"queryOnly": true,
"readOnlyConnection": true,
"forbidMultipleStatements": true,
"forbidAttach": true,
"forbidWritePragmas": true,
"forbidExtensionLoading": true,
"maxRows": 1,
"timeoutMs": 2000,
"maxResultBytes": 32768
},
"evidence": {
"source": "openspec/changes/replace-scum-projections-with-real-data-management/evidence/scum-current-service-sqlite-diagnostic-2026-08-12.md",
"sessionBoundary": "Online state and local sessions are created from authenticated login/logout events; this query only enriches a login-created identity with proven database facts."
}
}
@@ -1,28 +0,0 @@
{
"key": "scum-players-read-v1",
"adapterVersion": "scum-live-data-v1",
"queryVersion": "scum-sqlite-query-v1",
"capability": "players.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"statementType": "single-select-or-cte",
"statement": "WITH player_rows AS (SELECT u.id AS externalPlayerId, u.id_type AS idType, u.provider AS provider, u.creation_time AS userCreationTime, u.last_login_time AS userLastLoginTime, u.is_banned AS isBanned, up.id AS profileId, up.name AS displayName, up.type AS profileType, p.id AS prisonerId, pe.entity_id AS entityId FROM \"user\" u LEFT JOIN user_profile up ON up.user_id = u.id AND up.type = 1 LEFT JOIN prisoner p ON p.id = up.prisoner_id AND p.user_profile_id = up.id LEFT JOIN prisoner_entity pe ON pe.prisoner_id = p.id ORDER BY u.last_login_time DESC, u.id ASC LIMIT :limit OFFSET :offset) SELECT externalPlayerId, idType, provider, userCreationTime, userLastLoginTime, isBanned, profileId, displayName, profileType, prisonerId, entityId FROM player_rows",
"parameters": [
{ "name": "limit", "binding": ":limit", "type": "integer", "required": true, "minimum": 1, "maximum": 100 },
{ "name": "offset", "binding": ":offset", "type": "integer", "required": true, "minimum": 0, "maximum": 1000000 }
],
"safety": {
"queryOnly": true,
"readOnlyConnection": true,
"forbidMultipleStatements": true,
"forbidAttach": true,
"forbidWritePragmas": true,
"forbidExtensionLoading": true,
"maxRows": 100,
"timeoutMs": 2000,
"maxResultBytes": 65536
},
"evidence": {
"source": "openspec/changes/replace-scum-projections-with-real-data-management/evidence/scum-current-service-sqlite-diagnostic-2026-08-12.md",
"provenJoins": ["user_profile.user_id -> user.id", "user_profile.prisoner_id -> prisoner.id", "prisoner.user_profile_id -> user_profile.id", "prisoner_entity.prisoner_id -> prisoner.id"]
}
}
@@ -1,30 +0,0 @@
{
"key": "scum-positions-read-v1",
"adapterVersion": "scum-live-data-v1",
"queryVersion": "scum-sqlite-query-v1",
"capability": "positions.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"statementType": "single-select-or-cte",
"statement": "WITH position_rows AS (SELECT 'player' AS subjectType, u.id AS subjectId, u.id AS externalPlayerId, up.id AS profileId, p.id AS prisonerId, pe.entity_id AS entityId, CAST(NULL AS INTEGER) AS vehicleEntityId, CAST(NULL AS INTEGER) AS vehicleAssetId, CAST(NULL AS INTEGER) AS flagElementId, e.location_x AS x, e.location_y AS y, e.location_z AS z, p.last_save_time AS sourceTimeValue, 'prisoner.last_save_time' AS sourceTimeKind FROM \"user\" u INNER JOIN user_profile up ON up.user_id = u.id AND up.type = 1 INNER JOIN prisoner p ON p.id = up.prisoner_id AND p.user_profile_id = up.id INNER JOIN prisoner_entity pe ON pe.prisoner_id = p.id INNER JOIN entity e ON e.id = pe.entity_id WHERE :subjectType IN ('all', 'player') UNION ALL SELECT 'vehicle' AS subjectType, CAST(vs.vehicle_entity_id AS TEXT) AS subjectId, CAST(NULL AS TEXT) AS externalPlayerId, CAST(NULL AS INTEGER) AS profileId, CAST(NULL AS INTEGER) AS prisonerId, ve.entity_id AS entityId, vs.vehicle_entity_id AS vehicleEntityId, vs.vehicle_asset_id AS vehicleAssetId, CAST(NULL AS INTEGER) AS flagElementId, e.location_x AS x, e.location_y AS y, e.location_z AS z, CAST(NULL AS INTEGER) AS sourceTimeValue, CAST(NULL AS TEXT) AS sourceTimeKind FROM vehicle_spawner vs INNER JOIN vehicle_entity ve ON ve.entity_id = vs.vehicle_entity_id INNER JOIN entity e ON e.id = ve.entity_id WHERE :subjectType IN ('all', 'vehicle') UNION ALL SELECT 'flag' AS subjectType, CAST(bef.element_id AS TEXT) AS subjectId, CAST(NULL AS TEXT) AS externalPlayerId, CAST(NULL AS INTEGER) AS profileId, CAST(NULL AS INTEGER) AS prisonerId, CAST(NULL AS INTEGER) AS entityId, CAST(NULL AS INTEGER) AS vehicleEntityId, CAST(NULL AS INTEGER) AS vehicleAssetId, bef.element_id AS flagElementId, be.location_x AS x, be.location_y AS y, be.location_z AS z, CAST(NULL AS INTEGER) AS sourceTimeValue, CAST(NULL AS TEXT) AS sourceTimeKind FROM base_element_flag bef INNER JOIN base_element be ON be.element_id = bef.element_id INNER JOIN base b ON b.id = be.element_id WHERE :subjectType IN ('all', 'flag')) SELECT subjectType, subjectId, externalPlayerId, profileId, prisonerId, entityId, vehicleEntityId, vehicleAssetId, flagElementId, x, y, z, sourceTimeValue, sourceTimeKind FROM position_rows ORDER BY subjectType ASC, subjectId ASC LIMIT :limit OFFSET :offset",
"parameters": [
{ "name": "subjectType", "binding": ":subjectType", "type": "string", "required": true, "enum": ["all", "player", "vehicle", "flag"] },
{ "name": "limit", "binding": ":limit", "type": "integer", "required": true, "minimum": 1, "maximum": 500 },
{ "name": "offset", "binding": ":offset", "type": "integer", "required": true, "minimum": 0, "maximum": 1000000 }
],
"safety": {
"queryOnly": true,
"readOnlyConnection": true,
"forbidMultipleStatements": true,
"forbidAttach": true,
"forbidWritePragmas": true,
"forbidExtensionLoading": true,
"maxRows": 500,
"timeoutMs": 2000,
"maxResultBytes": 262144
},
"evidence": {
"source": "openspec/changes/replace-scum-projections-with-real-data-management/evidence/scum-current-service-sqlite-diagnostic-2026-08-12.md",
"provenJoins": ["user_profile.user_id -> user.id", "user_profile.prisoner_id -> prisoner.id", "prisoner_entity.prisoner_id -> prisoner.id", "vehicle_spawner.vehicle_entity_id -> vehicle_entity.entity_id", "vehicle_entity.entity_id -> entity.id", "base_element_flag.element_id -> base_element.element_id", "base_element.element_id -> base.id"],
"cadenceCaveat": "SCUM.db does not prove sub-10s realtime cadence; sourceTimeValue is nullable and only reflects fields proven by the adapter."
}
}
@@ -1,30 +0,0 @@
{
"key": "scum-squad-members-read-v1",
"adapterVersion": "scum-live-data-v1",
"queryVersion": "scum-sqlite-query-v1",
"capability": "squad-members.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"statementType": "single-select-or-cte",
"statement": "SELECT sm.squad_id AS squadId, sm.user_profile_id AS userProfileId, sm.rank AS rankCode, CAST(NULL AS TEXT) AS rankMeaning, CAST(NULL AS INTEGER) AS isLeader, up.user_id AS externalPlayerId, u.id_type AS idType, u.provider AS provider, up.name AS displayName, up.type AS profileType FROM squad_member sm INNER JOIN squad s ON s.id = sm.squad_id INNER JOIN user_profile up ON up.id = sm.user_profile_id LEFT JOIN \"user\" u ON u.id = up.user_id WHERE (:squadId IS NULL OR sm.squad_id = :squadId) ORDER BY sm.squad_id ASC, sm.user_profile_id ASC LIMIT :limit OFFSET :offset",
"parameters": [
{ "name": "squadId", "binding": ":squadId", "type": "integer", "required": true, "nullable": true, "minimum": 0, "maximum": 2147483647 },
{ "name": "limit", "binding": ":limit", "type": "integer", "required": true, "minimum": 1, "maximum": 500 },
{ "name": "offset", "binding": ":offset", "type": "integer", "required": true, "minimum": 0, "maximum": 1000000 }
],
"safety": {
"queryOnly": true,
"readOnlyConnection": true,
"forbidMultipleStatements": true,
"forbidAttach": true,
"forbidWritePragmas": true,
"forbidExtensionLoading": true,
"maxRows": 500,
"timeoutMs": 2000,
"maxResultBytes": 131072
},
"evidence": {
"source": "openspec/changes/replace-scum-projections-with-real-data-management/evidence/scum-current-service-sqlite-diagnostic-2026-08-12.md",
"provenJoins": ["squad_member.squad_id -> squad.id", "squad_member.user_profile_id -> user_profile.id", "user_profile.user_id -> user.id"],
"ambiguousFieldsKeptNull": ["rankMeaning", "isLeader"]
}
}
@@ -1,29 +0,0 @@
{
"key": "scum-squads-read-v1",
"adapterVersion": "scum-live-data-v1",
"queryVersion": "scum-sqlite-query-v1",
"capability": "squads.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"statementType": "single-select-or-cte",
"statement": "WITH member_counts AS (SELECT sm.squad_id AS squadId, COUNT(*) AS memberCount FROM squad_member sm GROUP BY sm.squad_id) SELECT s.id AS squadId, s.name AS squadName, COALESCE(mc.memberCount, 0) AS memberCount, CAST(NULL AS INTEGER) AS leaderProfileId, CAST(NULL AS TEXT) AS leaderExternalPlayerId, CAST(NULL AS INTEGER) AS leaderRankCode, CAST(NULL AS TEXT) AS leaderRankMeaning FROM squad s LEFT JOIN member_counts mc ON mc.squadId = s.id ORDER BY s.id ASC LIMIT :limit OFFSET :offset",
"parameters": [
{ "name": "limit", "binding": ":limit", "type": "integer", "required": true, "minimum": 1, "maximum": 100 },
{ "name": "offset", "binding": ":offset", "type": "integer", "required": true, "minimum": 0, "maximum": 1000000 }
],
"safety": {
"queryOnly": true,
"readOnlyConnection": true,
"forbidMultipleStatements": true,
"forbidAttach": true,
"forbidWritePragmas": true,
"forbidExtensionLoading": true,
"maxRows": 100,
"timeoutMs": 2000,
"maxResultBytes": 65536
},
"evidence": {
"source": "openspec/changes/replace-scum-projections-with-real-data-management/evidence/scum-current-service-sqlite-diagnostic-2026-08-12.md",
"provenFields": ["squad.id", "squad.name", "squad_member.squad_id"],
"ambiguousFieldsKeptNull": ["leaderProfileId", "leaderExternalPlayerId", "leaderRankCode", "leaderRankMeaning"]
}
}
@@ -1,29 +0,0 @@
{
"key": "scum-vehicles-read-v1",
"adapterVersion": "scum-live-data-v1",
"queryVersion": "scum-sqlite-query-v1",
"capability": "vehicles.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"statementType": "single-select-or-cte",
"statement": "SELECT vs.vehicle_entity_id AS vehicleEntityId, ve.entity_id AS entityId, vs.vehicle_asset_id AS vehicleAssetId, vs.is_vehicle_functional AS isVehicleFunctional, CAST(NULL AS TEXT) AS vehicleLabel, CAST(NULL AS INTEGER) AS ownerProfileId, CAST(NULL AS TEXT) AS ownerExternalPlayerId, CAST(NULL AS INTEGER) AS ownerSquadId, e.location_x AS x, e.location_y AS y, e.location_z AS z FROM vehicle_spawner vs INNER JOIN vehicle_entity ve ON ve.entity_id = vs.vehicle_entity_id INNER JOIN entity e ON e.id = ve.entity_id ORDER BY vs.vehicle_entity_id ASC LIMIT :limit OFFSET :offset",
"parameters": [
{ "name": "limit", "binding": ":limit", "type": "integer", "required": true, "minimum": 1, "maximum": 500 },
{ "name": "offset", "binding": ":offset", "type": "integer", "required": true, "minimum": 0, "maximum": 1000000 }
],
"safety": {
"queryOnly": true,
"readOnlyConnection": true,
"forbidMultipleStatements": true,
"forbidAttach": true,
"forbidWritePragmas": true,
"forbidExtensionLoading": true,
"maxRows": 500,
"timeoutMs": 2000,
"maxResultBytes": 262144
},
"evidence": {
"source": "openspec/changes/replace-scum-projections-with-real-data-management/evidence/scum-current-service-sqlite-diagnostic-2026-08-12.md",
"provenJoins": ["vehicle_spawner.vehicle_entity_id -> vehicle_entity.entity_id", "vehicle_entity.entity_id -> entity.id"],
"ambiguousFieldsKeptNull": ["vehicleLabel", "ownerProfileId", "ownerExternalPlayerId", "ownerSquadId"]
}
}
@@ -82,7 +82,7 @@ func e2eClaim(id, commandType string, payload map[string]any, stamp time.Time) C
}
func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
stamp := time.Now().UTC()
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
port := &isolatedAdapterPort{
configFields: map[string]string{"ServerName": "Moonlight", "Password": "never-return", "hostPath": "C:/private/server.ini"},
notifyAccept: true,
@@ -130,7 +130,7 @@ func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
}
func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T) {
stamp := time.Now().UTC()
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
for name, testCase := range map[string]struct {
availability HandlerAvailability
adapter RuntimeAdapter
@@ -156,7 +156,7 @@ func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T
}
func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(t *testing.T) {
stamp := time.Now().UTC()
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
port := &isolatedAdapterPort{patchErr: errors.New("private port failed"), notifyErr: errors.New("private notification failed")}
adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port}
gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{
@@ -170,11 +170,11 @@ func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(t *test
for id, results := range gateway.completed {
result := results[0]
if result.Payload["result"] != "failed" || strings.Contains(stringifyPayload(result.Payload), "private") || strings.Contains(result.Summary, "private") {
t.Fatalf("%s did not redact failed adapter output: %+v", id, result)
t.Fatalf("%s did not redact failed typed-port output: %+v", id, result)
}
}
if len(port.notifications) != 1 || port.notifications[0].protectedAuditCommand == "" {
t.Fatalf("notification fixture did not receive one protected request: %+v", port.notifications)
t.Fatalf("notification fixture did not receive one protected typed request: %+v", port.notifications)
}
}
@@ -239,7 +239,7 @@ func safeCommandField(key string, value any) bool {
}
func unsupportedResult(code string) CommandResult {
return CommandResult{Status: "failed", Summary: "operation unavailable", Payload: map[string]any{"result": code}}
return CommandResult{Status: "failed", Summary: "typed operation unavailable", Payload: map[string]any{"result": code}}
}
func redactTypedPayload(payload map[string]any) map[string]any {
result := map[string]any{}
@@ -1,232 +0,0 @@
package companion
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"regexp"
"strings"
"time"
"unicode/utf16"
)
const (
SCUMLoginLogParserKey = "scum-login-log-parser-v1"
SCUMLoginLogParserVersion = "scum-login-log-v1"
SCUMLoginLogMaxLineBytes = 4096
)
var scumLoginLogPattern = regexp.MustCompile(`^(\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}): '(.+)\s+(\d{17}):(.+)\(([^()]*)\)' logged (in|out) at: X=([+-]?\d+(?:\.\d+)?) Y=([+-]?\d+(?:\.\d+)?) Z=([+-]?\d+(?:\.\d+)?)$`)
// SCUMLoginLogRecord is a single complete or buffered line from a plugin-declared
// UTF-16LE SCUM login log source. It carries transport identity, not host paths.
type SCUMLoginLogRecord struct {
ServerID string
SourceIdentity string
StreamGeneration string
Sequence uint64
RawLine []byte
Partial bool
}
type SCUMLoginLogParseOptions struct {
MaxLineBytes int
AcknowledgedTransportCursors map[SCUMTransportCursor]struct{}
AcknowledgedLogicalEventIdentities map[string]struct{}
}
type SCUMTransportCursor struct {
SourceIdentity string
StreamGeneration string
Sequence uint64
}
type SCUMLoginLogEvent struct {
ServerID string
ParserKey string
ParserVersion string
TransportCursor SCUMTransportCursor
Type string
ExternalPlayerID string
DisplayName string
ProfileLocalID string
OccurredAt time.Time
OccurredAtSourceText string
LogicalEventIdentity string
}
type SCUMLoginLogDiagnostic struct {
ServerID string
TransportCursor SCUMTransportCursor
Code string
}
type SCUMLoginLogBatch struct {
ServerID string
ParserKey string
ParserVersion string
Events []SCUMLoginLogEvent
Diagnostics []SCUMLoginLogDiagnostic
}
type SCUMLoginLogParserAvailability struct {
Available bool
ParserKey string
ParserVersion string
MaxLineBytes int
Reason string
}
func VerifiedSCUMLoginLogParser() SCUMLoginLogParserAvailability {
return SCUMLoginLogParserAvailability{Available: true, ParserKey: SCUMLoginLogParserKey, ParserVersion: SCUMLoginLogParserVersion, MaxLineBytes: SCUMLoginLogMaxLineBytes, Reason: "SCUM UTF-16LE login log parser is available"}
}
func ParseSCUMLoginLogRecords(serverID string, records []SCUMLoginLogRecord, options SCUMLoginLogParseOptions) SCUMLoginLogBatch {
maxLineBytes := options.MaxLineBytes
if maxLineBytes <= 0 || maxLineBytes > SCUMLoginLogMaxLineBytes {
maxLineBytes = SCUMLoginLogMaxLineBytes
}
batch := SCUMLoginLogBatch{ServerID: serverID, ParserKey: SCUMLoginLogParserKey, ParserVersion: SCUMLoginLogParserVersion}
seenTransport := cloneTransportSet(options.AcknowledgedTransportCursors)
seenLogical := cloneStringSet(options.AcknowledgedLogicalEventIdentities)
lastSequenceByGeneration := map[string]uint64{}
if len(records) > 100 {
records = records[:100]
}
for _, record := range records {
cursor := SCUMTransportCursor{SourceIdentity: record.SourceIdentity, StreamGeneration: record.StreamGeneration, Sequence: record.Sequence}
if record.ServerID != serverID || cursor.SourceIdentity == "" || cursor.StreamGeneration == "" || cursor.Sequence == 0 {
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "invalid-transport-cursor")
continue
}
generationKey := cursor.SourceIdentity + "\x00" + cursor.StreamGeneration
if last := lastSequenceByGeneration[generationKey]; last != 0 && cursor.Sequence < last {
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "out-of-order-transport")
}
if cursor.Sequence > lastSequenceByGeneration[generationKey] {
lastSequenceByGeneration[generationKey] = cursor.Sequence
}
if _, ok := seenTransport[cursor]; ok {
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "duplicate-transport-cursor")
continue
}
seenTransport[cursor] = struct{}{}
if record.Partial {
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "partial-line")
continue
}
if len(record.RawLine) == 0 || len(record.RawLine) > maxLineBytes {
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "oversized-line")
continue
}
line, ok := decodeUTF16LELine(record.RawLine)
if !ok {
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "undecodable-line")
continue
}
event, diagnosticCode := parseSCUMLoginLogLine(serverID, cursor, line)
if diagnosticCode != "" {
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, diagnosticCode)
continue
}
if _, ok := seenLogical[event.LogicalEventIdentity]; ok {
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "duplicate-logical-event")
continue
}
seenLogical[event.LogicalEventIdentity] = struct{}{}
batch.Events = append(batch.Events, event)
}
return batch
}
func parseSCUMLoginLogLine(serverID string, cursor SCUMTransportCursor, rawLine string) (SCUMLoginLogEvent, string) {
line := strings.TrimPrefix(strings.TrimRight(rawLine, "\r\n"), "\ufeff")
lower := strings.ToLower(line)
if strings.Contains(lower, "failed") || strings.Contains(lower, "rejected") {
return SCUMLoginLogEvent{}, "failed-login"
}
match := scumLoginLogPattern.FindStringSubmatch(line)
if match == nil {
return SCUMLoginLogEvent{}, "malformed-line"
}
occurredAt, err := time.ParseInLocation("2006.01.02-15.04.05", match[1], time.UTC)
if err != nil {
return SCUMLoginLogEvent{}, "malformed-line"
}
eventType := "scum.login"
if match[6] == "out" {
eventType = "scum.logout"
}
displayName := strings.TrimSpace(match[4])
profileLocalID := strings.TrimSpace(match[5])
if displayName == "" || profileLocalID == "" {
return SCUMLoginLogEvent{}, "malformed-line"
}
event := SCUMLoginLogEvent{
ServerID: serverID,
ParserKey: SCUMLoginLogParserKey,
ParserVersion: SCUMLoginLogParserVersion,
TransportCursor: cursor,
Type: eventType,
ExternalPlayerID: match[3],
DisplayName: displayName,
ProfileLocalID: profileLocalID,
OccurredAt: occurredAt,
OccurredAtSourceText: match[1],
}
event.LogicalEventIdentity = scumLoginLogicalIdentity(serverID, eventType, match[1], event.ExternalPlayerID, displayName, profileLocalID)
return event, ""
}
func scumLoginLogicalIdentity(serverID, eventType, occurrenceText, externalPlayerID, displayName, profileLocalID string) string {
normalized := strings.Join([]string{SCUMLoginLogParserVersion, serverID, eventType, occurrenceText, externalPlayerID, displayName, profileLocalID}, "\x00")
digest := sha256.Sum256([]byte(normalized))
return "sha256:" + hex.EncodeToString(digest[:])
}
func decodeUTF16LELine(raw []byte) (string, bool) {
if len(raw)%2 != 0 {
return "", false
}
codeUnits := make([]uint16, len(raw)/2)
for index := range codeUnits {
codeUnits[index] = binary.LittleEndian.Uint16(raw[index*2:])
}
for index := 0; index < len(codeUnits); index++ {
unit := codeUnits[index]
if 0xD800 <= unit && unit <= 0xDBFF {
if index+1 >= len(codeUnits) || codeUnits[index+1] < 0xDC00 || codeUnits[index+1] > 0xDFFF {
return "", false
}
index++
continue
}
if 0xDC00 <= unit && unit <= 0xDFFF {
return "", false
}
}
return string(utf16.Decode(codeUnits)), true
}
func appendSCUMLoginDiagnostic(existing []SCUMLoginLogDiagnostic, serverID string, cursor SCUMTransportCursor, code string) []SCUMLoginLogDiagnostic {
if len(existing) >= 64 {
return existing
}
return append(existing, SCUMLoginLogDiagnostic{ServerID: serverID, TransportCursor: cursor, Code: code})
}
func cloneTransportSet(source map[SCUMTransportCursor]struct{}) map[SCUMTransportCursor]struct{} {
target := make(map[SCUMTransportCursor]struct{}, len(source))
for key, value := range source {
target[key] = value
}
return target
}
func cloneStringSet(source map[string]struct{}) map[string]struct{} {
target := make(map[string]struct{}, len(source))
for key, value := range source {
target[key] = value
}
return target
}
@@ -1,149 +0,0 @@
package companion
import (
"encoding/binary"
"encoding/json"
"strings"
"testing"
"unicode/utf16"
)
func TestSCUMLoginLogParserParsesAuthenticUTF16LELoginAndLogout(t *testing.T) {
availability := VerifiedSCUMLoginLogParser()
if !availability.Available || availability.ParserKey != SCUMLoginLogParserKey || availability.ParserVersion != SCUMLoginLogParserVersion {
t.Fatalf("login log parser should be versioned and available: %+v", availability)
}
records := []SCUMLoginLogRecord{
scumLoginFixtureRecord(1, "gen-1", "2026.07.05-05.39.10: '203.0.113.10:7777 76561198000000001:Fixture One(profile-1)' logged in at: X=123.45 Y=-456.78 Z=90"),
scumLoginFixtureRecord(2, "gen-1", "2026.07.05-07.21.15: '203.0.113.10:7777 76561198000000001:Fixture One(profile-1)' logged out at: X=124.45 Y=-455.78 Z=91"),
}
batch := ParseSCUMLoginLogRecords("server-1", records, SCUMLoginLogParseOptions{})
if len(batch.Diagnostics) != 0 {
t.Fatalf("unexpected diagnostics: %+v", batch.Diagnostics)
}
if len(batch.Events) != 2 {
t.Fatalf("expected login and logout events, got %+v", batch.Events)
}
login := batch.Events[0]
if login.Type != "scum.login" || login.ExternalPlayerID != "76561198000000001" || login.DisplayName != "Fixture One" || login.ProfileLocalID != "profile-1" || login.OccurredAtSourceText != "2026.07.05-05.39.10" {
t.Fatalf("unexpected login event: %+v", login)
}
logout := batch.Events[1]
if logout.Type != "scum.logout" || logout.LogicalEventIdentity == login.LogicalEventIdentity || logout.TransportCursor.Sequence != 2 {
t.Fatalf("unexpected logout event: %+v", logout)
}
serialized, err := json.Marshal(batch.Events)
if err != nil {
t.Fatal(err)
}
serializedText := string(serialized)
for _, forbidden := range []string{"203.0.113.10", "7777", "123.45", "-456.78", "124.45", "-455.78"} {
if strings.Contains(serializedText, forbidden) {
t.Fatalf("parser leaked network or coordinate material %q in %s", forbidden, serializedText)
}
}
}
func TestSCUMLoginLogParserRejectsFailedPartialUndecodableOversizedAndMalformedLines(t *testing.T) {
records := []SCUMLoginLogRecord{
scumLoginFixtureRecord(1, "gen-1", "2026.07.05-05.39.10: '203.0.113.20 76561198000000002:Rejected(profile-2)' login failed at: X=1 Y=2 Z=3"),
{ServerID: "server-1", SourceIdentity: "source-1", StreamGeneration: "gen-1", Sequence: 2, RawLine: scumUTF16LE("2026.07.05-05.39.10: '203.0.113.20 76561198000000002:Partial(profile-2)' logged in at: X=1 Y=2 Z=3"), Partial: true},
{ServerID: "server-1", SourceIdentity: "source-1", StreamGeneration: "gen-1", Sequence: 3, RawLine: []byte{0x00, 0xD8}},
{ServerID: "server-1", SourceIdentity: "source-1", StreamGeneration: "gen-1", Sequence: 4, RawLine: make([]byte, SCUMLoginLogMaxLineBytes+2)},
scumLoginFixtureRecord(5, "gen-1", "not a real login line"),
}
batch := ParseSCUMLoginLogRecords("server-1", records, SCUMLoginLogParseOptions{})
if len(batch.Events) != 0 {
t.Fatalf("invalid lines must not create events: %+v", batch.Events)
}
assertDiagnosticCodes(t, batch.Diagnostics, []string{"failed-login", "partial-line", "undecodable-line", "oversized-line", "malformed-line"})
}
func TestSCUMLoginLogParserDeduplicatesRotationOverlapAcrossNewGeneration(t *testing.T) {
line := "2026.06.20-06.36.46: '198.51.100.44 76561198000000003:Overlap(profile-3)' logged in at: X=10 Y=20 Z=30"
first := scumLoginFixtureRecord(5, "gen-old", line)
second := scumLoginFixtureRecord(1, "gen-new", line)
batch := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{first, second}, SCUMLoginLogParseOptions{})
if len(batch.Events) != 1 {
t.Fatalf("rotation overlap should emit one logical event, got %+v", batch.Events)
}
assertDiagnosticCodes(t, batch.Diagnostics, []string{"duplicate-logical-event"})
oldGeneration := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{first}, SCUMLoginLogParseOptions{})
newGeneration := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{second}, SCUMLoginLogParseOptions{})
if oldGeneration.Events[0].LogicalEventIdentity != newGeneration.Events[0].LogicalEventIdentity {
t.Fatalf("logical identity must be generation-independent: %s != %s", oldGeneration.Events[0].LogicalEventIdentity, newGeneration.Events[0].LogicalEventIdentity)
}
}
func TestSCUMLoginLogParserLogicalIdentityExcludesNetworkAndCoordinates(t *testing.T) {
first := scumLoginFixtureRecord(1, "gen-1", "2026.06.20-06.36.46: '198.51.100.44 76561198000000003:Private(profile-3)' logged in at: X=10 Y=20 Z=30")
second := scumLoginFixtureRecord(2, "gen-1", "2026.06.20-06.36.46: '203.0.113.44:7777 76561198000000003:Private(profile-3)' logged in at: X=-999 Y=888 Z=777")
firstBatch := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{first}, SCUMLoginLogParseOptions{})
secondBatch := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{second}, SCUMLoginLogParseOptions{})
if len(firstBatch.Events) != 1 || len(secondBatch.Events) != 1 {
t.Fatalf("expected both privacy variants to parse: %+v %+v", firstBatch, secondBatch)
}
if firstBatch.Events[0].LogicalEventIdentity != secondBatch.Events[0].LogicalEventIdentity {
t.Fatalf("logical identity must exclude network and coordinates: %s != %s", firstBatch.Events[0].LogicalEventIdentity, secondBatch.Events[0].LogicalEventIdentity)
}
}
func TestSCUMLoginLogParserHonorsRestartResumeAndDuplicateDelivery(t *testing.T) {
record := scumLoginFixtureRecord(9, "gen-1", "2026.06.20-12.30.26: '198.51.100.60 76561198000000004:Resume(profile-4)' logged in at: X=10 Y=20 Z=30")
first := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{record}, SCUMLoginLogParseOptions{})
if len(first.Events) != 1 {
t.Fatalf("expected first delivery to emit one event: %+v", first)
}
acknowledgedTransport := map[SCUMTransportCursor]struct{}{first.Events[0].TransportCursor: {}}
acknowledgedLogical := map[string]struct{}{first.Events[0].LogicalEventIdentity: {}}
duplicateCursor := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{record}, SCUMLoginLogParseOptions{AcknowledgedTransportCursors: acknowledgedTransport, AcknowledgedLogicalEventIdentities: acknowledgedLogical})
if len(duplicateCursor.Events) != 0 {
t.Fatalf("duplicate acknowledged cursor must not emit events: %+v", duplicateCursor.Events)
}
assertDiagnosticCodes(t, duplicateCursor.Diagnostics, []string{"duplicate-transport-cursor"})
replayedAfterRestart := record
replayedAfterRestart.StreamGeneration = "gen-after-restart"
replayedAfterRestart.Sequence = 1
duplicateLogical := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{replayedAfterRestart}, SCUMLoginLogParseOptions{AcknowledgedLogicalEventIdentities: acknowledgedLogical})
if len(duplicateLogical.Events) != 0 {
t.Fatalf("restart replay must be idempotent by logical identity: %+v", duplicateLogical.Events)
}
assertDiagnosticCodes(t, duplicateLogical.Diagnostics, []string{"duplicate-logical-event"})
}
func TestSCUMLoginLogParserAcceptsOutOfOrderNewEventsWithDiagnostic(t *testing.T) {
records := []SCUMLoginLogRecord{
scumLoginFixtureRecord(8, "gen-1", "2026.06.20-12.32.40: '198.51.100.70 76561198000000005:Late(profile-5)' logged out at: X=10 Y=20 Z=30"),
scumLoginFixtureRecord(7, "gen-1", "2026.06.20-12.30.26: '198.51.100.70 76561198000000005:Late(profile-5)' logged in at: X=10 Y=20 Z=30"),
}
batch := ParseSCUMLoginLogRecords("server-1", records, SCUMLoginLogParseOptions{})
if len(batch.Events) != 2 {
t.Fatalf("out-of-order unique events should still be parsed: %+v", batch.Events)
}
assertDiagnosticCodes(t, batch.Diagnostics, []string{"out-of-order-transport"})
}
func scumLoginFixtureRecord(sequence uint64, generation string, line string) SCUMLoginLogRecord {
return SCUMLoginLogRecord{ServerID: "server-1", SourceIdentity: "source-1", StreamGeneration: generation, Sequence: sequence, RawLine: scumUTF16LE(line)}
}
func scumUTF16LE(value string) []byte {
encoded := utf16.Encode([]rune(value))
bytes := make([]byte, len(encoded)*2)
for index, codeUnit := range encoded {
binary.LittleEndian.PutUint16(bytes[index*2:], codeUnit)
}
return bytes
}
func assertDiagnosticCodes(t *testing.T, diagnostics []SCUMLoginLogDiagnostic, expected []string) {
t.Helper()
if len(diagnostics) != len(expected) {
t.Fatalf("expected diagnostics %v, got %+v", expected, diagnostics)
}
for index, code := range expected {
if diagnostics[index].Code != code {
t.Fatalf("expected diagnostic %d to be %q, got %+v", index, code, diagnostics[index])
}
}
}
@@ -0,0 +1,28 @@
import type { SCUMCommandResult, SCUMConfigPatch, SCUMConfigRead, SCUMFeatureAvailability, SCUMFeatureKey, SCUMGiftGrant, SCUMPlayerProfile, SCUMStatePatch, SCUMStateSnapshot, SCUMTrajectoryCollection, SCUMVehicleSpawn } from "./contracts.js";
import { validateConfigPatch, validateStatePatch, validateVehicleSpawn } from "./schemas.js";
export type PluginFeatureBridge = { dispatch(action: "game-client.command" | "game-client.snapshot.read", payload: Record<string, string>): Promise<{ status: string; result?: Record<string, string>; error?: { message: string } }> };
export type SCUMFeatureAPI = {
availability(feature: SCUMFeatureKey): Promise<SCUMFeatureAvailability>; readConfig(): Promise<SCUMConfigRead | null>; patchConfig(patch: SCUMConfigPatch): Promise<SCUMCommandResult>;
playerProfile(playerId: string): Promise<SCUMPlayerProfile | null>; stateSnapshot(playerId: string): Promise<SCUMStateSnapshot | null>; requestStatePatch(patch: SCUMStatePatch): Promise<SCUMCommandResult>;
requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise<SCUMCommandResult>;
giftGrants(): Promise<SCUMGiftGrant[]>; trajectories(): Promise<SCUMTrajectoryCollection>;
};
export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatures: readonly SCUMFeatureAvailability[]): SCUMFeatureAPI {
const availability = async (feature: SCUMFeatureKey) => availableFeatures.find((item) => item.feature === feature) ?? { feature, available: false, reason: "插件未声明此功能。" };
return {
availability,
async readConfig() { const result = await bridge.dispatch("game-client.command", { type: "config.read" }); return result.status === "ok" ? decode<SCUMConfigRead>(result.result) : null; },
async patchConfig(patch) { const error = validateConfigPatch(patch); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "config.patch", patch: JSON.stringify(patch) })); },
async playerProfile(playerId) { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", subjectId: playerId }); return result.status === "ok" ? decode<SCUMPlayerProfile>(result.result) : null; },
async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode<SCUMStateSnapshot>(result.result) : null; },
async requestStatePatch(patch) { const error = validateStatePatch(patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); },
async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); },
async giftGrants() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "gifts" }); return result.status === "ok" ? decode<SCUMGiftGrant[]>(result.result) ?? [] : []; },
async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "trajectories" }); return result.status === "ok" ? decode<SCUMTrajectoryCollection>(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; }
};
}
function commandResult(result: { status: string; result?: Record<string, string>; error?: { message: string } }): SCUMCommandResult { if (result.status === "queued") return { status: "queued", summary: result.result?.summary ?? "已进入受控队列。" }; if (result.status === "unsupported") return { status: "unsupported", summary: result.error?.message ?? "当前运行时不支持此操作。" }; return { status: "failed", summary: result.error?.message ?? "受控操作未被接受。" }; }
function decode<T>(result: Record<string, string> | undefined): T | null { const payload = result?.payload; if (!payload) return null; try { return JSON.parse(payload) as T; } catch { return null; } }
@@ -1,8 +1,14 @@
export const scumFeatureKeys = ["configuration", "players", "rewards"] as const;
export const scumFeatureKeys = ["configuration", "players", "rewards", "state-patches", "trajectories"] as const;
export type SCUMFeatureKey = (typeof scumFeatureKeys)[number];
export type SCUMFeatureAvailability = { feature: SCUMFeatureKey; available: boolean; reason?: string };
export type SCUMMigrationProvenance = "plugin" | "transitional-read-only";
export type SCUMMigrationRecord<T = Record<string, unknown>> = { provenance: SCUMMigrationProvenance; readOnly: boolean; payload: T; recordedAt: string; sourceRecordId?: string };
export type SCUMFeatureMigrationAuthority = { serverInstanceId: string; feature: SCUMFeatureKey; authority: "plugin" | "transitional-read-only"; reason?: string };
export type SCUMFeatureMigrationStatus = { authority: "plugin" | "transitional-read-only"; readOnlyHistory: true; pluginWritesEnabled: boolean; reason?: string };
export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string; audit?: Record<string, unknown> };
export type SCUMVehicleSpawn = { vehicleCode: string };
export type SCUMVehicleSpawnOption = { code: string; label: string };
export type SCUMLogicalDirectory = { key: string; label: string; scope: "config" | "logs" };
export type SCUMLogicalFile = { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; editable?: boolean };
@@ -15,10 +21,19 @@ export type SCUMConfigPatch = { changes: Array<{ key: string; value: string }>;
export type SCUMPlayer = { id: string; gamePlayerId: string; displayName: string; lastSeenAt?: string; status: "online" | "offline" | "unknown" };
export type SCUMPlayerSession = { id: string; playerId: string; kind: "login" | "logout"; occurredAt: string; networkCorrelation?: string };
export type SCUMPlayerProfile = { player: SCUMPlayer; sessions: SCUMPlayerSession[] };
export type SCUMPlayerRisk = { kind: string; level: "low" | "medium" | "high"; observedAt: string; summary: string };
export type SCUMPlayerProfile = { player: SCUMPlayer; sessions: SCUMPlayerSession[]; risks: SCUMPlayerRisk[] };
export type SCUMGiftItem = { key: string; label: string; quantity: number };
export type SCUMGiftRevision = { id: string; catalogId: string; revision: number; items: SCUMGiftItem[]; publishedAt: string };
export type SCUMGiftGrant = { id: string; revisionId: string; playerId: string; notice: string; status: "pending-approval" | "queued" | "delivered" | "notification_failed" | "failed" | "unknown"; createdAt: string; completedAt?: string };
export type SCUMStateField = { key: string; label: string; value: number; minimum: number; maximum: number; editable: boolean; reason?: string };
export type SCUMStateSnapshot = { playerId: string; stateVersion: string; safetyWindow?: string; fields: SCUMStateField[]; observedAt: string };
export type SCUMStatePatch = { id: string; playerId: string; expectedStateVersion: string; safetyWindow: string; reason: string; changes: Array<{ fieldKey: string; before: number; after: number }>; status: "pending-approval" | "queued" | "succeeded" | "failed" | "unsupported" | "unknown"; createdAt: string };
export type SCUMTrajectoryPoint = { occurredAt: string; subjectId: string; subjectType: "player" | "vehicle"; x: number; y: number; z?: number; source: string };
export type SCUMTrajectory = { subjectId: string; subjectType: "player" | "vehicle"; points: SCUMTrajectoryPoint[]; provenance: SCUMMigrationProvenance };
export type SCUMTrajectoryCollection = { available: boolean; reason?: string; trajectories: SCUMTrajectory[] };
export type SCUMFeatureWorkspace = { defaultDirectoryKey?: string; directories?: SCUMLogicalDirectory[]; files?: SCUMLogicalFile[]; configFields?: SCUMConfigField[]; map?: { mapId: string; mapVersion: string; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number } };
@@ -0,0 +1,75 @@
import type { SCUMConfigRead, SCUMFeatureKey, SCUMFeatureMigrationAuthority, SCUMFeatureMigrationStatus, SCUMGiftGrant, SCUMMigrationRecord, SCUMPlayer, SCUMPlayerProfile, SCUMPlayerRisk, SCUMPlayerSession, SCUMStatePatch, SCUMTrajectory, SCUMTrajectoryPoint } from "./contracts.js";
import { configurationCatalog } from "./schemas.js";
export function transitionalReadOnly<T extends Record<string, unknown>>(payload: T, recordedAt: string, sourceRecordId?: string): SCUMMigrationRecord<T> { return { provenance: "transitional-read-only", readOnly: true, payload, recordedAt, sourceRecordId }; }
export function pluginOwned<T extends Record<string, unknown>>(payload: T, recordedAt: string): SCUMMigrationRecord<T> { return { provenance: "plugin", readOnly: false, payload, recordedAt }; }
// The authority flag is exact-server. Missing, duplicate, or
// transitional flags fail closed: history remains readable, but plugin writes
// are not enabled. Execution still additionally requires Companion feature
// availability; this flag never authorizes a command by itself.
export function migrationStatus(flags: readonly SCUMFeatureMigrationAuthority[], serverInstanceId: string, feature: SCUMFeatureKey): SCUMFeatureMigrationStatus {
const matches = flags.filter((flag) => flag.serverInstanceId === serverInstanceId && flag.feature === feature);
if (matches.length !== 1) return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: matches.length ? "迁移标记冲突,已保持只读。" : "当前服务器尚未启用插件权威记录。" };
const flag = matches[0];
if (flag.authority !== "plugin") return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: flag.reason ?? "过渡记录仅供只读查看。" };
return { authority: "plugin", readOnlyHistory: true, pluginWritesEnabled: true, reason: flag.reason };
}
export function migratePlayerRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMPlayer> | null {
const id = text(record.id); const gamePlayerId = text(record.gamePlayerId); const displayName = text(record.displayName); if (!id || !gamePlayerId || !displayName) return null;
const lastSeenAt = timestamp(record.lastSeenAt); const recordedAt = timestamp(record.updatedAt) ?? lastSeenAt ?? new Date(0).toISOString();
return transitionalReadOnly({ id, gamePlayerId, displayName, ...(lastSeenAt ? { lastSeenAt } : {}), status: record.online === true ? "online" : "unknown" }, recordedAt, id);
}
export function migrateConfigurationRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMConfigRead> | null {
const fields = allowlistedConfigFields(record.fields); const observedAt = timestamp(record.observedAt) ?? timestamp(record.updatedAt); if (!fields || !observedAt) return null;
return transitionalReadOnly({ fields, observedAt }, observedAt, text(record.id));
}
export function migratePlayerProfileRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMPlayerProfile> | null {
const player = migratePlayerRecord(object(record.player) ?? record); if (!player) return null;
const sessions = array(record.sessions).map((item) => migrateSession(item, player.payload.id)).filter((item): item is SCUMPlayerSession => item !== null);
const risks = [...array(record.accessAttempts), ...array(record.securitySignals)].map(migrateRisk).filter((item): item is SCUMPlayerRisk => item !== null);
const recordedAt = timestamp(record.updatedAt) ?? player.recordedAt;
return transitionalReadOnly({ player: player.payload, sessions, risks }, recordedAt, player.sourceRecordId);
}
export function migrateGiftGrantRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMGiftGrant> | null {
const id = text(record.id); const revisionId = text(record.revisionId); const playerId = text(record.gamePlayerRecordId) ?? text(record.playerId); const status = giftStatus(record.status); const createdAt = timestamp(record.createdAt); if (!id || !revisionId || !playerId || !status || !createdAt) return null;
const completedAt = timestamp(record.completedAt); const notice = optionalText(record.notice) ?? "";
return transitionalReadOnly({ id, revisionId, playerId, notice, status, createdAt, ...(completedAt ? { completedAt } : {}) }, timestamp(record.updatedAt) ?? createdAt, id);
}
export function migrateStatePatchRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMStatePatch> | null {
const id = text(record.id); const playerId = text(record.gamePlayerRecordId) ?? text(record.playerId); const expectedStateVersion = text(record.expectedStateVersion); const safetyWindow = text(record.safetyWindow); const reason = optionalText(record.reason) ?? ""; const status = stateStatus(record.status); const createdAt = timestamp(record.createdAt); const changes = array(record.changes).map(migrateStateChange).filter((item): item is { fieldKey: string; before: number; after: number } => item !== null);
if (!id || !playerId || !expectedStateVersion || !safetyWindow || !status || !createdAt || !changes.length) return null;
return transitionalReadOnly({ id, playerId, expectedStateVersion, safetyWindow, reason, changes, status, createdAt }, timestamp(record.updatedAt) ?? createdAt, id);
}
export function migrateTrajectoryRecord(record: Record<string, unknown>): SCUMTrajectory | null {
const subjectId = text(record.playerRecordId) ?? text(record.gamePlayerRecordId) ?? text(record.vehicleId) ?? text(record.entityId); const subjectType = trajectorySubjectType(record); const points = Array.isArray(record.points) && subjectId && subjectType ? record.points.map((point) => migratePoint(point, subjectId, subjectType)).filter((point): point is SCUMTrajectoryPoint => point !== null) : [];
if (!subjectId || !subjectType || !points.length) return null;
return { subjectId, subjectType, points, provenance: "transitional-read-only" };
}
export function migrateTrajectoryHistoryRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMTrajectory> | null {
const trajectory = migrateTrajectoryRecord(record); if (!trajectory) return null;
const recordedAt = timestamp(record.updatedAt) ?? trajectory.points[trajectory.points.length - 1].occurredAt;
return transitionalReadOnly(trajectory, recordedAt, text(record.id));
}
function migratePoint(value: unknown, defaultSubjectId: string, defaultSubjectType: SCUMTrajectoryPoint["subjectType"]): SCUMTrajectoryPoint | null { const record = object(value); if (!record) return null; const x = number(record.mapX) ?? number(record.worldX); const y = number(record.mapY) ?? number(record.worldY); const occurredAt = timestamp(record.recordedAt) ?? timestamp(record.occurredAt); if (x === undefined || y === undefined || !occurredAt) return null; const subjectId = text(record.playerRecordId) ?? text(record.gamePlayerRecordId) ?? text(record.vehicleId) ?? text(record.entityId) ?? defaultSubjectId; const subjectType = trajectorySubjectType(record) ?? defaultSubjectType; return { occurredAt, subjectId, subjectType, x, y, ...(number(record.worldZ) !== undefined ? { z: number(record.worldZ) } : {}), source: "transitional-read-only" }; }
function migrateSession(value: unknown, defaultPlayerId: string): SCUMPlayerSession | null { const record = object(value); const id = record && text(record.id); const playerId = record && (text(record.gamePlayerRecordId) ?? text(record.playerId) ?? defaultPlayerId); const startedAt = record && timestamp(record.startedAt); if (!id || !playerId || !startedAt) return null; const endedAt = timestamp(record.endedAt); return { id, playerId, kind: endedAt ? "logout" : "login", occurredAt: endedAt ?? startedAt }; }
function migrateRisk(value: unknown): SCUMPlayerRisk | null { const record = object(value); const observedAt = record && (timestamp(record.occurredAt) ?? timestamp(record.lastObservedAt)); const kind = record && (text(record.ruleKey) ?? text(record.outcome)); const summary = record && (text(record.summary) ?? text(record.reason)); if (!observedAt || !kind || !summary) return null; return { kind, level: "medium", observedAt, summary }; }
function migrateStateChange(value: unknown): { fieldKey: string; before: number; after: number } | null { const record = object(value); if (!record) return null; const fieldKey = text(record.fieldKey); const before = number(record.before); const after = number(record.after); return fieldKey && before !== undefined && after !== undefined ? { fieldKey, before, after } : null; }
function allowlistedConfigFields(value: unknown): Record<string, string> | null { const fields = object(value); const allowed = new Set(configurationCatalog.map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record<string, string> = {}; for (const [key, field] of Object.entries(fields)) { if (allowed.has(key) && (typeof field === "string" || typeof field === "number" || typeof field === "boolean")) result[key] = String(field); } return Object.keys(result).length ? result : null; }
function giftStatus(value: unknown): SCUMGiftGrant["status"] | null { return value === "pending-approval" || value === "queued" || value === "delivered" || value === "notification_failed" || value === "failed" || value === "unknown" ? value : null; }
function stateStatus(value: unknown): SCUMStatePatch["status"] | null { if (value === "pending-approval" || value === "queued" || value === "unsupported" || value === "unknown" || value === "execution-unknown") return value === "execution-unknown" ? "unknown" : value; if (value === "confirmed") return "succeeded"; return value === "execution-failed" || value === "confirmation-failed" || value === "failed" ? "failed" : null; }
function trajectorySubjectType(record: Record<string, unknown>): SCUMTrajectoryPoint["subjectType"] | null { if (record.kind === "player" || record.kind === "vehicle") return record.kind; return text(record.playerRecordId) || text(record.gamePlayerRecordId) ? "player" : text(record.vehicleId) ? "vehicle" : null; }
function array(value: unknown): unknown[] { return Array.isArray(value) ? value : []; }
function object(value: unknown): Record<string, unknown> | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null; }
function text(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
function optionalText(value: unknown): string | undefined { return text(value); }
function number(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; }
function timestamp(value: unknown): string | undefined { const candidate = text(value); return candidate && !Number.isNaN(Date.parse(candidate)) ? candidate : undefined; }
@@ -1,17 +1,198 @@
type ReactLike = { createElement: (...args: any[]) => any };
type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
export type SCUMPageContext = { pageKey?: string; pageTitle?: string; serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }> };
export type ReactLike = {
createElement: (...args: any[]) => any;
useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void;
useState?: <T>(initialState: T | (() => T)) => [T, StateSetter<T>];
};
export type SCUMPageContext = {
pageKey?: string;
pageTitle?: string;
serverInstanceId?: string;
permissions: string[];
availability: { available: boolean; reason?: string };
featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>;
workspaceActions?: SCUMWorkspaceActions;
};
type SCUMWorkspaceActions = {
listSCUMPlayers?: () => Promise<unknown>;
listSCUMSquads?: () => Promise<unknown>;
listSCUMSquadMembers?: () => Promise<unknown>;
listSCUMVehicles?: () => Promise<unknown>;
listSCUMFlags?: () => Promise<unknown>;
listSCUMPositions?: () => Promise<unknown>;
listSCUMOperations?: () => Promise<unknown>;
createSCUMOperation?: (request: unknown) => Promise<unknown>;
listSCUMWorkflows?: () => Promise<unknown>;
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
};
type RecordMap = Record<string, unknown>;
type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData };
type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: string };
type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[]; operations: RecordMap[]; workflows: RecordMap[]; steps: RecordMap[] };
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [], operations: [], workflows: [], steps: [] };
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
const pageKey = normalizePageKey(input.pageKey);
const title = input.pageTitle ?? surfaceTitle(pageKey);
const reason = input.availability.reason ?? "等待当前服务器完成兼容性验证。";
return react.createElement("section", { className: "console-panel", "aria-label": title },
react.createElement("div", { className: "panel-header" }, react.createElement("div", null, react.createElement("h2", null, title), react.createElement("p", { className: "provider-id" }, surfaceSummary(pageKey))), react.createElement("span", { className: "page-status" }, input.availability.available ? "本地数据接口准备中" : reason)),
react.createElement("p", { className: "page-status" }, "当前页面不会读取遗留快照或派发远程查询;兼容的本地数据接口启用后将显示已验证记录。")
const e = react.createElement;
const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
const pageKey = input.pageKey ?? "players";
const refresh = () => {
const actions = input.workspaceActions;
if (!input.serverInstanceId || !actions) {
setState({ status: "error", reason: "插件页面没有绑定服务器,无法读取 SCUM 投影。" });
return;
}
setState({ status: "loading" });
void Promise.all([
safeList(actions.listSCUMPlayers), safeList(actions.listSCUMSquads), safeList(actions.listSCUMSquadMembers), safeList(actions.listSCUMVehicles),
safeList(actions.listSCUMFlags), safeList(actions.listSCUMPositions), safeList(actions.listSCUMOperations), safeList(actions.listSCUMWorkflows), safeList(actions.listSCUMWorkflowSteps)
]).then(([players, squads, members, vehicles, flags, positions, operations, workflows, steps]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions, operations, workflows, steps } }))
.catch((error) => setState({ status: "error", reason: error instanceof Error ? error.message : "SCUM 投影读取失败。" }));
};
if (react.useEffect) react.useEffect(() => { refresh(); return undefined; }, [input.serverInstanceId, pageKey, input.workspaceActions]);
const data = state.status === "ready" ? state.data : emptyData;
return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
e("div", { className: "panel-header" },
e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))),
e("div", { className: "console-row-actions" },
e("span", { className: "page-status" }, input.availability.available ? "投影/Companion 可用" : input.availability.reason ?? "等待 Run/Companion"),
e("button", { type: "button", className: "icon-command", onClick: refresh }, "刷新投影"),
workflowButton(e, input, setAction, refresh, pageWorkflow(pageKey))
)
),
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台本地 SCUM 投影…") : null,
state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, setAction, refresh) : null
);
}
function normalizePageKey(pageKey?: string): string { return pageKey === "squads" || pageKey === "live-map" || pageKey === "gifts" || pageKey === "players" ? pageKey : "players"; }
function surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : "用户管理"; }
function surfaceSummary(pageKey: string): string { return pageKey === "live-map" ? "地图仅显示经当前服务器验证的坐标。" : pageKey === "gifts" ? "礼包、资格和发放历史仅来自本地业务记录。" : pageKey === "squads" ? "队伍、成员和旗帜仅显示经当前服务器验证的事实。" : "玩家记录将由已验证登录事件和同步结果建立。"; }
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
switch (pageKey) {
case "players": return playersSurface(e, data, input, setAction, refresh);
case "squads": return squadsSurface(e, data);
case "live-map": return mapSurface(e, data);
case "gifts": return giftsSurface(e, data, input, setAction, refresh);
case "workflows": return workflowsSurface(e, data);
default: return playersSurface(e, data, input, setAction, refresh);
}
}
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
return e("div", { className: "console-record-list" },
statsStrip(e, [["玩家投影", data.players.length], ["在线", data.players.filter((p) => boolField(p, "Online", "online")).length], ["坐标", data.positions.length], ["待审操作", data.operations.filter((op) => field(op, "Status", "status") === "waiting").length]]),
data.players.length ? data.players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || textField(player, "GamePlayerID", "gamePlayerId") || "未知玩家"), e("span", { className: `status-pill ${boolField(player, "Online", "online") ? "status-active" : "status-disabled"}` }, boolField(player, "Online", "online") ? "在线" : "离线/未知")),
e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "SteamID", "steamId") || "unknown"}`), e("span", null, `Profile ${textField(player, "UserProfileID", "userProfileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId") || "unknown"}`), e("span", null, freshness(player))),
e("span", { className: "provider-id" }, `Fame ${numField(player, "FamePoints", "famePoints")} · Cash ${numField(player, "NormalBalance", "normalBalance")} · Gold ${numField(player, "GoldBalance", "goldBalance")} · ${coords(field(player, "Position", "position") as RecordMap | undefined)}`),
e("div", { className: "console-row-actions" },
operationButton(e, input, setAction, refresh, player, "player.fame.set", "fame", "Fame +100", 100),
operationButton(e, input, setAction, refresh, player, "player.currency.normal.set", "amount", "现金 +1000", 1000),
operationButton(e, input, setAction, refresh, player, "player.attribute.855.set", "after", "855 审批", Number(numField(player, "855", "855")) || 1, true)
)
)) : e("p", { className: "page-status" }, "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。")
);
}
function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
return e("div", { className: "overview-two-col" },
tablePanel(e, "队伍", data.squads, (squad) => [textField(squad, "Name", "name") || textField(squad, "SquadID", "squadId"), `成员 ${numField(squad, "MemberCount", "memberCount")}`, `队长 ${textField(squad, "LeaderProfileID", "leaderProfileId") || "unknown"}`, freshness(squad)]),
tablePanel(e, "成员 / 旗帜", [...data.members.slice(0, 40), ...data.flags.slice(0, 40)], (item) => [textField(item, "DisplayName", "displayName") || textField(item, "FlagID", "flagId") || "unknown", textField(item, "Rank", "rank") || textField(item, "OwnershipConfidence", "ownershipConfidence") || "unknown", textField(item, "SquadID", "squadId") || textField(item, "OwnerSquadID", "ownerSquadId") || "unknown", freshness(item)])
);
}
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
const overlays = [...data.positions, ...data.vehicles.map((v) => field(v, "Position", "position") as RecordMap).filter(Boolean), ...data.flags.map((f) => field(f, "Position", "position") as RecordMap).filter(Boolean)];
return e("div", { className: "console-record-list" },
statsStrip(e, [["玩家", data.players.length], ["载具", data.vehicles.length], ["旗帜", data.flags.length], ["坐标点", overlays.length]]),
e("div", { className: "map-projection-board" }, overlays.slice(0, 120).map((point, index) => e("span", { key: `${idOf(point)}:${index}`, className: "map-projection-dot", title: `${textField(point, "SubjectType", "subjectType") || "point"} ${coords(point)}`, style: dotStyle(point) }, ""))),
tablePanel(e, "地图覆盖物", overlays, (point) => [textField(point, "SubjectType", "subjectType") || "unknown", textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), freshness(point)])
);
}
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
return e("div", { className: "console-record-list" },
statsStrip(e, [["可选玩家", data.players.length], ["发放操作", data.operations.filter((op) => textField(op, "TemplateKey", "templateKey") === "reward.deliver").length], ["未知态", data.operations.filter((op) => field(op, "Status", "status") === "unknown").length]]),
e("p", { className: "page-status" }, "礼包只创建 typed delivery workflow;确认结果未知时不会重复发放。"),
data.players.slice(0, 40).map((player) => e("article", { key: idOf(player), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || idOf(player)), e("span", { className: "status-pill status-disabled" }, freshness(player))),
e("div", { className: "console-row-actions" }, operationButton(e, input, setAction, refresh, player, "reward.deliver", "rewardKey", "创建礼包发放", "starter-pack"), operationButton(e, input, setAction, refresh, player, "player.notify", "message", "发送通知", "你的礼包正在审核发放。"))
))
);
}
function workflowsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
return e("div", { className: "console-record-list" },
data.workflows.length ? data.workflows.map((wf) => e("article", { key: idOf(wf), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(wf, "TemplateKey", "templateKey") || idOf(wf)), e("span", { className: "status-pill status-active" }, textField(wf, "Status", "status") || "queued")),
e("div", { className: "console-record-meta" }, e("span", null, `当前步骤 ${textField(wf, "CurrentStepKey", "currentStepKey") || "等待调度"}`), e("span", null, `创建 ${dateField(wf, "CreatedAt", "createdAt")}`)),
e("span", { className: "provider-id" }, summaryText(wf))
)) : e("p", { className: "page-status" }, "暂无 workflow。可以从各页面发起 refresh/audit/correction/gift workflow。"),
tablePanel(e, "步骤", data.steps, (step) => [textField(step, "StepKey", "stepKey"), textField(step, "Status", "status"), textField(step, "Capability", "capability") || textField(step, "QueryTemplateKey", "queryTemplateKey") || textField(step, "OperationKey", "operationKey"), summaryText(step)])
);
}
function workflowButton(e: ReactLike["createElement"], input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, templateKey: string) {
if (!templateKey) return null;
return e("button", { type: "button", className: "primary-command", disabled: !input.workspaceActions?.createSCUMWorkflow, onClick: () => createWorkflow(input, setAction, refresh, templateKey) }, workflowLabel(templateKey));
}
function operationButton(e: ReactLike["createElement"], input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, player: RecordMap, templateKey: string, valueKey: string, label: string, value: unknown, guarded = false) {
return e("button", { type: "button", className: "icon-command", disabled: !input.workspaceActions?.createSCUMOperation, onClick: () => createOperation(input, setAction, refresh, player, templateKey, valueKey, value, guarded) }, label);
}
function createWorkflow(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, templateKey: string) {
setAction({ status: "pending", message: `正在创建 ${templateKey} workflow…` });
void input.workspaceActions?.createSCUMWorkflow?.({ templateKey, idempotencyKey: `plugin:${templateKey}:${input.serverInstanceId}:${Date.now()}` }).then((result) => {
setAction({ status: "ok", message: `Workflow 已创建:${textField(result as RecordMap, "id") || templateKey}` }); refresh();
}).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "Workflow 创建失败。" }));
}
function createOperation(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, player: RecordMap, templateKey: string, valueKey: string, value: unknown, guarded: boolean) {
const playerId = textField(player, "GamePlayerID", "gamePlayerId") || textField(player, "SteamID", "steamId");
const before = guarded ? field(field(player, "UnknownFields", "unknownFields") as RecordMap | undefined, "855") ?? 0 : undefined;
const payload: RecordMap = guarded ? { fieldKey: "855", before, after: value, safetyWindow: `plugin-maintenance-${Date.now()}`, backupRef: `backup-required:${Date.now()}` } : { [valueKey]: value };
setAction({ status: "pending", message: `正在创建 ${templateKey} typed operation…` });
void input.workspaceActions?.createSCUMOperation?.({ templateKey, playerId, payload, reason: "SCUM plugin projection surface request", idempotencyKey: `plugin:${templateKey}:${playerId}:${Date.now()}` }).then((result) => {
setAction({ status: "ok", message: `操作已进入审批/确认队列:${textField(result as RecordMap, "id") || templateKey}` }); refresh();
}).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "操作创建失败。" }));
}
function pageWorkflow(pageKey: string): string {
switch (pageKey) {
case "players": return "scum.player-refresh";
case "squads": return "scum.territory-audit";
case "live-map": return "scum.world-refresh";
case "gifts": return "scum.gift-delivery";
case "workflows": return "scum.product-cleanup";
default: return "scum.bootstrap-real-data";
}
}
function surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍/旗帜管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : pageKey === "workflows" ? "Workflow 状态" : "用户管理"; }
function surfaceSummary(pageKey: string): string { return pageKey === "live-map" ? "玩家、载具、旗帜坐标来自平台本地投影;缺失时显示 stale/unknown。" : pageKey === "gifts" ? "礼包发放、通知和确认都通过 typed workflow,不直接改投影。" : pageKey === "squads" ? "队伍、成员、旗帜所有权来自 SCUM.db typed observations。" : "玩家列表由登录日志和 SCUM.db typed observations 创建,不显示样例数据。"; }
function workflowLabel(templateKey: string): string { return templateKey.includes("audit") ? "发起审计" : templateKey.includes("gift") ? "创建发放 workflow" : templateKey.includes("world") ? "刷新世界投影" : templateKey.includes("cleanup") ? "清理旧入口" : "刷新真实数据"; }
function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("div", { className: "console-stat-strip" }, items.map(([label, value]) => e("span", { key: label, className: "server-card-stat" }, e("span", null, label), e("strong", null, String(value))))); }
function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length}`)), e("div", { className: "console-row-list" }, rows.length ? rows.slice(0, 100).map((row) => e("div", { key: idOf(row), className: "console-row" }, render(row).map((part, i) => i === 0 ? e("span", { key: i }, String(part ?? "unknown")) : e("strong", { key: i }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实投影数据。"))); }
function dotStyle(point: RecordMap): Record<string, string> { const x = Number(field(point, "X", "x") ?? 0); const y = Number(field(point, "Y", "y") ?? 0); return { left: `${Math.max(2, Math.min(98, 50 + x / 10000))}%`, top: `${Math.max(2, Math.min(98, 50 - y / 10000))}%` }; }
function safeList(fn?: () => Promise<unknown>): Promise<RecordMap[]> { return fn ? fn().then((value) => Array.isArray((value as RecordMap)?.items) ? (value as { items: RecordMap[] }).items : []) : Promise.resolve([]); }
function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(initial) : [initial, () => undefined]; }
function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) if (row[key] !== undefined) return row[key]; return undefined; }
function textField(row: RecordMap | unknown, ...keys: string[]): string { const value = field(row as RecordMap, ...keys); return value === undefined || value === null ? "" : String(value); }
function boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === "true"; }
function numField(row: RecordMap, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null || value === "" ? "--" : String(value); }
function idOf(row: RecordMap): string { return textField(row, "ID", "id", "GamePlayerID", "gamePlayerId", "SquadID", "squadId", "VehicleID", "vehicleId", "FlagID", "flagId", "StepKey", "stepKey") || Math.random().toString(36).slice(2); }
function freshness(row: RecordMap): string { const fresh = field(row, "Freshness", "freshness") as RecordMap | undefined; return textField(fresh, "Status", "status") || "unknown"; }
function coords(row?: RecordMap): string { if (!row) return "坐标 unknown"; const ok = field(row, "HasCoordinates", "hasCoordinates"); return ok === false ? "坐标 unknown" : `X ${numField(row, "X", "x")} / Y ${numField(row, "Y", "y")} / Z ${numField(row, "Z", "z")}`; }
function summaryText(row: RecordMap): string { const summary = field(row, "SafeSummary", "safeSummary") as RecordMap | undefined; return textField(summary, "Message", "message") || textField(row, "BlockerReason", "blockerReason") || "safe summary pending"; }
function dateField(row: RecordMap, ...keys: string[]): string { const value = textField(row, ...keys); return value ? new Date(value).toLocaleString() : "unknown"; }
@@ -1,4 +1,4 @@
import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability } from "./contracts.js";
import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMStateField, SCUMVehicleSpawn, SCUMVehicleSpawnOption } from "./contracts.js";
// These are safe fallback allowlists. A Companion schema probe may narrow them
// per server, but a game version never enables or disables a feature.
@@ -9,5 +9,10 @@ export const configurationCatalog: readonly SCUMConfigField[] = [
{ key: "max-players", fileKey: "scum-server-settings", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" },
{ key: "welcome-message", fileKey: "scum-server-settings", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" }
];
export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }];
export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }];
export function supportsStateField(field: string): boolean { return stateFieldCatalog.some((candidate) => candidate.key === field); }
export function featureUnavailable(reason: string): SCUMFeatureAvailability { return { feature: "configuration", available: false, reason }; }
export function validateConfigPatch(patch: SCUMConfigPatch): string | null { if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; for (const change of patch.changes) { const field = configurationCatalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 不在受控目录中。`; if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; if ((field.control === "number" || field.control === "port") && (!Number.isInteger(Number(change.value)) || (field.minimum !== undefined && Number(change.value) < field.minimum) || (field.maximum !== undefined && Number(change.value) > field.maximum))) return `字段 ${field.label} 超出允许范围。`; } return null; }
export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { const definition = stateFieldCatalog.find((candidate) => candidate.key === field.fieldKey); if (!definition) return `字段 ${field.fieldKey} 不在运行时字段白名单中。`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after) || field.after < definition.minimum || field.after > definition.maximum) return `字段 ${definition.label} 超出允许范围。`; } return null; }
export function validateVehicleSpawn(spawn: SCUMVehicleSpawn): string | null { if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; if (!vehicleSpawnCatalog.some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在受控目录中声明。"; return null; }
+353 -308
View File
@@ -70,7 +70,6 @@
"remote.rsync.write",
"remote.run.files.read",
"remote.run.files.write",
"remote.run.db.sqlite.probe",
"remote.run.db.sqlite.query",
"remote.run.process.start",
"remote.run.process.stop",
@@ -96,7 +95,6 @@
"runCapabilities": [
"remote.run.files.read",
"remote.run.files.write",
"remote.run.db.sqlite.probe",
"remote.run.db.sqlite.query",
"remote.run.process.start",
"remote.run.process.stop",
@@ -284,11 +282,219 @@
"maxRecords": 1000
}
],
"queryTemplates": [],
"operationTemplates": [],
"queryTemplates": [
{
"key": "scum.player.profile",
"title": "Read SCUM player profile, economy, squad, and position facts",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-player-profile.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json",
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.squads",
"title": "Read SCUM squad records",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json",
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.squad-members",
"title": "Read SCUM squad membership records",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json",
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.vehicles",
"title": "Read SCUM vehicle records and coordinates",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json",
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.flags",
"title": "Read SCUM flag and ownership records",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json",
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.positions",
"title": "Read SCUM current player, vehicle, and flag coordinates",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json",
"maxRows": 500,
"timeoutSeconds": 15
}
],
"operationTemplates": [
{
"key": "player.fame.set",
"title": "Set SCUM player fame through RCON",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/player-fame-set.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-fame-set.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 2048,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
},
{
"key": "player.currency.normal.set",
"title": "Set SCUM normal currency through RCON",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/player-currency-set.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-currency-set.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 2048,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
},
{
"key": "player.currency.gold.set",
"title": "Set SCUM gold currency through RCON",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/player-currency-set.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-currency-set.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 2048,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
},
{
"key": "player.notify",
"title": "Notify SCUM player through RCON chat",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/player-notify.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-notify.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-notify.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 2048,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
},
{
"key": "reward.deliver",
"title": "Deliver approved SCUM reward through typed command workflow",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/reward-deliver.payload.schema.json",
"resultSchemaRef": "schemas/bridge/reward-deliver.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/reward-deliver.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 4096,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
},
{
"key": "player.attribute.855.set",
"title": "Set SCUM DB-only player attribute 855",
"permission": "server.game-client.maintenance",
"approvalLevel": "platform-admin",
"kind": "sqlite-mutation",
"transportKey": "scum-database",
"targetKey": "scum-database",
"payloadSchemaRef": "schemas/bridge/player-attribute-855-set.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-attribute-855-set.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-attribute-855-set.confirmation.schema.json",
"timeoutSeconds": 120,
"maxPayloadBytes": 4096,
"maxRowsAffected": 1,
"mutation": {
"fieldKey": "855",
"tableKey": "prisoner",
"identityKey": "user_profile_id",
"valueKey": "value",
"confirmationQueryKey": "scum.player.profile",
"allowedValueType": "integer",
"minValue": 0,
"maxValue": 100000
},
"safety": {
"requiresApproval": true,
"requiresOfflinePlayer": true,
"requiresMaintenanceWindow": true,
"requiresBeforeValue": true,
"requiresConfirmation": true,
"backupRequired": true
}
}
],
"commandRetentionSeconds": 604800,
"maxCommands": 1000,
"features": [
{
"key": "player.intelligence",
"title": "SCUM player intelligence",
"permission": "server.game-client.read",
"requiredHandlers": [
"player.lookup"
],
"requiredEventProducers": [
"semantic.events"
]
},
{
"key": "reward.delivery",
"title": "SCUM reward delivery",
@@ -313,6 +519,14 @@
"requiredHandlers": [
"vehicle.spawn"
]
},
{
"key": "trajectory.collect",
"title": "SCUM trajectories",
"permission": "server.game-client.read",
"requiredEventProducers": [
"semantic.events"
]
}
],
"pages": [
@@ -322,7 +536,21 @@
"players",
"online.sessions"
],
"featureKeys": []
"queryTemplateKeys": [
"scum.player.profile",
"scum.positions"
],
"operationKeys": [
"player.fame.set",
"player.currency.normal.set",
"player.currency.gold.set",
"player.notify",
"player.attribute.855.set"
],
"featureKeys": [
"player.intelligence",
"state.patch"
]
},
{
"pageKey": "squads",
@@ -330,7 +558,14 @@
"squads",
"flags"
],
"featureKeys": []
"queryTemplateKeys": [
"scum.squads",
"scum.squad-members",
"scum.flags"
],
"featureKeys": [
"player.intelligence"
]
},
{
"pageKey": "live-map",
@@ -339,14 +574,54 @@
"vehicles",
"flags"
],
"featureKeys": []
"queryTemplateKeys": [
"scum.player.profile",
"scum.vehicles",
"scum.flags",
"scum.positions"
],
"featureKeys": [
"trajectory.collect"
]
},
{
"pageKey": "gifts",
"snapshotTypes": [
"players"
],
"featureKeys": []
"operationKeys": [
"reward.deliver",
"player.notify"
],
"featureKeys": [
"reward.delivery"
]
},
{
"pageKey": "workflows",
"queryTemplateKeys": [
"scum.player.profile",
"scum.squads",
"scum.squad-members",
"scum.vehicles",
"scum.flags",
"scum.positions"
],
"operationKeys": [
"player.fame.set",
"player.currency.normal.set",
"player.currency.gold.set",
"player.notify",
"reward.deliver",
"player.attribute.855.set"
],
"featureKeys": [
"player.intelligence",
"reward.delivery",
"state.patch",
"vehicle.spawn",
"trajectory.collect"
]
}
],
"companion": {
@@ -365,223 +640,6 @@
"requestTimeoutSeconds": 15
}
},
"scumLiveData": {
"schemaVersion": "1",
"probe": {
"capability": "remote.run.db.sqlite.probe",
"targetKey": "scum-database",
"bounds": {
"maxObjects": 256,
"maxColumnsPerObject": 128,
"maxIndexesPerObject": 64,
"maxForeignKeys": 64,
"maxCardinalityReads": 64,
"maxSampleRows": 3,
"timeoutMs": 5000,
"maxResultBytes": 524288
}
},
"capabilityGates": [
{ "capability": "schema-probe", "gate": "enabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "evidenceStatus": "compatible", "safeReason": "当前绑定 durable schema probe 已返回匹配的 redacted schema fingerprint。" },
{ "capability": "players.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:b3359f3c5939a410a98ddfccb920791044df76da4f8e5df4e726a9fe58423520", "sha256:d9c262d0b4f1b600b608c8279a6dfb052de1a605fe1286ddc15f74892eace4fc"], "evidenceStatus": "missing", "safeReason": "schema 和 asset 已匹配;等待 SQLite-template terminal envelope 验证后启用。" },
{ "capability": "player-details.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:c980807b736f8988cd7c37ecb97c72e5e707050450ebc8c539dccec06c60e1f8", "sha256:919c5f68f74fdb0743fa5d9b59aeecd003dcdac5bc51e52d3a486ed1045c9a48"], "evidenceStatus": "missing", "safeReason": "详情和经济只读 asset 已匹配;等待 terminal envelope 验证后启用。" },
{ "capability": "squads.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:e7746ac4f4ea86038c1a035087154d4dc3a622fdb14b3ad94b45fec819486f50"], "evidenceStatus": "missing", "safeReason": "队伍只读 asset 已匹配;rank 和 leader 语义保持 nullable,等待 envelope 验证。" },
{ "capability": "squad-members.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:e49b634d8a63b0d1f979706ff4a99050c11b5661d494f72897e89da6554f0654"], "evidenceStatus": "missing", "safeReason": "成员 join asset 已匹配;等待 SQLite-template terminal envelope 验证后启用。" },
{ "capability": "vehicles.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:e2362451c3a4392adeca38c5e81055674f78e982cee710a33717b3d06bb78dc3"], "evidenceStatus": "missing", "safeReason": "载具 identity 和坐标 asset 已匹配;ownership 保持 nullable,等待 envelope 验证。" },
{ "capability": "flags.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:42ddc6e8a237b7d576834845d1b1d5b2a2fc087668579eac0e3dc08ce2941000"], "evidenceStatus": "missing", "safeReason": "旗子和 base join asset 已匹配;territory 语义保持 gated,等待 envelope 验证。" },
{ "capability": "positions.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:88629de001e6882ce9e72bf5f0b17770d408c665869d04cd768c2e3fdfb9f790", "sha256:74800836553c7e4372a0747c5e9511adcee940b057194488bbf65bf164df372b", "sha256:f1941109167a86818e9e71996821884aeb8bd7e863584454b891e525695ba478"], "evidenceStatus": "missing", "safeReason": "坐标 query 和 map transform 已匹配;等待 envelope 验证且不声明 sub-10s cadence。" },
{ "capability": "profile-xml.write", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "evidenceStatus": "missing", "safeReason": "等待 XML patch safety、backup、offline、readback 和 855 映射验证。" },
{ "capability": "economy-command.write", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "evidenceStatus": "missing", "safeReason": "等待 Fame 和 currency command 的确认读回语义验证。" },
{ "capability": "gift-command.write", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "evidenceStatus": "missing", "safeReason": "等待 gift catalog、transport 和 conclusive receipt 验证。" }
],
"logParsers": [
{
"key": "scum-login-log-login-parser",
"adapterVersion": "scum-live-data-v1",
"assetPath": "assets/scum-live/login-log-parser.json",
"digest": "sha256:264835fb36255071fed46dd50724ec511986db901ac8056b08ddafb10f5f0056",
"parserVersion": "scum-login-log-v1",
"sourceKey": "scum-login-events",
"eventType": "scum.login",
"eventSchemaRef": "schemas/log-events/login.event.schema.json",
"maxLineBytes": 4096,
"cursorPolicy": "source-generation-sequence",
"privacy": { "stripNetworkIdentifiers": true, "logicalEventIdentity": "native-or-sanitized-fields" }
},
{
"key": "scum-login-log-logout-parser",
"adapterVersion": "scum-live-data-v1",
"assetPath": "assets/scum-live/login-log-parser.json",
"digest": "sha256:264835fb36255071fed46dd50724ec511986db901ac8056b08ddafb10f5f0056",
"parserVersion": "scum-login-log-v1",
"sourceKey": "scum-login-events",
"eventType": "scum.logout",
"eventSchemaRef": "schemas/log-events/logout.event.schema.json",
"maxLineBytes": 4096,
"cursorPolicy": "source-generation-sequence",
"privacy": { "stripNetworkIdentifiers": true, "logicalEventIdentity": "native-or-sanitized-fields" }
}
],
"sqliteQueries": [
{
"key": "scum-players-read",
"adapterVersion": "scum-live-data-v1",
"assetPath": "assets/scum-live/queries/players-read.json",
"digest": "sha256:b3359f3c5939a410a98ddfccb920791044df76da4f8e5df4e726a9fe58423520",
"capability": "players.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/scum-live/players-read.parameters.schema.json",
"resultSchemaRef": "schemas/scum-live/players-read.result.schema.json",
"maxRows": 100,
"timeoutMs": 2000,
"maxResultBytes": 65536
},
{
"key": "scum-player-details-read",
"adapterVersion": "scum-live-data-v1",
"assetPath": "assets/scum-live/queries/player-details-read.json",
"digest": "sha256:c980807b736f8988cd7c37ecb97c72e5e707050450ebc8c539dccec06c60e1f8",
"capability": "player-details.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/scum-live/player-details-read.parameters.schema.json",
"resultSchemaRef": "schemas/scum-live/player-details-read.result.schema.json",
"maxRows": 1,
"timeoutMs": 2000,
"maxResultBytes": 32768
},
{
"key": "scum-player-economy-read",
"adapterVersion": "scum-live-data-v1",
"assetPath": "assets/scum-live/queries/player-economy-read.json",
"digest": "sha256:919c5f68f74fdb0743fa5d9b59aeecd003dcdac5bc51e52d3a486ed1045c9a48",
"capability": "player-details.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/scum-live/player-economy-read.parameters.schema.json",
"resultSchemaRef": "schemas/scum-live/player-economy-read.result.schema.json",
"maxRows": 16,
"timeoutMs": 2000,
"maxResultBytes": 32768
},
{
"key": "scum-player-session-enrichment-read",
"adapterVersion": "scum-live-data-v1",
"assetPath": "assets/scum-live/queries/player-session-enrichment-read.json",
"digest": "sha256:d9c262d0b4f1b600b608c8279a6dfb052de1a605fe1286ddc15f74892eace4fc",
"capability": "players.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/scum-live/player-session-enrichment-read.parameters.schema.json",
"resultSchemaRef": "schemas/scum-live/player-session-enrichment-read.result.schema.json",
"maxRows": 1,
"timeoutMs": 2000,
"maxResultBytes": 32768
},
{
"key": "scum-squads-read",
"adapterVersion": "scum-live-data-v1",
"assetPath": "assets/scum-live/queries/squads-read.json",
"digest": "sha256:e7746ac4f4ea86038c1a035087154d4dc3a622fdb14b3ad94b45fec819486f50",
"capability": "squads.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/scum-live/squads-read.parameters.schema.json",
"resultSchemaRef": "schemas/scum-live/squads-read.result.schema.json",
"maxRows": 100,
"timeoutMs": 2000,
"maxResultBytes": 65536
},
{
"key": "scum-squad-members-read",
"adapterVersion": "scum-live-data-v1",
"assetPath": "assets/scum-live/queries/squad-members-read.json",
"digest": "sha256:e49b634d8a63b0d1f979706ff4a99050c11b5661d494f72897e89da6554f0654",
"capability": "squad-members.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/scum-live/squad-members-read.parameters.schema.json",
"resultSchemaRef": "schemas/scum-live/squad-members-read.result.schema.json",
"maxRows": 500,
"timeoutMs": 2000,
"maxResultBytes": 131072
},
{
"key": "scum-vehicles-read",
"adapterVersion": "scum-live-data-v1",
"assetPath": "assets/scum-live/queries/vehicles-read.json",
"digest": "sha256:e2362451c3a4392adeca38c5e81055674f78e982cee710a33717b3d06bb78dc3",
"capability": "vehicles.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/scum-live/vehicles-read.parameters.schema.json",
"resultSchemaRef": "schemas/scum-live/vehicles-read.result.schema.json",
"maxRows": 500,
"timeoutMs": 2000,
"maxResultBytes": 262144
},
{
"key": "scum-flags-read",
"adapterVersion": "scum-live-data-v1",
"assetPath": "assets/scum-live/queries/flags-read.json",
"digest": "sha256:42ddc6e8a237b7d576834845d1b1d5b2a2fc087668579eac0e3dc08ce2941000",
"capability": "flags.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/scum-live/flags-read.parameters.schema.json",
"resultSchemaRef": "schemas/scum-live/flags-read.result.schema.json",
"maxRows": 200,
"timeoutMs": 2000,
"maxResultBytes": 65536
},
{
"key": "scum-positions-read",
"adapterVersion": "scum-live-data-v1",
"assetPath": "assets/scum-live/queries/positions-read.json",
"digest": "sha256:88629de001e6882ce9e72bf5f0b17770d408c665869d04cd768c2e3fdfb9f790",
"capability": "positions.read",
"requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/scum-live/positions-read.parameters.schema.json",
"resultSchemaRef": "schemas/scum-live/positions-read.result.schema.json",
"maxRows": 500,
"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": [
"server.create",
"server.read",
@@ -635,54 +693,6 @@
{
"path": "bin/scum-start.cmd",
"mode": 448
},
{
"path": "assets/scum-live/login-log-parser.json",
"mode": 384
},
{
"path": "assets/scum-live/queries/players-read.json",
"mode": 384
},
{
"path": "assets/scum-live/queries/player-details-read.json",
"mode": 384
},
{
"path": "assets/scum-live/queries/player-economy-read.json",
"mode": 384
},
{
"path": "assets/scum-live/queries/player-session-enrichment-read.json",
"mode": 384
},
{
"path": "assets/scum-live/queries/squads-read.json",
"mode": 384
},
{
"path": "assets/scum-live/queries/squad-members-read.json",
"mode": 384
},
{
"path": "assets/scum-live/queries/vehicles-read.json",
"mode": 384
},
{
"path": "assets/scum-live/queries/flags-read.json",
"mode": 384
},
{
"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": {
@@ -708,8 +718,8 @@
"title": "用户管理",
"path": "/players",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.4",
"bundleIntegritySha256": "sha256:8b57e66b529dd35cd277ee2e5b839d8844c557b690f1d57db3ec026c26b713dc",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.game-client.read",
@@ -717,58 +727,95 @@
"server.game-client.maintenance"
],
"bridgeActions": [
"server.instances.read"
"server.instances.read",
"remote.access.request"
],
"featureKeys": []
"featureKeys": [
"player.intelligence",
"state.patch"
]
},
{
"key": "squads",
"title": "队伍管理",
"path": "/squads",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.4",
"bundleIntegritySha256": "sha256:8b57e66b529dd35cd277ee2e5b839d8844c557b690f1d57db3ec026c26b713dc",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.game-client.read"
],
"bridgeActions": [
"server.instances.read"
"server.instances.read",
"remote.access.request"
],
"featureKeys": []
"featureKeys": [
"player.intelligence"
]
},
{
"key": "live-map",
"title": "实时地图",
"path": "/live-map",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.4",
"bundleIntegritySha256": "sha256:8b57e66b529dd35cd277ee2e5b839d8844c557b690f1d57db3ec026c26b713dc",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.game-client.read"
],
"bridgeActions": [
"server.instances.read"
"server.instances.read",
"remote.access.request"
],
"featureKeys": []
"featureKeys": [
"trajectory.collect"
]
},
{
"key": "gifts",
{
"key": "gifts",
"title": "礼包管理",
"path": "/gifts",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.4",
"bundleIntegritySha256": "sha256:8b57e66b529dd35cd277ee2e5b839d8844c557b690f1d57db3ec026c26b713dc",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.game-client.read",
"server.game-client.command"
],
"bridgeActions": [
"server.instances.read"
"bridgeActions": [
"server.instances.read"
],
"featureKeys": [
"reward.delivery"
]
},
{
"key": "workflows",
"title": "Workflow 状态",
"path": "/workflows",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.game-client.read",
"server.game-client.command",
"server.game-client.maintenance"
],
"featureKeys": []
"bridgeActions": [
"server.instances.read",
"remote.access.request"
],
"featureKeys": [
"player.intelligence",
"reward.delivery",
"state.patch",
"vehicle.spawn",
"trajectory.collect"
]
}
],
"ai": {
@@ -778,6 +825,20 @@
"mediation": "platform",
"configWritePolicy": "review-required"
},
"mapTrajectories": {
"mapId": "scum-island",
"mapVersion": "0.9",
"worldMinX": -500000,
"worldMinY": -500000,
"worldMaxX": 500000,
"worldMaxY": 500000,
"imageWidth": 2048,
"imageHeight": 2048,
"precision": 1,
"sampleDistance": 4,
"sampleIntervalSeconds": 20,
"retentionSeconds": 604800
},
"runtimeProfiles": {
"discovery": [
{
@@ -1138,7 +1199,6 @@
"kind": "sqlite",
"targetKey": "scum-database",
"capabilities": [
"remote.run.db.sqlite.probe",
"remote.run.db.sqlite.query",
"remote.run.protected.sql"
]
@@ -1160,21 +1220,6 @@
]
}
],
"dataTargets": [
{
"key": "scum-database",
"kind": "sqlite.snapshot",
"transportKey": "scum-database",
"sourceRootKey": "server-root",
"sourcePath": "SCUM/Saved/SaveFiles/SCUM.db",
"workspaceKey": "databases/scum-database",
"refreshPolicy": "on-demand-snapshot",
"maxBytes": 1073741824,
"platforms": [
"windows"
]
}
],
"clientManagers": [
{
"key": "scum-client-manager",
@@ -1,5 +1,5 @@
import { renderSCUMFeaturePage } from "../features/page.js";
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.4", integritySha256: "sha256:8b57e66b529dd35cd277ee2e5b839d8844c557b690f1d57db3ec026c26b713dc" };
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.3", integritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e" };
export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { pageKey: input.page?.key ?? "players", pageTitle: input.page?.title ?? "用户管理", serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features }); }
export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { pageKey: input.page?.key ?? "players", pageTitle: input.page?.title ?? "用户管理", serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspaceActions: input.workspaceActions }); }
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "fieldKey", "value", "observedAt", "checksum"],
"properties": {
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"fieldKey": { "const": "855" },
"value": { "type": "integer", "minimum": 0, "maximum": 100000 },
"observedAt": { "type": "string", "format": "date-time" },
"checksum": { "type": "string", "minLength": 1, "maxLength": 160 }
}
}
@@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "fieldKey", "before", "after", "safetyWindow", "backupRef"],
"properties": {
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"fieldKey": { "const": "855" },
"before": { "type": "integer", "minimum": 0, "maximum": 100000 },
"after": { "type": "integer", "minimum": 0, "maximum": 100000 },
"safetyWindow": { "type": "string", "minLength": 1, "maxLength": 120 },
"backupRef": { "type": "string", "minLength": 1, "maxLength": 180 },
"reason": { "type": "string", "maxLength": 240 }
}
}
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["outcome", "affectedRows", "mutationChecksum"],
"properties": {
"outcome": { "enum": ["succeeded", "failed", "unknown", "stale-before"] },
"affectedRows": { "type": "integer", "minimum": 0, "maximum": 1 },
"mutationChecksum": { "type": "string", "minLength": 1, "maxLength": 160 },
"safeMessage": { "type": "string", "maxLength": 240 }
}
}
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerCurrencySetConfirmation",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "amount", "observedAt"],
"properties": {
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
"amount": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"currency": { "enum": ["normal", "gold"] },
"observationId": { "type": "string", "minLength": 1, "maxLength": 160 },
"observedAt": { "type": "string", "format": "date-time" }
}
}
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerCurrencySetPayload",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "amount"],
"properties": {
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
"amount": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"reason": { "type": "string", "minLength": 1, "maxLength": 240 }
}
}
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerFameSetConfirmation",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "fame", "observedAt"],
"properties": {
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
"fame": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647 },
"observationId": { "type": "string", "minLength": 1, "maxLength": 160 },
"observedAt": { "type": "string", "format": "date-time" }
}
}
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerFameSetPayload",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "fame"],
"properties": {
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
"fame": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647 },
"reason": { "type": "string", "minLength": 1, "maxLength": 240 }
}
}
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerNotifyConfirmation",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "outcome", "observedAt"],
"properties": {
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
"outcome": { "enum": ["sent", "failed", "unknown"] },
"observedAt": { "type": "string", "format": "date-time" }
}
}
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerRCONSetResult",
"type": "object",
"additionalProperties": false,
"required": ["outcome"],
"properties": {
"outcome": { "enum": ["queued", "succeeded", "failed", "unknown"] },
"runJobId": { "type": "string", "minLength": 1, "maxLength": 160 },
"summary": { "type": "string", "maxLength": 240 }
}
}
@@ -0,0 +1,14 @@
# SCUM.db Query Contract
These query template keys are browser-safe declarations. They intentionally do not carry SQL text, host paths, DSNs, sockets, or credentials. The bound run/agent beside the current SCUM service owns the actual SQLite read implementation and must return rows matching the referenced result schemas.
| Template key | SCUM.db source tables | Projection target |
| --- | --- | --- |
| `scum.player.profile` | `user_profile`, `prisoner`, `prisoner_entity`, `entity`, `bank_account_registry`, `bank_account_registry_currencies`, optional `squad_member` / `squad` joins | Player identity, economy, squad summary, and current position |
| `scum.squads` | `squad`, optional `squad_member`, optional `user_profile` leader joins | Squad records and leader/member counts |
| `scum.squad-members` | `squad_member`, `user_profile`, optional `squad` joins | Squad roster and member identity mapping |
| `scum.vehicles` | `vehicle_spawner`, `entity`, optional owner/squad joins when present | Vehicle inventory and coordinates; unknown class labels remain unknown |
| `scum.flags` | `base_element`, `entity`, `user_profile`, `squad_member`, `squad` where available | Flag ownership, ownership confidence, and coordinates |
| `scum.positions` | `prisoner_entity`, `vehicle_spawner`, `base_element`, `entity` | Current player, vehicle, and flag coordinates |
`last_save_time` is freshness evidence only. It must not be treated as proof that a player is online; online state comes from login/logoff evidence or an explicit typed online field.
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMFlagsParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"flagId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMFlagsResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["flagId"],
"properties": {
"flagId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerSquadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerSquadName": { "type": "string", "minLength": 1, "maxLength": 80 },
"ownershipConfidence": { "enum": ["direct", "member", "squad", "unknown"] },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerProfileParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"steamId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"search": { "type": "string", "minLength": 1, "maxLength": 80 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerProfileResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["userProfileId"],
"properties": {
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"squadName": { "type": "string", "minLength": 1, "maxLength": 80 },
"famePoints": { "type": "number" },
"normalBalance": { "type": "number" },
"goldBalance": { "type": "number" },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" },
"lastSaveTime": { "type": "string", "format": "date-time" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -1,11 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMVehiclesReadParameters",
"title": "SCUMPositionsParameters",
"type": "object",
"additionalProperties": false,
"required": ["limit", "offset"],
"properties": {
"limit": { "type": "integer", "minimum": 1, "maximum": 500 },
"offset": { "type": "integer", "minimum": 0, "maximum": 1000000 }
"subjectType": { "enum": ["player", "vehicle", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPositionsResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["subjectType", "subjectId", "x", "y"],
"properties": {
"subjectType": { "enum": ["player", "vehicle", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" },
"lastSaveTime": { "type": "string", "format": "date-time" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadMembersParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadMembersResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["squadId", "userProfileId"],
"properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"rank": { "type": "string", "minLength": 1, "maxLength": 32 },
"isLeader": { "type": "boolean" },
"joinedAt": { "type": "string", "format": "date-time" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadsParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"search": { "type": "string", "minLength": 1, "maxLength": 80 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadsResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["squadId"],
"properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"leaderProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"leaderPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"memberCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
"score": { "type": "number" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMVehiclesParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMVehiclesResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["vehicleId"],
"properties": {
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"className": { "type": "string", "minLength": 1, "maxLength": 120 },
"label": { "type": "string", "minLength": 1, "maxLength": 120 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMRewardDeliverConfirmation",
"type": "object",
"additionalProperties": false,
"required": ["grantId", "outcome", "observedAt"],
"properties": {
"grantId": { "type": "string", "minLength": 1, "maxLength": 160 },
"outcome": { "enum": ["delivered", "failed", "unknown"] },
"observationId": { "type": "string", "minLength": 1, "maxLength": 160 },
"observedAt": { "type": "string", "format": "date-time" }
}
}
@@ -1 +1 @@
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","playerId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"const":"companion"},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}}
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","playerId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","log-projection"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}}
@@ -1 +1 @@
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"const":"companion"},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}}
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","log-projection"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}}
@@ -1 +1 @@
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"const":"companion"},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}}
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","log-projection"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}}
@@ -1 +1 @@
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","vehicleId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"const":"companion"},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}}
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","vehicleId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","log-projection"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}}
@@ -1,11 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMFlagsReadParameters",
"type": "object",
"additionalProperties": false,
"required": ["limit", "offset"],
"properties": {
"limit": { "type": "integer", "minimum": 1, "maximum": 200 },
"offset": { "type": "integer", "minimum": 0, "maximum": 1000000 }
}
}
@@ -1,34 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMFlagsReadResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 200,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["flagElementId", "baseElementId", "baseId", "ownerProfileId", "ownerExternalPlayerId", "ownerDisplayName", "ownerSquadId", "ownerSquadName", "flagX", "flagY", "flagZ", "territoryX", "territoryY", "territoryZ"],
"properties": {
"flagElementId": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"baseElementId": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"baseId": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"ownerProfileId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"ownerExternalPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"ownerDisplayName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 },
"ownerSquadId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"ownerSquadName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 },
"flagX": { "type": ["number", "null"] },
"flagY": { "type": ["number", "null"] },
"flagZ": { "type": ["number", "null"] },
"territoryX": { "type": ["number", "null"] },
"territoryY": { "type": ["number", "null"] },
"territoryZ": { "type": ["number", "null"] }
}
}
}
}
}
@@ -1,54 +0,0 @@
{
"$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}$" }
}
}
@@ -1,10 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerDetailsReadParameters",
"type": "object",
"additionalProperties": false,
"required": ["externalPlayerId"],
"properties": {
"externalPlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }
}
}
@@ -1,34 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerDetailsReadResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["externalPlayerId", "idType", "provider", "userCreationTime", "userLastLoginTime", "isBanned", "profileId", "displayName", "profileType", "prisonerId", "prisonerUserProfileId", "prisonerLastSaveTime", "entityId", "profileXmlLength"],
"properties": {
"externalPlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"idType": { "type": "string", "minLength": 1, "maxLength": 32 },
"provider": { "type": "string", "minLength": 1, "maxLength": 32 },
"userCreationTime": { "type": "string", "minLength": 1, "maxLength": 64 },
"userLastLoginTime": { "type": "string", "minLength": 1, "maxLength": 64 },
"isBanned": { "type": "integer", "minimum": 0, "maximum": 1 },
"profileId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"displayName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 },
"profileType": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"prisonerId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"prisonerUserProfileId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"prisonerLastSaveTime": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"entityId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"profileXmlLength": { "type": ["integer", "null"], "minimum": 0, "maximum": 1048576 }
}
}
}
}
}
@@ -1,11 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerEconomyReadParameters",
"type": "object",
"additionalProperties": false,
"required": ["externalPlayerId", "limit"],
"properties": {
"externalPlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"limit": { "type": "integer", "minimum": 1, "maximum": 16 }
}
}
@@ -1,26 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerEconomyReadResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 16,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["externalPlayerId", "profileId", "bankAccountId", "currencyRowId", "currencyType", "accountBalance"],
"properties": {
"externalPlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"profileId": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"bankAccountId": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"currencyRowId": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"currencyType": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"accountBalance": { "type": ["integer", "null"], "minimum": -2147483648, "maximum": 2147483647 }
}
}
}
}
}
@@ -1,10 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerSessionEnrichmentReadParameters",
"type": "object",
"additionalProperties": false,
"required": ["externalPlayerId"],
"properties": {
"externalPlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }
}
}
@@ -1,30 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerSessionEnrichmentReadResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["externalPlayerId", "idType", "provider", "userCreationTime", "userLastLoginTime", "isBanned", "profileId", "displayName", "profileType", "prisonerId"],
"properties": {
"externalPlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"idType": { "type": "string", "minLength": 1, "maxLength": 32 },
"provider": { "type": "string", "minLength": 1, "maxLength": 32 },
"userCreationTime": { "type": "string", "minLength": 1, "maxLength": 64 },
"userLastLoginTime": { "type": "string", "minLength": 1, "maxLength": 64 },
"isBanned": { "type": "integer", "minimum": 0, "maximum": 1 },
"profileId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"displayName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 },
"profileType": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"prisonerId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 }
}
}
}
}
}
@@ -1,11 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayersReadParameters",
"type": "object",
"additionalProperties": false,
"required": ["limit", "offset"],
"properties": {
"limit": { "type": "integer", "minimum": 1, "maximum": 100 },
"offset": { "type": "integer", "minimum": 0, "maximum": 1000000 }
}
}
@@ -1,31 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayersReadResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 100,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["externalPlayerId", "idType", "provider", "userCreationTime", "userLastLoginTime", "isBanned", "profileId", "displayName", "profileType", "prisonerId", "entityId"],
"properties": {
"externalPlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"idType": { "type": "string", "minLength": 1, "maxLength": 32 },
"provider": { "type": "string", "minLength": 1, "maxLength": 32 },
"userCreationTime": { "type": "string", "minLength": 1, "maxLength": 64 },
"userLastLoginTime": { "type": "string", "minLength": 1, "maxLength": 64 },
"isBanned": { "type": "integer", "minimum": 0, "maximum": 1 },
"profileId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"displayName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 },
"profileType": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"prisonerId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"entityId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 }
}
}
}
}
}
@@ -1,12 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPositionsReadParameters",
"type": "object",
"additionalProperties": false,
"required": ["subjectType", "limit", "offset"],
"properties": {
"subjectType": { "enum": ["all", "player", "vehicle", "flag"] },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 },
"offset": { "type": "integer", "minimum": 0, "maximum": 1000000 }
}
}
@@ -1,34 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPositionsReadResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["subjectType", "subjectId", "externalPlayerId", "profileId", "prisonerId", "entityId", "vehicleEntityId", "vehicleAssetId", "flagElementId", "x", "y", "z", "sourceTimeValue", "sourceTimeKind"],
"properties": {
"subjectType": { "enum": ["player", "vehicle", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"externalPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"profileId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"prisonerId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"entityId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"vehicleEntityId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"vehicleAssetId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"flagElementId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"x": { "type": ["number", "null"] },
"y": { "type": ["number", "null"] },
"z": { "type": ["number", "null"] },
"sourceTimeValue": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"sourceTimeKind": { "type": ["string", "null"], "enum": ["prisoner.last_save_time", null] }
}
}
}
}
}
@@ -1,12 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadMembersReadParameters",
"type": "object",
"additionalProperties": false,
"required": ["squadId", "limit", "offset"],
"properties": {
"squadId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 },
"offset": { "type": "integer", "minimum": 0, "maximum": 1000000 }
}
}
@@ -1,30 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadMembersReadResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["squadId", "userProfileId", "rankCode", "rankMeaning", "isLeader", "externalPlayerId", "idType", "provider", "displayName", "profileType"],
"properties": {
"squadId": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"userProfileId": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"rankCode": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"rankMeaning": { "type": ["string", "null"], "minLength": 1, "maxLength": 32 },
"isLeader": { "type": ["boolean", "null"] },
"externalPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"idType": { "type": ["string", "null"], "minLength": 1, "maxLength": 32 },
"provider": { "type": ["string", "null"], "minLength": 1, "maxLength": 32 },
"displayName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 },
"profileType": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 }
}
}
}
}
}
@@ -1,11 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadsReadParameters",
"type": "object",
"additionalProperties": false,
"required": ["limit", "offset"],
"properties": {
"limit": { "type": "integer", "minimum": 1, "maximum": 100 },
"offset": { "type": "integer", "minimum": 0, "maximum": 1000000 }
}
}
@@ -1,27 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadsReadResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 100,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["squadId", "squadName", "memberCount", "leaderProfileId", "leaderExternalPlayerId", "leaderRankCode", "leaderRankMeaning"],
"properties": {
"squadId": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"squadName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 },
"memberCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
"leaderProfileId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"leaderExternalPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"leaderRankCode": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"leaderRankMeaning": { "type": ["string", "null"], "minLength": 1, "maxLength": 32 }
}
}
}
}
}
@@ -1,31 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMVehiclesReadResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["vehicleEntityId", "entityId", "vehicleAssetId", "isVehicleFunctional", "vehicleLabel", "ownerProfileId", "ownerExternalPlayerId", "ownerSquadId", "x", "y", "z"],
"properties": {
"vehicleEntityId": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"entityId": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"vehicleAssetId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"isVehicleFunctional": { "type": ["integer", "null"], "minimum": 0, "maximum": 1 },
"vehicleLabel": { "type": ["string", "null"], "minLength": 1, "maxLength": 120 },
"ownerProfileId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"ownerExternalPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"ownerSquadId": { "type": ["integer", "null"], "minimum": 0, "maximum": 2147483647 },
"x": { "type": ["number", "null"] },
"y": { "type": ["number", "null"] },
"z": { "type": ["number", "null"] }
}
}
}
}
}
@@ -50,7 +50,6 @@
"gameClientBridge": {
"$ref": "#/$defs/gameClientBridgeManifest"
},
"scumLiveData": { "$ref": "#/$defs/scumLiveDataManifest" },
"mapTrajectories": { "$ref": "#/$defs/mapTrajectoryDeclaration" },
"capabilities": {
"type": "array",
@@ -133,12 +132,6 @@
"items": { "$ref": "#/$defs/runtimeTransportProfile" },
"uniqueItems": true
},
"dataTargets": {
"type": "array",
"items": { "$ref": "#/$defs/runtimeDataTarget" },
"uniqueItems": true,
"maxItems": 64
},
"clientManagers": {
"type": "array",
"items": { "$ref": "#/$defs/runtimeClientManagerProfile" },
@@ -294,101 +287,6 @@
"companion": { "$ref": "#/$defs/gameClientBridgeCompanion" }
}
},
"scumLiveDataManifest": {
"type": "object",
"required": ["schemaVersion", "probe", "capabilityGates"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "1" },
"probe": { "$ref": "#/$defs/scumSchemaProbeDeclaration" },
"capabilityGates": { "type": "array", "items": { "$ref": "#/$defs/scumLiveDataCapabilityGate" }, "uniqueItems": true, "minItems": 1, "maxItems": 32 },
"logParsers": { "type": "array", "items": { "$ref": "#/$defs/scumLogParserAsset" }, "uniqueItems": true, "maxItems": 16 },
"sqliteQueries": { "type": "array", "items": { "$ref": "#/$defs/scumSQLiteQueryAsset" }, "uniqueItems": true, "maxItems": 64 },
"syncCadences": { "type": "array", "items": { "$ref": "#/$defs/scumSyncCadence" }, "uniqueItems": true, "maxItems": 16 },
"typedRconTemplates": { "type": "array", "items": { "$ref": "#/$defs/scumTypedRconTemplate" }, "uniqueItems": true, "maxItems": 32 },
"guardedMutations": { "type": "array", "items": { "$ref": "#/$defs/scumGuardedMutation" }, "uniqueItems": true, "maxItems": 16 },
"mapAssets": { "type": "array", "items": { "$ref": "#/$defs/scumMapAsset" }, "uniqueItems": true, "maxItems": 8 },
"giftCatalogs": { "type": "array", "items": { "$ref": "#/$defs/scumGiftCatalog" }, "uniqueItems": true, "maxItems": 16 }
}
},
"scumSchemaProbeDeclaration": {
"type": "object",
"required": ["capability", "targetKey", "bounds"],
"additionalProperties": false,
"properties": {
"capability": { "const": "remote.run.db.sqlite.probe" },
"targetKey": { "$ref": "#/$defs/logicalKey" },
"bounds": { "$ref": "#/$defs/scumSchemaProbeBounds" }
}
},
"scumSchemaProbeBounds": {
"type": "object",
"required": ["maxObjects", "maxColumnsPerObject", "maxIndexesPerObject", "maxForeignKeys", "maxCardinalityReads", "maxSampleRows", "timeoutMs", "maxResultBytes"],
"additionalProperties": false,
"properties": {
"maxObjects": { "type": "integer", "minimum": 1, "maximum": 512 }, "maxColumnsPerObject": { "type": "integer", "minimum": 1, "maximum": 256 }, "maxIndexesPerObject": { "type": "integer", "minimum": 0, "maximum": 128 }, "maxForeignKeys": { "type": "integer", "minimum": 0, "maximum": 128 }, "maxCardinalityReads": { "type": "integer", "minimum": 0, "maximum": 512 }, "maxSampleRows": { "type": "integer", "minimum": 0, "maximum": 3 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 10000 }, "maxResultBytes": { "type": "integer", "minimum": 1, "maximum": 1048576 }
}
},
"scumLiveDataCapabilityGate": {
"type": "object",
"required": ["capability", "gate", "adapterVersion", "evidenceStatus", "safeReason"],
"additionalProperties": false,
"properties": {
"capability": { "enum": ["schema-probe", "players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"] },
"gate": { "enum": ["disabled", "enabled"] },
"adapterVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$" },
"requiredSchemaFingerprint": { "type": "string", "pattern": "^(sha256:)?[a-fA-F0-9]{16,128}$" },
"requiredAssetDigests": { "type": "array", "items": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" }, "uniqueItems": true, "maxItems": 16 },
"evidenceStatus": { "enum": ["missing", "compatible", "incompatible", "failed"] },
"safeReason": { "type": "string", "minLength": 1, "maxLength": 240 }
}
},
"scumAssetDigest": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" },
"scumSchemaFingerprint": { "type": "string", "pattern": "^(sha256:)?[a-fA-F0-9]{16,128}$" },
"scumAdapterVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$" },
"scumVersionedAssetBase": {
"type": "object",
"required": ["key", "adapterVersion", "assetPath", "digest"],
"properties": {
"key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }
}
},
"scumReadCapability": { "enum": ["players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read"] },
"scumWriteCapability": { "enum": ["profile-xml.write", "economy-command.write", "gift-command.write"] },
"scumLogParserAsset": {
"allOf": [{ "$ref": "#/$defs/scumVersionedAssetBase" }, { "type": "object", "required": ["parserVersion", "sourceKey", "eventType", "eventSchemaRef", "maxLineBytes", "cursorPolicy", "privacy"], "additionalProperties": false, "properties": {
"key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }, "parserVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "sourceKey": { "$ref": "#/$defs/logicalKey" }, "eventType": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" }, "eventSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "maxLineBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }, "cursorPolicy": { "const": "source-generation-sequence" }, "privacy": { "type": "object", "required": ["stripNetworkIdentifiers", "logicalEventIdentity"], "additionalProperties": false, "properties": { "stripNetworkIdentifiers": { "const": true }, "logicalEventIdentity": { "const": "native-or-sanitized-fields" } } }
} }]
},
"scumSQLiteQueryAsset": {
"allOf": [{ "$ref": "#/$defs/scumVersionedAssetBase" }, { "type": "object", "required": ["capability", "requiredSchemaFingerprint", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "maxRows", "timeoutMs", "maxResultBytes"], "additionalProperties": false, "properties": {
"key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }, "capability": { "$ref": "#/$defs/scumReadCapability" }, "requiredSchemaFingerprint": { "$ref": "#/$defs/scumSchemaFingerprint" }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "maxRows": { "type": "integer", "minimum": 1, "maximum": 500 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxResultBytes": { "type": "integer", "minimum": 1, "maximum": 1048576 }
} }]
},
"scumSyncCadence": {
"type": "object", "required": ["capability", "intervalSeconds", "jitterPercent", "timeoutMs", "maxConcurrentPerServer"], "additionalProperties": false,
"properties": { "capability": { "$ref": "#/$defs/scumReadCapability" }, "intervalSeconds": { "type": "integer", "minimum": 5, "maximum": 86400 }, "jitterPercent": { "type": "integer", "minimum": 0, "maximum": 100 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxConcurrentPerServer": { "type": "integer", "minimum": 1, "maximum": 16 } }
},
"scumTypedRconTemplate": {
"allOf": [{ "$ref": "#/$defs/scumVersionedAssetBase" }, { "type": "object", "required": ["capability", "transportKey", "targetKey", "permission", "payloadSchemaRef", "resultSchemaRef", "confirmationSchemaRef", "timeoutMs", "maxPayloadBytes"], "additionalProperties": false, "properties": {
"key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }, "capability": { "enum": ["economy-command.write", "gift-command.write"] }, "requiredSchemaFingerprint": { "$ref": "#/$defs/scumSchemaFingerprint" }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "permission": { "const": "server.game-client.command" }, "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "confirmationSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }
} }]
},
"scumGuardedMutation": {
"allOf": [{ "$ref": "#/$defs/scumVersionedAssetBase" }, { "type": "object", "required": ["capability", "requiredSchemaFingerprint", "transportKey", "targetKey", "permission", "payloadSchemaRef", "resultSchemaRef", "confirmationSchemaRef", "timeoutMs", "maxPayloadBytes", "maxRowsAffected", "safety"], "additionalProperties": false, "properties": {
"key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }, "capability": { "const": "profile-xml.write" }, "requiredSchemaFingerprint": { "$ref": "#/$defs/scumSchemaFingerprint" }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "permission": { "const": "server.game-client.maintenance" }, "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "confirmationSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }, "maxRowsAffected": { "const": 1 }, "safety": { "type": "object", "required": ["requiresExpectedChecksum", "requiresBackupEvidence", "requiresOfflineOrMaintenance", "requiresReadAfterWrite"], "additionalProperties": false, "properties": { "requiresExpectedChecksum": { "const": true }, "requiresBackupEvidence": { "const": true }, "requiresOfflineOrMaintenance": { "const": true }, "requiresReadAfterWrite": { "const": true } } }
} }]
},
"scumMapAsset": {
"allOf": [{ "$ref": "#/$defs/scumVersionedAssetBase" }, { "type": "object", "required": ["requiredSchemaFingerprint", "metadataSchemaRef", "transformAssetPath", "transformDigest", "worldBounds", "image"], "additionalProperties": false, "properties": {
"key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }, "requiredSchemaFingerprint": { "$ref": "#/$defs/scumSchemaFingerprint" }, "metadataSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "transformAssetPath": { "$ref": "#/$defs/relativePathRef" }, "transformDigest": { "$ref": "#/$defs/scumAssetDigest" }, "worldBounds": { "type": "object", "required": ["minX", "minY", "maxX", "maxY"], "additionalProperties": false, "properties": { "minX": { "type": "number" }, "minY": { "type": "number" }, "maxX": { "type": "number" }, "maxY": { "type": "number" } } }, "image": { "type": "object", "required": ["width", "height"], "additionalProperties": false, "properties": { "width": { "type": "integer", "minimum": 1, "maximum": 100000 }, "height": { "type": "integer", "minimum": 1, "maximum": 100000 } } }
} }]
},
"scumGiftCatalog": {
"allOf": [{ "$ref": "#/$defs/scumVersionedAssetBase" }, { "type": "object", "required": ["catalogVersion", "itemSchemaRef", "transportTemplateKeys"], "additionalProperties": false, "properties": {
"key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }, "catalogVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "itemSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "transportTemplateKeys": { "type": "array", "items": { "$ref": "#/$defs/logicalKey" }, "uniqueItems": true, "minItems": 1, "maxItems": 16 }
} }]
},
"gameClientBridgeCompanion": {
"type": "object",
"required": ["profileKey", "configTemplateKey", "configSchemaRef", "configFormat", "platformBaseUrlSource", "registrationProof", "proofMaterialSource", "proofMaterialEnv", "sessionMode", "tlsPolicy", "heartbeatIntervalSeconds", "commandPollIntervalSeconds", "requestTimeoutSeconds"],
@@ -565,7 +463,6 @@
"remote.run.process.start",
"remote.run.process.stop",
"remote.run.db.mysql.query",
"remote.run.db.sqlite.probe",
"remote.run.db.sqlite.query",
"remote.run.logs.transfer",
"remote.run.rcon.command",
@@ -844,22 +741,6 @@
"capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 }
}
},
"runtimeDataTarget": {
"type": "object",
"required": ["key", "kind", "transportKey", "sourceRootKey", "sourcePath", "workspaceKey", "refreshPolicy", "maxBytes"],
"additionalProperties": false,
"properties": {
"key": { "$ref": "#/$defs/logicalKey" },
"kind": { "const": "sqlite.snapshot" },
"transportKey": { "$ref": "#/$defs/logicalKey" },
"sourceRootKey": { "$ref": "#/$defs/logicalKey" },
"sourcePath": { "$ref": "#/$defs/relativePathRef" },
"workspaceKey": { "type": "string", "pattern": "^databases/[a-z0-9][a-z0-9._/-]{0,119}$" },
"refreshPolicy": { "const": "on-demand-snapshot" },
"maxBytes": { "type": "integer", "minimum": 1, "maximum": 1073741824 },
"platforms": { "type": "array", "items": { "enum": ["windows", "linux", "darwin"] }, "uniqueItems": true }
}
},
"relativePathRef": {
"type": "string",
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+$",
@@ -1,87 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://browser.local/schemas/scum-live-data.schema.json",
"title": "SCUMLiveDataManifestDeclaration",
"type": "object",
"required": ["schemaVersion", "probe", "capabilityGates"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "1" },
"probe": {
"type": "object",
"required": ["capability", "targetKey", "bounds"],
"additionalProperties": false,
"properties": {
"capability": { "const": "remote.run.db.sqlite.probe" },
"targetKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._/-]*$", "maxLength": 120 },
"bounds": { "$ref": "#/$defs/probeBounds" }
}
},
"capabilityGates": { "type": "array", "items": { "$ref": "#/$defs/capabilityGate" }, "minItems": 1, "maxItems": 32 },
"logParsers": { "type": "array", "items": { "$ref": "#/$defs/logParserAsset" }, "uniqueItems": true, "maxItems": 16 },
"sqliteQueries": { "type": "array", "items": { "$ref": "#/$defs/sqliteQueryAsset" }, "uniqueItems": true, "maxItems": 64 },
"syncCadences": { "type": "array", "items": { "$ref": "#/$defs/syncCadence" }, "uniqueItems": true, "maxItems": 16 },
"typedRconTemplates": { "type": "array", "items": { "$ref": "#/$defs/typedRconTemplate" }, "uniqueItems": true, "maxItems": 32 },
"guardedMutations": { "type": "array", "items": { "$ref": "#/$defs/guardedMutation" }, "uniqueItems": true, "maxItems": 16 },
"mapAssets": { "type": "array", "items": { "$ref": "#/$defs/mapAsset" }, "uniqueItems": true, "maxItems": 8 },
"giftCatalogs": { "type": "array", "items": { "$ref": "#/$defs/giftCatalog" }, "uniqueItems": true, "maxItems": 16 }
},
"$defs": {
"logicalKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._/-]*$", "maxLength": 120 },
"relativePathRef": { "type": "string", "pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+$", "maxLength": 160 },
"relativeJsonRef": { "type": "string", "pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+\\.json$" },
"assetDigest": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" },
"schemaFingerprint": { "type": "string", "pattern": "^(sha256:)?[a-fA-F0-9]{16,128}$" },
"adapterVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$" },
"readCapability": { "enum": ["players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read"] },
"probeBounds": {
"type": "object",
"required": ["maxObjects", "maxColumnsPerObject", "maxIndexesPerObject", "maxForeignKeys", "maxCardinalityReads", "maxSampleRows", "timeoutMs", "maxResultBytes"],
"additionalProperties": false,
"properties": {
"maxObjects": { "type": "integer", "minimum": 1, "maximum": 512 }, "maxColumnsPerObject": { "type": "integer", "minimum": 1, "maximum": 256 }, "maxIndexesPerObject": { "type": "integer", "minimum": 0, "maximum": 128 }, "maxForeignKeys": { "type": "integer", "minimum": 0, "maximum": 128 }, "maxCardinalityReads": { "type": "integer", "minimum": 0, "maximum": 512 }, "maxSampleRows": { "type": "integer", "minimum": 0, "maximum": 3 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 10000 }, "maxResultBytes": { "type": "integer", "minimum": 1, "maximum": 1048576 }
}
},
"capabilityGate": {
"type": "object",
"required": ["capability", "gate", "adapterVersion", "evidenceStatus", "safeReason"],
"additionalProperties": false,
"properties": {
"capability": { "enum": ["schema-probe", "players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"] },
"gate": { "enum": ["disabled", "enabled"] },
"adapterVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$" },
"requiredSchemaFingerprint": { "type": "string", "pattern": "^(sha256:)?[a-fA-F0-9]{16,128}$" },
"requiredAssetDigests": { "type": "array", "items": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" }, "uniqueItems": true, "maxItems": 16 },
"evidenceStatus": { "enum": ["missing", "compatible", "incompatible", "failed"] },
"safeReason": { "type": "string", "minLength": 1, "maxLength": 240 }
}
},
"assetBase": {
"type": "object",
"required": ["key", "adapterVersion", "assetPath", "digest"],
"properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" } }
},
"logParserAsset": {
"allOf": [{ "$ref": "#/$defs/assetBase" }, { "type": "object", "required": ["parserVersion", "sourceKey", "eventType", "eventSchemaRef", "maxLineBytes", "cursorPolicy", "privacy"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" }, "parserVersion": { "$ref": "#/$defs/adapterVersion" }, "sourceKey": { "$ref": "#/$defs/logicalKey" }, "eventType": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" }, "eventSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "maxLineBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }, "cursorPolicy": { "const": "source-generation-sequence" }, "privacy": { "type": "object", "required": ["stripNetworkIdentifiers", "logicalEventIdentity"], "additionalProperties": false, "properties": { "stripNetworkIdentifiers": { "const": true }, "logicalEventIdentity": { "const": "native-or-sanitized-fields" } } } } }]
},
"sqliteQueryAsset": {
"allOf": [{ "$ref": "#/$defs/assetBase" }, { "type": "object", "required": ["capability", "requiredSchemaFingerprint", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "maxRows", "timeoutMs", "maxResultBytes"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" }, "capability": { "$ref": "#/$defs/readCapability" }, "requiredSchemaFingerprint": { "$ref": "#/$defs/schemaFingerprint" }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "maxRows": { "type": "integer", "minimum": 1, "maximum": 500 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxResultBytes": { "type": "integer", "minimum": 1, "maximum": 1048576 } } }]
},
"syncCadence": {
"type": "object", "required": ["capability", "intervalSeconds", "jitterPercent", "timeoutMs", "maxConcurrentPerServer"], "additionalProperties": false,
"properties": { "capability": { "$ref": "#/$defs/readCapability" }, "intervalSeconds": { "type": "integer", "minimum": 5, "maximum": 86400 }, "jitterPercent": { "type": "integer", "minimum": 0, "maximum": 100 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxConcurrentPerServer": { "type": "integer", "minimum": 1, "maximum": 16 } }
},
"typedRconTemplate": {
"allOf": [{ "$ref": "#/$defs/assetBase" }, { "type": "object", "required": ["capability", "transportKey", "targetKey", "permission", "payloadSchemaRef", "resultSchemaRef", "confirmationSchemaRef", "timeoutMs", "maxPayloadBytes"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" }, "capability": { "enum": ["economy-command.write", "gift-command.write"] }, "requiredSchemaFingerprint": { "$ref": "#/$defs/schemaFingerprint" }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "permission": { "const": "server.game-client.command" }, "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "confirmationSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 } } }]
},
"guardedMutation": {
"allOf": [{ "$ref": "#/$defs/assetBase" }, { "type": "object", "required": ["capability", "requiredSchemaFingerprint", "transportKey", "targetKey", "permission", "payloadSchemaRef", "resultSchemaRef", "confirmationSchemaRef", "timeoutMs", "maxPayloadBytes", "maxRowsAffected", "safety"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" }, "capability": { "const": "profile-xml.write" }, "requiredSchemaFingerprint": { "$ref": "#/$defs/schemaFingerprint" }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "permission": { "const": "server.game-client.maintenance" }, "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "confirmationSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }, "maxRowsAffected": { "const": 1 }, "safety": { "type": "object", "required": ["requiresExpectedChecksum", "requiresBackupEvidence", "requiresOfflineOrMaintenance", "requiresReadAfterWrite"], "additionalProperties": false, "properties": { "requiresExpectedChecksum": { "const": true }, "requiresBackupEvidence": { "const": true }, "requiresOfflineOrMaintenance": { "const": true }, "requiresReadAfterWrite": { "const": true } } } } }]
},
"mapAsset": {
"allOf": [{ "$ref": "#/$defs/assetBase" }, { "type": "object", "required": ["requiredSchemaFingerprint", "metadataSchemaRef", "transformAssetPath", "transformDigest", "worldBounds", "image"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" }, "requiredSchemaFingerprint": { "$ref": "#/$defs/schemaFingerprint" }, "metadataSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "transformAssetPath": { "$ref": "#/$defs/relativePathRef" }, "transformDigest": { "$ref": "#/$defs/assetDigest" }, "worldBounds": { "type": "object", "required": ["minX", "minY", "maxX", "maxY"], "additionalProperties": false, "properties": { "minX": { "type": "number" }, "minY": { "type": "number" }, "maxX": { "type": "number" }, "maxY": { "type": "number" } } }, "image": { "type": "object", "required": ["width", "height"], "additionalProperties": false, "properties": { "width": { "type": "integer", "minimum": 1, "maximum": 100000 }, "height": { "type": "integer", "minimum": 1, "maximum": 100000 } } } } }]
},
"giftCatalog": {
"allOf": [{ "$ref": "#/$defs/assetBase" }, { "type": "object", "required": ["catalogVersion", "itemSchemaRef", "transportTemplateKeys"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" }, "catalogVersion": { "$ref": "#/$defs/adapterVersion" }, "itemSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "transportTemplateKeys": { "type": "array", "items": { "$ref": "#/$defs/logicalKey" }, "uniqueItems": true, "minItems": 1, "maxItems": 16 } } }]
}
}
}
-308
View File
@@ -1,5 +1,4 @@
import fs from "node:fs";
import crypto from "node:crypto";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -14,10 +13,6 @@ function readJson(filePath: string): unknown {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function sha256FileDigest(filePath: string): string {
return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex")}`;
}
function formatErrors(prefix: string, errors: ErrorObject[] | null | undefined): string[] {
return (errors ?? []).map((error) => `${prefix}${error.instancePath}: ${error.message}`);
}
@@ -1059,308 +1054,6 @@ export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
return errors;
}
export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: string): string[] {
if (typeof manifest !== "object" || manifest === null) return [];
type Gate = { capability?: string; gate?: string; adapterVersion?: string; requiredSchemaFingerprint?: string; requiredAssetDigests?: string[]; evidenceStatus?: string; safeReason?: string };
type Probe = { capability?: string; targetKey?: string; bounds?: { maxSampleRows?: number; timeoutMs?: number; maxResultBytes?: number } };
type Transport = { key?: string; kind?: string; targetKey?: string; capabilities?: string[] };
type DataTarget = { key?: string; kind?: string; transportKey?: string; sourceRootKey?: string; sourcePath?: string; workspaceKey?: string; refreshPolicy?: string; maxBytes?: number; platforms?: string[] };
type RuntimeLogSource = { key?: string };
type AssetFile = { path?: string };
type Asset = { key?: string; adapterVersion?: string; assetPath?: string; digest?: string };
type LogParser = Asset & { parserVersion?: string; sourceKey?: string; eventType?: string; eventSchemaRef?: string; maxLineBytes?: number; cursorPolicy?: string; privacy?: { stripNetworkIdentifiers?: boolean; logicalEventIdentity?: string } };
type SQLiteQuery = Asset & { capability?: string; requiredSchemaFingerprint?: string; transportKey?: string; targetKey?: string; parameterSchemaRef?: string; resultSchemaRef?: string; maxRows?: number; timeoutMs?: number; maxResultBytes?: number };
type SyncCadence = { capability?: string; intervalSeconds?: number; jitterPercent?: number; timeoutMs?: number; maxConcurrentPerServer?: number };
type TypedRCON = Asset & { capability?: string; requiredSchemaFingerprint?: string; transportKey?: string; targetKey?: string; permission?: string; payloadSchemaRef?: string; resultSchemaRef?: string; confirmationSchemaRef?: string; timeoutMs?: number; maxPayloadBytes?: number };
type GuardedMutation = Asset & { capability?: string; requiredSchemaFingerprint?: string; transportKey?: string; targetKey?: string; permission?: string; payloadSchemaRef?: string; resultSchemaRef?: string; confirmationSchemaRef?: string; timeoutMs?: number; maxPayloadBytes?: number; maxRowsAffected?: number; safety?: { requiresExpectedChecksum?: boolean; requiresBackupEvidence?: boolean; requiresOfflineOrMaintenance?: boolean; requiresReadAfterWrite?: boolean } };
type MapAsset = Asset & { requiredSchemaFingerprint?: string; metadataSchemaRef?: string; transformAssetPath?: string; transformDigest?: string; worldBounds?: { minX?: number; minY?: number; maxX?: number; maxY?: number }; image?: { width?: number; height?: number } };
type GiftCatalog = Asset & { catalogVersion?: string; itemSchemaRef?: string; transportTemplateKeys?: string[] };
type LiveData = { schemaVersion?: string; probe?: Probe; capabilityGates?: Gate[]; logParsers?: LogParser[]; sqliteQueries?: SQLiteQuery[]; syncCadences?: SyncCadence[]; typedRconTemplates?: TypedRCON[]; guardedMutations?: GuardedMutation[]; mapAssets?: MapAsset[]; giftCatalogs?: GiftCatalog[] };
const declaration = manifest as { id?: string; capabilities?: string[]; permissions?: string[]; assetFiles?: AssetFile[]; remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] }; runtimeProfiles?: { transportProfiles?: Transport[]; dataTargets?: DataTarget[]; logSources?: RuntimeLogSource[] }; scumLiveData?: LiveData };
const liveData = declaration.scumLiveData;
if (!liveData) return [];
const errors: string[] = [];
const declaredCapabilities = new Set(declaration.capabilities ?? []);
const declaredPermissions = new Set(declaration.permissions ?? []);
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
const remoteDatabaseEngines = new Set(declaration.remoteAccess?.databaseEngines ?? []);
const transportProfiles = declaration.runtimeProfiles?.transportProfiles ?? [];
const dataTargets = declaration.runtimeProfiles?.dataTargets ?? [];
const logSources = new Set((declaration.runtimeProfiles?.logSources ?? []).map((source) => source.key ?? ""));
const assetFiles = new Set((declaration.assetFiles ?? []).map((asset) => asset.path ?? ""));
const probe = liveData.probe;
const location = "manifest.scumLiveData";
const digestPattern = /^sha256:[a-fA-F0-9]{64}$/;
const fingerprintPattern = /^(sha256:)?[a-fA-F0-9]{16,128}$/;
const adapterPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/;
const logicalKeyPattern = /^[a-z0-9][a-z0-9._/-]{0,119}$/;
const readCapabilities = new Set(["players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read"]);
errors.push(...scanUnsafeValues(liveData, location));
if (liveData.schemaVersion !== "1") errors.push(`${location}.schemaVersion: must be 1`);
if (probe?.capability !== "remote.run.db.sqlite.probe") errors.push(`${location}.probe.capability: must be remote.run.db.sqlite.probe`);
if (!declaredCapabilities.has("remote.run.db.sqlite.probe") || !remoteCapabilities.has("remote.run.db.sqlite.probe")) errors.push(`${location}.probe: plugin and remoteAccess must declare remote.run.db.sqlite.probe`);
const transport = transportProfiles.find((candidate) => candidate.targetKey === probe?.targetKey || candidate.key === probe?.targetKey);
if (!transport) {
errors.push(`${location}.probe.targetKey: must reference a declared runtime transport profile or target`);
} else {
if (transport.kind !== "sqlite") errors.push(`${location}.probe.targetKey: schema probe requires a sqlite transport profile`);
if (!transport.capabilities?.includes("remote.run.db.sqlite.probe")) errors.push(`${location}.probe.targetKey: sqlite transport must declare remote.run.db.sqlite.probe`);
}
if ((probe?.bounds?.maxSampleRows ?? 0) > 3) errors.push(`${location}.probe.bounds.maxSampleRows: redacted samples are limited to 3`);
if ((probe?.bounds?.timeoutMs ?? 0) > 10000) errors.push(`${location}.probe.bounds.timeoutMs: must be bounded to 10 seconds or less`);
if ((probe?.bounds?.maxResultBytes ?? 0) > 1048576) errors.push(`${location}.probe.bounds.maxResultBytes: must be bounded to 1 MiB or less`);
const gates = liveData.capabilityGates ?? [];
const seen = new Set<string>();
for (const [index, gate] of gates.entries()) {
const gateLocation = `${location}.capabilityGates[${index}]`;
const capability = gate.capability ?? "";
if (seen.has(capability)) errors.push(`${gateLocation}.capability: duplicate gate ${capability}`);
seen.add(capability);
const reasonErrors = unsafeStringReasons(gate.safeReason ?? "");
errors.push(...reasonErrors.map((reason) => `${gateLocation}.safeReason: ${reason}`));
if (gate.gate === "enabled") {
if (gate.evidenceStatus !== "compatible") errors.push(`${gateLocation}.evidenceStatus: enabled gates require compatible evidence`);
if (!/^(sha256:)?[a-fA-F0-9]{16,128}$/.test(gate.requiredSchemaFingerprint ?? "")) errors.push(`${gateLocation}.requiredSchemaFingerprint: enabled gates require a schema fingerprint`);
if (capability !== "schema-probe" && (!Array.isArray(gate.requiredAssetDigests) || gate.requiredAssetDigests.length === 0)) errors.push(`${gateLocation}.requiredAssetDigests: enabled gates require immutable asset digests`);
}
if (gate.gate === "disabled" && gate.evidenceStatus === "compatible") errors.push(`${gateLocation}.evidenceStatus: disabled gates must not claim compatible evidence`);
}
const requireAsset = (asset: Asset, assetLocation: string): void => {
const key = asset.key ?? "";
if (!logicalKeyPattern.test(key)) errors.push(`${assetLocation}.key: must be a safe logical key`);
if (!adapterPattern.test(asset.adapterVersion ?? "")) errors.push(`${assetLocation}.adapterVersion: must be a bounded adapter version`);
if (!asset.assetPath || !isSafeRelativePathRef(asset.assetPath)) errors.push(`${assetLocation}.assetPath: must be a contained package-relative path`);
if (asset.assetPath && !assetFiles.has(asset.assetPath)) errors.push(`${assetLocation}.assetPath: must be declared in manifest.assetFiles`);
if (!digestPattern.test(asset.digest ?? "")) errors.push(`${assetLocation}.digest: must be a sha256 digest`);
if (asset.assetPath && asset.digest) validateSCUMAssetDigest(asset.assetPath, asset.digest, `${assetLocation}.digest`);
};
const gateByCapability = new Map(gates.map((gate) => [gate.capability ?? "", gate]));
const requireCapabilityGateMatch = (capability: string | undefined, adapterVersion: string | undefined, schemaFingerprint: string | undefined, digests: Array<string | undefined>, itemLocation: string): void => {
const safeCapability = capability ?? "";
const gate = gateByCapability.get(safeCapability);
if (!gate) {
errors.push(`${itemLocation}: capability ${safeCapability} must have a release gate`);
return;
}
if (gate.adapterVersion !== adapterVersion) errors.push(`${itemLocation}: capability gate adapterVersion must match the asset adapterVersion`);
if (schemaFingerprint && gate.requiredSchemaFingerprint !== schemaFingerprint) errors.push(`${itemLocation}: capability gate schema fingerprint must match the asset requirement`);
const gateDigests = new Set(gate.requiredAssetDigests ?? []);
for (const digest of digests) {
if (digestPattern.test(digest ?? "") && !gateDigests.has(digest ?? "")) errors.push(`${itemLocation}: capability gate must include asset digest ${digest}`);
}
};
const validateSCUMAssetDigest = (assetPath: string, expectedDigest: string, digestLocation: string): void => {
if (!manifestDir || !isSafeRelativePathRef(assetPath) || !digestPattern.test(expectedDigest)) return;
const absoluteAssetPath = path.resolve(manifestDir, assetPath);
if (!fs.existsSync(absoluteAssetPath) || !fs.statSync(absoluteAssetPath).isFile()) {
errors.push(`${digestLocation}: missing packaged asset file ${assetPath}`);
return;
}
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(absoluteAssetPath));
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
errors.push(`${digestLocation}: asset file must remain inside the plugin manifest directory`);
return;
}
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()) {
const key = asset.key ?? "";
if (seenKeys.has(key)) errors.push(`${location}.${collection}[${index}].key: duplicate asset key ${key}`);
seenKeys.add(key);
}
};
const transportByKey = (key?: string): Transport | undefined => transportProfiles.find((candidate) => candidate.key === key);
const probeDataTarget = dataTargets.find((candidate) => candidate.key === probe?.targetKey);
const expectedProbeWorkspaceKey = `databases/${String(probe?.targetKey ?? "").replace(/^databases\//, "")}`;
if (!probeDataTarget) {
errors.push(`${location}.probe.targetKey: must reference a declared runtime data target`);
} else {
const probeDataTargetTransport = transportByKey(probeDataTarget.transportKey);
if (probeDataTarget.kind !== "sqlite.snapshot" || probeDataTarget.workspaceKey !== expectedProbeWorkspaceKey || probeDataTarget.refreshPolicy !== "on-demand-snapshot" || !probeDataTargetTransport || probeDataTargetTransport.kind !== "sqlite" || !probeDataTargetTransport.capabilities?.includes("remote.run.db.sqlite.probe")) {
errors.push(`${location}.probe.targetKey: must reference a sqlite snapshot data target for the generated Run workspace`);
}
if (!Number.isInteger(probeDataTarget.maxBytes) || (probeDataTarget.maxBytes ?? 0) < 1 || (probeDataTarget.maxBytes ?? 0) > 1073741824) errors.push(`${location}.probe.targetKey.maxBytes: must be between 1 and 1073741824`);
}
const requireRefs = (entry: Record<string, unknown>, entryLocation: string, refs: string[]): void => {
for (const field of refs) {
const ref = entry[field];
if (typeof ref !== "string" || !isSafeRelativeJsonRef(ref)) errors.push(`${entryLocation}.${field}: must be a safe relative JSON schema reference`);
}
};
requireUniqueAssetKeys(liveData.logParsers, "logParsers");
for (const [index, parser] of (liveData.logParsers ?? []).entries()) {
const itemLocation = `${location}.logParsers[${index}]`;
requireAsset(parser, itemLocation);
if (!adapterPattern.test(parser.parserVersion ?? "")) errors.push(`${itemLocation}.parserVersion: must be a bounded parser version`);
if (!logSources.has(parser.sourceKey ?? "")) errors.push(`${itemLocation}.sourceKey: must reference a declared runtime log source`);
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(parser.eventType ?? "")) errors.push(`${itemLocation}.eventType: must be a safe event type`);
requireRefs(parser as Record<string, unknown>, itemLocation, ["eventSchemaRef"]);
if (!Number.isInteger(parser.maxLineBytes) || (parser.maxLineBytes ?? 0) < 1 || (parser.maxLineBytes ?? 0) > 65536) errors.push(`${itemLocation}.maxLineBytes: must be between 1 and 65536`);
if (parser.cursorPolicy !== "source-generation-sequence") errors.push(`${itemLocation}.cursorPolicy: must be source-generation-sequence`);
if (parser.privacy?.stripNetworkIdentifiers !== true || parser.privacy?.logicalEventIdentity !== "native-or-sanitized-fields") errors.push(`${itemLocation}.privacy: must strip network identifiers and use a privacy-safe logical identity`);
}
requireUniqueAssetKeys(liveData.sqliteQueries, "sqliteQueries");
for (const [index, query] of (liveData.sqliteQueries ?? []).entries()) {
const itemLocation = `${location}.sqliteQueries[${index}]`;
requireAsset(query, itemLocation);
if (!readCapabilities.has(query.capability ?? "")) errors.push(`${itemLocation}.capability: must be a read capability`);
if (!fingerprintPattern.test(query.requiredSchemaFingerprint ?? "")) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`);
requireCapabilityGateMatch(query.capability, query.adapterVersion, query.requiredSchemaFingerprint, [query.digest], itemLocation);
requireRefs(query as Record<string, unknown>, itemLocation, ["parameterSchemaRef", "resultSchemaRef"]);
if (!Number.isInteger(query.maxRows) || (query.maxRows ?? 0) < 1 || (query.maxRows ?? 0) > 500) errors.push(`${itemLocation}.maxRows: must be between 1 and 500`);
if (!Number.isInteger(query.timeoutMs) || (query.timeoutMs ?? 0) < 1 || (query.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`);
if (!Number.isInteger(query.maxResultBytes) || (query.maxResultBytes ?? 0) < 1 || (query.maxResultBytes ?? 0) > 1048576) errors.push(`${itemLocation}.maxResultBytes: must be between 1 and 1048576`);
const queryTransport = transportByKey(query.transportKey);
if (!queryTransport || queryTransport.kind !== "sqlite" || !queryTransport.capabilities?.includes("remote.run.db.sqlite.query") || queryTransport.targetKey !== query.targetKey) errors.push(`${itemLocation}.transportKey: must reference sqlite transport with remote.run.db.sqlite.query and matching targetKey`);
if (!declaredCapabilities.has("remote.run.db.sqlite.query") || !remoteCapabilities.has("remote.run.db.sqlite.query") || !remoteDatabaseEngines.has("sqlite")) errors.push(`${itemLocation}: sqlite query assets require plugin and remote-access sqlite query capability`);
}
for (const [index, cadence] of (liveData.syncCadences ?? []).entries()) {
const itemLocation = `${location}.syncCadences[${index}]`;
if (!readCapabilities.has(cadence.capability ?? "")) errors.push(`${itemLocation}.capability: must be a read capability`);
if (!Number.isInteger(cadence.intervalSeconds) || (cadence.intervalSeconds ?? 0) < 5 || (cadence.intervalSeconds ?? 0) > 86400) errors.push(`${itemLocation}.intervalSeconds: must be between 5 and 86400`);
if (!Number.isInteger(cadence.jitterPercent) || (cadence.jitterPercent ?? -1) < 0 || (cadence.jitterPercent ?? 101) > 100) errors.push(`${itemLocation}.jitterPercent: must be between 0 and 100`);
if (!Number.isInteger(cadence.timeoutMs) || (cadence.timeoutMs ?? 0) < 1 || (cadence.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`);
if (!Number.isInteger(cadence.maxConcurrentPerServer) || (cadence.maxConcurrentPerServer ?? 0) < 1 || (cadence.maxConcurrentPerServer ?? 0) > 16) errors.push(`${itemLocation}.maxConcurrentPerServer: must be between 1 and 16`);
}
requireUniqueAssetKeys(liveData.typedRconTemplates, "typedRconTemplates");
const typedRCONKeys = new Set((liveData.typedRconTemplates ?? []).map((template) => template.key ?? ""));
for (const [index, template] of (liveData.typedRconTemplates ?? []).entries()) {
const itemLocation = `${location}.typedRconTemplates[${index}]`;
requireAsset(template, itemLocation);
if (!["economy-command.write", "gift-command.write"].includes(template.capability ?? "")) errors.push(`${itemLocation}.capability: must be economy-command.write or gift-command.write`);
if (template.requiredSchemaFingerprint && !fingerprintPattern.test(template.requiredSchemaFingerprint)) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`);
requireCapabilityGateMatch(template.capability, template.adapterVersion, template.requiredSchemaFingerprint, [template.digest], itemLocation);
if (template.permission !== "server.game-client.command" || !declaredPermissions.has("server.game-client.command")) errors.push(`${itemLocation}.permission: must require declared server.game-client.command`);
requireRefs(template as Record<string, unknown>, itemLocation, ["payloadSchemaRef", "resultSchemaRef", "confirmationSchemaRef"]);
if (!Number.isInteger(template.timeoutMs) || (template.timeoutMs ?? 0) < 1 || (template.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`);
if (!Number.isInteger(template.maxPayloadBytes) || (template.maxPayloadBytes ?? 0) < 1 || (template.maxPayloadBytes ?? 0) > 65536) errors.push(`${itemLocation}.maxPayloadBytes: must be between 1 and 65536`);
const rconTransport = transportByKey(template.transportKey);
if (!rconTransport || rconTransport.kind !== "rcon" || !rconTransport.capabilities?.includes("remote.run.protected.rcon") || rconTransport.targetKey !== template.targetKey) errors.push(`${itemLocation}.transportKey: must reference rcon transport with remote.run.protected.rcon and matching targetKey`);
if (!declaredCapabilities.has("remote.run.protected.rcon") || !remoteCapabilities.has("remote.run.protected.rcon")) errors.push(`${itemLocation}: typed RCON templates require plugin and remote-access protected RCON capability`);
}
requireUniqueAssetKeys(liveData.guardedMutations, "guardedMutations");
for (const [index, mutation] of (liveData.guardedMutations ?? []).entries()) {
const itemLocation = `${location}.guardedMutations[${index}]`;
requireAsset(mutation, itemLocation);
if (mutation.capability !== "profile-xml.write") errors.push(`${itemLocation}.capability: must be profile-xml.write`);
if (!fingerprintPattern.test(mutation.requiredSchemaFingerprint ?? "")) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`);
requireCapabilityGateMatch(mutation.capability, mutation.adapterVersion, mutation.requiredSchemaFingerprint, [mutation.digest], itemLocation);
if (mutation.permission !== "server.game-client.maintenance" || !declaredPermissions.has("server.game-client.maintenance")) errors.push(`${itemLocation}.permission: must require declared server.game-client.maintenance`);
requireRefs(mutation as Record<string, unknown>, itemLocation, ["payloadSchemaRef", "resultSchemaRef", "confirmationSchemaRef"]);
if (!Number.isInteger(mutation.timeoutMs) || (mutation.timeoutMs ?? 0) < 1 || (mutation.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`);
if (!Number.isInteger(mutation.maxPayloadBytes) || (mutation.maxPayloadBytes ?? 0) < 1 || (mutation.maxPayloadBytes ?? 0) > 65536) errors.push(`${itemLocation}.maxPayloadBytes: must be between 1 and 65536`);
if (mutation.maxRowsAffected !== 1) errors.push(`${itemLocation}.maxRowsAffected: must be exactly 1`);
if (mutation.safety?.requiresExpectedChecksum !== true || mutation.safety?.requiresBackupEvidence !== true || mutation.safety?.requiresOfflineOrMaintenance !== true || mutation.safety?.requiresReadAfterWrite !== true) errors.push(`${itemLocation}.safety: must require checksum, backup evidence, offline/maintenance, and read-after-write`);
const mutationTransport = transportByKey(mutation.transportKey);
if (!mutationTransport || mutationTransport.kind !== "sqlite" || !mutationTransport.capabilities?.includes("remote.run.protected.sql") || mutationTransport.targetKey !== mutation.targetKey) errors.push(`${itemLocation}.transportKey: must reference sqlite transport with remote.run.protected.sql and matching targetKey`);
if (!declaredCapabilities.has("remote.run.protected.sql") || !remoteCapabilities.has("remote.run.protected.sql") || !remoteDatabaseEngines.has("sqlite")) errors.push(`${itemLocation}: guarded mutations require plugin and remote-access protected sqlite capability`);
}
requireUniqueAssetKeys(liveData.mapAssets, "mapAssets");
for (const [index, mapAsset] of (liveData.mapAssets ?? []).entries()) {
const itemLocation = `${location}.mapAssets[${index}]`;
requireAsset(mapAsset, itemLocation);
if (!fingerprintPattern.test(mapAsset.requiredSchemaFingerprint ?? "")) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`);
requireCapabilityGateMatch("positions.read", mapAsset.adapterVersion, mapAsset.requiredSchemaFingerprint, [mapAsset.digest, mapAsset.transformDigest], itemLocation);
requireRefs(mapAsset as Record<string, unknown>, itemLocation, ["metadataSchemaRef"]);
if (!mapAsset.transformAssetPath || !isSafeRelativePathRef(mapAsset.transformAssetPath)) errors.push(`${itemLocation}.transformAssetPath: must be a contained package-relative path`);
if (mapAsset.transformAssetPath && !assetFiles.has(mapAsset.transformAssetPath)) errors.push(`${itemLocation}.transformAssetPath: must be declared in manifest.assetFiles`);
if (!digestPattern.test(mapAsset.transformDigest ?? "")) errors.push(`${itemLocation}.transformDigest: must be a sha256 digest`);
if (mapAsset.transformAssetPath && mapAsset.transformDigest) validateSCUMAssetDigest(mapAsset.transformAssetPath, mapAsset.transformDigest, `${itemLocation}.transformDigest`);
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()) {
const itemLocation = `${location}.giftCatalogs[${index}]`;
requireAsset(catalog, itemLocation);
if (!adapterPattern.test(catalog.catalogVersion ?? "")) errors.push(`${itemLocation}.catalogVersion: must be a bounded catalog version`);
requireCapabilityGateMatch("gift-command.write", catalog.adapterVersion, undefined, [catalog.digest], itemLocation);
requireRefs(catalog as Record<string, unknown>, itemLocation, ["itemSchemaRef"]);
if (!Array.isArray(catalog.transportTemplateKeys) || catalog.transportTemplateKeys.length === 0) errors.push(`${itemLocation}.transportTemplateKeys: must reference at least one typed RCON transport template`);
for (const templateKey of catalog.transportTemplateKeys ?? []) {
if (!typedRCONKeys.has(templateKey)) errors.push(`${itemLocation}.transportTemplateKeys: undeclared typed RCON template ${templateKey}`);
}
}
return errors;
}
type GameClientBridgeSchemaReference = {
location: string;
ref: string;
@@ -1679,7 +1372,6 @@ export function validateManifestFile(manifestPath: string): string[] {
errors.push(...validateClientManagerProfiles(manifest));
errors.push(...validateDLLExtensionProfiles(manifest));
errors.push(...validateGameClientBridgeCatalog(manifest));
errors.push(...validateSCUMLiveDataManifest(manifest, manifestDir));
errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir));
errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir));
errors.push(...validateRuntimeLogEventCatalog(manifest));
+1 -156
View File
@@ -43,7 +43,6 @@ export type RunCapability =
| "remote.run.process.start"
| "remote.run.process.stop"
| "remote.run.db.mysql.query"
| "remote.run.db.sqlite.probe"
| "remote.run.db.sqlite.query"
| "remote.run.logs.transfer"
| "remote.run.rcon.command"
@@ -269,146 +268,6 @@ export interface GameClientBridgeQueryTemplateDeclaration {
export type GameClientBridgeOperationKind = "rcon" | "sqlite-mutation";
export type SCUMLiveDataCapability =
| "schema-probe"
| "players.read"
| "player-details.read"
| "squads.read"
| "squad-members.read"
| "vehicles.read"
| "flags.read"
| "positions.read"
| "profile-xml.write"
| "economy-command.write"
| "gift-command.write";
export type SCUMLiveDataGateState = "disabled" | "enabled";
export type SCUMLiveDataEvidenceStatus = "missing" | "compatible" | "incompatible" | "failed";
export interface SCUMLiveDataCapabilityGateDeclaration {
capability: SCUMLiveDataCapability;
gate: SCUMLiveDataGateState;
adapterVersion: string;
requiredSchemaFingerprint?: string;
requiredAssetDigests?: `sha256:${string}`[];
evidenceStatus: SCUMLiveDataEvidenceStatus;
safeReason: string;
}
export interface SCUMSchemaProbeBoundsDeclaration {
maxObjects: number;
maxColumnsPerObject: number;
maxIndexesPerObject: number;
maxForeignKeys: number;
maxCardinalityReads: number;
maxSampleRows: number;
timeoutMs: number;
maxResultBytes: number;
}
export interface SCUMSchemaProbeDeclaration {
capability: Extract<RunCapability, "remote.run.db.sqlite.probe">;
targetKey: string;
bounds: SCUMSchemaProbeBoundsDeclaration;
}
export type SCUMVersionedAssetDigest = `sha256:${string}`;
export type SCUMLiveDataReadCapability = Extract<SCUMLiveDataCapability, "players.read" | "player-details.read" | "squads.read" | "squad-members.read" | "vehicles.read" | "flags.read" | "positions.read">;
export type SCUMLiveDataWriteCapability = Extract<SCUMLiveDataCapability, "profile-xml.write" | "economy-command.write" | "gift-command.write">;
export interface SCUMVersionedAssetDeclaration {
key: string;
adapterVersion: string;
assetPath: string;
digest: SCUMVersionedAssetDigest;
}
export interface SCUMLoginLogParserDeclaration extends SCUMVersionedAssetDeclaration {
parserVersion: string;
sourceKey: string;
eventType: string;
eventSchemaRef: string;
maxLineBytes: number;
cursorPolicy: "source-generation-sequence";
privacy: { stripNetworkIdentifiers: true; logicalEventIdentity: "native-or-sanitized-fields" };
}
export interface SCUMSQLiteQueryAssetDeclaration extends SCUMVersionedAssetDeclaration {
capability: SCUMLiveDataReadCapability;
requiredSchemaFingerprint: string;
transportKey: string;
targetKey: string;
parameterSchemaRef: string;
resultSchemaRef: string;
maxRows: number;
timeoutMs: number;
maxResultBytes: number;
}
export interface SCUMLiveDataSyncCadenceDeclaration {
capability: SCUMLiveDataReadCapability;
intervalSeconds: number;
jitterPercent: number;
timeoutMs: number;
maxConcurrentPerServer: number;
}
export interface SCUMTypedRCONTemplateDeclaration extends SCUMVersionedAssetDeclaration {
capability: Extract<SCUMLiveDataWriteCapability, "economy-command.write" | "gift-command.write">;
requiredSchemaFingerprint?: string;
transportKey: string;
targetKey: string;
permission: Extract<PluginPermission, "server.game-client.command">;
payloadSchemaRef: string;
resultSchemaRef: string;
confirmationSchemaRef: string;
timeoutMs: number;
maxPayloadBytes: number;
}
export interface SCUMGuardedMutationDeclaration extends SCUMVersionedAssetDeclaration {
capability: Extract<SCUMLiveDataWriteCapability, "profile-xml.write">;
requiredSchemaFingerprint: string;
transportKey: string;
targetKey: string;
permission: Extract<PluginPermission, "server.game-client.maintenance">;
payloadSchemaRef: string;
resultSchemaRef: string;
confirmationSchemaRef: string;
timeoutMs: number;
maxPayloadBytes: number;
maxRowsAffected: 1;
safety: { requiresExpectedChecksum: true; requiresBackupEvidence: true; requiresOfflineOrMaintenance: true; requiresReadAfterWrite: true };
}
export interface SCUMMapAssetDeclaration extends SCUMVersionedAssetDeclaration {
requiredSchemaFingerprint: string;
metadataSchemaRef: string;
transformAssetPath: string;
transformDigest: SCUMVersionedAssetDigest;
worldBounds: { minX: number; minY: number; maxX: number; maxY: number };
image: { width: number; height: number };
}
export interface SCUMGiftCatalogDeclaration extends SCUMVersionedAssetDeclaration {
catalogVersion: string;
itemSchemaRef: string;
transportTemplateKeys: string[];
}
export interface SCUMLiveDataManifestDeclaration {
schemaVersion: "1";
probe: SCUMSchemaProbeDeclaration;
capabilityGates: SCUMLiveDataCapabilityGateDeclaration[];
logParsers?: SCUMLoginLogParserDeclaration[];
sqliteQueries?: SCUMSQLiteQueryAssetDeclaration[];
syncCadences?: SCUMLiveDataSyncCadenceDeclaration[];
typedRconTemplates?: SCUMTypedRCONTemplateDeclaration[];
guardedMutations?: SCUMGuardedMutationDeclaration[];
mapAssets?: SCUMMapAssetDeclaration[];
giftCatalogs?: SCUMGiftCatalogDeclaration[];
}
export interface GameClientBridgeOperationSafety {
requiresApproval?: boolean;
requiresOfflinePlayer?: boolean;
@@ -655,23 +514,11 @@ export interface RuntimeLogEventDeclaration {
export interface RuntimeTransportProfile {
key: string;
kind: "file" | "ftp" | "rsync" | "mysql" | "sqlite" | "rcon" | "program";
kind: "file" | "ftp" | "rsync" | "mysql" | "sqlite" | "rcon";
targetKey?: string;
capabilities: RunCapability[];
}
export interface RuntimeDataTargetDeclaration {
key: string;
kind: "sqlite.snapshot";
transportKey: string;
sourceRootKey: string;
sourcePath: string;
workspaceKey: string;
refreshPolicy: "on-demand-snapshot";
maxBytes: number;
platforms?: RuntimePlatform[];
}
export interface RuntimeClientManagerProfile {
key: string;
displayName?: string;
@@ -751,7 +598,6 @@ export interface GamePluginRuntimeProfiles {
logSources?: RuntimeLogSource[];
logEvents?: RuntimeLogEventDeclaration[];
transportProfiles?: RuntimeTransportProfile[];
dataTargets?: RuntimeDataTargetDeclaration[];
clientManagers?: RuntimeClientManagerProfile[];
dllExtensions?: RuntimeDLLExtensionProfile[];
}
@@ -876,7 +722,6 @@ export interface GamePluginManifest {
remoteAccess?: GamePluginRemoteAccess;
runtimeProfiles?: GamePluginRuntimeProfiles;
gameClientBridge?: GameClientBridgeManifest;
scumLiveData?: SCUMLiveDataManifestDeclaration;
actions?: GamePluginActions;
assetFiles?: PluginAssetFile[];
productionLifecycle: {
+22
View File
@@ -0,0 +1,22 @@
export const scumMigrationParityFixtures = {
configuration: {
source: { id: "config-1", observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: 64, hostPath: "/srv/scum", RconPassword: "not-migrated" } },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "config-1", recordedAt: "2026-07-29T00:00:00Z", payload: { observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: "64" } } }
},
playerHistory: {
source: { updatedAt: "2026-07-29T00:10:00Z", player: { id: "player-1", gamePlayerId: "steam-1", displayName: "Mira", lastSeenAt: "2026-07-29T00:09:00Z", online: true }, sessions: [{ id: "session-1", startedAt: "2026-07-29T00:01:00Z", endedAt: "2026-07-29T00:08:00Z", networkFingerprint: "not-migrated" }], accessAttempts: [{ occurredAt: "2026-07-29T00:02:00Z", outcome: "review", reason: "manual review", networkCorrelationKey: "not-migrated" }], securitySignals: [{ lastObservedAt: "2026-07-29T00:03:00Z", ruleKey: "repeat-access", summary: "manual review", evidenceCount: 2 }] },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "player-1", recordedAt: "2026-07-29T00:10:00Z", payload: { player: { id: "player-1", gamePlayerId: "steam-1", displayName: "Mira", lastSeenAt: "2026-07-29T00:09:00Z", status: "online" }, sessions: [{ id: "session-1", playerId: "player-1", kind: "logout", occurredAt: "2026-07-29T00:08:00Z" }], risks: [{ kind: "review", level: "medium", observedAt: "2026-07-29T00:02:00Z", summary: "manual review" }, { kind: "repeat-access", level: "medium", observedAt: "2026-07-29T00:03:00Z", summary: "manual review" }] } }
},
gift: {
source: { id: "gift-1", revisionId: "revision-1", gamePlayerRecordId: "player-1", notice: "Welcome", status: "unknown", createdAt: "2026-07-29T00:20:00Z", completedAt: "2026-07-29T00:21:00Z", deliveryCommandId: "not-migrated" },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "gift-1", recordedAt: "2026-07-29T00:20:00Z", payload: { id: "gift-1", revisionId: "revision-1", playerId: "player-1", notice: "Welcome", status: "unknown", createdAt: "2026-07-29T00:20:00Z", completedAt: "2026-07-29T00:21:00Z" } }
},
statePatch: {
source: { id: "patch-1", gamePlayerRecordId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "execution-unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }], bridgeCommandId: "not-migrated" },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "patch-1", recordedAt: "2026-07-29T00:30:00Z", payload: { id: "patch-1", playerId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] } }
},
trajectory: {
source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "log-projection" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "trajectory-1", recordedAt: "2026-07-29T00:40:00Z", payload: { subjectId: "player-1", subjectType: "player", provenance: "transitional-read-only", points: [{ occurredAt: "2026-07-29T00:39:00Z", subjectId: "player-1", subjectType: "player", x: 10, y: 20, source: "transitional-read-only" }] } }
}
} as const;
+27 -418
View File
@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -31,7 +30,6 @@ import {
type GameClientBridgeProtectedRequestDeclaration,
type GameClientBridgeCompanionDeclaration,
type GamePluginManifest,
type SCUMLiveDataManifestDeclaration,
type RuntimeLogEventDeclaration,
type RuntimeClientManagerProfile,
type PluginLifecycleActionDeclaration,
@@ -80,97 +78,6 @@ function writeFixtureJSON(fixtureDir: string, relativePath: string, value: unkno
fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
const currentSCUMSchemaFingerprint = "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0";
const scumSQLiteQueryAssetVersion = "scum-sqlite-query-v1";
const scumPaginatedQueryKeys = new Set(["scum-players-read", "scum-squads-read", "scum-squad-members-read", "scum-vehicles-read", "scum-flags-read", "scum-positions-read"]);
const unsafeSCUMSQLiteReadStatementPattern = /;|\b(INSERT|UPDATE|DELETE|REPLACE|DROP|ALTER|CREATE|VACUUM|ATTACH|DETACH|ANALYZE|REINDEX)\b|\bload_extension\s*\(|\bPRAGMA\s+(?!(?:table_info|foreign_key_list|index_list|index_info|schema_version|data_version)\b)/i;
type SCUMSQLiteQueryAssetParameter = {
name: string;
binding: string;
type?: string;
required?: boolean;
nullable?: boolean;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
enum?: string[];
};
type SCUMSQLiteQueryAsset = {
key?: string;
adapterVersion?: string;
queryVersion?: string;
capability?: string;
requiredSchemaFingerprint?: string;
statementType?: string;
statement?: string;
parameters?: SCUMSQLiteQueryAssetParameter[];
safety?: {
queryOnly?: boolean;
readOnlyConnection?: boolean;
forbidMultipleStatements?: boolean;
forbidAttach?: boolean;
forbidWritePragmas?: boolean;
forbidExtensionLoading?: boolean;
maxRows?: number;
timeoutMs?: number;
maxResultBytes?: number;
};
};
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;
required?: string[];
properties?: Record<string, Record<string, unknown>>;
};
function sortedValues(values: Iterable<string>): string[] {
return [...values].sort((left, right) => left.localeCompare(right));
}
function extractSQLiteParameterBindings(statement: string): string[] {
return [...statement.matchAll(/:[A-Za-z_][A-Za-z0-9_]*/g)].map((match) => match[0]);
}
function expectSCUMSQLiteReadStatementBoundary(statementType: string | undefined, statement: string | undefined): void {
expect(statementType).toBe("single-select-or-cte");
const trimmed = statement?.trim() ?? "";
expect(trimmed).toMatch(/^(WITH|SELECT)\b/i);
expect(trimmed).not.toMatch(unsafeSCUMSQLiteReadStatementPattern);
}
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);
@@ -283,306 +190,16 @@ describe("plugin manifest validation", () => {
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
});
it("removes unverified SCUM query and operation declarations", () => {
it("removes raw protected SQL and management request command surfaces", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; protectedRequest?: { kind: string } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } };
const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest);
expect(commands).toEqual([]);
expect(manifest.gameClientBridge.commands.map((command) => command.type)).not.toEqual(expect.arrayContaining(["config.read", "config.patch", "database.request", "management.rcon.request", "management.program.request"]));
expect(manifest.gameClientBridge.queryTemplates).toEqual([]);
expect(manifest.gameClientBridge.operationTemplates).toEqual([]);
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(false);
});
it("matches SCUM live-data gates to the current-service adapter matrix", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as GamePluginManifest & { scumLiveData: SCUMLiveDataManifestDeclaration; remoteAccess: { runCapabilities: string[] } };
expect(manifest.capabilities).toContain("remote.run.db.sqlite.probe");
expect(manifest.remoteAccess.runCapabilities).toContain("remote.run.db.sqlite.probe");
expect(manifest.scumLiveData.probe).toMatchObject({ capability: "remote.run.db.sqlite.probe", targetKey: "scum-database" });
expect(manifest.runtimeProfiles?.dataTargets?.find((target) => target.key === "scum-database")).toMatchObject({ kind: "sqlite.snapshot", transportKey: "scum-database", sourceRootKey: "server-root", sourcePath: "SCUM/Saved/SaveFiles/SCUM.db", workspaceKey: "databases/scum-database", refreshPolicy: "on-demand-snapshot" });
const gates = new Map(manifest.scumLiveData.capabilityGates.map((gate) => [gate.capability, gate]));
expect(gates.get("schema-probe")).toMatchObject({ gate: "enabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: currentSCUMSchemaFingerprint, evidenceStatus: "compatible" });
const queryDigestsByCapability = new Map<string, string[]>();
for (const query of manifest.scumLiveData.sqliteQueries ?? []) {
const digests = queryDigestsByCapability.get(query.capability) ?? [];
digests.push(query.digest);
queryDigestsByCapability.set(query.capability, digests);
}
const mapAsset = manifest.scumLiveData.mapAssets?.[0];
if (mapAsset) queryDigestsByCapability.set("positions.read", [...(queryDigestsByCapability.get("positions.read") ?? []), mapAsset.digest, mapAsset.transformDigest]);
for (const [capability, digests] of queryDigestsByCapability) {
const gate = gates.get(capability as any);
expect(gate).toMatchObject({ gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: currentSCUMSchemaFingerprint, evidenceStatus: "missing" });
for (const digest of digests) expect(gate?.requiredAssetDigests).toContain(digest);
}
expect([...gates.keys()]).toEqual(expect.arrayContaining(["players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"]));
for (const capability of ["profile-xml.write", "economy-command.write", "gift-command.write"] as const) expect(gates.get(capability)).toMatchObject({ gate: "disabled", adapterVersion: "scum-live-data-v1", evidenceStatus: "missing" });
expect(JSON.stringify(manifest.scumLiveData).toLowerCase()).not.toMatch(/select\s+.+from|sqlite:\/\/|mysql:\/\/|password|credential|socket|hostpath/);
});
it("omits unverified SCUM typed RCON templates and gift catalogs", () => {
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 liveData = manifest.scumLiveData;
const gates = new Map(liveData.capabilityGates.map((gate) => [gate.capability, gate]));
expect(liveData.typedRconTemplates ?? []).toEqual([]);
expect(liveData.giftCatalogs ?? []).toEqual([]);
expect(gates.get("economy-command.write")).toMatchObject({ gate: "disabled", evidenceStatus: "missing" });
expect(gates.get("gift-command.write")).toMatchObject({ gate: "disabled", evidenceStatus: "missing" });
expect((manifest.assetFiles ?? []).map((file) => file.path).filter((assetPath) => /assets\/scum-live\/(?:rcon|gifts)\//.test(assetPath))).toEqual([]);
expect(JSON.stringify(liveData).toLowerCase()).not.toMatch(/#setcurrency|#setfame|sendchat|commandtext|rawcommand|starter-pack|bandage|water-bottle|improvised-spear/);
});
it("packages SCUM live read query assets with exact bounded result schemas", () => {
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 queries = manifest.scumLiveData.sqliteQueries ?? [];
expect(manifest.scumLiveData.schemaVersion).toBe("1");
expect(queries.map((query) => query.key)).toEqual([
"scum-players-read",
"scum-player-details-read",
"scum-player-economy-read",
"scum-player-session-enrichment-read",
"scum-squads-read",
"scum-squad-members-read",
"scum-vehicles-read",
"scum-flags-read",
"scum-positions-read"
]);
for (const query of queries) {
expect(assetPaths.has(query.assetPath)).toBe(true);
const assetPath = path.join(pluginDir, query.assetPath);
const actualDigest = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(assetPath)).digest("hex")}`;
expect(query.digest).toBe(actualDigest);
const asset = JSON.parse(fs.readFileSync(assetPath, "utf8")) as SCUMSQLiteQueryAsset;
expect(asset).toMatchObject({ key: `${query.key}-v1`, adapterVersion: query.adapterVersion, queryVersion: scumSQLiteQueryAssetVersion, capability: query.capability });
expect(query.requiredSchemaFingerprint).toBe(currentSCUMSchemaFingerprint);
expect(asset.requiredSchemaFingerprint).toBe(query.requiredSchemaFingerprint);
expectSCUMSQLiteReadStatementBoundary(asset.statementType, asset.statement);
expect(query.maxRows).toBeGreaterThan(0);
expect(query.maxRows).toBeLessThanOrEqual(500);
expect(query.timeoutMs).toBeGreaterThan(0);
expect(query.timeoutMs).toBeLessThanOrEqual(60000);
expect(query.maxResultBytes).toBeGreaterThan(0);
expect(query.maxResultBytes).toBeLessThanOrEqual(1048576);
expect(asset.safety).toMatchObject({ queryOnly: true, readOnlyConnection: true, forbidMultipleStatements: true, forbidAttach: true, forbidWritePragmas: true, forbidExtensionLoading: true });
expect(asset.safety?.maxRows).toBeLessThanOrEqual(query.maxRows);
expect(asset.safety?.timeoutMs).toBeLessThanOrEqual(query.timeoutMs);
expect(asset.safety?.maxResultBytes).toBeLessThanOrEqual(query.maxResultBytes);
if (["scum-squads-read", "scum-squad-members-read", "scum-vehicles-read", "scum-flags-read"].includes(query.key)) {
expect(asset.statement).toContain("CAST(NULL");
}
for (const schemaRef of [query.parameterSchemaRef, query.resultSchemaRef]) {
expect(fs.existsSync(path.join(pluginDir, schemaRef))).toBe(true);
}
const parameterSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, query.parameterSchemaRef), "utf8")) as JSONSchemaObject;
const parameters = asset.parameters ?? [];
const parameterNames = parameters.map((parameter) => parameter.name);
const parameterBindings = new Set(extractSQLiteParameterBindings(asset.statement ?? ""));
expect(parameterSchema).toMatchObject({ type: "object", additionalProperties: false });
expect(new Set(parameterNames).size).toBe(parameterNames.length);
expect(sortedValues(parameterSchema.required ?? [])).toEqual(sortedValues(parameterNames));
expect(sortedValues(Object.keys(parameterSchema.properties ?? {}))).toEqual(sortedValues(parameterNames));
for (const parameter of parameters) {
expect(parameter.required).toBe(true);
expect(parameter.binding).toBe(`:${parameter.name}`);
expect(parameterBindings.has(parameter.binding)).toBe(true);
const schemaProperty = parameterSchema.properties?.[parameter.name] ?? {};
if (parameter.enum !== undefined) {
expect(schemaProperty.enum).toEqual(parameter.enum);
if (schemaProperty.type !== undefined) expect(schemaProperty.type).toBe(parameter.type);
} else if (parameter.type !== undefined) {
expect(schemaProperty.type).toEqual(parameter.nullable ? [parameter.type, "null"] : parameter.type);
}
for (const bound of ["minimum", "maximum", "minLength", "maxLength"] as const) {
if (parameter[bound] !== undefined) expect(schemaProperty[bound]).toBe(parameter[bound]);
}
}
expect(sortedValues(parameterBindings)).toEqual(sortedValues(parameters.map((parameter) => parameter.binding)));
if (scumPaginatedQueryKeys.has(query.key)) {
const limit = parameters.find((parameter) => parameter.name === "limit");
const offset = parameters.find((parameter) => parameter.name === "offset");
expect(limit).toMatchObject({ binding: ":limit", type: "integer", required: true, minimum: 1 });
expect(limit?.maximum).toBeLessThanOrEqual(query.maxRows);
expect(offset).toMatchObject({ binding: ":offset", type: "integer", required: true, minimum: 0 });
expect(offset?.maximum).toBeLessThanOrEqual(1000000);
expect(asset.statement).toMatch(/\bLIMIT\s+:limit\b/i);
expect(asset.statement).toMatch(/\bOFFSET\s+:offset\b/i);
}
const resultSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, query.resultSchemaRef), "utf8")) as { properties?: { rows?: { maxItems?: number } } };
expect(resultSchema.properties?.rows?.maxItems).toBeLessThanOrEqual(asset.safety?.maxRows ?? query.maxRows);
}
});
it("guards SCUM live query asset SQL boundary rules against unsafe input classes", () => {
const rejectedStatements = [
"SELECT 1; SELECT 2",
"INSERT INTO players VALUES (1)",
"UPDATE players SET name = 'x'",
"DELETE FROM players",
"DROP TABLE players",
"ALTER TABLE players ADD COLUMN x INTEGER",
"CREATE TABLE unsafe (id INTEGER)",
"ATTACH DATABASE 'other.db' AS other",
"DETACH DATABASE other",
"SELECT load_extension('unsafe')",
"PRAGMA journal_mode = WAL",
"PRAGMA writable_schema = ON"
];
const allowedReadBoundaries = ["SELECT 1", "WITH one AS (SELECT 1) SELECT * FROM one", "PRAGMA table_info('players')", "PRAGMA foreign_key_list('players')"];
for (const statement of rejectedStatements) expect(statement).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", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.runtimeProfiles.dataTargets = [];
});
expect(errors.some((error) => error.includes("probe.targetKey") && error.includes("runtime data target"))).toBe(true);
});
it("rejects enabling SCUM live-data gates without compatible evidence and immutable digests", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.scumLiveData.capabilityGates[1] = { capability: "players.read", gate: "enabled", adapterVersion: "scum-live-data-v1", evidenceStatus: "missing", safeReason: "waiting for current service evidence" };
});
expect(errors.some((error) => error.includes("enabled gates require compatible evidence"))).toBe(true);
expect(errors.some((error) => error.includes("enabled gates require a schema fingerprint"))).toBe(true);
expect(errors.some((error) => error.includes("enabled gates require immutable asset digests"))).toBe(true);
});
it("accepts safe SCUM live-data asset declarations without enabling unproven capabilities", () => {
const fingerprint = `sha256:${"f".repeat(64)}`;
const assetPaths = [
"assets/scum-live/login-parser.json",
"assets/scum-live/queries/fixture-players-read.json",
"assets/scum-live/rcon/gift-grant.json",
"assets/scum-live/mutations/profile-xml-patch.json",
"assets/scum-live/map/island.png",
"assets/scum-live/map/transform.json",
"assets/scum-live/gifts/catalog.json"
];
const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => {
manifest.assetFiles = [...manifest.assetFiles, ...assetPaths.map((assetPath) => ({ path: assetPath, mode: 384 }))];
for (const assetPath of [assetPaths[0], assetPaths[1], assetPaths[2], assetPaths[3], assetPaths[6]]) writeFixtureJSON(fixtureDir, assetPath, { packaged: true, assetPath });
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 giftGrantDigest = digest(assetPaths[2]);
const mutationDigest = digest(assetPaths[3]);
const mapDigest = digest(assetPaths[4]);
const transformDigest = digest(assetPaths[5]);
const catalogDigest = digest(assetPaths[6]);
manifest.scumLiveData = {
...manifest.scumLiveData,
capabilityGates: [
{ capability: "schema-probe", gate: "disabled", adapterVersion: "scum-live-data-v1", evidenceStatus: "missing", safeReason: "waiting for current service evidence" },
{ capability: "players.read", gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, requiredAssetDigests: [digest(assetPaths[1])], evidenceStatus: "missing", safeReason: "waiting for query execution evidence" },
{ capability: "positions.read", gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, requiredAssetDigests: [mapDigest, transformDigest], evidenceStatus: "missing", safeReason: "waiting for map execution evidence" },
{ capability: "gift-command.write", gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, requiredAssetDigests: [giftGrantDigest, catalogDigest], evidenceStatus: "missing", safeReason: "waiting for gift command evidence" },
{ capability: "profile-xml.write", gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, requiredAssetDigests: [mutationDigest], evidenceStatus: "missing", safeReason: "waiting for mutation safety evidence" }
],
logParsers: [{ key: "login-parser", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[0], digest: digest(assetPaths[0]), parserVersion: "login-v1", sourceKey: "scum-login-events", eventType: "scum.login", eventSchemaRef: "schemas/scum-live/login-event.schema.json", maxLineBytes: 4096, cursorPolicy: "source-generation-sequence", privacy: { stripNetworkIdentifiers: true, logicalEventIdentity: "native-or-sanitized-fields" } }],
sqliteQueries: [{ key: "players-read", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[1], digest: digest(assetPaths[1]), capability: "players.read", requiredSchemaFingerprint: fingerprint, transportKey: "scum-database", targetKey: "scum-database", parameterSchemaRef: "schemas/scum-live/players-read.parameters.schema.json", resultSchemaRef: "schemas/scum-live/players-read.result.schema.json", maxRows: 100, timeoutMs: 5000, maxResultBytes: 65536 }],
syncCadences: [{ capability: "players.read", intervalSeconds: 300, jitterPercent: 20, timeoutMs: 5000, maxConcurrentPerServer: 1 }],
typedRconTemplates: [{ key: "gift-grant", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[2], digest: giftGrantDigest, capability: "gift-command.write", requiredSchemaFingerprint: fingerprint, transportKey: "scum-management", targetKey: "scum-management", permission: "server.game-client.command", payloadSchemaRef: "schemas/scum-live/gift-grant.payload.schema.json", resultSchemaRef: "schemas/scum-live/gift-grant.result.schema.json", confirmationSchemaRef: "schemas/scum-live/gift-grant.confirmation.schema.json", timeoutMs: 5000, maxPayloadBytes: 4096 }],
guardedMutations: [{ key: "profile-xml-patch", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[3], digest: mutationDigest, capability: "profile-xml.write", requiredSchemaFingerprint: fingerprint, transportKey: "scum-database", targetKey: "scum-database", permission: "server.game-client.maintenance", payloadSchemaRef: "schemas/scum-live/profile-xml-patch.payload.schema.json", resultSchemaRef: "schemas/scum-live/profile-xml-patch.result.schema.json", confirmationSchemaRef: "schemas/scum-live/profile-xml-patch.confirmation.schema.json", timeoutMs: 10000, maxPayloadBytes: 8192, maxRowsAffected: 1, safety: { requiresExpectedChecksum: true, requiresBackupEvidence: true, requiresOfflineOrMaintenance: true, requiresReadAfterWrite: true } }],
mapAssets: [{ key: "island-map", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[4], digest: mapDigest, requiredSchemaFingerprint: fingerprint, metadataSchemaRef: "schemas/scum-live/map-metadata.schema.json", transformAssetPath: assetPaths[5], transformDigest, worldBounds: { minX: -100000, minY: -100000, maxX: 100000, maxY: 100000 }, image: { width: 4096, height: 4096 } }],
giftCatalogs: [{ key: "starter-gifts", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[6], digest: catalogDigest, catalogVersion: "catalog-v1", itemSchemaRef: "schemas/scum-live/gift-item.schema.json", transportTemplateKeys: ["gift-grant"] }]
};
});
expect(errors).toEqual([]);
});
it("rejects SCUM live-data asset digests that do not match packaged files", () => {
const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => {
const assetPath = "assets/scum-live/login-parser.json";
manifest.assetFiles = [...manifest.assetFiles, { path: assetPath, mode: 384 }];
writeFixtureJSON(fixtureDir, assetPath, { packaged: true });
manifest.scumLiveData.logParsers = [{ key: "login-parser", adapterVersion: "scum-live-data-v1", assetPath, digest: `sha256:${"a".repeat(64)}`, parserVersion: "login-v1", sourceKey: "scum-login-events", eventType: "scum.login", eventSchemaRef: "schemas/scum-live/login-event.schema.json", maxLineBytes: 4096, cursorPolicy: "source-generation-sequence", privacy: { stripNetworkIdentifiers: true, logicalEventIdentity: "native-or-sanitized-fields" } }];
});
expect(errors.some((error) => error.includes("digest does not match packaged asset content"))).toBe(true);
});
it("rejects unsafe SCUM live-data asset declarations", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.scumLiveData.sqliteQueries = [{ key: "players-read", adapterVersion: "scum-live-data-v1", assetPath: "/srv/scum/SCUM.db", digest: "sha256:bad", capability: "players.read", requiredSchemaFingerprint: "sha256:bad", transportKey: "scum-database", targetKey: "scum-database", parameterSchemaRef: "schemas/scum-live/players-read.parameters.schema.json", resultSchemaRef: "schemas/scum-live/players-read.result.schema.json", maxRows: 1000, timeoutMs: 70000, maxResultBytes: 2097152, rawSql: "SELECT * FROM prisoner" }];
manifest.scumLiveData.giftCatalogs = [{ key: "starter-gifts", adapterVersion: "scum-live-data-v1", assetPath: "assets/scum-live/gifts/catalog.json", digest: `sha256:${"2".repeat(64)}`, catalogVersion: "catalog-v1", itemSchemaRef: "schemas/scum-live/gift-item.schema.json", transportTemplateKeys: ["missing-template"] }];
});
expect(errors.some((error) => error.includes("assetPath") && error.includes("contained package-relative path"))).toBe(true);
expect(errors.some((error) => error.includes("digest") && error.includes("sha256"))).toBe(true);
expect(errors.some((error) => error.includes("must NOT have additional properties") || error.includes("additional properties"))).toBe(true);
expect(errors.some((error) => error.includes("transportTemplateKeys") && error.includes("missing-template"))).toBe(true);
});
it("rejects SCUM typed RCON templates without conclusive confirmation schemas", () => {
const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => {
const assetPath = "assets/scum-live/rcon/unverified-gift.json";
manifest.assetFiles = [...(manifest.assetFiles ?? []), { path: assetPath, mode: 384 }];
writeFixtureJSON(fixtureDir, assetPath, { packaged: true });
writeFixtureJSON(fixtureDir, "schemas/scum-live/unverified-gift.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]));
writeFixtureJSON(fixtureDir, "schemas/scum-live/unverified-gift.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["succeeded", "failed", "unknown"] } }, ["outcome"]));
manifest.scumLiveData.typedRconTemplates = [{ key: "unverified-gift", adapterVersion: "scum-live-data-v1", assetPath, digest: sha256FixtureDigest(fixtureDir, assetPath), capability: "gift-command.write", requiredSchemaFingerprint: currentSCUMSchemaFingerprint, transportKey: "scum-management", targetKey: "scum-management", permission: "server.game-client.command", payloadSchemaRef: "schemas/scum-live/unverified-gift.payload.schema.json", resultSchemaRef: "schemas/scum-live/unverified-gift.result.schema.json", timeoutMs: 5000, maxPayloadBytes: 2048 }];
});
expect(errors.some((error) => error.includes("confirmationSchemaRef"))).toBe(true);
expect(manifest.gameClientBridge.queryTemplates.map((query) => query.key)).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"]));
expect(manifest.gameClientBridge.operationTemplates.map((operation) => operation.key)).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver", "player.attribute.855.set"]));
expect(manifest.gameClientBridge.operationTemplates.find((operation) => operation.key === "player.attribute.855.set")?.kind).toBe("sqlite-mutation");
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
});
it("declares SCUM install/update and start lifecycle through plugin assets", () => {
@@ -647,7 +264,6 @@ describe("plugin manifest validation", () => {
runtimeProfiles?: {
lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[] }>;
transportProfiles?: Array<{ key?: string; kind?: string; capabilities?: string[] }>;
dataTargets?: Array<Record<string, unknown>>;
};
};
const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local");
@@ -658,9 +274,6 @@ describe("plugin manifest validation", () => {
expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.protected.rcon"] }),
expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] })
]));
expect(manifest.runtimeProfiles?.dataTargets).toEqual(expect.arrayContaining([
expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot", workspaceKey: "databases/scum-database", refreshPolicy: "on-demand-snapshot" })
]));
});
it("defines a generated SCUM companion config without inline proof or session material", () => {
@@ -841,7 +454,7 @@ describe("plugin manifest validation", () => {
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[]; operationKeys?: string[] }>;
};
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
pages: Array<{ key: string; permissions?: string[] }>;
fileWorkspace?: {
defaultDirectoryKey: string;
directories: Array<{ key: string; label: string; scope: string }>;
@@ -878,13 +491,18 @@ describe("plugin manifest validation", () => {
"maintenance.prepare"
]));
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(["players", "squads", "live-map", "gifts"]);
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config");
expect(manifest.gameClientBridge.pages.every((page) => !(page.queryTemplateKeys?.length) && !(page.operationKeys?.length))).toBe(true);
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")).toBeUndefined();
expect(manifest.pages.map((page) => page.key)).toEqual(["players", "squads", "live-map", "gifts"]);
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "players")?.operationKeys).toEqual(expect.arrayContaining([
"player.fame.set",
"player.currency.normal.set",
"player.currency.gold.set",
"player.notify",
"player.attribute.855.set"
]));
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.vehicles", "scum.flags", "scum.positions"]));
expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
expect(manifest.pages.map((page) => page.key)).not.toContain("files-config");
expect(manifest.pages.every((page) => !(page.bridgeActions ?? []).includes("remote.access.request"))).toBe(true);
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
expect(manifest.fileWorkspace).toBeUndefined();
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
@@ -963,7 +581,7 @@ describe("plugin manifest validation", () => {
}
});
it("declares bounded SCUM snapshot schemas for local management reads", () => {
it("declares bounded SCUM snapshot schemas for operations projections", () => {
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
gameClientBridge: {
@@ -999,16 +617,7 @@ describe("plugin manifest validation", () => {
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "vehicles", "flags"]));
});
it("rejects the retired SCUM log-projection source in map event schemas", () => {
const schemaDir = path.join(pluginsRoot, "examples/scum-server-plugin/schemas/log-events");
for (const filename of ["player-position.event.schema.json", "vehicle-position.event.schema.json", "player-vehicle-enter.event.schema.json", "player-vehicle-leave.event.schema.json"]) {
const schema = JSON.parse(fs.readFileSync(path.join(schemaDir, filename), "utf8")) as { properties?: { source?: { const?: string; enum?: string[] } } };
expect(schema.properties?.source).toEqual({ const: "companion" });
expect(schema.properties?.source?.enum).toBeUndefined();
}
});
it("keeps SCUM.db query templates absent until current-service evidence exists", () => {
it("declares typed SCUM.db query templates without browser-visible SQL", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
permissions: string[];
@@ -1032,7 +641,7 @@ describe("plugin manifest validation", () => {
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
};
const expectedKeys: string[] = [];
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"];
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
@@ -1056,17 +665,17 @@ describe("plugin manifest validation", () => {
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
expect(playersPage?.queryTemplateKeys).toBeUndefined();
expect(squadsPage?.queryTemplateKeys).toBeUndefined();
expect(mapPage?.queryTemplateKeys).toBeUndefined();
expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions"]));
expect(squadsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.squads", "scum.squad-members", "scum.flags"]));
expect(mapPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.vehicles", "scum.flags", "scum.positions"]));
for (const pageKey of ["players", "squads", "live-map"]) {
const pluginPage = manifest.pages.find((page) => page.key === pageKey);
expect(pluginPage?.permissions).toContain("server.game-client.read");
expect(pluginPage?.bridgeActions).not.toContain("remote.access.request");
expect(pluginPage?.bridgeActions).toContain("remote.access.request");
}
});
it("keeps SCUM RCON operation templates absent until command confirmation is verified", () => {
it("declares typed SCUM RCON operations without arbitrary command inputs", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
gameClientBridge: {
@@ -1075,7 +684,7 @@ describe("plugin manifest validation", () => {
};
pages: Array<{ key: string; permissions?: string[] }>;
};
const expectedKeys: string[] = [];
const expectedKeys = ["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver"];
const operationsByKey = new Map(manifest.gameClientBridge.operationTemplates.map((operation) => [operation.key, operation]));
expect([...operationsByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
for (const key of expectedKeys) {
@@ -1094,8 +703,8 @@ describe("plugin manifest validation", () => {
}
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
const giftsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "gifts");
expect(playersPage?.operationKeys).toBeUndefined();
expect(giftsPage?.operationKeys).toBeUndefined();
expect(playersPage?.operationKeys).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify"]));
expect(giftsPage?.operationKeys).toEqual(expect.arrayContaining(["reward.deliver", "player.notify"]));
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toContain("server.game-client.command");
expect(manifest.pages.find((page) => page.key === "gifts")?.permissions).toContain("server.game-client.command");
});
+152 -15
View File
@@ -3,35 +3,172 @@ import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
import { configurationCatalog, validateConfigPatch } from "../examples/scum-server-plugin/features/schemas.js";
import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js";
import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js";
const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8");
const projectionData = {
players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, position: { x: 10, y: 20, z: 3, hasCoordinates: true }, freshness: { status: "fresh" }, unknownFields: { "855": 100 } }],
squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 3, leaderProfileId: "profile-1", freshness: { status: "fresh" } }],
members: [{ gamePlayerId: "steam-1", displayName: "Mira", squadId: "squad-1", rank: "Leader", freshness: { status: "fresh" } }],
vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { subjectType: "vehicle", subjectId: "veh-1", x: 400, y: 200, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
flags: [{ flagId: "flag-1", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { subjectType: "flag", subjectId: "flag-1", x: 100, y: 80, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
positions: [{ subjectType: "player", subjectId: "steam-1", gamePlayerId: "steam-1", x: 10, y: 20, z: 3, hasCoordinates: true, freshness: { status: "fresh" } }],
operations: [{ id: "op-1", templateKey: "player.fame.set", status: "waiting", safeSummary: { message: "awaiting approval" } }],
workflows: [{ id: "wf-1", templateKey: "scum.world-refresh", status: "queued", currentStepKey: "read-positions", createdAt: "2026-08-10T00:00:00Z", safeSummary: { message: "world refresh queued" } }],
steps: [{ stepKey: "read-positions", status: "queued", capability: "remote.run.db.sqlite.query", safeSummary: { message: "queued safely" } }]
};
describe("SCUM plugin feature module", () => {
it("owns runtime allowlists without a version gate", () => {
expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message");
expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围");
expect(validateStatePatch([{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull();
expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toContain("白名单");
expect(vehicleSpawnCatalog.map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]);
expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull();
expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效");
expect(validateVehicleSpawn({ vehicleCode: "BPC_Unknown_C" })).toContain("受控目录");
});
it("keeps the five management pages free of legacy snapshot reads and fake controls", () => {
for (const pageKey of ["players", "squads", "live-map", "gifts", "workflows"]) {
const view = renderAndCollect(pageKey);
expect(view.texts.join("\n")).toContain("不会读取遗留快照");
}
for (const removed of ["listSCUM", "map-local-board", "Fame +100", "starter-pack", "855", "Workflow", "投影"]) expect(pageSource).not.toContain(removed);
it("maps transitional records only as read-only provenance", () => {
expect(migratePlayerRecord({ id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" })).toMatchObject({ provenance: "transitional-read-only", readOnly: true, payload: { gamePlayerId: "steam-1" } });
expect(migrateTrajectoryRecord({ playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ provenance: "transitional-read-only", points: [{ x: 10, y: 20 }] });
});
it("falls legacy routes back to user management", () => {
const view = renderAndCollect("workflows");
it("preserves only allowlisted transitional history for every feature area", () => {
expect(migrateConfigurationRecord({ id: "cfg-1", version: "0.9.700.90357", fields: { MaxPlayers: 64 }, observedAt: "2026-07-29T00:00:00Z", hostPath: "C:/secret" })).toMatchObject({ readOnly: true, payload: { fields: { MaxPlayers: "64" } } });
expect(migratePlayerProfileRecord({ player: { id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" }, sessions: [{ id: "s-1", gamePlayerRecordId: "p-1", startedAt: "2026-07-29T00:00:00Z", networkFingerprint: "never-copy" }], accessAttempts: [{ occurredAt: "2026-07-29T00:01:00Z", outcome: "review", reason: "manual" }] })).toMatchObject({ payload: { sessions: [{ kind: "login" }], risks: [{ summary: "manual" }] } });
expect(migrateGiftGrantRecord({ id: "gift-1", revisionId: "r-1", gamePlayerRecordId: "p-1", status: "unknown", createdAt: "2026-07-29T00:00:00Z" })).toMatchObject({ payload: { status: "unknown" }, readOnly: true });
expect(migrateStatePatchRecord({ id: "patch-1", gamePlayerRecordId: "p-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", status: "confirmed", createdAt: "2026-07-29T00:00:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] })).toMatchObject({ payload: { status: "succeeded" }, readOnly: true });
expect(migrateTrajectoryHistoryRecord({ id: "track-1", playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ sourceRecordId: "track-1", readOnly: true });
});
it("matches controlled transitional fixtures without carrying sensitive fields into plugin history", () => {
expect(migrateConfigurationRecord(scumMigrationParityFixtures.configuration.source)).toEqual(scumMigrationParityFixtures.configuration.expected);
expect(migratePlayerProfileRecord(scumMigrationParityFixtures.playerHistory.source)).toEqual(scumMigrationParityFixtures.playerHistory.expected);
expect(migrateGiftGrantRecord(scumMigrationParityFixtures.gift.source)).toEqual(scumMigrationParityFixtures.gift.expected);
expect(migrateStatePatchRecord(scumMigrationParityFixtures.statePatch.source)).toEqual(scumMigrationParityFixtures.statePatch.expected);
expect(migrateTrajectoryHistoryRecord(scumMigrationParityFixtures.trajectory.source)).toEqual(scumMigrationParityFixtures.trajectory.expected);
expect(migrateConfigurationRecord({ version: "0.9.700.90357", observedAt: "2026-07-29T00:00:00Z", fields: { hostPath: "/srv/scum" } })).toBeNull();
});
it("enables plugin authority only for one exact server-feature flag", () => {
const flags = [{ serverInstanceId: "server-1", feature: "configuration" as const, authority: "plugin" as const }];
expect(migrationStatus(flags, "server-1", "configuration")).toMatchObject({ authority: "plugin", pluginWritesEnabled: true, readOnlyHistory: true });
expect(migrationStatus(flags, "server-2", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
});
it("renders projection-backed user management without raw file/config panels", () => {
const view = renderAndCollect();
expect(view.nodes).toContain("section:用户管理");
expect(view.texts.join("\n")).toContain("登录日志和 SCUM.db typed observations");
expect(view.texts).toContain("投影/Companion 可用");
expect(view.texts).toContain("刷新投影");
expect(view.texts).toContain("刷新真实数据");
expect(view.texts).toContain("Mira");
expect(view.texts.join("\n")).toContain("Steam 76561198000000001");
expect(view.texts.join("\n")).toContain("Profile profile-1");
expect(view.texts.join("\n")).toContain("Fame 42");
expect(view.buttons.find((button) => button.label === "Fame +100")?.disabled).toBe(false);
expect(view.buttons.find((button) => button.label === "现金 +1000")?.disabled).toBe(false);
expect(view.buttons.find((button) => button.label === "855 审批")?.disabled).toBe(false);
for (const removedText of ["ServerSettings.ini", "Game.ini", "配置表单", "键值视图", "原文模式", "读取文件", "提交写入"]) expect(view.texts.join("\n")).not.toContain(removedText);
});
it("does not invent fake players when projections are empty", () => {
const view = renderAndCollect({ data: { ...projectionData, players: [], positions: [] } });
expect(view.texts.join("\n")).toContain("暂无玩家投影");
expect(view.texts.join("\n")).toContain("不会显示假玩家");
expect(view.texts).not.toContain("Mira");
expect(pageSource).not.toContain("fallbackFiles");
expect(pageSource).not.toContain("samplePlayers");
});
it("renders squad and flag governance from projections", () => {
const view = renderAndCollect({ pageKey: "squads", pageTitle: "队伍管理" });
expect(view.nodes).toContain("section:队伍管理");
expect(view.texts).toContain("队伍");
expect(view.texts).toContain("成员 / 旗帜");
expect(view.texts).toContain("Wolves");
expect(view.texts.join("\n")).toContain("成员 3");
expect(view.texts.join("\n")).toContain("verified");
});
it("renders realtime map overlays without sample coordinates", () => {
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" });
expect(view.nodes).toContain("section:实时地图");
expect(view.texts).toContain("地图覆盖物");
expect(view.texts.join("\n")).toContain("坐标点");
expect(view.texts.join("\n")).toContain("X 10 / Y 20 / Z 3");
expect(pageSource).toContain("map-projection-board");
expect(pageSource).not.toContain("sampleCoordinates");
});
it("renders gift and workflow typed status surfaces", () => {
const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
expect(gifts.nodes).toContain("section:礼包管理");
expect(gifts.texts.join("\n")).toContain("typed delivery workflow");
expect(gifts.buttons.find((button) => button.label === "创建礼包发放")?.disabled).toBe(false);
expect(gifts.buttons.find((button) => button.label === "发送通知")?.disabled).toBe(false);
const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "Workflow 状态" });
expect(workflows.nodes).toContain("section:Workflow 状态");
expect(workflows.texts.join("\n")).toContain("scum.world-refresh");
expect(workflows.texts.join("\n")).toContain("read-positions");
});
it("loads projections through typed workspace actions instead of file snapshots", () => {
expect(pageSource).toContain("listSCUMPlayers");
expect(pageSource).toContain("createSCUMOperation");
expect(pageSource).toContain("createSCUMWorkflow");
expect(pageSource).not.toContain("getFileSnapshot");
expect(pageSource).not.toContain("requestFile");
expect(pageSource).not.toContain("writeFile");
expect(pageSource).not.toContain("setInterval");
});
});
function renderAndCollect(pageKey: string) {
const nodes: string[] = []; const texts: string[] = [];
const collect = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collect); else if (value && typeof value === "object" && "children" in value) collect((value as { children?: unknown }).children); };
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); children.forEach(collect); return { type, props, children }; } };
renderPluginPage(react, { page: { key: pageKey, title: pageKey === "squads" ? "队伍管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : "用户管理" }, context: { serverInstanceId: "server-1", permissions: ["server.game-client.read"] }, availability: { available: false, reason: "等待当前服务器完成兼容性验证。" } });
return { nodes, texts };
function renderAndCollect(options: { data?: typeof projectionData; permissions?: string[]; pageKey?: string; pageTitle?: string } = {}) {
const nodes: string[] = [];
const texts: string[] = [];
const buttons: Array<{ label: string; disabled: boolean }> = [];
const collectText = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collectText); else if (value && typeof value === "object" && "children" in value) collectText((value as { children?: unknown }).children); };
let stateCall = 0;
const react = {
createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => {
if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`);
children.forEach(collectText);
if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled) });
return { type, props, children };
},
useEffect: () => undefined,
useState: <T,>(initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => {
stateCall += 1;
if (stateCall === 1) return [{ status: "ready", data: options.data ?? projectionData } as T, () => undefined];
return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined];
}
};
renderPluginPage(react, {
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] },
availability: { available: true, features: [{ key: "player.intelligence", available: true }] },
workspaceActions: {
listSCUMPlayers: async () => ({ items: projectionData.players, count: projectionData.players.length }),
listSCUMSquads: async () => ({ items: projectionData.squads, count: projectionData.squads.length }),
listSCUMSquadMembers: async () => ({ items: projectionData.members, count: projectionData.members.length }),
listSCUMVehicles: async () => ({ items: projectionData.vehicles, count: projectionData.vehicles.length }),
listSCUMFlags: async () => ({ items: projectionData.flags, count: projectionData.flags.length }),
listSCUMPositions: async () => ({ items: projectionData.positions, count: projectionData.positions.length }),
listSCUMOperations: async () => ({ items: projectionData.operations, count: projectionData.operations.length }),
listSCUMWorkflows: async () => ({ items: projectionData.workflows, count: projectionData.workflows.length }),
listSCUMWorkflowSteps: async () => ({ items: projectionData.steps, count: projectionData.steps.length }),
createSCUMOperation: async () => ({ id: "op-new", status: "waiting" }),
createSCUMWorkflow: async () => ({ id: "wf-new", status: "queued" })
}
});
return { nodes, texts, buttons };
}