diff --git a/packages/cultcache-py/PARITY.md b/packages/cultcache-py/PARITY.md index e331085b..8475da64 100644 --- a/packages/cultcache-py/PARITY.md +++ b/packages/cultcache-py/PARITY.md @@ -202,7 +202,10 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti. - Python CultMesh session watches: `CultMeshGameSession.watch_candidates(...)` and `watch_simulation_facts(...)` expose the same gameplay-facing observation and - committed-fact watch points as the C# session facade. + committed-fact watch points as the C# session facade. A game session can also + serve its own Verse catalog, peer catalog, and simulation observation hub over + the local framed CultMesh server so peer-facing session state uses the same + wire contracts as lower-level server callers. - Python package health: `cultcache-py-verify --json` checks the public CultCache/CultNet/CultMesh export surface declared by each package `__all__`, `py.typed` markers, a live diff --git a/packages/cultcache-py/src/cultmesh_py/session.py b/packages/cultcache-py/src/cultmesh_py/session.py index d29c4d33..3e861a5d 100644 --- a/packages/cultcache-py/src/cultmesh_py/session.py +++ b/packages/cultcache-py/src/cultmesh_py/session.py @@ -51,6 +51,9 @@ class CultMeshGameSessionOptions: authority_leases: CultMeshAuthorityLeaseCatalog | None = None consensus_options: CultNetSimulationConsensusOptions | None = None client_authority_scopes: tuple[CultNetClientAuthorityScope, ...] = () + serve_verse_discovery: bool = True + serve_peer_exchange: bool = True + serve_simulation_observations: bool = True @dataclass @@ -70,6 +73,32 @@ class CultMeshGameSession: self._committed_fact_ids: set[str] = set() self._client_authority_scopes = tuple(options.client_authority_scopes) self._predictions: dict[tuple[str, str], CultMeshPrediction] = {} + self._serve_verse_discovery = options.serve_verse_discovery + self._serve_peer_exchange = options.serve_peer_exchange + self._serve_simulation_observations = options.serve_simulation_observations + + def serve( + self, + *, + host: str = "127.0.0.1", + port: int = 0, + display_name: str | None = None, + max_snapshot_documents: int | None = None, + max_snapshot_bytes: int | None = None, + ) -> Any: + from .server import CultMeshLocalServer + + return CultMeshLocalServer( + node=self.node, + verse_catalog=self.verse_catalog if self._serve_verse_discovery else CultMeshVerseCatalog(), + peer_catalog=self.peer_catalog if self._serve_peer_exchange else CultMeshPeerCatalog(), + observation_hub=self.observation_hub if self._serve_simulation_observations else None, + host=host, + port=port, + display_name=display_name, + max_snapshot_documents=max_snapshot_documents, + max_snapshot_bytes=max_snapshot_bytes, + ).start() def predict(self, document: DocumentDefinition[Any], key: str, value: Any) -> CultMeshPrediction: schema_id = document.catalog_entry().schema_id diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index fe15a529..5c6c3dd4 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -2132,6 +2132,81 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(stored.claim_hash, claim_hash) self.assertEqual(stored.witness_count, 2) + def test_cultmesh_game_session_serves_catalogs_and_observations_over_wire(self) -> None: + verse_catalog = CultMeshVerseCatalog() + peer_catalog = CultMeshPeerCatalog() + verse_catalog.upsert(CultMeshVerseDescriptor( + verse_id="session-verse", + display_name="Session Verse", + authority_model="local", + compatibility=CultMeshVerseCompatibility("cultmesh.v0", "session-rules"), + )) + peer_catalog.upsert(CultMeshPeerCard( + peer_id="session-peer", + verse_id="session-verse", + endpoints=("cultnet://127.0.0.1:0",), + roles=("simulation-observer", "read-replica"), + shard_ids=("arena",), + )) + session = CultMesh.create_game_session( + CultMesh.create_node(runtime_id="session-peer"), + CultMeshGameSessionOptions( + verse_catalog=verse_catalog, + peer_catalog=peer_catalog, + consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0), + ), + ) + server = session.serve(display_name="Session Peer") + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + hello_response = raw_client.request( + hello(runtime_id="session-prober"), + expected_schema_version="cultnet.hello.v0", + ) + discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0) + verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") + peers = discovery_client.fetch_peers(verse_id="session-verse", roles=["simulation-observer"]) + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") + first = raw_client.request( + simulation_observation( + message_id="session-wire-1", + witness_runtime_id="watcher-1", + shard_id="arena", + shard_epoch=4, + frame=100, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice shot bob first", + ), + expected_schema_version="cultnet.simulation_consensus_candidate.v0", + ) + second = raw_client.request( + simulation_observation( + message_id="session-wire-2", + witness_runtime_id="watcher-2", + shard_id="arena", + shard_epoch=4, + frame=100, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice shot bob first", + ), + expected_schema_version="cultnet.simulation_consensus_candidate.v0", + ) + finally: + server.stop() + + self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) + self.assertEqual(verses[0].verse_id, "session-verse") + self.assertEqual(peers[0].peer_id, "session-peer") + self.assertIn("simulation-observer", peers[0].roles) + self.assertFalse(first["hasQuorum"]) + self.assertEqual(first["witnessCount"], 1) + self.assertTrue(second["hasQuorum"]) + self.assertEqual(second["witnessCount"], 2) + def test_cultmesh_game_session_prediction_requires_scope_and_reconciles_shard_log(self) -> None: note_doc = define_database_entry_type( "mesh.input",