diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index 5730747..889e4f7 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -280,6 +280,14 @@ Exit criteria: Recently cut: +- Aetheria interactive world surfaces now carry a structured provider-owned + `worldInteraction` contract. The provider advertisement names projection + kind, provider state schemas, daemon command boundary, receipt schema, + lowering targets, and ownership rules for `aetheria.daemon.game` and + `aetheria.daemon.editor`. The provider scenario requires the daemon game + world-surface contract, parity validates it against the advertisement, the + conformance export carries `surfaceContracts`, and the Aetheria consumer + smoke asserts those fields without importing Aetheria or Eve internals. - EveUnity test lifecycle now carries a structured Unity EditMode runner contract. `packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json` names the runner script, consumer Unity project, default Unity editor path, diff --git a/docs/provider-advertisement-contract.md b/docs/provider-advertisement-contract.md index b4a9233..d13a2b3 100644 --- a/docs/provider-advertisement-contract.md +++ b/docs/provider-advertisement-contract.md @@ -78,6 +78,12 @@ Required top-level meanings: the advertised surface, schemas, and command boundary. - `surfaces[].interactionModel`: short name for how interaction returns to the provider, such as `provider-command-receipts`. +- `surfaces[].worldInteraction`: optional structured contract for + provider-authored world surfaces. It names the projection kind, provider-owned + state schemas, command boundary, receipt schema, expected lowering targets, + and ownership rule. Runtimes use it to decide how to lower the surface and + where command intents must return; they do not gain authority over world + state because they can render it. - `surfaces[].requiresPlugins`: sidecar plugin capabilities required to render or interact with that surface. Runtimes compare these requirements with their local projection support and report visible capability gaps; they must not diff --git a/docs/world-state-lowering.md b/docs/world-state-lowering.md index 1158222..8efd0fb 100644 --- a/docs/world-state-lowering.md +++ b/docs/world-state-lowering.md @@ -76,6 +76,23 @@ World surfaces should be able to describe: The renderer chooses the best native projection it can support. It must not invent provider semantics to compensate for missing contract information. +Provider advertisements attach the reusable boundary to world surfaces through +`surfaces[].worldInteraction`. That block names: + +- `projectionKind`: the generic world presentation role, not a game-specific + rule name; +- `stateSchemas`: provider-owned state documents that describe the world; +- `commandBoundary`: the provider command route that receives interaction + intents; +- `receiptSchema`: the provider-owned receipt document schema; +- `loweringTargets`: runtime families expected to consume the surface; +- `ownership`: the plain-language authority rule for state, assets, command + acceptance, and receipts. + +Conformance scenarios can require `worldSurfaces[]` entries and the parity +harness checks them against the advertisement. This makes an interactive world +surface portable without giving Eve or a runtime private Aetheria knowledge. + ## Quality Tiers A low-end or debugging renderer may lower a world surface into labels, simple @@ -120,6 +137,13 @@ missing a generic scalar-field primitive or the provider surface is underspecified. If a renderer must know "Aetheria planet" to draw a body, the object row or asset contract is underspecified. +The incubating Aetheria fixture advertises `aetheria.daemon.game` as +`interactive-world` with `worldInteraction.projectionKind: +provider-authored-world-surface`, `commandBoundary: aetheria.daemon.commands`, +and `receiptSchema: aetheria.eve_command_acceptance_status.v1`. Web, Unity UI +Toolkit, Unity scene, and TUI lowerers are lowering targets; Aetheria remains +the owner of world state, assets, command acceptance, and receipts. + ## Non-Goals Eve does not own provider gameplay rules. diff --git a/schemas/gamecult.eve.provider_advertisement.v1.schema.json b/schemas/gamecult.eve.provider_advertisement.v1.schema.json index e298d3a..aaedac1 100644 --- a/schemas/gamecult.eve.provider_advertisement.v1.schema.json +++ b/schemas/gamecult.eve.provider_advertisement.v1.schema.json @@ -144,6 +144,50 @@ "type": "string", "minLength": 1 }, + "worldInteraction": { + "type": "object", + "required": [ + "projectionKind", + "stateSchemas", + "commandBoundary", + "receiptSchema", + "loweringTargets", + "ownership" + ], + "properties": { + "projectionKind": { + "type": "string", + "minLength": 1 + }, + "stateSchemas": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "commandBoundary": { + "type": "string", + "minLength": 1 + }, + "receiptSchema": { + "type": "string", + "minLength": 1 + }, + "loweringTargets": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "ownership": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + }, "requiresPlugins": { "type": "array", "items": { diff --git a/schemas/gamecult.eve.provider_scenario.v1.schema.json b/schemas/gamecult.eve.provider_scenario.v1.schema.json index c7828ca..fe94df1 100644 --- a/schemas/gamecult.eve.provider_scenario.v1.schema.json +++ b/schemas/gamecult.eve.provider_scenario.v1.schema.json @@ -51,6 +51,42 @@ "minLength": 1 } }, + "worldSurfaces": { + "type": "array", + "items": { + "type": "object", + "required": [ + "surfaceId", + "surfaceKind", + "projectionKind", + "commandBoundary", + "receiptSchema" + ], + "properties": { + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "surfaceKind": { + "type": "string", + "minLength": 1 + }, + "projectionKind": { + "type": "string", + "minLength": 1 + }, + "commandBoundary": { + "type": "string", + "minLength": 1 + }, + "receiptSchema": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + } + }, "commands": { "type": "array", "items": { diff --git a/scripts/run-aetheria-conformance-consumer-smoke.ps1 b/scripts/run-aetheria-conformance-consumer-smoke.ps1 index f7f8c34..96b8b6b 100644 --- a/scripts/run-aetheria-conformance-consumer-smoke.ps1 +++ b/scripts/run-aetheria-conformance-consumer-smoke.ps1 @@ -45,6 +45,13 @@ try { --expect-provider-surface aetheria:aetheria.daemon.editor ` --expect-provider-surface-kind aetheria:aetheria.daemon.game:interactive-world ` --expect-provider-surface-kind aetheria:aetheria.daemon.editor:interactive-world-editor ` + --expect-provider-surface-field aetheria:aetheria.daemon.game:worldInteraction.projectionKind:provider-authored-world-surface ` + --expect-provider-surface-field aetheria:aetheria.daemon.game:worldInteraction.commandBoundary:aetheria.daemon.commands ` + --expect-provider-surface-field aetheria:aetheria.daemon.game:worldInteraction.receiptSchema:aetheria.eve_command_acceptance_status.v1 ` + --expect-provider-surface-field aetheria:aetheria.daemon.game:worldInteraction.ownership:provider-owns-world-state-assets-command-acceptance-and-receipts ` + --expect-provider-surface-field aetheria:aetheria.daemon.editor:worldInteraction.projectionKind:provider-authored-world-editor-surface ` + --expect-provider-surface-field aetheria:aetheria.daemon.editor:worldInteraction.commandBoundary:aetheria.daemon.commands ` + --expect-provider-surface-field aetheria:aetheria.daemon.editor:worldInteraction.receiptSchema:aetheria.eve_command_acceptance_status.v1 ` --expect-provider-command aetheria:aetheria.daemon.commands ` --expect-provider-receipt-state aetheria:accepted ` --expect-provider-receipt-state aetheria:reconciled ` diff --git a/scripts/run-aetheria-provider-handoff-smoke.ps1 b/scripts/run-aetheria-provider-handoff-smoke.ps1 index 06098e3..c35da36 100644 --- a/scripts/run-aetheria-provider-handoff-smoke.ps1 +++ b/scripts/run-aetheria-provider-handoff-smoke.ps1 @@ -54,6 +54,30 @@ if ($scenario.ownerRepo -ne $handoff.ownerRepo) { throw "Aetheria scenario owner does not match handoff owner: $($scenario.ownerRepo)" } +$worldSurfaces = @{} +foreach ($surface in @($advertisement.surfaces)) { + if ($surface.surfaceId -and $surface.worldInteraction) { + $worldSurfaces[$surface.surfaceId] = $surface + } +} +foreach ($requirement in @($scenario.requires.worldSurfaces)) { + if (-not $worldSurfaces.ContainsKey($requirement.surfaceId)) { + throw "Aetheria scenario requires world surface not advertised with worldInteraction: $($requirement.surfaceId)" + } + $advertisedSurface = $worldSurfaces[$requirement.surfaceId] + if ($advertisedSurface.surfaceKind -ne $requirement.surfaceKind) { + throw "Aetheria world surface kind mismatch for $($requirement.surfaceId): $($advertisedSurface.surfaceKind)" + } + foreach ($property in @("projectionKind", "commandBoundary", "receiptSchema")) { + if ($advertisedSurface.worldInteraction.$property -ne $requirement.$property) { + throw "Aetheria world surface $($requirement.surfaceId) $property mismatch: $($advertisedSurface.worldInteraction.$property)" + } + } + if (-not $advertisedSurface.worldInteraction.ownership) { + throw "Aetheria world surface $($requirement.surfaceId) missing ownership rule" + } +} + foreach ($fixtureId in @($handoff.fixtureIds)) { if (-not (@($scenario.requires.fixtures) -contains $fixtureId)) { throw "Aetheria provider handoff fixture is not required by scenario: $fixtureId" diff --git a/tools/conformance/consume-export.mjs b/tools/conformance/consume-export.mjs index bd37067..be367d3 100644 --- a/tools/conformance/consume-export.mjs +++ b/tools/conformance/consume-export.mjs @@ -28,6 +28,7 @@ if (!exportDirectory) { " --expect-plugin-handoff ", " --expect-provider-surface ", " --expect-provider-surface-kind ", + " --expect-provider-surface-field ", " --expect-provider-command ", " --expect-provider-receipt-state ", " --expect-provider-handoff ", @@ -239,6 +240,23 @@ function validateIndex(index, directory, expectations, errors) { errors.push(`providers:${expectation.providerId}:surfaceKind:${expectation.surfaceId}:expected ${expectation.surfaceKind} got ${surface.surfaceKind || ""}`); } } + for (const expectation of expectations.providerSurfaceFields) { + const provider = providers.find(candidate => candidate.providerId === expectation.providerId); + if (!provider) { + errors.push(`providers:${expectation.providerId}:missing`); + continue; + } + const surfaceContracts = Array.isArray(provider.surfaceContracts) ? provider.surfaceContracts : []; + const surface = surfaceContracts.find(candidate => candidate.surfaceId === expectation.surfaceId); + if (!surface) { + errors.push(`providers:${expectation.providerId}:surfaceContract:${expectation.surfaceId}:missing`); + continue; + } + const actual = readNestedField(surface, expectation.fieldPath); + if (actual !== expectation.value) { + errors.push(`providers:${expectation.providerId}:surfaceContract:${expectation.surfaceId}.${expectation.fieldPath}:expected ${expectation.value} got ${actual || ""}`); + } + } for (const expectation of expectations.providerCommands) { const provider = providers.find(candidate => candidate.providerId === expectation.providerId); if (!provider) { @@ -514,6 +532,7 @@ function parseArguments(args) { providers: [], providerSurfaces: [], providerSurfaceKinds: [], + providerSurfaceFields: [], providerCommands: [], providerReceiptStates: [], providerHandoffs: [], @@ -552,6 +571,7 @@ function parseArguments(args) { ["--expect-plugin-handoff", expectations.pluginHandoffs], ["--expect-provider-surface", expectations.providerSurfaces], ["--expect-provider-surface-kind", expectations.providerSurfaceKinds], + ["--expect-provider-surface-field", expectations.providerSurfaceFields], ["--expect-provider-command", expectations.providerCommands], ["--expect-provider-receipt-state", expectations.providerReceiptStates], ["--expect-provider-handoff", expectations.providerHandoffs], @@ -607,6 +627,8 @@ function parseArguments(args) { target.push(parseProviderExpectation(value, "surfaceId")); } else if (option === "--expect-provider-surface-kind") { target.push(parseProviderSurfaceKindExpectation(value)); + } else if (option === "--expect-provider-surface-field") { + target.push(parseProviderSurfaceFieldExpectation(value)); } else if (option === "--expect-provider-command") { target.push(parseProviderExpectation(value, "command")); } else if (option === "--expect-provider-receipt-state") { @@ -669,6 +691,20 @@ function parsePluginRuntimeFieldExpectation(value) { }; } +function parseProviderSurfaceFieldExpectation(value) { + const parts = value.split(":"); + if (parts.length < 4 || !parts[0] || !parts[1] || !parts[2] || !parts.slice(3).join(":")) { + console.error(`Expected provider surface field in form, got: ${value}`); + process.exit(2); + } + return { + providerId: parts[0], + surfaceId: parts[1], + fieldPath: parts[2], + value: parts.slice(3).join(":"), + }; +} + function parseProviderExpectation(value, field) { const separator = value.indexOf(":"); if (separator <= 0 || separator === value.length - 1) { diff --git a/tools/parity/run-parity.mjs b/tools/parity/run-parity.mjs index 3317f6e..0c45811 100644 --- a/tools/parity/run-parity.mjs +++ b/tools/parity/run-parity.mjs @@ -629,6 +629,7 @@ async function evaluateProvider(provider, fixtureResults, pluginResults) { ])].sort() : []; const surfaceIds = advertisement ? (advertisement.surfaces || []).map(surface => surface.surfaceId).filter(Boolean).sort() : []; const surfaceKinds = advertisement ? collectProviderSurfaceKinds(advertisement) : []; + const surfaceContracts = advertisement ? collectProviderSurfaceContracts(advertisement) : []; const commandIds = advertisement ? (advertisement.commands || []).map(command => command.command).filter(Boolean).sort() : []; const witnessKinds = advertisement ? (advertisement.witnesses || []).map(witness => witness.kind).filter(Boolean).sort() : []; const pluginRequirements = advertisement ? collectProviderPluginRequirements(advertisement) : []; @@ -679,6 +680,7 @@ async function evaluateProvider(provider, fixtureResults, pluginResults) { advertisedSchemaIds, surfaceIds, surfaceKinds, + surfaceContracts, commandIds, witnessKinds, pluginRequirements, @@ -720,6 +722,18 @@ function collectProviderSurfaceKinds(advertisement) { .sort((left, right) => left.surfaceId.localeCompare(right.surfaceId)); } +function collectProviderSurfaceContracts(advertisement) { + return (advertisement.surfaces || []) + .filter(surface => surface.surfaceId && surface.worldInteraction) + .map(surface => ({ + surfaceId: surface.surfaceId, + surfaceKind: surface.surfaceKind || "", + interactionModel: surface.interactionModel || "", + worldInteraction: surface.worldInteraction || {}, + })) + .sort((left, right) => left.surfaceId.localeCompare(right.surfaceId)); +} + function validateProviderSurfaceKinds(provider, surfaceKinds) { const errors = []; const kindsBySurface = new Map(surfaceKinds.map(surface => [surface.surfaceId, surface.surfaceKind])); @@ -797,6 +811,25 @@ async function validateProviderScenario(provider, advertisement, fixtureResults, errors.push(...missingMembers(scenario.requires?.surfaces || [], surfaceIds, `${provider.scenarioPath}:requires.surfaces`)); errors.push(...missingMembers(scenario.requires?.commands || [], commandIds, `${provider.scenarioPath}:requires.commands`)); errors.push(...missingMembers(scenario.requires?.schemas || [], advertisedSchemaIds, `${provider.scenarioPath}:requires.schemas`)); + const advertisedWorldSurfaces = new Map((advertisement.surfaces || []) + .filter(surface => surface.surfaceId && surface.worldInteraction) + .map(surface => [surface.surfaceId, surface])); + for (const requirement of scenario.requires?.worldSurfaces || []) { + const advertisedSurface = advertisedWorldSurfaces.get(requirement.surfaceId); + if (!advertisedSurface) { + errors.push(`${provider.scenarioPath}:requires.worldSurfaces:${requirement.surfaceId}:missing`); + continue; + } + if (advertisedSurface.surfaceKind !== requirement.surfaceKind) { + errors.push(`${provider.scenarioPath}:requires.worldSurfaces:${requirement.surfaceId}:surfaceKind:expected ${requirement.surfaceKind} got ${advertisedSurface.surfaceKind || ""}`); + } + for (const key of ["projectionKind", "commandBoundary", "receiptSchema"]) { + const actual = advertisedSurface.worldInteraction?.[key] || ""; + if (actual !== requirement[key]) { + errors.push(`${provider.scenarioPath}:requires.worldSurfaces:${requirement.surfaceId}:${key}:expected ${requirement[key]} got ${actual}`); + } + } + } const receipts = scenario.expectedReceipts || []; const receiptKeys = new Set(receipts.map(receipt => `${receipt.command}:${receipt.commandId}`)); @@ -1548,6 +1581,7 @@ function buildConformanceExport(report) { receiptStates: provider.scenarioReceiptStates || [], surfaces: provider.surfaceIds, surfaceKinds: provider.surfaceKinds, + surfaceContracts: provider.surfaceContracts, commands: provider.commandIds, pluginRequirements: provider.pluginRequirements, })), @@ -1599,6 +1633,7 @@ function buildCapabilityMatrix(report) { status: provider.status, surfaces: provider.surfaceIds || [], surfaceKinds: provider.surfaceKinds || [], + surfaceContracts: provider.surfaceContracts || [], commands: provider.commandIds || [], receiptStates: provider.scenarioReceiptStates || [], handoffExportPath: makeHandoffExportPath("provider", provider.providerId, provider.handoffPath), diff --git a/web/fixtures/aetheria-world-scenario.json b/web/fixtures/aetheria-world-scenario.json index cc92b93..cb7bf4d 100644 --- a/web/fixtures/aetheria-world-scenario.json +++ b/web/fixtures/aetheria-world-scenario.json @@ -9,6 +9,15 @@ "requires": { "fixtures": ["aetheria-world"], "surfaces": ["aetheria.daemon.game"], + "worldSurfaces": [ + { + "surfaceId": "aetheria.daemon.game", + "surfaceKind": "interactive-world", + "projectionKind": "provider-authored-world-surface", + "commandBoundary": "aetheria.daemon.commands", + "receiptSchema": "aetheria.eve_command_acceptance_status.v1" + } + ], "commands": ["aetheria.daemon.commands"], "schemas": ["gamecult.eve.surface.v1", "gamecult.eve.command.v1", "aetheria.eve_command_acceptance_status.v1"] }, diff --git a/web/fixtures/aetheria-world-surface.conformance.json b/web/fixtures/aetheria-world-surface.conformance.json index 575725a..4b4d2b4 100644 --- a/web/fixtures/aetheria-world-surface.conformance.json +++ b/web/fixtures/aetheria-world-surface.conformance.json @@ -14,6 +14,7 @@ "field.surface2d world projection", "daemon command boundary", "provider-owned bindings", + "provider-advertised world interaction contract", "Aetheria style tokens" ], "splitTarget": "Aetheria" diff --git a/web/fixtures/aetheria.provider-advertisement.json b/web/fixtures/aetheria.provider-advertisement.json index 0bc5abe..59bf2cb 100644 --- a/web/fixtures/aetheria.provider-advertisement.json +++ b/web/fixtures/aetheria.provider-advertisement.json @@ -96,6 +96,18 @@ "mode": "interactive", "surfaceKind": "interactive-world", "interactionModel": "provider-command-receipts", + "worldInteraction": { + "projectionKind": "provider-authored-world-surface", + "stateSchemas": [ + "aetheria.world_state.v1", + "aetheria.runtime.daemon.frame.v1", + "aetheria.runtime.daemon.soa_view.v1" + ], + "commandBoundary": "aetheria.daemon.commands", + "receiptSchema": "aetheria.eve_command_acceptance_status.v1", + "loweringTargets": ["web-reference", "unity-uitoolkit", "unity-scene", "tui"], + "ownership": "provider-owns-world-state-assets-command-acceptance-and-receipts" + }, "summary": "Authoritative daemon game surface consumed by Unity and web lowerers." }, { @@ -116,7 +128,19 @@ "audience": "operator", "mode": "interactive", "surfaceKind": "interactive-world-editor", - "interactionModel": "provider-command-receipts" + "interactionModel": "provider-command-receipts", + "worldInteraction": { + "projectionKind": "provider-authored-world-editor-surface", + "stateSchemas": [ + "aetheria.world_state.v1", + "aetheria.runtime.daemon.editor_surface.v1", + "aetheria.runtime.daemon.command_boundary.v1" + ], + "commandBoundary": "aetheria.daemon.commands", + "receiptSchema": "aetheria.eve_command_acceptance_status.v1", + "loweringTargets": ["web-reference", "unity-uitoolkit", "tui"], + "ownership": "provider-owns-editor-state-assets-command-acceptance-and-receipts" + } } ], "commands": [