diff --git a/docs/start/README.md b/docs/start/README.md index 3c7d9b7e..42adc5d9 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -16,10 +16,10 @@ this page. | | CultCache | CultMath | Geometry | CultNet | CultMesh | | --- | --- | --- | --- | --- | --- | -| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [package](../../src/GameCult.Networking); snippet not yet | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | -| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [package](../../packages/cultnet-ts/README.md); snippet not yet | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | -| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples); snippet not yet | [package](../../packages/cultmesh-rs); snippet not yet | -| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [package](../../packages/cultnet-py/README.md); snippet not yet | [package](../../packages/cultmesh-py/README.md); snippet not yet | +| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | +| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | +| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet | +| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet | For the whole picture of what each piece is for, see the @@ -222,6 +222,244 @@ def first_call() -> Item: return reopened.get_required(item_doc, "item:potion") ``` +## CultNet first calls + +CultNet is schema-v0 messages in length-prefixed frames. The first call in each runtime is the same +shape: define a typed document, build a `cultnet.document_put_raw.v0` message carrying its MessagePack +payload, send one frame over a loopback pipe, and apply the received message to a second cache. Each +block is verbatim from the test that runs it; a second test in the same file fails if the block stops +matching. + +### C# CultNet first call + +Source: [`tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs`](../../tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs), +run by `dotnet test tests/GameCult.Networking.Tests --filter CultNetFirstCallTests`. More: `src/GameCult.Networking/README.md`. + +```csharp +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Networking; +using MessagePack; + +[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")] +[MessagePackObject] +public sealed class Note +{ + [Key(0)] [CultName] public string Title = string.Empty; + [Key(1)] public string Body = string.Empty; +} + +public static class CultNetFirstCall +{ + public static async Task Run() + { + var inbox = new CultCache(); // the receiver's cache + var documents = new CultNetDocumentRegistry(); + var received = new TaskCompletionSource(); + + // Receive: a loopback listener that applies each raw document put to the inbox. + using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0)); + server.OnCultNet(async (put, peer) => + { + await documents.ApplyRawDocumentPutMessageAsync(inbox, put); + received.TrySetResult(true); + }); + + // Send: a typed document as a cultnet.document_put_raw.v0 message. + using var client = new TcpFramedCultNetSchemaClient(); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + var handle = new CultRecordHandle(new CultRecordKey("note:hello")); + client.SendCultNet(documents.CreateRawDocumentPutMessage( + "put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" })); + + await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); + return inbox.Get(handle.Key); + } +} +``` + +### TypeScript CultNet first call + +Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by +`npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports +from `../src` where the block reads `cultnet-ts`. More: `packages/cultnet-ts/README.md`. + +```ts +import { createServer, connect } from "node:net"; +import { z } from "zod"; +import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts"; +import { + CultNetDocumentRegistry, + createTcpFramedCultNetPeer, + defineCultNetDocumentBinding, +} from "cultnet-ts"; + +const noteDocument = defineDocumentType({ + type: "note", + schemaId: "gamecult.first_call_note.v1", + schemaName: "gamecult.first_call_note", + schemaVersion: "gamecult.first_call_note.v1", + schema: z.object({ title: z.string(), body: z.string() }), + name: "title", +}); +const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]); + +async function firstCall() { + const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const; + const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache + + // Receiver: a loopback listener whose peer applies each document put to the inbox. + const received = new Promise((resolve, reject) => { + const server = createServer((socket) => { + const peer = createTcpFramedCultNetPeer(socket, options); + peer.on("invalidMessage", reject); + peer.on("message", async (message) => { + if (message.schemaVersion !== "cultnet.document_put_raw.v0") return; + await documents.applyRawDocumentPutMessage(inbox, message); + peer.close(); + server.close(); + resolve(); + }); + }).listen(0, "127.0.0.1", () => { + // Sender: connect, build a typed put message, send it. + const { port } = server.address() as { port: number }; + const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options); + sender.send( + documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", { + title: "Hello", + body: "sent over CultNet", + }), + ); + received.finally(() => sender.close()); + }); + }); + await received; + return inbox.get(noteDocument, "note:hello"); +} +``` + +### Rust CultNet first call + +Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by +`cargo test --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`. + +```rust +use std::net::{TcpListener, TcpStream}; +use std::thread; + +use cultnet_rs::{ + CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract, + TcpFramedTransportConnection, create_tcp_framed_transport_profile, + decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, +}; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "note")] +struct Note { + #[cultcache(key = 0)] + title: String, + #[cultcache(key = 1)] + body: String, +} + +fn first_call() -> anyhow::Result { + let mut documents = CultNetDocumentRegistry::new(); + documents.register(CultNetDocumentBinding::for_entry_with_schema_id::( + "gamecult.first_call_note.v1".to_string(), + "gamecult.first_call_note.v1".to_string(), + ))?; + let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default()); + + // Receiver: a loopback listener that applies the document put it receives to its own cache. + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + let receiver = thread::spawn({ + let (documents, profile) = (documents.clone(), profile("receiver")); + move || -> anyhow::Result { + let (stream, _) = listener.accept()?; + let frame = TcpFramedTransportConnection::new(stream, profile).receive()?; + let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?; + let mut inbox = CultCache::new(); + inbox.register_entry_type::()?; + documents.apply_raw_document_put_message::(&mut inbox, &message) + } + }); + + // Sender: build a typed put message, encode it for the wire, send one frame. + let put = documents.create_raw_document_put_message( + "put-1", + "note:hello", + &Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() }, + CultNetDocumentPutOptions::default(), + )?; + let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?; + TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender")) + .send("schema", &bytes)?; + + receiver.join().expect("receiver thread") +} +``` + +### Python CultNet first call + +Source: [`packages/cultnet-py/tests/test_first_call.py`](../../packages/cultnet-py/tests/test_first_call.py), run by +`python -m unittest tests.test_first_call` in `packages/cultnet-py` with both `src` and `../cultcache-py/src` on +`PYTHONPATH` and `msgpack` installed. The socket pair stands in for a TCP connection. More: `packages/cultnet-py/README.md`. + +```python +import socket +from dataclasses import asdict, dataclass + +from cultcache_py import CultCache, define_document_type +from cultnet_py import ( + apply_raw_document_record, + document_put_raw, + encode_frame, + parse_message, + read_frame, + schema_document_map, +) + + +@dataclass +class Note: + title: str + body: str + + +note_doc = define_document_type( + "note", + encode=lambda note: asdict(note), + decode=lambda raw: Note(**raw), + name="title", +) + + +def first_call() -> Note: + sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP + + # Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame. + put = document_put_raw( + message_id="put-1", + key="note:hello", + schema_id=note_doc.catalog_entry().schema_id, + stored_at="2026-01-01T00:00:00Z", + payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")), + ) + sender.sendall(encode_frame(put.to_bytes())) + + # Receive: read the frame, parse the message, apply its record to a second cache. + message = parse_message(read_frame(receiver.makefile("rb"))) + assert message.schema_version == "cultnet.document_put_raw.v0" + inbox = CultCache.builder().register_document_type(note_doc).build() + apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"]) + sender.close() + receiver.close() + return inbox.get_required(note_doc, "note:hello") +``` + ## Where next - Smallest durable mesh path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`. diff --git a/packages/cultnet-py/tests/test_first_call.py b/packages/cultnet-py/tests/test_first_call.py new file mode 100644 index 00000000..9815043c --- /dev/null +++ b/packages/cultnet-py/tests/test_first_call.py @@ -0,0 +1,72 @@ +"""The Python CultNet first call from docs/start/README.md. The docs show the region between the +`snippet` markers verbatim, and test_docs_show_the_tested_snippet fails when they differ.""" + +import unittest +from pathlib import Path + +# snippet:begin +import socket +from dataclasses import asdict, dataclass + +from cultcache_py import CultCache, define_document_type +from cultnet_py import ( + apply_raw_document_record, + document_put_raw, + encode_frame, + parse_message, + read_frame, + schema_document_map, +) + + +@dataclass +class Note: + title: str + body: str + + +note_doc = define_document_type( + "note", + encode=lambda note: asdict(note), + decode=lambda raw: Note(**raw), + name="title", +) + + +def first_call() -> Note: + sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP + + # Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame. + put = document_put_raw( + message_id="put-1", + key="note:hello", + schema_id=note_doc.catalog_entry().schema_id, + stored_at="2026-01-01T00:00:00Z", + payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")), + ) + sender.sendall(encode_frame(put.to_bytes())) + + # Receive: read the frame, parse the message, apply its record to a second cache. + message = parse_message(read_frame(receiver.makefile("rb"))) + assert message.schema_version == "cultnet.document_put_raw.v0" + inbox = CultCache.builder().register_document_type(note_doc).build() + apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"]) + sender.close() + receiver.close() + return inbox.get_required(note_doc, "note:hello") +# snippet:end + + +class FirstCallTests(unittest.TestCase): + def test_a_typed_document_crosses_a_pipe_into_a_second_cache(self) -> None: + self.assertEqual(first_call(), Note("Hello", "sent over CultNet")) + + def test_docs_show_the_tested_snippet(self) -> None: + source = Path(__file__).read_text(encoding="utf-8") + snippet = source.split("# snippet:begin\n", 1)[1].split("# snippet:end\n", 1)[0] + docs = (Path(__file__).parents[3] / "docs" / "start" / "README.md").read_text(encoding="utf-8") + self.assertIn("```python\n" + snippet + "```", docs) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultnet-rs/tests/first_call.rs b/packages/cultnet-rs/tests/first_call.rs new file mode 100644 index 00000000..e2ef1e6f --- /dev/null +++ b/packages/cultnet-rs/tests/first_call.rs @@ -0,0 +1,77 @@ +//! The Rust CultNet first call from docs/start/README.md. The docs show the region between the +//! `snippet` markers verbatim, and `docs_show_the_tested_snippet` fails when they differ. + +use cultcache_rs::{CultCache, DatabaseEntry}; + +// snippet:begin +use std::net::{TcpListener, TcpStream}; +use std::thread; + +use cultnet_rs::{ + CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract, + TcpFramedTransportConnection, create_tcp_framed_transport_profile, + decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, +}; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "note")] +struct Note { + #[cultcache(key = 0)] + title: String, + #[cultcache(key = 1)] + body: String, +} + +fn first_call() -> anyhow::Result { + let mut documents = CultNetDocumentRegistry::new(); + documents.register(CultNetDocumentBinding::for_entry_with_schema_id::( + "gamecult.first_call_note.v1".to_string(), + "gamecult.first_call_note.v1".to_string(), + ))?; + let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default()); + + // Receiver: a loopback listener that applies the document put it receives to its own cache. + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + let receiver = thread::spawn({ + let (documents, profile) = (documents.clone(), profile("receiver")); + move || -> anyhow::Result { + let (stream, _) = listener.accept()?; + let frame = TcpFramedTransportConnection::new(stream, profile).receive()?; + let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?; + let mut inbox = CultCache::new(); + inbox.register_entry_type::()?; + documents.apply_raw_document_put_message::(&mut inbox, &message) + } + }); + + // Sender: build a typed put message, encode it for the wire, send one frame. + let put = documents.create_raw_document_put_message( + "put-1", + "note:hello", + &Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() }, + CultNetDocumentPutOptions::default(), + )?; + let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?; + TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender")) + .send("schema", &bytes)?; + + receiver.join().expect("receiver thread") +} +// snippet:end + +#[test] +fn a_typed_document_crosses_loopback_into_a_second_cache() { + let note = first_call().unwrap(); + assert_eq!(note, Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() }); +} + +#[test] +fn docs_show_the_tested_snippet() { + let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/first_call.rs")).unwrap(); + let begin = source.find("// snippet:begin\n").unwrap() + "// snippet:begin\n".len(); + let end = source.find("// snippet:end\n").unwrap(); + let docs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/start/README.md")).unwrap(); + let block = format!("```rust\n{}```", &source[begin..end]); + assert!(docs.contains(&block), "docs/start/README.md no longer shows tests/first_call.rs verbatim"); +} diff --git a/packages/cultnet-ts/test/first-call.test.ts b/packages/cultnet-ts/test/first-call.test.ts new file mode 100644 index 00000000..b389ca95 --- /dev/null +++ b/packages/cultnet-ts/test/first-call.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +// The TypeScript CultNet first-call snippet that docs/start/README.md shows: define a typed document, send it +// over a loopback TCP pipe as a cultnet.document_put_raw.v0 message, receive it into a second cache. The docs show +// the region between the `snippet` markers verbatim, except that the package import reads +// "cultnet-ts" where this file reads "../src"; the docs test below fails when they differ. +// snippet:begin +import { createServer, connect } from "node:net"; +import { z } from "zod"; +import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts"; +import { + CultNetDocumentRegistry, + createTcpFramedCultNetPeer, + defineCultNetDocumentBinding, +} from "../src"; + +const noteDocument = defineDocumentType({ + type: "note", + schemaId: "gamecult.first_call_note.v1", + schemaName: "gamecult.first_call_note", + schemaVersion: "gamecult.first_call_note.v1", + schema: z.object({ title: z.string(), body: z.string() }), + name: "title", +}); +const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]); + +async function firstCall() { + const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const; + const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache + + // Receiver: a loopback listener whose peer applies each document put to the inbox. + const received = new Promise((resolve, reject) => { + const server = createServer((socket) => { + const peer = createTcpFramedCultNetPeer(socket, options); + peer.on("invalidMessage", reject); + peer.on("message", async (message) => { + if (message.schemaVersion !== "cultnet.document_put_raw.v0") return; + await documents.applyRawDocumentPutMessage(inbox, message); + peer.close(); + server.close(); + resolve(); + }); + }).listen(0, "127.0.0.1", () => { + // Sender: connect, build a typed put message, send it. + const { port } = server.address() as { port: number }; + const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options); + sender.send( + documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", { + title: "Hello", + body: "sent over CultNet", + }), + ); + received.finally(() => sender.close()); + }); + }); + await received; + return inbox.get(noteDocument, "note:hello"); +} +// snippet:end + +test("send a typed document over loopback and receive it into a second cache", async () => { + const note = await firstCall(); + assert.deepEqual(note, { title: "Hello", body: "sent over CultNet" }); +}); + +test("docs show the tested snippet", async () => { + // Compiled to dist-test/test/first-call.test.js, so the package root is two levels up. + const source = await readFile(join(__dirname, "..", "..", "test", "first-call.test.ts"), "utf8"); + const snippet = source + .split("// snippet:begin\n")[1] + .split("// snippet:end\n")[0] + .replace('"../src"', '"cultnet-ts"'); + const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n"); + assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts"); +}); diff --git a/tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs b/tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs new file mode 100644 index 00000000..67c2f06d --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs @@ -0,0 +1,80 @@ +#nullable enable +using System; +using System.IO; +using NUnit.Framework; + +// snippet:begin +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Networking; +using MessagePack; + +[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")] +[MessagePackObject] +public sealed class Note +{ + [Key(0)] [CultName] public string Title = string.Empty; + [Key(1)] public string Body = string.Empty; +} + +public static class CultNetFirstCall +{ + public static async Task Run() + { + var inbox = new CultCache(); // the receiver's cache + var documents = new CultNetDocumentRegistry(); + var received = new TaskCompletionSource(); + + // Receive: a loopback listener that applies each raw document put to the inbox. + using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0)); + server.OnCultNet(async (put, peer) => + { + await documents.ApplyRawDocumentPutMessageAsync(inbox, put); + received.TrySetResult(true); + }); + + // Send: a typed document as a cultnet.document_put_raw.v0 message. + using var client = new TcpFramedCultNetSchemaClient(); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + var handle = new CultRecordHandle(new CultRecordKey("note:hello")); + client.SendCultNet(documents.CreateRawDocumentPutMessage( + "put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" })); + + await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); + return inbox.Get(handle.Key); + } +} +// snippet:end + +namespace GameCult.Networking.Tests +{ + // The C# CultNet first-call snippet that docs/start/README.md shows verbatim: define a typed document, send it + // over a loopback TCP pipe, receive it into a second cache. DocsShowTheTestedSnippet fails when they differ. + public class CultNetFirstCallTests + { + [Test] + public async Task SendATypedDocumentOverLoopbackAndReceiveItIntoASecondCache() + { + var note = await CultNetFirstCall.Run(); + Assert.That(note, Is.Not.Null); + Assert.That(note!.Title, Is.EqualTo("Hello")); + Assert.That(note.Body, Is.EqualTo("sent over CultNet")); + } + + [Test] + public void DocsShowTheTestedSnippet() + { + var source = File.ReadAllText(SourcePath()); + const string begin = "// snippet:begin\n"; + var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length; + var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)]; + var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(SourcePath())!, "..", "..", "docs", "start", "README.md")); + Assert.That(docs.Replace("\r\n", "\n"), Does.Contain("```csharp\n" + snippet + "```"), + "docs/start/README.md no longer shows tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs verbatim"); + } + + static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path; + } +}