diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml new file mode 100644 index 00000000..ce361fc8 --- /dev/null +++ b/.github/workflows/cultmesh-portability.yml @@ -0,0 +1,97 @@ +name: CultMesh portability and performance + +on: + push: + branches: + - main + - "codex/**" + pull_request: + workflow_dispatch: + +jobs: + getting-started: + name: ${{ matrix.os }} one-command multiplayer onboarding + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + + steps: + - name: Check out CultLib + uses: actions/checkout@v4 + with: + path: CultLib + + - name: Check out the matching Eve contract + uses: actions/checkout@v4 + with: + repository: GameCult/Eve + # Eve main is the contract this sample is written against; the codex + # branch this used to pin was never merged and stopped on 2026-08-25. + ref: ${{ vars.EVE_INTEGRATION_REF || 'main' }} + path: Eve + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Run the documented one-command checkpoint + shell: pwsh + run: ./CultLib/scripts/verify-eve-getting-started.ps1 -EveRoot ./Eve + + reactive-and-resource-lifetime: + name: ${{ matrix.os }} reactive semantics and leases + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + + steps: + - name: Check out CultLib + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install TypeScript workspace dependencies + run: npm install --no-package-lock + + - name: Install Python CultLib packages + run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py + + - name: Verify explicit reactive scheduling across runtimes + shell: pwsh + run: ./scripts/verify-reactive-document-scaling.ps1 + + - name: Measure representative reactive workload + shell: pwsh + run: ./scripts/measure-reactive-document-performance.ps1 -Quick -SkipRuntimeBuild + + - name: Churn 100,000 distinct document leases + run: >- + dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj + --filter FullyQualifiedName~Document_OneHundredThousandDistinctLeaseCyclesLeaveNoResourcesOrCallbacks + --logger "console;verbosity=minimal" diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml index 1bde67ef..6876a76a 100644 --- a/.github/workflows/cultnet-interop.yml +++ b/.github/workflows/cultnet-interop.yml @@ -33,10 +33,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24 - cache: npm - cache-dependency-path: | - packages/cultcache-ts/package-lock.json - packages/cultnet-ts/package-lock.json - name: Set up Python uses: actions/setup-python@v5 @@ -73,7 +69,7 @@ jobs: run: npm install --no-package-lock - name: Install Python package dependencies - run: python -m pip install -e packages/cultcache-py + run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py - name: Build TypeScript workspace dependencies run: npm run build --workspace packages/cultcache-ts @@ -87,6 +83,24 @@ jobs: npm run build --workspace packages/cultnet-ts npx tsc -p packages/cultnet-ts/tsconfig.test.json --pretty false + - name: Verify TypeScript RUDP reliability + run: npm test --workspace packages/cultnet-ts + + - name: Verify Python RUDP reliability + run: | + python -m unittest discover -s packages/cultcache-py/tests + python -m unittest discover -s packages/cultnet-py/tests + python -m unittest discover -s packages/cultmesh-py/tests + + - name: Verify Rust RUDP reliability + run: cargo test --manifest-path packages/cultnet-rs/Cargo.toml rudp + + - name: Verify Rust CultMesh runtime-local surface + run: cargo test --manifest-path packages/cultmesh-rs/Cargo.toml + + - name: Verify C# RUDP reliability + run: dotnet test tests/GameCult.Networking.Tests/GameCult.Networking.Tests.csproj --filter "FullyQualifiedName~Rudp" + - name: Interop RUDP Python lane timeout-minutes: 8 run: node --test --test-name-pattern "TypeScript and Python|Python and TypeScript" packages/cultnet-ts/dist-test/test/interop/cultnet-interop.test.js diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml new file mode 100644 index 00000000..6061ca7a --- /dev/null +++ b/.github/workflows/publish-packages.yml @@ -0,0 +1,146 @@ +name: Publish packages + +# Non-Rust consumers cannot take a package from a subdirectory of a git repo the +# way cargo can, which is why they ended up vendoring forks instead. This gives +# them a versioned artifact to depend on so CultLib stays the only source. +# +# Rust needs nothing here: cargo git dependencies pin a CultLib revision +# directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin +# consume the core stack. +# +# Tag a release to publish, for example `cultcache-ts-v0.13.6` or +# `cultnet-py-v0.1.0`. The tag's version must match the version in the package +# manifest; the workflow refuses the publish otherwise rather than shipping a +# mislabelled artifact. + +on: + push: + tags: + - "cultcache-ts-v*" + - "cultcache-py-v*" + - "cultnet-py-v*" + - "cultmesh-py-v*" + workflow_dispatch: + inputs: + package: + description: Package to build and test (publishes nothing on manual runs) + required: true + type: choice + options: + - cultcache-ts + - cultcache-py + - cultnet-py + - cultmesh-py + +permissions: + contents: read + +jobs: + cultcache-ts: + name: "@gamecult/cultcache-ts to npm" + if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') || inputs.package == 'cultcache-ts' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out CultLib + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 24.x + registry-url: https://registry.npmjs.org + + - name: Install workspace dependencies + run: npm ci + + - name: Verify the tag matches the manifest version + if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') + run: | + manifest="$(node -p "require('./packages/cultcache-ts/package.json').version")" + tagged="${GITHUB_REF_NAME#cultcache-ts-v}" + if [ "$manifest" != "$tagged" ]; then + echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2 + exit 1 + fi + + - name: Build and test + working-directory: packages/cultcache-ts + run: npm test + + # Scoped packages default to restricted even on public npm, and this is + # MIT like the rest of the repo, so say public explicitly. + - name: Publish + if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') + working-directory: packages/cultcache-ts + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # The three Python packages layer cultcache-py < cultnet-py < cultmesh-py, the + # same way the Rust crates and npm packages do. One job shape serves all + # three; the matrix entry names the package and the tag prefix that releases it. + python: + name: ${{ matrix.package }} to PyPI + if: startsWith(github.ref, 'refs/tags/cultcache-py-v') || startsWith(github.ref, 'refs/tags/cultnet-py-v') || startsWith(github.ref, 'refs/tags/cultmesh-py-v') || endsWith(inputs.package, '-py') + strategy: + fail-fast: false + matrix: + package: [cultcache-py, cultnet-py, cultmesh-py] + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: pypi + permissions: + contents: read + # PyPI trusted publishing exchanges this for a short-lived token, so no + # long-lived API token is stored in the repository. + id-token: write + steps: + - name: Skip packages this run does not name + id: selected + run: | + if [ "${GITHUB_REF_NAME#${{ matrix.package }}-v}" != "$GITHUB_REF_NAME" ] || [ "${{ inputs.package }}" = "${{ matrix.package }}" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + fi + + - name: Check out CultLib + if: steps.selected.outputs.run == 'true' + uses: actions/checkout@v4 + + - name: Set up Python + if: steps.selected.outputs.run == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Verify the tag matches the manifest version + if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/') + run: | + manifest="$(python -c "import tomllib,pathlib;print(tomllib.loads(pathlib.Path('packages/${{ matrix.package }}/pyproject.toml').read_text())['project']['version'])")" + tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}" + if [ "$manifest" != "$tagged" ]; then + echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2 + exit 1 + fi + + # Lower layers install from source so a release of one package is tested + # against the sibling revision it was cut with, not whatever PyPI has. + - name: Install and test + if: steps.selected.outputs.run == 'true' + run: | + python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py + python -m unittest discover -s packages/${{ matrix.package }}/tests + + - name: Build sdist and wheel + if: steps.selected.outputs.run == 'true' + run: | + python -m pip install --upgrade build + python -m build packages/${{ matrix.package }} + + - name: Publish + if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/${{ matrix.package }}/dist diff --git a/.gitignore b/.gitignore index d639c709..e4661501 100644 --- a/.gitignore +++ b/.gitignore @@ -303,10 +303,22 @@ node_modules/ !packages/cultmesh-ts/** !packages/cultcache-py/ !packages/cultcache-py/** +!packages/cultnet-py/ +!packages/cultnet-py/** +!packages/cultmesh-py/ +!packages/cultmesh-py/** !packages/cultcache-rs/ !packages/cultcache-rs/** !packages/cultnet-rs/ !packages/cultnet-rs/** +!packages/cultmesh-rs/ +!packages/cultmesh-rs/** +!packages/cultmath/ +!packages/cultmath/** +!packages/cultmesh-browser/ +!packages/cultmesh-browser/** +!packages/cultmesh-kotlin/ +!packages/cultmesh-kotlin/** packages/**/node_modules/ packages/**/dist/ packages/**/dist-test/ @@ -504,3 +516,7 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# Python package build output +packages/*-py/build/ +*.egg-info/ diff --git a/.voidbot/status/void-memory-maintenance.json b/.voidbot/status/void-memory-maintenance.json deleted file mode 100644 index 0d0b876a..00000000 --- a/.voidbot/status/void-memory-maintenance.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "status": "starting", - "startedAt": "2026-06-14T19:46:12.715Z", - "stateFile": "E:\\Projects\\CultLib\\.voidbot\\state\\libby.cc", - "launcher": "repo_face_heartbeat", - "reason": "repo_face_stale_short_term_memory" -} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..640c1a14 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +Repository doctrine for this project lives in AGENTS.md, shared with Codex. + +@AGENTS.md diff --git a/CultLib.sln b/CultLib.sln index f6ae25e9..fbc20922 100644 --- a/CultLib.sln +++ b/CultLib.sln @@ -11,10 +11,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching", "src\Gam EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack", "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", "{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Generator", "src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj", "{0E990C96-3808-450E-9349-A0C3262195ED}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Analyzers", "src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj", "{DBE06954-EC85-4034-9049-72C7D27556DF}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking", "src\GameCult.Networking\GameCult.Networking.csproj", "{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh", "src\GameCult.Mesh\GameCult.Mesh.csproj", "{8D1B01A7-BF9E-44E6-88E1-B17255728B42}" @@ -31,6 +27,28 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Tests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Tests", "tests\GameCult.Mesh.Tests\GameCult.Mesh.Tests.csproj", "{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic", "src\GameCult.Mesh.Quic\GameCult.Mesh.Quic.csproj", "{B67042B8-1135-4E54-B1EB-D45342C720D7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic.Tests", "tests\GameCult.Mesh.Quic.Tests\GameCult.Mesh.Quic.Tests.csproj", "{21F26329-2119-4037-93CC-DD1801A1AB78}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets", "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj", "{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets.Tests", "tests\GameCult.Networking.WebSockets.Tests\GameCult.Networking.WebSockets.Tests.csproj", "{B9A441EC-DADE-4500-B092-C635F3EB296A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EveBrowserNetworkSample", "samples\eve-browser-network\EveBrowserNetworkSample.csproj", "{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cultmath", "cultmath", "{B48EAE30-152F-01E3-6E31-82B350494D3C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath", "packages\cultmath\src\CultMath\CultMath.csproj", "{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath.Tests", "packages\cultmath\tests\CultMath.Tests\CultMath.Tests.csproj", "{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.ConsumerBuild", "tests\GameCult.Caching.ConsumerBuild\GameCult.Caching.ConsumerBuild.csproj", "{D3138F3C-9531-4906-A5D3-74BD857D0685}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -77,30 +95,6 @@ Global {26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x64.Build.0 = Release|Any CPU {26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.ActiveCfg = Release|Any CPU {26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.Build.0 = Release|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x64.ActiveCfg = Debug|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x64.Build.0 = Debug|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x86.ActiveCfg = Debug|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x86.Build.0 = Debug|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.Build.0 = Release|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x64.ActiveCfg = Release|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x64.Build.0 = Release|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x86.ActiveCfg = Release|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x86.Build.0 = Release|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x64.ActiveCfg = Debug|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x64.Build.0 = Debug|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x86.ActiveCfg = Debug|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x86.Build.0 = Debug|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|Any CPU.Build.0 = Release|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x64.ActiveCfg = Release|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x64.Build.0 = Release|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x86.ActiveCfg = Release|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x86.Build.0 = Release|Any CPU {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.Build.0 = Debug|Any CPU {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -185,6 +179,102 @@ Global {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Release|x64.Build.0 = Release|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Release|x86.ActiveCfg = Release|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Release|x86.Build.0 = Release|Any CPU + {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x64.ActiveCfg = Debug|Any CPU + {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x64.Build.0 = Debug|Any CPU + {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x86.ActiveCfg = Debug|Any CPU + {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x86.Build.0 = Debug|Any CPU + {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|Any CPU.Build.0 = Release|Any CPU + {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x64.ActiveCfg = Release|Any CPU + {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x64.Build.0 = Release|Any CPU + {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x86.ActiveCfg = Release|Any CPU + {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x86.Build.0 = Release|Any CPU + {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x64.ActiveCfg = Debug|Any CPU + {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x64.Build.0 = Debug|Any CPU + {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x86.ActiveCfg = Debug|Any CPU + {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x86.Build.0 = Debug|Any CPU + {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|Any CPU.Build.0 = Release|Any CPU + {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x64.ActiveCfg = Release|Any CPU + {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x64.Build.0 = Release|Any CPU + {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.ActiveCfg = Release|Any CPU + {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.Build.0 = Release|Any CPU + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x64.ActiveCfg = Debug|Any CPU + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x64.Build.0 = Debug|Any CPU + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x86.ActiveCfg = Debug|Any CPU + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x86.Build.0 = Debug|Any CPU + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|Any CPU.Build.0 = Release|Any CPU + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x64.ActiveCfg = Release|Any CPU + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x64.Build.0 = Release|Any CPU + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x86.ActiveCfg = Release|Any CPU + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x86.Build.0 = Release|Any CPU + {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x64.ActiveCfg = Debug|Any CPU + {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x64.Build.0 = Debug|Any CPU + {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x86.ActiveCfg = Debug|Any CPU + {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x86.Build.0 = Debug|Any CPU + {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|Any CPU.Build.0 = Release|Any CPU + {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.ActiveCfg = Release|Any CPU + {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.Build.0 = Release|Any CPU + {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.ActiveCfg = Release|Any CPU + {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.Build.0 = Release|Any CPU + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.ActiveCfg = Debug|Any CPU + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.Build.0 = Debug|Any CPU + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.ActiveCfg = Debug|Any CPU + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.Build.0 = Debug|Any CPU + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.Build.0 = Release|Any CPU + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.ActiveCfg = Release|Any CPU + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.Build.0 = Release|Any CPU + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.ActiveCfg = Release|Any CPU + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.Build.0 = Release|Any CPU + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.ActiveCfg = Debug|Any CPU + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.Build.0 = Debug|Any CPU + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x86.ActiveCfg = Debug|Any CPU + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x86.Build.0 = Debug|Any CPU + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|Any CPU.Build.0 = Release|Any CPU + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x64.ActiveCfg = Release|Any CPU + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x64.Build.0 = Release|Any CPU + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x86.ActiveCfg = Release|Any CPU + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x86.Build.0 = Release|Any CPU + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x64.ActiveCfg = Debug|Any CPU + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x64.Build.0 = Debug|Any CPU + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x86.ActiveCfg = Debug|Any CPU + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x86.Build.0 = Debug|Any CPU + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|Any CPU.Build.0 = Release|Any CPU + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x64.ActiveCfg = Release|Any CPU + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x64.Build.0 = Release|Any CPU + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x86.ActiveCfg = Release|Any CPU + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x86.Build.0 = Release|Any CPU + {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x64.ActiveCfg = Debug|Any CPU + {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x64.Build.0 = Debug|Any CPU + {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x86.ActiveCfg = Debug|Any CPU + {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x86.Build.0 = Debug|Any CPU + {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|Any CPU.Build.0 = Release|Any CPU + {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x64.ActiveCfg = Release|Any CPU + {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x64.Build.0 = Release|Any CPU + {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.ActiveCfg = Release|Any CPU + {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -193,8 +283,6 @@ Global {72AEDD42-E881-4EB7-99A8-FD3B9667189C} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {9DDA917D-B4DB-4AC1-9B9A-4ECF06A5ABA1} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {26EE4C38-F06E-4E7A-A89C-908318BCB0DB} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} - {0E990C96-3808-450E-9349-A0C3262195ED} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} - {DBE06954-EC85-4034-9049-72C7D27556DF} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {8D1B01A7-BF9E-44E6-88E1-B17255728B42} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {E2DC8FF6-D39E-4FCA-9549-1F7681F0AA81} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} @@ -202,5 +290,14 @@ Global {94C086DA-3359-424B-9688-29A198806481} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {C95A9A21-34FA-4D82-BE5B-88FBDA836D31} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} + {B67042B8-1135-4E54-B1EB-D45342C720D7} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} + {21F26329-2119-4037-93CC-DD1801A1AB78} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} + {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} + {B9A441EC-DADE-4500-B092-C635F3EB296A} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} + {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {B48EAE30-152F-01E3-6E31-82B350494D3C} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE} + {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F} = {B48EAE30-152F-01E3-6E31-82B350494D3C} + {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D} = {B48EAE30-152F-01E3-6E31-82B350494D3C} + {D3138F3C-9531-4906-A5D3-74BD857D0685} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} EndGlobalSection EndGlobal diff --git a/CultLib.txt b/CultLib.txt deleted file mode 100644 index 4755ac07..00000000 --- a/CultLib.txt +++ /dev/null @@ -1,132 +0,0 @@ - - -// E:\Projects\CultLib\src\GameCult.Caching\GameCult.Caching.csproj - - - - CultCache: Concurrent polymorphic cache with modular backing stores for .NET - - - - - - - - - - -// E:\Projects\CultLib\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj - - - - File-based MessagePack backing store for CultCache - - - - - - - - - -// E:\Projects\CultLib\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj - - - - - - true - enable - true - false - - - Source generator that emits MessagePack formatters for DatabaseEntry subclasses. - MessagePack;SourceGenerator;DatabaseEntry - - - - - - - - - - - - - - - - -// E:\Projects\CultLib\src\GameCult.Logging\GameCult.Logging.csproj - - - - Simple pluggable logging interface for CultLib - - - -// E:\Projects\CultLib\src\GameCult.Logging.Unity\GameCult.Logging.Unity.csproj - - - - Unity integration for GameCult.Logging - - - - - - - - -// E:\Projects\CultLib\src\GameCult.Networking\GameCult.Networking.csproj - - - - CultNet: Secure, high-performance messaging for games/servers - - - - - - - - - - - - - -// E:\Projects\CultLib\tests\GameCult.Networking.Tests\GameCult.Networking.Tests.csproj - - - - - net8.0 - false - GameCult.Networking.Tests - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - all - - - - diff --git a/Directory.Build.props b/Directory.Build.props index 519e7a93..10c8d749 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,8 @@ MIT © Meta Vens and contributors true - 1.0.3 + 1.0.35 + $(CultLibPackageVersion) true diff --git a/README.md b/README.md index d6e3778c..4977a02a 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ The stack is less a single product than a set of capabilities: - logging primitives and implementations - CultCache typed documents, `.cc` persistence, and SoA memory access -- CultNet schema-v0 messages over TCP, WebSocket, LiteNetLib adapters, and - CultLib's native cross-runtime RUDP pipe +- CultNet schema-v0 messages over TCP and LiteNetLib adapters, a bounded + ASP.NET Core WebSocket host adapter, and CultLib's native cross-runtime RUDP + pipe - CultMesh distributed realtime database, Verse discovery, authority leases, shard replication, and witness-authoritative simulation consensus - typed geometry domain/chunk documents for distributed CSG and LOD streaming @@ -37,8 +38,9 @@ and pretending the label makes it furniture. | Job | Start With | Owns | Use When | Do Not Use It For | | --- | --- | --- | --- | --- | | Local typed state | `GameCult.Caching` / CultCache | Document identity, schema compatibility, record keys, indexes, globals, and local persistence | You need a typed cache, file-compatible save data, local reactive reads, or a stable domain document model | Peer discovery, transport security, shard routing, or mesh consensus | -| Procedural geometry state | `GameCult.Geometry` | CultCache-native domain trees, LOD build requests, selected-cut diagnostics, and chunk artifact payloads | Rust, Unity, or remote workers need to share CSG/LOD geometry and graph metadata as typed state | Transport policy, peer discovery, or gameplay authority | -| Network transport and database plumbing | `GameCult.Networking` / CultNet | Native RUDP sessions, LiteNetLib/TCP/WebSocket adapters, authentication, schema-v0 wire contracts, shard authority, raw document replication, snapshots, and subscriptions | You need a client/server pipe, login/session flow, schema discovery, reliable UDP, or a low-level distributed CultCache lane | Gameplay-facing mesh ergonomics, Verse policy, mod branches, or simulation consensus composition | +| Portable math | `GameCult.Math` / CultMath | Shader-shaped numeric primitives, deterministic random, noise, and CPU/GPU parity helpers | Shared simulation and geometry code must not depend on an engine's math or RNG types | Domain-specific geometry, rendering, or world authority | +| Procedural geometry | `GameCult.Geometry` / CultGeometry | Isosurface extraction plus CultCache-native domains, LOD requests, selected cuts, and mesh artifacts | C#, Rust, Godot, or remote workers need shared geometry algorithms and typed results | Transport policy, peer discovery, or gameplay authority | +| Network transport and database plumbing | `GameCult.Networking` / CultNet | Native RUDP sessions, LiteNetLib/TCP adapters, an authenticated WebSocket host adapter, schema-v0 wire contracts, shard authority, raw document replication, snapshots, and subscriptions | You need a client/server pipe, login/session flow, schema discovery, reliable UDP, or a low-level distributed CultCache lane | Gameplay-facing mesh ergonomics, Verse policy, mod branches, or simulation consensus composition | | Distributed realtime gameplay state | `GameCult.Mesh` / CultMesh | Public mesh entrypoints, Verse discovery, peer exchange, shard replication defaults, authority leases, client prediction, and witness consensus | You want the game to treat clients and servers as one reactive database for persistent state, input state, and simulation facts | A tiny local-only tool, a bare transport client, or a storage format contract | | Realtime media/frame streams | `GameCult.Mesh` / CultMesh streaming mode | Stream identity, authority, clock metadata, body transport negotiation, frame cursors, and backpressure state | Audio/video/tensor frames need to move between runtimes through shared memory, GPU handles, platform buffers, or CultCache page refs | Durable document mutation, mesh consensus facts, or pretending inline bytes are zero-copy | | Daemon-published UI | Eve / CultUI | Typed operator and user-facing surfaces over CultMesh state | A daemon should expose controls, inspection, or workflow UI that can lower into GUI, TUI, web, native, overlay, or agent clients | One-off vendor dashboards, untyped debug panels, or runtime-local UI that cannot travel with the daemon | @@ -46,8 +48,10 @@ and pretending the label makes it furniture. Quick rule: - Choose CultCache when the problem is "how do I model and persist typed state?" -- Choose GameCult.Geometry when the problem is "how do geometry workers share - domain trees, LOD build requests, and mesh chunks as typed state?" +- Choose GameCult.Math when the problem is "which portable numeric and random + semantics do these runtimes share?" +- Choose GameCult.Geometry when the problem is "how do geometry workers build + and share engine-neutral geometry?" - Choose CultNet when the problem is "how do peers exchange authenticated, schema-aware database messages?" - Choose CultMesh when the problem is "how does a game join a Verse and share @@ -62,8 +66,25 @@ the stack. If a design needs another peer-to-peer category, first check whether it is really Verse policy, peer exchange, or shard authority wearing a fake mustache. -If you want the smallest durable "open node -> put typed doc -> get typed doc" -path, start with +If you want to build an application rather than tour the component drawer, +start with the maintained +[`CultMesh And Eve Getting Started`](src/GameCult.Mesh/docs/getting-started/README.md) +series. Its first executable package-consumer checkpoint is +[`samples/eve-two-runtime`](samples/eve-two-runtime/README.md). +The real-process browser continuation is +[`samples/eve-browser-network`](samples/eve-browser-network/README.md). +One documented command runs both layers: + +```powershell +pwsh -File ./scripts/verify-eve-getting-started.ps1 +``` + +The implemented browser client boundary and remaining deployed-infrastructure +gates are mapped explicitly in +[`browser-verse-transport.md`](src/GameCult.Mesh/docs/browser-verse-transport.md). + +If you want only the smallest durable "open node -> put typed doc -> get typed doc" +path, use [`src/GameCult.Mesh/docs/durable-node-quickstart.md`](src/GameCult.Mesh/docs/durable-node-quickstart.md). If you want the lower-level "typed local document -> raw wire document -> mesh/runtime watch surface" handoff, follow it with @@ -77,10 +98,10 @@ The solution includes: - `GameCult.Caching`: `DatabaseEntry`-based cache, indexes, global entries, and backing-store abstractions - `GameCult.Caching.MessagePack`: MessagePack-backed persistence for the cache - `GameCult.Caching.NewtonsoftJson`: Newtonsoft.Json-backed persistence for the cache -- `GameCult.Caching.MessagePack.Generator`: source generator for MessagePack formatters for cache models -- `GameCult.Caching.MessagePack.Analyzers`: packaging project that delivers the generator to consuming projects -- `GameCult.Geometry`: CultCache-native geometry domain, selected-cut, and chunk artifact documents for VibeGeometry/Fensalir-style pipelines +- `GameCult.Math` / CultMath: portable numeric primitives, deterministic random, noise, and shader-parity helpers +- `GameCult.Geometry` / CultGeometry: engine-neutral geometry algorithms plus CultCache-native domain, selected-cut, and mesh artifact documents - `GameCult.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions +- `GameCult.Networking.WebSockets`: bounded ASP.NET Core binary WebSocket host adapter for browser CultMesh clients - `GameCult.Mesh`: CultMesh package home for distributed realtime database, shard replication, client prediction, Verse discovery, and mesh witness consensus - `GameCult.Caching.Tests`: NUnit tests for cache and backing-store behavior - `GameCult.Networking.Tests`: NUnit tests for networking behavior @@ -88,7 +109,10 @@ The solution includes: - `packages/cultcache-ts`: TypeScript CultCache with MessagePack persistence and inspector tooling - `packages/cultnet-ts`: TypeScript CultNet schema-v0 contracts, framing, discovery, raw document replication, and interop tests - `packages/cultmesh-ts`: TypeScript CultMesh local node and mesh catalog surface for local runtimes such as VoidBot -- `packages/cultcache-py`: Python CultCache/CultNet/CultMesh package with CultCache v1 wire parity +- `packages/cultmesh-browser`: browser-safe, read-only CultMesh document leases and typed operations over an Odin-resolved WebSocket route +- `packages/cultcache-py`: Python CultCache with CultCache v1 wire parity +- `packages/cultnet-py`: Python CultNet contracts, framing, RUDP transport, replication, and interop peer +- `packages/cultmesh-py`: Python CultMesh local node, discovery, sessions, local server, and daemon - `packages/cultcache-rs`: Rust CultCache and derive macro - `packages/cultnet-rs`: Rust CultNet contracts, framing, discovery, and interop peer - `packages/cultmesh-kotlin`: Kotlin/JVM CultMesh and CultNet surface for Android-adjacent runtimes @@ -102,10 +126,9 @@ src/ GameCult.Caching/ GameCult.Caching.MessagePack/ GameCult.Caching.NewtonsoftJson/ - GameCult.Caching.MessagePack.Generator/ - GameCult.Caching.MessagePack.Analyzers/ GameCult.Geometry/ GameCult.Networking/ + GameCult.Networking.WebSockets/ GameCult.Mesh/ GameCult.Unity/ tests/ @@ -113,11 +136,15 @@ tests/ GameCult.Geometry.Tests/ GameCult.Networking.Tests/ packages/ + cultmath/ cultcache-ts/ cultnet-ts/ cultmesh-ts/ + cultmesh-browser/ cultmesh-kotlin/ cultcache-py/ + cultnet-py/ + cultmesh-py/ cultcache-rs/ cultnet-rs/ ``` @@ -141,8 +168,22 @@ npm test --workspace packages/cultcache-ts npm test --workspace packages/cultnet-ts npm run test:interop --workspace packages/cultnet-ts npm test --workspace packages/cultmesh-ts +npm test --workspace packages/cultmesh-browser ``` +Measured reactive, retained-session, and shared-memory hot-body gates: + +```powershell +pwsh -File ./scripts/measure-reactive-document-performance.ps1 -Quick +pwsh -File ./scripts/verify-eve-getting-started.ps1 +``` + +The first command measures the same 16 KiB document workload in C#, +TypeScript, and Python and includes a mapped hot-body probe with explicit copy +telemetry on Windows named maps and Unix file-backed maps. The second includes +10,000 real WebSocket operations on one retained +identity-owned C# session after provider route replacement. + Rust package tests: ```powershell @@ -192,41 +233,23 @@ public class ItemData : DatabaseEntry, INamedEntry - backing stores are persistence adapters - indexes and name maps are maintained inside the cache, not inside the store -### Important: Multiple Backing Stores +### Multiple Backing Stores -When multiple backing stores are added, behavior depends on how they are registered. - -If a store is added with domain types: +Every document type has exactly one home store. There are no mirrors. ```csharp -cache.AddBackingStore(playerStore, typeof(PlayerData)); -cache.AddBackingStore(settingsStore, typeof(AppSettings)); +cache.AddBackingStore(catalogStore, typeof(ItemData), typeof(Faction)); +cache.AddBackingStore(runStore, typeof(SavedGame)); ``` -then that store becomes the direct persistence target for those types. +- the home is chosen by the document's runtime type: the most derived routed type wins, otherwise the untyped store +- at most one untyped store; a type claimed by two stores is an error +- a write with no home is an error once any store is attached +- attaching a store reads it; attach routed stores first and the untyped store (if any) last, because a record's home cannot move after it is loaded +- loading never writes: opening and flushing without a mutation leaves every file byte-identical +- a read-only store refuses writes and is skipped by flush -If a store is added without domain types: - -```csharp -cache.AddBackingStore(primaryStore); -cache.AddBackingStore(mirrorStore); -``` - -then the first generic store acts as the primary writable store for non-domain-specific entries. Additional generic stores subscribe to the existing stores and mirror their change events. - -Implications: - -- order matters for generic stores -- `AddAsync` writes to the type-specific store when one exists -- otherwise `AddAsync` writes to the first generic store -- later generic stores do not become co-primaries; they mirror earlier stores -- `PullAllBackingStoresAsync` pulls from every registered store - -Recommended patterns: - -- use one generic primary store if you want simple persistence -- use domain-specific stores when different entry types belong in different persistence layers -- treat additional generic stores as mirrors or downstream replicas, not independent write targets +The contract is `src/GameCult.Caching/Contracts/cultcache-store-composition.md`. Until the C# cache implements it, `AddBackingStore` takes no home types and every attached store receives every record; attach one store per cache. ## Example: Cache + Networking @@ -322,8 +345,6 @@ Each subproject has a local README with package-specific detail: - [GameCult.Caching](src/GameCult.Caching/README.md) - [GameCult.Caching.MessagePack](src/GameCult.Caching.MessagePack/README.md) - [GameCult.Caching.NewtonsoftJson](src/GameCult.Caching.NewtonsoftJson/README.md) -- [GameCult.Caching.MessagePack.Generator](src/GameCult.Caching.MessagePack.Generator/README.md) -- [GameCult.Caching.MessagePack.Analyzers](src/GameCult.Caching.MessagePack.Analyzers/README.md) - [GameCult.Geometry](src/GameCult.Geometry/README.md) - [GameCult.Networking](src/GameCult.Networking/README.md) - [GameCult.Mesh](src/GameCult.Mesh/README.md) diff --git a/docs/cultmesh-provider-session.md b/docs/cultmesh-provider-session.md new file mode 100644 index 00000000..3e45c799 --- /dev/null +++ b/docs/cultmesh-provider-session.md @@ -0,0 +1,61 @@ +# CultMesh Provider Session + +## Authority map + +- Owner: the provider-session broker owns registration, active lease fencing, + accepted publication membership, command retention, receipt acceptance, and + withdrawal. +- Inputs: a physical CultNet session, an explicit registration-authorization + decision, correlated typed operations, the broker clock, provider commands, + and persistence callbacks. The current RUDP adapter does not itself provide + an authenticated claim. +- Outputs: leases, application acceptances, accepted raw documents, pushed + typed commands, persisted receipts, and deterministic removals. +- Derived state: socket sessions, retry schedules, connection health, and + provider projections are observations. They do not authorize publications. +- Forbidden writers: RUDP packet acknowledgements, source addresses, direct raw + document callbacks, renderer-owned catalogs, and arrival-time freshness maps + cannot create accepted provider truth. +- Shared paths: initial connect and reconnect use `provider.register`; initial, + replayed, and live documents use `provider.publication.put`; explicit + withdrawal and lease expiry remove the broker-owned publication set; command + retry ends only when `provider.receipt.put` is accepted. +- Cut line: a receiver must stop accepting unleased provider raw puts before it + claims this protocol as its ingress authority. + +## Wire contract + +Provider lifecycle operations use the reliable ordered CultNet schema channel +and the existing `cultnet.operation_request.v0` / `cultnet.operation_response.v0` +envelopes with service id `gamecult.mesh.provider_session`. Inner payloads are +camel-case MessagePack maps encoded as `messagepack-base64`. + +The v1 operations are: + +| Operation | Request payload | Successful payload | +| --- | --- | --- | +| `provider.register` | `gamecult.mesh.provider_registration.v1` | `gamecult.mesh.provider_lease.v1` | +| `provider.renew` | `gamecult.mesh.provider_lease_renewal.v1` | `gamecult.mesh.provider_lease.v1` | +| `provider.publication.put` | `gamecult.mesh.provider_publication_put.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` | +| `provider.publication.delete` | `gamecult.mesh.provider_publication_delete.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` | +| `provider.receipt.put` | `gamecult.mesh.provider_receipt_put.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` | +| `provider.withdraw` | `gamecult.mesh.provider_withdrawal.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` | + +Broker-to-provider commands are raw typed documents using +`gamecult.mesh.provider_command.v1`. Asset bodies do not travel inside these +operation payloads. + +Responses use `ok`, `conflict`, `expired`, `denied`, or `invalid`. A transport +ACK is never an application acceptance. Renewal issues a new lease id and +immediately fences the previous id. The broker derives the set removed by full +withdrawal; a provider cannot use a supplied list to hide owned publications. + +Public deployment additionally requires a CultNet authentication claim that +authorizes the provider identity. Until that claim is wired and checked, the +TypeScript RUDP adapter is a private-development transport. + +`CultMeshProviderSessionBroker` is an in-memory reference broker and socket +proof. Its command and receipt retention does not survive process restart. +Production receivers must persist that control-plane state atomically with +accepted publication ownership; Odin must not substitute these volatile maps +for its durable broker authority. diff --git a/docs/cultnet-transport-parity.md b/docs/cultnet-transport-parity.md index 5f0fec91..d0779196 100644 --- a/docs/cultnet-transport-parity.md +++ b/docs/cultnet-transport-parity.md @@ -31,7 +31,7 @@ CultNet owns cross-runtime transport semantics: | C# interop peer | Schema-v0 MessagePack over TCP-framed or shared RUDP transport | UDP multicast probe/announce with TCP and RUDP transport profiles | Test harness transport parity | | TypeScript `cultnet-ts` | `CultNetPeer` over any Node `Duplex`, TCP-framed transport, or single-peer RUDP socket transport; interop uses TCP | UDP multicast probe/announce in the interop peer | First UDP socket binding for the shared RUDP reliability owner | | Rust `cultnet-rs` | Interop example serves and dials schema-v0 MessagePack over TCP-framed or shared RUDP transport; single-peer RUDP socket transport exists in the library | UDP multicast probe/announce with TCP and RUDP transport profiles | UDP socket binding for the shared RUDP reliability owner | -| Python `cultcache-py` | TCP sockets with 4-byte length-prefixed MessagePack frames for local CultMesh/CultNet server and client; single-peer RUDP socket transport exists in the library | Endpoint lists and CultMesh peer/Verse catalogs | UDP socket binding for the shared RUDP reliability owner | +| Python `cultnet-py` | TCP sockets with 4-byte length-prefixed MessagePack frames for local CultMesh/CultNet server and client; single-peer RUDP socket transport exists in the library | Endpoint lists and CultMesh peer/Verse catalogs | UDP socket binding for the shared RUDP reliability owner | | Kotlin `cultmesh-kotlin` | Channel-aware WebSocket transport connection for reliable ordered `schema` frames; single-peer RUDP socket transport exists in the library; interop CLI serves and dials schema-v0 MessagePack over TCP-framed or shared RUDP transport | CultMesh Verse and peer catalogs, plus endpoint lists carried in schema-v0 catalog messages | WebSocket/TCP adapters plus UDP socket binding for the shared RUDP reliability owner, with build-script and TypeScript harness proof | The live split is therefore: payload language and the native RUDP packet/session @@ -258,11 +258,19 @@ Current progress: echo against C#/Kotlin/Python/Rust UDP peers; local runtime tests prove timeout state transitions. - TypeScript, C#, Rust, Python, and Kotlin now share bounded reliable-send - backpressure for RUDP sessions. `maxPendingReliablePackets` is advertised on - RUDP transport-profile channels and enforced by the session before reliable - connect, accept, single-packet sends, or fragmented sends enqueue anything; - TCP-framed profiles do not publish the field because they do not own the RUDP - pending-reliable queue. + backpressure for RUDP sessions. At most 32 reliable packets are admitted to + the wire at once; later fragments remain in a session-owned FIFO and + acknowledgements promote the next packets. Receivers emit a cumulative ACK + for packets inside the 32-sequence ACK horizon and an exact ACK for an older + retransmit, so unrelated unreliable traffic cannot strand reliable work. + This keeps the send frontier well + inside the 4,096-sequence receive history instead of blasting an entire large + document and hoping the UDP buffers impersonate flow control. + `maxPendingReliablePackets` bounds both queued and in-flight packets before a + reliable frame is accepted. Socket transports expose an explicit reliable + flush where the runtime supports one, so callers that require confirmed + transport delivery do not substitute a fixed sleep. TCP-framed profiles do + not publish the field because they do not own the RUDP pending-reliable queue. - TypeScript, C#, Rust, Python, and Kotlin now share a portable `cultnet.reconnect_policy.v0` document and deterministic exponential delay helper, and RUDP transport profiles advertise that policy under diff --git a/docs/nuget-packaging.md b/docs/nuget-packaging.md new file mode 100644 index 00000000..afc94570 --- /dev/null +++ b/docs/nuget-packaging.md @@ -0,0 +1,23 @@ +# NuGet Packaging + +CultLib publishes the managed dependency graph as separate packages. The leaf +package is `GameCult.Mesh`; its NuGet dependencies preserve the CultMesh, +CultNet, and CultCache ownership boundaries. + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\pack-nuget.ps1 +``` + +The command creates a local feed under `artifacts/nuget`, verifies that every +internal dependency package exists at the same version, then restores and runs +a clean .NET consumer using only `PackageReference Include="GameCult.Mesh"`. + +The Unity distribution remains `org.gamecult.cultlib`, built by +`scripts/build-unity-package.ps1`, because Unity consumes the managed assembly +closure through UPM rather than NuGet restore. + +Install the Unity package from the immutable release tag: + +```text +https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.46 +``` diff --git a/docs/parked-features.md b/docs/parked-features.md new file mode 100644 index 00000000..aa65294b --- /dev/null +++ b/docs/parked-features.md @@ -0,0 +1,32 @@ +# Parked Features + +A parked feature was built for a stated reason, has no consumer, and was +removed from the tree. Its last working form lives at an annotated tag so it +can be restored instead of reinvented. + +## CultCache SoA table + +- Tag: `parked/cultcache-soa` +- Intent: ECS-style structure-of-arrays columns over cached documents, for + hot-loop performance without giving up POCO document ergonomics. +- Span at the tag: `src/GameCult.Caching/CultManagedDocument.cs:133-414` + (`CultSoaTable`, `CultSoaColumn`, `CultCacheSoaStore`, + `CultCacheSoaTypeTable`, `CultCacheSoaMember`); `CultCache.Soa()` and the + `_soa` field with its upsert/remove hooks in `CultCache.cs`; the SoA tests in + `tests/GameCult.Caching.Tests/BackingStoreTests.cs` and + `tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs`. +- Managed document: `CultManagedDocument` (`CultManagedDocument.cs:72-131` at + the tag) with `CultCache.Document` and `CultNetDatabase.Document`, the + reactive POCO handle added with the SoA storage in 5428870 "Make SoA + cache-managed document storage". Same tag, same intent: the document stays a + POCO while the cache owns its columnar storage. +- Tag message correction: the `parked/cultcache-soa` tag message says + `CultManagedDocument` was "deleted separately, not parked". This note + supersedes it: `CultManagedDocument` is parked under the same tag and is + restored alongside SoA. The tag itself is left as written. +- Consumers when parked: none outside tests. +- Restore: `git show parked/cultcache-soa:src/GameCult.Caching/CultManagedDocument.cs`, + take the SoA types and `CultManagedDocument`, restore both `Document` + openers from `CultCache.cs` and `CultNetDatabase.cs` at the tag, and re-hook a + `CultCacheSoaStore` into the cache's single + admission and eviction paths (`Admit`/`Evict`). diff --git a/docs/persisted-record-encoding.md b/docs/persisted-record-encoding.md new file mode 100644 index 00000000..b8e13f8a --- /dev/null +++ b/docs/persisted-record-encoding.md @@ -0,0 +1,68 @@ +# The encoding of a persisted CultCache record is part of its contract + +Changing how a stored record serializes is never a flag flip in this estate. +Consumers verify canonicality by **re-encoding what they decoded and comparing +bytes**, so a record that reads back correctly but re-encodes differently is +refused — and the refusal happens on live data, at startup or on the next read. + +Three guards, three separate stores, one idiom: + +| store | guard | refusal | +|---|---|---| +| Idunn runtime projection | `Idunn/src/drivers.rs:3611` | `service trust-anchor projection is noncanonical` | +| Idunn deployment brake | `Idunn/src/control_plane.rs` (`read_deployment_brake`) | `deployment brake is noncanonical or keyed by another authority` | +| Idunn trust bindings | `Idunn/src/provisioning.rs:546`, `:630` | `trust store contains a noncanonical or mismatched binding` | + +Idunn's control store applies the same rule to its own transactions. Assume the +guard exists and go looking for it before changing any persisted record's +serialization; three independent instances is a house pattern, not a +coincidence. + +## What this means for `#[cultcache(key = N, bytes)]` + +The attribute switches a `Vec` slot from a MessagePack array-of-integers +(serde's default) to a real `bin`. Measured, not assumed +(`packages/cultnet-rs/tests/byte_slot_migration.rs`): + +- **Reading stays compatible in both directions.** `serde_bytes::ByteBuf` + accepts an array marker, and a plain `Vec` accepts `bin`. No dual-read + path is needed, and reverting a binary is survivable. +- **Reproduction does not.** A record written before the flip can be read but + can never re-encode to the bytes it was stored as, and its `canonical_sha256` + moves with it. + +So the cost lands entirely on the guards above, not on readability. A field with +persisted records must flip the attribute **and** rewrite its store in the same +change, one target at a time, and never during a deployment — the projection +store is what the observer reads, and refusing it takes the observer down. + +A field with no live producer is free. Check for one before assuming a +migration: `IdunnAuthenticatedProviderHealthProjectionRecord` is constructed +only inside a test module, so its byte field costs nothing to convert. + +Size, for whether it is worth doing at all: 406 bytes as an array against 278 as +`bin` for a 256-byte payload, and 1234 against 851 for an 848-byte one. About +1.45x on every byte, which matters on a media path and is noise on a control +record. + +## `Option>` cannot take the attribute yet + +The derive emits `serde_bytes::Bytes::new(&self.field)`, which needs `&[u8]`, +and builds the field via `.into_vec()`, which yields `Vec`. Both are type +errors against an `Option>`, so the derive fails loudly rather than +collapsing "absent slot" into "unsigned". `IdunnDeploymentBrakeRecord.signature` +needs an `Option`-aware path in `cultcache-rs-derive` before it can move. + +## Fields validated for shape and produced by nothing + +`trust_binding_sha256` is declared on two records with different schemas +(`idunn.authenticated_provider_health_projection.v1` in cultnet-rs, +`idunn.authenticated_daemon_health_admission.v1` in odin-core), shape-validated +in both, and computed by nothing in CultLib, Idunn or Odin. + +Do not assume that means "omission". It may be a fossil — a contract whose +producer was deleted. The estate has form: Muninn's receiver runs a Reed-Solomon +audio FEC path against parity shards the Rust sender has never emitted, while an +acceptance ledger describes that FEC as having worked. Settling which requires +reading history, not source, and the answer changes whether the right move is to +implement the field or delete it. diff --git a/packages/cultcache-py/PARITY.md b/docs/python-runtime-parity.md similarity index 96% rename from packages/cultcache-py/PARITY.md rename to docs/python-runtime-parity.md index 18607d87..1cc222b7 100644 --- a/packages/cultcache-py/PARITY.md +++ b/docs/python-runtime-parity.md @@ -1,7 +1,7 @@ # Python Runtime Parity Map -This file is the current audit map for `cultcache-py` as a Python runtime for -CultCache, CultNet, and CultMesh. It is evidence, not confetti. +This file is the current audit map for the Python runtime packages +`cultcache-py`, `cultnet-py`, and `cultmesh-py`. It is evidence, not confetti. ## Proven Surfaces @@ -255,14 +255,14 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti. individual discovery, peer exchange, or simulation surfaces unserved without advertising disabled capabilities. - Python package health: - `cultcache-py-verify --json` checks the public CultCache/CultNet/CultMesh + `cultmesh-py-verify --json` checks the public CultCache/CultNet/CultMesh export surface declared by each package `__all__`, `py.typed` markers, a live local CultMesh framed-wire smoke over hello/schema/snapshot/shard catalog/shard log, typed peer-error export availability, peer-advertised mutation contracts, payload schema wire contracts, simulation capability truth, and benchmark sanity for Python-owned hot paths. - Python/C# public cache baseline: - `cultcache_py.compare_csharp` runs Python and C# `CultCache` upsert/get + `cultnet_py.compare_csharp` runs Python and C# `CultCache` upsert/get benchmarks with the same operation names and reports median Python-to-C# ratios from three samples by default. It also emits per-metric threshold checks and an overall parity status so performance claims have an explicit evidence gate. @@ -275,12 +275,14 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti. ## Local Python Gates ```powershell -$env:PYTHONPATH="$PWD\packages\cultcache-py\src" +$env:PYTHONPATH="$PWD\packages\cultcache-py\src;$PWD\packages\cultnet-py\src;$PWD\packages\cultmesh-py\src" python -m unittest discover -s packages\cultcache-py\tests -python -m cultcache_py.verify --json -python -m cultcache_py.benchmark --records 1000 --json -python -m cultcache_py.compare_csharp --records 1000 --samples 3 --json -python -m pip wheel --no-deps -w $env:TEMP packages\cultcache-py +python -m unittest discover -s packages\cultnet-py\tests +python -m unittest discover -s packages\cultmesh-py\tests +python -m cultmesh_py.verify --json +python -m cultnet_py.benchmark --records 1000 --json +python -m cultnet_py.compare_csharp --records 1000 --samples 3 --json +python -m pip wheel --no-deps -w $env:TEMP packages\cultcache-py packages\cultnet-py packages\cultmesh-py ``` ## Cross-Runtime Gate diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index dc8f5fe9..44715c7c 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -21,20 +21,23 @@ Cross-runtime parity has three layers: transport helpers instead of assembling raw maps and channel strings. SoA performance parity is deliberately not a cross-runtime requirement. C# -owns the CPU-local SoA table and shared-memory streaming body path. Other -runtimes may add native hot paths when their runtime role earns them, but they -do not need to mimic Unity/C# memory layout to speak CultNet or participate in -CultMesh. +owns the shared-memory streaming body path. The C# CultCache SoA table (ECS-style +structure-of-arrays columns over cached documents) is parked at the tag +`parked/cultcache-soa`: it was built for hot-loop performance, had no consumer +outside tests, and is restorable from that tag (`docs/parked-features.md`). +It is not a supported surface in the current tree. Other runtimes may add native hot paths when their +runtime role earns them, but they do not need to mimic Unity/C# memory layout +to speak CultNet or participate in CultMesh. ## Current Targeted Runtimes | Runtime | Role | Claimed parity | Not claimed | | --- | --- | --- | --- | -| C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption | +| C# | Reference implementation and production game/server substrate | CultCache typed documents, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption | | Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup, shard catalogs/logs, schema-transport factory for raw client request/subscription flows, local TCP and RUDP CultMesh server listeners, Python interop RUDP serve/dial path, session/prediction/simulation fact helpers, RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe | | TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, advertised schema/shard catalog wire contracts plus request/sync helpers, transport profiles, TCP-framed transport port and peer helper, shared RUDP socket transport, RUDP interop serve/dial path, RUDP reconnect policy/controller, RUDP reconnect-loop helper, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup | Full C# game session/server stack | -| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade, stream catalog, or production game-session server | -| Kotlin | JVM/Android client substrate | Typed MessagePack cache, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store, full C# server daemon, or Unity hot-loop memory layout | +| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, typed same-schema alias hydration from raw snapshots, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade or production game-session server. `packages/cultmesh-rs` now holds a Rust stream and session surface -- node, document sets, RUDP document publication, stream descriptors, negotiation, stream catalog, and shared-memory frame rings -- but no interop lane exercises it, so it carries no cross-runtime parity claim | +| Kotlin | JVM/Android client substrate | Typed MessagePack cache, one-call typed snapshot document sync through local same-schema aliases, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store (parked at `parked/cultcache-soa`), full C# server daemon, or Unity hot-loop memory layout | ## Evidence Gates @@ -52,7 +55,7 @@ CultMesh. `reconnectPolicy` profile field from C#, Rust, Python, and Kotlin peers. Runtime-local tests cover the deterministic delay helper and reconnect controller in C#, TypeScript, Rust, Python, and Kotlin. -- `packages/cultcache-py/PARITY.md` is the Python-specific audit ledger. Its +- `docs/python-runtime-parity.md` is the Python-specific audit ledger. Its "Still Not Claimed" section is part of the contract, not an apology. - `docs/cultnet-transport-parity.md` is the transport ownership map. It tracks the remaining work to lower older LiteNetLib/WebSocket bodies behind the @@ -85,6 +88,16 @@ server body, is deliberately not made. backfilling C# server responsibilities it should not own. Kotlin WebSocket and RUDP adapters now share one `CultNetSchemaMessageTransport` helper shape for schema/shard catalog request and sync flows. +- Bring `packages/cultmesh-rs` under the wire claim. It arrived from a consumer repo where it grew + outside any conformance harness, and its stream, negotiation, and shared-memory frame-ring + surfaces have never been checked against the browser, Kotlin, and TypeScript CultMesh bodies + they must interoperate with. Runtime-local tests pass; that is not a parity claim. +- Carry the RUDP receive-path bounds to the other runtimes. Rust now caps tracked ordered + and sequenced channels, caps the out-of-order hold buffer by frames and bytes, refuses a + packet far ahead of the highest received so a forged sequence cannot starve a session, and + reports reliable sequence exhaustion instead of aborting. C#, TypeScript, Python and Kotlin + still accept all of it. These are refusals rather than wire-format changes, so the interop + lanes stay green either way -- which is exactly why the gap will not announce itself. - Keep performance claims evidence-bound: C# owns SoA; Python has benchmark comparison gates; other runtimes should advertise measured native hot paths only when those paths exist. diff --git a/native/GameCult.Mesh.Quic.Native/CMakeLists.txt b/native/GameCult.Mesh.Quic.Native/CMakeLists.txt new file mode 100644 index 00000000..2850951d --- /dev/null +++ b/native/GameCult.Mesh.Quic.Native/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.24) +project(gamecult_mesh_quic_native LANGUAGES CXX) + +if(NOT WIN32) + message(FATAL_ERROR "The current CultMesh native QUIC connector supports Windows desktop only") +endif() +if(NOT DEFINED MSQUIC_ROOT) + message(FATAL_ERROR "MSQUIC_ROOT must point at the extracted Microsoft.Native.Quic.MsQuic.Schannel package build/native directory") +endif() + +add_library(gamecult_mesh_quic_native SHARED cultmesh_quic_native.cpp) +target_compile_features(gamecult_mesh_quic_native PRIVATE cxx_std_20) +target_compile_definitions(gamecult_mesh_quic_native PRIVATE GAMECULT_MESH_QUIC_NATIVE_EXPORTS WIN32_LEAN_AND_MEAN NOMINMAX) +target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/include") +target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/lib/x64") +target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic crypt32 bcrypt) + +set_target_properties(gamecult_mesh_quic_native PROPERTIES + OUTPUT_NAME "gamecult_mesh_quic_native" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") diff --git a/native/GameCult.Mesh.Quic.Native/README.md b/native/GameCult.Mesh.Quic.Native/README.md new file mode 100644 index 00000000..875ebb3b --- /dev/null +++ b/native/GameCult.Mesh.Quic.Native/README.md @@ -0,0 +1,10 @@ +# GameCult.Mesh.Quic.Native + +This Windows desktop bridge keeps MsQuic's callback-heavy C API below the +managed runtime boundary. It accepts a discovery-authorized SHA-256 certificate +pin, negotiates `cultmesh-state-v1`, and emits complete CultMesh realtime frame +payloads. It owns transport only; frame meaning remains in `GameCult.Mesh`. + +The build consumes Microsoft's pinned +`Microsoft.Native.Quic.MsQuic.Schannel` package. It does not contain +Aetheria-specific code. diff --git a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp new file mode 100644 index 00000000..bbb6e193 --- /dev/null +++ b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp @@ -0,0 +1,440 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(GAMECULT_MESH_QUIC_NATIVE_EXPORTS) +#define CULTMESH_API extern "C" __declspec(dllexport) +#else +#define CULTMESH_API extern "C" __declspec(dllimport) +#endif + +namespace { + +constexpr uint32_t kApiVersion = 2; +constexpr uint64_t kConnectionCloseCode = 0x43554c54; +constexpr uint64_t kStreamAbortCode = 0x53544154; +constexpr uint8_t kReliableStream = 1; +constexpr uint8_t kLatestOnlyStream = 2; +constexpr uint32_t kMaximumEncodedFrameBytes = (64u * 1024u * 1024u) + 37u + (3u * 65535u); +constexpr char kAlpn[] = "cultmesh-state-v1"; + +enum class ClientState : int32_t { + Connecting = 0, + Connected = 1, + Failed = 2, + Closed = 3, +}; + +struct Client; + +struct StreamContext { + explicit StreamContext(Client* client) : owner(client) {} + Client* owner; + std::vector buffered; + uint8_t kind = 0; + bool has_kind = false; +}; + +struct Client { + const QUIC_API_TABLE* api = nullptr; + HQUIC registration = nullptr; + HQUIC configuration = nullptr; + HQUIC connection = nullptr; + std::array certificate_pin{}; + std::atomic state{ClientState::Connecting}; + std::atomic closing{false}; + std::mutex gate; + std::deque> received; + std::string error; + std::string trace; +}; + +void AppendTrace(Client* client, const std::string& event) { + if (client == nullptr || client->state.load() == ClientState::Connected) return; + std::lock_guard lock(client->gate); + if (!client->trace.empty()) client->trace += ","; + client->trace += event; +} + +std::string TraceSnapshot(Client* client) { + if (client == nullptr) return "none"; + std::lock_guard lock(client->gate); + return client->trace.empty() ? "none" : client->trace; +} + +void SetFailure(Client* client, const std::string& message) { + if (client == nullptr || client->closing.load()) return; + { + std::lock_guard lock(client->gate); + if (client->error.empty()) client->error = message; + } + client->state.store(ClientState::Failed); +} + +bool ParseHexNibble(char value, uint8_t& nibble) { + if (value >= '0' && value <= '9') nibble = static_cast(value - '0'); + else if (value >= 'a' && value <= 'f') nibble = static_cast(value - 'a' + 10); + else if (value >= 'A' && value <= 'F') nibble = static_cast(value - 'A' + 10); + else return false; + return true; +} + +bool ParsePin(const char* value, std::array& pin) { + if (value == nullptr || std::strlen(value) != 64) return false; + for (size_t index = 0; index < pin.size(); ++index) { + uint8_t high = 0; + uint8_t low = 0; + if (!ParseHexNibble(value[index * 2], high) || !ParseHexNibble(value[(index * 2) + 1], low)) + return false; + pin[index] = static_cast((high << 4) | low); + } + return true; +} + +uint32_t ReadUInt32LittleEndian(const uint8_t* value) { + return static_cast(value[0]) | + (static_cast(value[1]) << 8) | + (static_cast(value[2]) << 16) | + (static_cast(value[3]) << 24); +} + +void FailStream(StreamContext* stream, const char* message) { + SetFailure(stream->owner, message); + if (stream->owner != nullptr && stream->owner->api != nullptr && stream->owner->connection != nullptr) + stream->owner->api->ConnectionShutdown( + stream->owner->connection, + QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, + kStreamAbortCode); +} + +bool ConsumeFrames(StreamContext* stream) { + auto& bytes = stream->buffered; + if (!stream->has_kind) { + if (bytes.empty()) return true; + stream->kind = bytes.front(); + bytes.erase(bytes.begin()); + stream->has_kind = true; + if (stream->kind != kReliableStream && stream->kind != kLatestOnlyStream) { + FailStream(stream, "CultMesh QUIC stream kind is invalid."); + return false; + } + } + + while (bytes.size() >= sizeof(uint32_t)) { + const uint32_t length = ReadUInt32LittleEndian(bytes.data()); + if (length == 0 || length > kMaximumEncodedFrameBytes) { + FailStream(stream, "CultMesh QUIC frame length is invalid."); + return false; + } + const size_t framed_length = sizeof(uint32_t) + static_cast(length); + if (bytes.size() < framed_length) return true; + std::vector frame(length); + std::memcpy(frame.data(), bytes.data() + sizeof(uint32_t), length); + { + std::lock_guard lock(stream->owner->gate); + stream->owner->received.emplace_back(std::move(frame)); + } + bytes.erase(bytes.begin(), bytes.begin() + static_cast(framed_length)); + if (stream->kind == kLatestOnlyStream && !bytes.empty()) { + FailStream(stream, "CultMesh latest-only QUIC stream carried multiple frames."); + return false; + } + } + return true; +} + +QUIC_STATUS QUIC_API StreamCallback(HQUIC stream_handle, void* context, QUIC_STREAM_EVENT* event) { + auto* stream = static_cast(context); + switch (event->Type) { + case QUIC_STREAM_EVENT_RECEIVE: + for (uint32_t index = 0; index < event->RECEIVE.BufferCount; ++index) { + const auto& buffer = event->RECEIVE.Buffers[index]; + stream->buffered.insert(stream->buffered.end(), buffer.Buffer, buffer.Buffer + buffer.Length); + } + ConsumeFrames(stream); + break; + case QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN: + if (!stream->buffered.empty()) + FailStream(stream, "CultMesh QUIC stream ended with a truncated frame."); + break; + case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE: + if (!event->SHUTDOWN_COMPLETE.AppCloseInProgress) + stream->owner->api->StreamClose(stream_handle); + delete stream; + break; + default: + break; + } + return QUIC_STATUS_SUCCESS; +} + +bool CertificateMatchesPin(Client* client, QUIC_CERTIFICATE* certificate) { + if (certificate == nullptr) return false; + const auto* encoded = reinterpret_cast(certificate); + if (encoded->Buffer == nullptr || encoded->Length == 0) return false; + BYTE digest[32]{}; + DWORD digest_length = sizeof(digest); + if (!CryptHashCertificate2( + BCRYPT_SHA256_ALGORITHM, + 0, + nullptr, + encoded->Buffer, + encoded->Length, + digest, + &digest_length) || digest_length != sizeof(digest)) + return false; + return std::memcmp(digest, client->certificate_pin.data(), sizeof(digest)) == 0; +} + +std::string Hex(uint64_t value) { + std::ostringstream text; + text << "0x" << std::hex << std::uppercase << value; + return text.str(); +} + +std::string LoadedMsQuicPath() { + const auto module = GetModuleHandleW(L"msquic.dll"); + if (module == nullptr) return "unresolved"; + std::array path{}; + const auto length = GetModuleFileNameW(module, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size()) return "unresolved"; + const auto required = WideCharToMultiByte( + CP_UTF8, 0, path.data(), static_cast(length), nullptr, 0, nullptr, nullptr); + if (required <= 0) return "unresolved"; + std::string utf8(static_cast(required), '\0'); + WideCharToMultiByte( + CP_UTF8, 0, path.data(), static_cast(length), utf8.data(), required, nullptr, nullptr); + return utf8; +} + +QUIC_STATUS QUIC_API ConnectionCallback(HQUIC connection, void* context, QUIC_CONNECTION_EVENT* event) { + auto* client = static_cast(context); + AppendTrace(client, "event=" + std::to_string(static_cast(event->Type))); + switch (event->Type) { + case QUIC_CONNECTION_EVENT_CONNECTED: + client->state.store(ClientState::Connected); + break; + case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: { + auto* stream = new (std::nothrow) StreamContext(client); + if (stream == nullptr) return QUIC_STATUS_OUT_OF_MEMORY; + client->api->SetCallbackHandler( + event->PEER_STREAM_STARTED.Stream, + reinterpret_cast(StreamCallback), + stream); + break; + } + case QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED: + { + AppendTrace( + client, + "certificate-status=" + + Hex(static_cast(event->PEER_CERTIFICATE_RECEIVED.DeferredStatus))); + const auto matches = CertificateMatchesPin( + client, + event->PEER_CERTIFICATE_RECEIVED.Certificate); + if (!matches) { + SetFailure(client, "CultMesh QUIC provider certificate does not match the advertised SHA-256 pin."); + } + const auto completion = client->api->ConnectionCertificateValidationComplete( + connection, + matches ? TRUE : FALSE, + matches ? QUIC_TLS_ALERT_CODE_SUCCESS : QUIC_TLS_ALERT_CODE_BAD_CERTIFICATE); + if (QUIC_FAILED(completion)) { + SetFailure( + client, + "CultMesh QUIC certificate validation completion failed (status=" + + Hex(static_cast(completion)) + ")."); + return completion; + } + return QUIC_STATUS_PENDING; + } + case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT: + if (!client->closing.load()) { + SetFailure( + client, + "CultMesh QUIC connection was shut down by the transport " + "(status=" + Hex(static_cast(event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status)) + + ", error=" + Hex(event->SHUTDOWN_INITIATED_BY_TRANSPORT.ErrorCode) + + ", msquic=" + LoadedMsQuicPath() + + ", events=" + TraceSnapshot(client) + ")."); + } + break; + case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER: + if (!client->closing.load()) + SetFailure(client, "CultMesh QUIC connection was shut down by the provider."); + break; + case QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE: + if (client->closing.load()) client->state.store(ClientState::Closed); + break; + default: + break; + } + return QUIC_STATUS_SUCCESS; +} + +void CloseHandles(Client* client) { + if (client->connection != nullptr) { + client->api->ConnectionClose(client->connection); + client->connection = nullptr; + } + if (client->configuration != nullptr) { + client->api->ConfigurationClose(client->configuration); + client->configuration = nullptr; + } + if (client->registration != nullptr) { + client->api->RegistrationClose(client->registration); + client->registration = nullptr; + } + if (client->api != nullptr) { + MsQuicClose(client->api); + client->api = nullptr; + } +} + +} // namespace + +CULTMESH_API int32_t cultmesh_quic_open( + const char* host, + uint16_t port, + const char* certificate_sha256, + Client** result) { + if (host == nullptr || *host == '\0' || port == 0 || result == nullptr) return -1; + *result = nullptr; + auto client = std::make_unique(); + if (!ParsePin(certificate_sha256, client->certificate_pin)) return -2; + + QUIC_STATUS status = MsQuicOpenVersion( + kApiVersion, + reinterpret_cast(&client->api)); + if (QUIC_FAILED(status)) return status; + + const QUIC_REGISTRATION_CONFIG registration_config = { + "GameCult.Mesh.Quic.Native", + QUIC_EXECUTION_PROFILE_TYPE_REAL_TIME + }; + status = client->api->RegistrationOpen(®istration_config, &client->registration); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + + QUIC_SETTINGS settings{}; + settings.IdleTimeoutMs = 30000; + settings.IsSet.IdleTimeoutMs = TRUE; + settings.KeepAliveIntervalMs = 5000; + settings.IsSet.KeepAliveIntervalMs = TRUE; + settings.PeerUnidiStreamCount = 1024; + settings.IsSet.PeerUnidiStreamCount = TRUE; + const QUIC_BUFFER alpn = { + static_cast(sizeof(kAlpn) - 1), + reinterpret_cast(const_cast(kAlpn)) + }; + status = client->api->ConfigurationOpen( + client->registration, + &alpn, + 1, + &settings, + sizeof(settings), + nullptr, + &client->configuration); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + + QUIC_CREDENTIAL_CONFIG credentials{}; + credentials.Type = QUIC_CREDENTIAL_TYPE_NONE; + credentials.Flags = static_cast( + QUIC_CREDENTIAL_FLAG_CLIENT | + QUIC_CREDENTIAL_FLAG_INDICATE_CERTIFICATE_RECEIVED | + QUIC_CREDENTIAL_FLAG_DEFER_CERTIFICATE_VALIDATION | + QUIC_CREDENTIAL_FLAG_USE_PORTABLE_CERTIFICATES); + status = client->api->ConfigurationLoadCredential(client->configuration, &credentials); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + + status = client->api->ConnectionOpen( + client->registration, + ConnectionCallback, + client.get(), + &client->connection); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + status = client->api->ConnectionStart( + client->connection, + client->configuration, + QUIC_ADDRESS_FAMILY_UNSPEC, + host, + port); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + + *result = client.release(); + return 0; +} + +CULTMESH_API int32_t cultmesh_quic_state(Client* client) { + if (client == nullptr) return static_cast(ClientState::Failed); + return static_cast(client->state.load()); +} + +CULTMESH_API int32_t cultmesh_quic_poll( + Client* client, + uint8_t* destination, + int32_t destination_length, + int32_t* required_length) { + if (client == nullptr || required_length == nullptr || destination_length < 0) return -1; + std::lock_guard lock(client->gate); + if (client->received.empty()) { + *required_length = 0; + return client->state.load() == ClientState::Failed ? -2 : 0; + } + const auto& frame = client->received.front(); + if (frame.size() > static_cast(INT32_MAX)) return -3; + *required_length = static_cast(frame.size()); + if (destination == nullptr || destination_length < *required_length) return 2; + std::memcpy(destination, frame.data(), frame.size()); + client->received.pop_front(); + return 1; +} + +CULTMESH_API int32_t cultmesh_quic_error( + Client* client, + char* destination, + int32_t destination_length) { + if (client == nullptr || destination == nullptr || destination_length <= 0) return -1; + std::lock_guard lock(client->gate); + const size_t count = (std::min)(client->error.size(), static_cast(destination_length - 1)); + std::memcpy(destination, client->error.data(), count); + destination[count] = '\0'; + return static_cast(count); +} + +CULTMESH_API void cultmesh_quic_close(Client* client) { + if (client == nullptr) return; + client->closing.store(true); + if (client->connection != nullptr && client->api != nullptr) + client->api->ConnectionShutdown(client->connection, QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, kConnectionCloseCode); + CloseHandles(client); + client->state.store(ClientState::Closed); + delete client; +} diff --git a/package-lock.json b/package-lock.json index 37c13662..f027ecd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,15 @@ "workspaces": [ "packages/cultcache-ts", "packages/cultnet-ts", - "packages/cultmesh-ts" - ] + "packages/cultmesh-ts", + "packages/cultmesh-browser" + ], + "devDependencies": { + "esbuild": "^0.28.1", + "fast-uri": "3.1.5", + "js-yaml": "4.3.1", + "playwright-core": "^1.62.1" + } }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "11.9.3", @@ -29,6 +36,452 @@ "url": "https://github.com/sponsors/philsturgeon" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@gamecult/cultcache-ts": { + "resolved": "packages/cultcache-ts", + "link": true + }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", @@ -92,8 +545,8 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/cultcache-ts": { - "resolved": "packages/cultcache-ts", + "node_modules/cultmesh-browser": { + "resolved": "packages/cultmesh-browser", "link": true }, "node_modules/cultmesh-ts": { @@ -104,6 +557,48 @@ "resolved": "packages/cultnet-ts", "link": true }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -111,9 +606,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -168,9 +663,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -250,6 +745,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/prettier": { "version": "3.9.5", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", @@ -323,7 +831,8 @@ } }, "packages/cultcache-ts": { - "version": "0.13.5", + "name": "@gamecult/cultcache-ts", + "version": "0.14.0", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.3", @@ -332,35 +841,59 @@ "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cultmesh-browser": { + "version": "0.3.0", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.1.3", + "cultnet-ts": "^0.2.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=18" } }, "packages/cultmesh-ts": { - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { + "@gamecult/cultcache-ts": "^0.14.0", "@msgpack/msgpack": "^3.1.2", - "cultcache-ts": "file:../cultcache-ts", - "cultnet-ts": "file:../cultnet-ts" + "cultnet-ts": "^0.2.0" }, "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.0", "zod": "^3.25.76" + }, + "engines": { + "node": ">=18" } }, "packages/cultnet-ts": { - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { + "@gamecult/cultcache-ts": "^0.14.0", "@msgpack/msgpack": "^3.1.3", - "ajv": "^8.17.1", - "cultcache-ts": "file:../cultcache-ts" + "ajv": "^8.17.1" }, "devDependencies": { "@types/node": "^24.0.0", "json-schema-to-typescript": "^15.0.4", "typescript": "^5.9.0", "zod": "^3.25.76" + }, + "engines": { + "node": ">=18" } } } diff --git a/package.json b/package.json index f1b995a2..c9b79e47 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,19 @@ "workspaces": [ "packages/cultcache-ts", "packages/cultnet-ts", - "packages/cultmesh-ts" + "packages/cultmesh-ts", + "packages/cultmesh-browser" ], "scripts": { - "build:ts": "npm --workspaces run build", - "test:ts": "npm --workspaces run test" + "build:ts": "node scripts/run-typescript-workspaces.mjs build", + "test:ts": "node scripts/run-typescript-workspaces.mjs test", + "test:pack:ts": "node scripts/test-typescript-package-closure.mjs", + "test:eve-browser-network": "node scripts/verify-eve-browser-network.mjs" + }, + "devDependencies": { + "esbuild": "^0.28.1", + "fast-uri": "3.1.5", + "js-yaml": "4.3.1", + "playwright-core": "^1.62.1" } } diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index 265eecb7..23effdbd 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -1,12 +1,16 @@ -# cultcache-py +# cultcache-py -`cultcache-py` is the Python runtime package for CultCache, CultNet, and -CultMesh. Callers work with registered domain documents while the cache owns -schema identity, routing, globals, name lookups, indexes, and backing-store -envelopes. +`cultcache-py` is the Python CultCache runtime. Callers work with registered +domain documents while the cache owns schema identity, routing, globals, name +lookups, indexes, and backing-store envelopes. It is intentionally small. It is not an ORM, a database, or distributed consensus wearing rented authority. +It is the bottom of the Python CultLib stack and depends only on `msgpack`. +`cultnet-py` (transport and wire contracts) and `cultmesh-py` (nodes, +discovery, sessions, local server) build on it, mirroring `cultcache-rs` / +`cultnet-rs` / `cultmesh-rs` and the npm packages. + ## Current Shape - document types are registered explicitly @@ -17,14 +21,14 @@ It is intentionally small. It is not an ORM, a database, or distributed consensu - payloads decode only through the registered document definition for that type - unknown persisted types fail closed - global documents are singleton-style per type -- type-specific backing stores beat generic backing stores +- one home store per document type: a store added with `add_backing_store` + owns its types, otherwise the generic store is the home; a type claimed by a + second store, or a second generic store, raises `CultCacheError`; a write + touches exactly one store and nothing is mirrored; once any store is + attached, a write whose type has no home raises - the JSONL store uses only the Python standard library for bootstrap consumers - `define_database_entry_type(...)` emits Rust/C#-style slot-indexed MessagePack array payloads for cross-runtime `DatabaseEntry` contracts -- `cultnet_py` exposes schema-v0 MessagePack helpers and 4-byte big-endian - frame helpers for raw document state, subscriptions, shard catch-up, - simulation observations, and witness artifact bundles -- `cultmesh_py` exposes a cache-backed local node surface for Python tools ## Example @@ -144,336 +148,28 @@ That matches the Rust `#[derive(DatabaseEntry)]` formatter shape and the C# `[Key(n)]` intent: schema evolution comes from stable field slots, not source member order. -## CultNet And CultMesh - -`cultnet_py` provides the Python schema-v0 wire helpers and an interop peer: - -```python -from datetime import UTC, datetime - -from cultnet_py import ( - CultNetSecret, - CultNetServerSecurityOptions, - CultNetRawClient, - CultNetSimulationObservation, - CultNetWitnessArtifactBundle, - apply_raw_snapshot, - apply_shard_log_response, - compute_simulation_claim_hash, - database_subscribe, - decode_frame, - encode_frame, - hello, - parse_message, - shard_catalog_request, - simulation_observation, - witness_artifact_bundle, -) -from cultmesh_py import CultMesh - -payload = hello(runtime_id="python-runtime").to_bytes() -message = parse_message(decode_frame(encode_frame(payload))) - -client = CultNetRawClient("127.0.0.1", 3075) -catalog = client.fetch_schema_catalog(kinds=["wire_message"]) -snapshot = client.fetch_snapshot_response(schema_ids=["cultnet.interop-note"]) -shard_catalog = client.fetch_shard_catalog(schema_ids=["cultnet.interop-note"]) -# With a local CultCache and the matching registered document definitions: -# applied = apply_raw_snapshot(cache, [note_doc], snapshot) - -subscription = database_subscribe(subscription_id="ui", schema_ids=["cultnet.interop-note"]) -shard_request = shard_catalog_request(message_id="shards", schema_ids=["cultnet.interop-note"]) -shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=0) -# log_changes = apply_shard_log_response(cache, [note_doc], shard_log) -with client.subscribe_database(subscription_id="ui", schema_ids=["cultnet.interop-note"]) as live: - initial_snapshot = live.read_next_snapshot_response() - -# The same raw-client request surface can ride RUDP when the peer advertises a -# rudp:// endpoint and shares the connection id for that lane. -rudp_client = CultMesh.create_client( - endpoint="rudp://127.0.0.1:3076", - connection_id=0x43554C54, -) -rudp_catalog = rudp_client.fetch_schema_catalog(kinds=["wire_message"]) - -claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit") -observation = simulation_observation( - message_id="obs-1", - witness_runtime_id="python-runtime", - shard_id="interop", - shard_epoch=1, - frame=42, - subject_id="player-1", - claim_kind="hit", - claim_hash=claim_hash, -) -typed_observation = CultNetSimulationObservation.from_wire(observation.to_wire()) -witness = witness_artifact_bundle( - bundle_id="bundle-1", - witness_kind="interop-proof", - captured_at="2026-06-13T00:00:00Z", - subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"}, - contracts=[{"role": "payload", "schemaId": "cultnet.interop-note"}], - artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}], - provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-runtime"}, -) -typed_witness = CultNetWitnessArtifactBundle.from_wire(witness) -witness_payload = typed_witness.to_payload() - -security = CultNetServerSecurityOptions.development() -token = CultNetSecret.create_session_token( - "318fb4b6-ff5e-4c4f-b911-d81807de53a8", - datetime(2035, 1, 1, tzinfo=UTC), - security, - session_version=1, -) -session = CultNetSecret.try_validate_session_token(token, security) -``` - -AES-GCM string/byte encryption helpers are available through the same -`CultNetSecret` surface when the optional `crypto` extra is installed. - -The peer can serve, dial, and probe the same raw-state interop lane used by the -TypeScript, Rust, and C# test peers: - -```powershell -python -m cultnet_py.interop_peer serve --runtime-id python-peer --runtime-kind python --display-name "Python Peer" --agent-id python-agent --advertise-host 127.0.0.1 --tcp-port 3075 --discovery-port 4075 --discovery-group 239.77.44.11 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json -python -m cultnet_py.interop_peer dial --runtime-id python-client --runtime-kind python --display-name "Python Client" --agent-id python-client --target-host 127.0.0.1 --target-port 3075 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json -python -m cultnet_py.interop_peer probe --runtime-id python-prober --discovery-port 4075 --discovery-group 239.77.44.11 -``` - -`cultmesh_py` includes a local cache-backed node, schema-v0 helpers for the -CultMesh Verse catalog and peer exchange wire messages, local and verified -authority lease checks, stream transport negotiation, committed simulation fact -documents, and local prediction reconciliation: - -```python -from datetime import UTC, datetime, timedelta - -from cultcache_py import define_database_entry_type -from cultnet_py import ( - CultNetClientAuthorityScope, - CultNetFileShardReplicaCursorStore, - CultNetRawClient, - CultNetSchemaShardLogFetcher, - CultNetSchemaShardSnapshotFetcher, - CultNetShardDescriptor, - CultNetShardReplicator, - CultNetShardReplicatorOptions, -) -from cultmesh_py import ( - CultMesh, - CultMeshAuthorityLease, - CultMeshGameSessionOptions, - CultMeshHmacAuthorityLeaseVerifier, - CultMeshNodeOptions, - peer_exchange_request, -) - -note_doc = define_database_entry_type( - "mesh.note", - [("body", 0), ("name", 1), ("kind", 2)], - name="name", - indexes={"kind": "kind"}, -) -node = CultMesh.start_node( - "mesh.cc", - runtime_id="python-runtime", - options=CultMeshNodeOptions(enable_durable_shard_logs=True), -) -node.database.register_document(note_doc) -unsubscribe = node.database.watch_record(note_doc, "note:1", lambda change: print(change.change_kind)) -unsubscribe_named = node.database.watch_by_name(note_doc, "intro", lambda change: print(change.record_key)) -unsubscribe_kind = node.database.watch_by_index(note_doc, "kind", "guide", lambda change: print(change.record_key)) -node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide"}) -live_note = node.database.get_required(note_doc, "note:1") -put_message = node.database.put_raw_message( - note_doc, - "note:2", - {"body": "wire me", "name": "wire", "kind": "interop"}, - shard_id="interop", - shard_epoch=1, -) -snapshot_response = node.database.build_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id]) -snapshot_wire = node.database.create_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id]) -delete_message = node.database.delete_raw_message(note_doc, "note:2", shard_id="interop", shard_epoch=1) -shard_log_response = node.database.build_shard_log_response(shard_id="interop", shard_epoch=1) -shard_log_wire = node.database.create_shard_log_response(shard_id="interop", shard_epoch=1) - -peers = CultMesh.create_peer_catalog() -response = peers.create_response(peer_exchange_request("pex-1", verse_id="local")) -lease_verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"shared-lease-key"}) -lease_catalog = CultMesh.create_authority_lease_catalog( - signature_verifier=lease_verifier.verify, - require_verified_signatures=True, -) -lease_valid_from = datetime.now(UTC) -lease = lease_verifier.issue(CultMeshAuthorityLease( - lease_id="lease:python-runtime", - verse_id="local", - peer_id="python-runtime", - roles=("shard-primary",), - valid_from=lease_valid_from, - expires_at=lease_valid_from + timedelta(minutes=5), - shard_ids=("interop",), - issuer_runtime_id="odin", -)) -lease_catalog.upsert(lease) -session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - client_authority_scopes=( - CultNetClientAuthorityScope("python-runtime", schema_ids=(note_doc.catalog_entry().schema_id,), key_prefix="input:python"), - ), - ), -) -server = CultMesh.serve_node( - node, - peer_catalog=peers, - observation_hub=session.observation_hub, - port=3075, - max_snapshot_documents=1000, - max_snapshot_bytes=4 * 1024 * 1024, -) -``` - -The same local server can be launched as a package daemon when another runtime -or operator process needs a CultNet endpoint without embedding Python glue: - -```powershell -python -m cultmesh_py.daemon --runtime-id python-runtime --display-name "Python Runtime" --host 127.0.0.1 --port 3075 --cache-file mesh.cc --enable-durable-shard-logs --seed-interop-note --seed-shard-id interop --verse-id local --role shard-primary --ready-file mesh.ready.json -``` - -The ready file and stdout line are process readiness hints. The served state and -capability truth still live on the framed CultNet/CultMesh endpoint. Use -`--register-interop-note` to expose the package interop-note schema without -seeding a note, or `--seed-interop-note` to register it and publish one local -record for wire probes. Seeded records are committed through the raw mutation -path, so peers can read them through both snapshot and shard-log catch-up. Add -`--verse-id` and one or more `--role` values to publish the launched endpoint in -its own Verse and peer catalogs for discovery clients. When the daemon is -launched with `--cache-file`, `--enable-durable-shard-logs`, and -`--shard-log-file`, a later daemon process can rehydrate and serve the persisted -snapshot and shard log without reseeding. - -`CultMeshPeerHealthMonitor` probes peer-card endpoints with `cultnet.hello.v0` -and preserves the runtime id, display name, document types, message versions, -and mutation contracts reported by the peer. - -```python - -client = CultMesh.create_verse_discovery_client("127.0.0.1", 3075) -verses = client.fetch_verses(transport_version="cultmesh.v0") -mesh_peers = client.fetch_peers(verse_id="python-interop", roles=["read-replica"]) -client.sync_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"]) -client.fanout_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"]) - -raw_client = CultNetRawClient("127.0.0.1", 3075) -node.database.sync_snapshot(raw_client, schema_ids=[note_doc.catalog_entry().schema_id]) -node.database.sync_shard_log(raw_client, shard_id="interop", shard_epoch=1) -replicator = CultNetShardReplicator( - node.database, - CultNetShardReplicatorOptions( - fetcher=CultNetSchemaShardLogFetcher(), - snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(), - cursor_store=CultNetFileShardReplicaCursorStore("mesh.replica-cursors.msgpack"), - poll_interval_seconds=1.0, - on_error=lambda error: print(error), - ), -) -replica_shard = CultNetShardDescriptor( - shard_id="interop", - owner_runtime_id="primary-runtime", - epoch=1, - schema_ids=(note_doc.catalog_entry().schema_id,), - primary_endpoints=("cultnet://127.0.0.1:3075",), -) -replicator.pull_once(replica_shard) -replicator.start([replica_shard]) -replicator.stop() -server.stop() - -streams = CultMesh.create_stream_catalog() - -claim_hash = "accepted-claim-hash" -facts = CultMesh.create_simulation_fact_committer(node) -committed = facts.commit({ - "shardId": "arena", - "shardEpoch": 4, - "frame": 100, - "subjectId": "bob", - "claimKind": "hit", - "claimHash": claim_hash, - "witnessCount": 2, - "supportWeight": 2.0, - "totalWeight": 2.0, - "confidence": 1.0, - "hasQuorum": True, -}) -session_commits = session.submit_and_commit(typed_observation) -prediction = session.predict(note_doc, "input:python:move", {"body": "predicted input"}) -unsubscribe() -``` - ## Wire Parity -See [PARITY.md](PARITY.md) for the current evidence map, gates, and things this -package still does not claim. +See [docs/python-runtime-parity.md](../../docs/python-runtime-parity.md) for +the evidence map across the three Python packages. -The Python interop peer is `cultcache_py.interop`: +The store interop peer is `cultcache_py.interop`: ```powershell python -m cultcache_py.interop write --file cache.cc --runtime-id python python -m cultcache_py.interop read --file cache.cc ``` -Current receipts: +`packages/cultcache-ts/test/cult-cache.test.ts` includes Python in the shared +CultCache v1 parity matrix with TypeScript, Rust, and C#. `cultcache_py` ships +a `py.typed` marker so downstream type checkers can inspect the package surface. -- `packages/cultcache-ts/test/cult-cache.test.ts` includes Python in the shared - CultCache v1 parity matrix with TypeScript, Rust, and C#. -- `packages/cultnet-ts/test/interop/cultnet-interop.test.ts` includes Python in - the live TS/Rust/C#/Python schema-v0 peer ring: discovery, hello, schema - catalog, wire-message catalog discovery, raw snapshot, raw document - put/delete, mutation receipt, fire-command receipt, database subscription - changes, shard catalog, shard log catch-up, simulation consensus candidates, - committed simulation fact snapshots, Python session prediction reconciliation, - and witness artifact bundle round-trips. The same test asks the Python peer - for CultMesh Verse catalog and peer exchange responses over the CultNet pipe, - then verifies the public `CultMeshDiscoveryClient` can fetch typed Python - Verse and peer descriptors from that live endpoint. It also verifies - `CultNetRawClient` can fetch the live Python peer's schema catalog, raw - snapshot, and shard catalog. -- `packages/cultcache-py/tests/test_cultcache.py` covers Python CultMesh - Verse catalog, peer exchange, authority lease, stream negotiation, CultNet - helper shapes, raw snapshot/shard-log application, simulation claim hashing, - consensus aggregation, game-session fact commits, local prediction - reconciliation, committed simulation fact payload slots, the local CultMesh - server's TCP and RUDP schema request paths, and witness artifact bundle - payload slots. -- `cultcache_py`, `cultnet_py`, and `cultmesh_py` ship `py.typed` markers so - downstream type checkers can inspect the package surface instead of treating - the runtime as an untyped xenos swamp. - -## Performance Baseline - -The package ships a lightweight benchmark for Python-owned hot paths. In a -CultLib source checkout, it can also run the C# reference benchmark beside it: +## Tests ```powershell -python -m cultcache_py.benchmark --records 1000 --json -python -m cultcache_py.compare_csharp --records 1000 --json -python -m cultcache_py.verify --json +$env:PYTHONPATH="$PWD\packages\cultcache-py\src" +python -m unittest discover -s packages\cultcache-py\tests ``` -It reports slot-indexed `DatabaseEntry` encode/decode throughput, framed -CultNet MessagePack parse throughput, raw snapshot application into a -registered cache, and public `CultCache` upsert/get throughput. The C# compare -command also runs `packages/cultcache-py/tools/GameCult.Caching.Benchmark` and -reports median Python-to-C# ratios for the shared public cache operations. It -uses three samples by default; pass `--samples` when you need a different -evidence shape. Both runtimes precompute record keys before measured cache -upsert/get loops so the comparison is about public cache access, not string -construction noise. The public in-memory cache owner stores records in -type-partitioned dictionaries so hot `get(...)` calls avoid per-call -composite-key allocation. Treat the benchmark numbers as a local baseline, not -a claim that Python matches the C# reference in every workload. +The suite runs with only this package on the path. If a test here needs +`cultnet_py` or `cultmesh_py`, it belongs in that package. diff --git a/packages/cultcache-py/pyproject.toml b/packages/cultcache-py/pyproject.toml index 93ebf849..6c46d102 100644 --- a/packages/cultcache-py/pyproject.toml +++ b/packages/cultcache-py/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "cultcache-py" -version = "0.1.0" -description = "Python CultCache, CultNet, and CultMesh runtime package for GameCult typed state." +version = "0.3.0" +description = "Python CultCache runtime for GameCult typed state." readme = "README.md" requires-python = ">=3.10" -license = { text = "Private GameCult infrastructure" } +license = "MIT" authors = [{ name = "GameCult" }] classifiers = [ "Programming Language :: Python :: 3", @@ -17,23 +17,11 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] dependencies = ["msgpack>=1.0"] - -[project.optional-dependencies] -msgpack = ["msgpack>=1.0"] -crypto = ["cryptography>=42"] - [project.scripts] cultcache-py-interop = "cultcache_py.interop:main" -cultnet-py-interop = "cultnet_py.interop_peer:main" -cultcache-py-benchmark = "cultcache_py.benchmark:main" -cultcache-py-compare-csharp = "cultcache_py.compare_csharp:main" -cultcache-py-verify = "cultcache_py.verify:main" -cultmesh-py-daemon = "cultmesh_py.daemon:main" [tool.setuptools.packages.find] where = ["src"] [tool.setuptools.package-data] cultcache_py = ["py.typed"] -cultnet_py = ["py.typed"] -cultmesh_py = ["py.typed"] diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index 0ffdd18a..0da2c04a 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -1,15 +1,52 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any, Generic, TypeVar +import functools +import threading +from dataclasses import dataclass, field, replace +from typing import Any, Callable, Generic, TypeVar from .backing_store import BackingStore, CultCacheEnvelope from .documents import DocumentDefinition, extract_value T = TypeVar("T") +F = TypeVar("F", bound=Callable[..., Any]) GLOBAL_KEY = "__global__" +def _locked(method: F) -> F: + """Runs a read holding the cache's lock. Reads are reentrant and may run inside a mutation.""" + + @functools.wraps(method) + def wrapper(self: "CultCache", *args: Any, **kwargs: Any) -> Any: + with self._lock: + return method(self, *args, **kwargs) + + return wrapper # type: ignore[return-value] + + +def _mutating(method: F) -> F: + """Runs an attach, pull, registration, write or delete holding the cache's lock, so its validation, + store I/O and apply cannot interleave with another thread's. One started on a thread that is already + inside a mutation of this cache (from a store call, decoder, encoder, extractor or updater) raises + before touching anything.""" + + @functools.wraps(method) + def wrapper(self: "CultCache", *args: Any, **kwargs: Any) -> Any: + if self._mutating_thread == threading.get_ident(): + raise CultCacheError( + f"CultCache refuses a re-entrant {method.__name__}: a mutation cannot start from inside a store call, " + "decoder, encoder, extractor or updater of another mutation on the same cache" + ) + with self._lock: + self._mutating_thread = threading.get_ident() + try: + return method(self, *args, **kwargs) + finally: + self._mutating_thread = None + + return wrapper # type: ignore[return-value] + + class CultCacheError(RuntimeError): pass @@ -17,14 +54,34 @@ class CultCacheError(RuntimeError): @dataclass class _State: documents: dict[str, DocumentDefinition[Any]] = field(default_factory=dict) + documents_by_schema_name: dict[str, DocumentDefinition[Any]] = field(default_factory=dict) values: dict[str, dict[str, Any]] = field(default_factory=dict) envelopes: dict[str, dict[str, CultCacheEnvelope]] = field(default_factory=dict) - stores_by_type: dict[str, list[BackingStore]] = field(default_factory=dict) - generic_stores: list[BackingStore] = field(default_factory=list) + stores_by_type: dict[str, BackingStore] = field(default_factory=dict) + generic_store: BackingStore | None = None name_extractors: dict[str, str | Any] = field(default_factory=dict) index_extractors: dict[str, dict[str, str | Any]] = field(default_factory=dict) names: dict[tuple[str, str], str] = field(default_factory=dict) indexes: dict[tuple[str, str, str], str] = field(default_factory=dict) + # The name and index values each held record produced when it was admitted. Removing a + # record reads these and runs no extractor, so removal has no fallible step. + lookup_keys: dict[str, dict[str, "_LookupKeys"]] = field(default_factory=dict) + # Load compatibility shim: a global persisted under a key other than GLOBAL_KEY is held as that + # type's global under GLOBAL_KEY, and its persisted key is kept here so the first write or delete + # of that global removes it from the home store. Remove once no store holds a global under a key + # other than GLOBAL_KEY. + legacy_global_keys: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class _LookupKeys: + name: str | None + indexes: dict[str, str] + + +_Names = dict[tuple[str, str], str] +_Indexes = dict[tuple[str, str, str], str] +_LookupKeysByType = dict[str, dict[str, _LookupKeys]] class CultCacheBuilder: @@ -68,54 +125,176 @@ class CultCache: def __init__(self) -> None: self._state = _State() + # Mutations hold this lock and refuse to start on the thread already inside one (see _mutating). + # It is reentrant so a mutation's extractors, decoders and updaters may still read the cache. + # Single-structure reads (get, get_all, get_envelope, key lookups) stay unlocked. The guarantee is + # per call: each reads one dict under the GIL and sees it wholly before or after a write's install. + # Two unlocked calls can straddle a write or pull, and see different states. + self._lock = threading.RLock() + self._mutating_thread: int | None = None + @_mutating def register_document_type(self, document: DocumentDefinition[Any]) -> None: + self._register_document_type(document) + + def _register_document_type(self, document: DocumentDefinition[Any]) -> None: if document.type in self._state.documents: raise CultCacheError(f"Document type already registered: {document.type}") - self._state.documents[document.type] = document + schema_name = document.catalog_entry().schema_name + if schema_name in self._state.documents_by_schema_name: + raise CultCacheError(f"Document schema name already registered: {schema_name}") + name_extractors = dict(self._state.name_extractors) + index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()} if document.name is not None: - self.register_name_lookup(document, document.name) - for index, extractor in document.indexes.items(): - self.register_index(document, index, extractor) + name_extractors[document.type] = document.name + if document.indexes: + index_extractors.setdefault(document.type, {}).update(document.indexes) + self._install_lookups(name_extractors, index_extractors) + self._state.documents[document.type] = document + self._state.documents_by_schema_name[schema_name] = document + @_mutating def register_registry(self, documents: list[DocumentDefinition[Any]] | tuple[DocumentDefinition[Any], ...]) -> None: for document in documents: - self.register_document_type(document) + self._register_document_type(document) + @_mutating def register_name_lookup(self, document: DocumentDefinition[Any], extractor: str | Any) -> None: self._assert_registered(document) - self._state.name_extractors[document.type] = extractor - self._rebuild_indexes() + name_extractors = dict(self._state.name_extractors) + name_extractors[document.type] = extractor + self._install_lookups(name_extractors, self._state.index_extractors) + @_mutating def register_index(self, document: DocumentDefinition[Any], index: str, extractor: str | Any) -> None: self._assert_registered(document) - self._state.index_extractors.setdefault(document.type, {})[index] = extractor - self._rebuild_indexes() + index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()} + index_extractors.setdefault(document.type, {})[index] = extractor + self._install_lookups(self._state.name_extractors, index_extractors) + def _install_lookups( + self, name_extractors: dict[str, str | Any], index_extractors: dict[str, dict[str, str | Any]] + ) -> None: + """Derives every lookup under the candidate extractors, then installs extractors and lookups + together. An extractor that raises on a held value installs nothing.""" + names, indexes, lookup_keys = self._derive_lookups(self._state.values, name_extractors, index_extractors) + ( + self._state.name_extractors, + self._state.index_extractors, + self._state.names, + self._state.indexes, + self._state.lookup_keys, + ) = (name_extractors, index_extractors, names, indexes, lookup_keys) + + @_mutating def add_backing_store(self, store: BackingStore, types: list[str] | tuple[str, ...] | set[str]) -> None: + """Makes the store home to the given types, or the generic store when there are none. + + A type has exactly one home store, and attaching cannot move the home of a record the cache holds. + """ + types = list(types) + if not types: + self._add_generic_store(store) + return for type in types: - self._state.stores_by_type.setdefault(type, []).append(store) + if type in self._state.stores_by_type: + raise CultCacheError( + f"Document type {type} is already routed to another backing store; it cannot also route to this one" + ) + routes = dict(self._state.stores_by_type) + routes.update({type: store for type in types}) + self._refuse_home_moves(routes, self._state.generic_store) + self._state.stores_by_type = routes + @_mutating def add_generic_store(self, store: BackingStore) -> None: - self._state.generic_stores.append(store) + """Makes the store home to every type no other store claims. A cache has at most one.""" + self._add_generic_store(store) + def _add_generic_store(self, store: BackingStore) -> None: + if self._state.generic_store is not None: + raise CultCacheError( + "Backing store would be a second generic store; name the types it is home to" + ) + self._refuse_home_moves(self._state.stores_by_type, store) + self._state.generic_store = store + + def _refuse_home_moves(self, routes: dict[str, BackingStore], generic: BackingStore | None) -> None: + for type, values in self._state.values.items(): + if not values: + continue + before = self._state.stores_by_type.get(type, self._state.generic_store) + after = routes.get(type, generic) + if before is not after: + raise CultCacheError( + f"Attaching this backing store would move {type} from {self._describe(before, routes, generic)} " + f"to {self._describe(after, routes, generic)}; attach routed stores before the generic store" + ) + + @staticmethod + def _describe(store: BackingStore | None, routes: dict[str, BackingStore], generic: BackingStore | None) -> str: + if store is None: + return "memory" + if store is generic: + return "the generic store" + return "the store routed to " + ", ".join(sorted(type for type, routed in routes.items() if routed is store)) + + @_mutating def pull_all_backing_stores(self) -> None: - self._state.values.clear() - self._state.envelopes.clear() + """Builds the complete next state (values, envelopes, name and index lookups) in local + structures, running every check and every user extractor; the cache's state is replaced + only once that build finishes, so a refused load admits nothing.""" + loaded_values: dict[str, dict[str, Any]] = {} + loaded_envelopes: dict[str, dict[str, CultCacheEnvelope]] = {} + loaded_legacy_global_keys: dict[str, str] = {} + routes, generic = self._state.stores_by_type, self._state.generic_store seen_globals: set[str] = set() - for store in [*self._all_specific_stores(), *self._state.generic_stores]: + stores: list[BackingStore] = [] + for candidate in [*self._state.stores_by_type.values(), self._state.generic_store]: + if candidate is not None and all(candidate is not store for store in stores): + stores.append(candidate) + for store in stores: for envelope in store.pull_all(): - document = self._state.documents.get(envelope.type) + document = self._resolve_document_for_envelope(envelope) if document is None: raise CultCacheError(f"Unknown persisted document type: {envelope.type}") + if envelope.type != document.type: + envelope = CultCacheEnvelope( + key=envelope.key, + type=document.type, + payload=envelope.payload, + stored_at=envelope.stored_at, + schema_id=envelope.schema_id, + catalog_entry=envelope.catalog_entry, + ) + home = routes.get(envelope.type, generic) + if home is not store: + raise CultCacheError( + f"{envelope.type} record {envelope.key} was loaded from {self._describe(store, routes, generic)}, " + f"but its home is {self._describe(home, routes, generic)}" + ) if document.global_document: - if envelope.type in seen_globals and envelope.key == GLOBAL_KEY: + if envelope.type in seen_globals: raise CultCacheError(f"Duplicate global document for type: {envelope.type}") seen_globals.add(envelope.type) + if envelope.key != GLOBAL_KEY: + loaded_legacy_global_keys[envelope.type] = envelope.key + envelope = replace(envelope, key=GLOBAL_KEY) value = document.decode_payload(envelope.payload) - self._state.values.setdefault(envelope.type, {})[envelope.key] = value - self._state.envelopes.setdefault(envelope.type, {})[envelope.key] = envelope - self._rebuild_indexes() + loaded_values.setdefault(envelope.type, {})[envelope.key] = value + loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope + loaded_names, loaded_indexes, loaded_lookup_keys = self._derive_lookups( + loaded_values, self._state.name_extractors, self._state.index_extractors + ) + # Plain attribute assignments: nothing fallible runs once the swap begins. + ( + self._state.values, + self._state.envelopes, + self._state.names, + self._state.indexes, + self._state.lookup_keys, + self._state.legacy_global_keys, + ) = (loaded_values, loaded_envelopes, loaded_names, loaded_indexes, loaded_lookup_keys, loaded_legacy_global_keys) def get(self, document: DocumentDefinition[T], key: str) -> T | None: self._assert_registered(document) @@ -155,6 +334,9 @@ class CultCache: self._assert_registered(document) return self._state.names.get((document.type, name)) + # Name and index resolution read the lookup and then the values, two structures a pull swaps and a + # write updates in sequence, so they hold the lock to resolve a key and its value from one state. + @_locked def get_by_name(self, document: DocumentDefinition[T], name: str) -> T | None: key = self.get_key_by_name(document, name) return None if key is None else self.get(document, key) @@ -163,17 +345,19 @@ class CultCache: self._assert_registered(document) return self._state.indexes.get((document.type, index, value)) + @_locked def get_by_index(self, document: DocumentDefinition[T], index: str, value: str) -> T | None: key = self.get_key_by_index(document, index, value) return None if key is None else self.get(document, key) + @_mutating def put(self, document: DocumentDefinition[T], key: str, value: T) -> None: + self._put(document, key, value) + + def _put(self, document: DocumentDefinition[T], key: str, value: T) -> None: self._assert_registered(document) if document.global_document and key != GLOBAL_KEY: raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}") - values = self._state.values.setdefault(document.type, {}) - envelopes = self._state.envelopes.setdefault(document.type, {}) - old_value = values.get(key) catalog_entry = document.catalog_entry() envelope = CultCacheEnvelope.create( key=key, @@ -182,100 +366,139 @@ class CultCache: schema_id=catalog_entry.schema_id, catalog_entry=catalog_entry, ) - stores = self._stores_for_type(document.type) - for store in stores: - store.push(envelope) - if old_value is not None: - self._remove_value_indexes(document.type, key, old_value) - values[key] = value - envelopes[key] = envelope - self._add_value_indexes(document.type, key, value) + self._write(document, [(envelope, value)], batch=False) + @_mutating def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T: self._assert_registered(document) - if envelope.type != document.type: - raise CultCacheError( - f"Envelope type {envelope.type} does not match document type {document.type}" - ) - values = self._state.values.setdefault(document.type, {}) - envelopes = self._state.envelopes.setdefault(document.type, {}) - old_value = values.get(envelope.key) + self._check_envelope(document, envelope) value = document.decode_payload(envelope.payload) - for store in self._stores_for_type(document.type): - store.push(envelope) - if old_value is not None: - self._remove_value_indexes(document.type, envelope.key, old_value) - values[envelope.key] = value - envelopes[envelope.key] = envelope - self._add_value_indexes(document.type, envelope.key, value) + self._write(document, [(envelope, value)], batch=False) return value + @_mutating def put_envelopes(self, document: DocumentDefinition[T], envelopes: list[CultCacheEnvelope]) -> list[T]: self._assert_registered(document) values: list[T] = [] for envelope in envelopes: - if envelope.type != document.type: - raise CultCacheError( - f"Envelope type {envelope.type} does not match document type {document.type}" - ) - if document.global_document and envelope.key != GLOBAL_KEY: - raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}") + self._check_envelope(document, envelope) values.append(document.decode_payload(envelope.payload)) - - stores = self._stores_for_type(document.type) - for store in stores: - store.push_all(envelopes) - values_by_key = self._state.values.setdefault(document.type, {}) - envelopes_by_key = self._state.envelopes.setdefault(document.type, {}) - for envelope, value in zip(envelopes, values): - old_value = values_by_key.get(envelope.key) - if old_value is not None: - self._remove_value_indexes(document.type, envelope.key, old_value) - values_by_key[envelope.key] = value - envelopes_by_key[envelope.key] = envelope - self._add_value_indexes(document.type, envelope.key, value) + self._write(document, list(zip(envelopes, values)), batch=True) return values + @staticmethod + def _check_envelope(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> None: + if envelope.type != document.type: + raise CultCacheError( + f"Envelope type {envelope.type} does not match document type {document.type}" + ) + if not envelope.key or not envelope.key.strip(): + raise CultCacheError(f"Envelope key for document type {document.type} must be non-empty") + if document.global_document and envelope.key != GLOBAL_KEY: + raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}") + + def _write( + self, document: DocumentDefinition[Any], records: list[tuple[CultCacheEnvelope, Any]], *, batch: bool + ) -> None: + """Validates every record (home and name and index extractors included) before the store is + touched; once the store accepts, applying to the cache has no fallible step.""" + type = document.type + name_extractor = self._state.name_extractors.get(type) + index_extractors = self._state.index_extractors.get(type, {}) + admitted = [ + (envelope, value, self._lookup_keys_for(value, name_extractor, index_extractors)) + for envelope, value in records + ] + store = self._store_for_type(type) + legacy_key = self._state.legacy_global_keys.get(type) + if store is not None: + if batch: + store.push_all([envelope for envelope, _ in records]) + else: + store.push(records[0][0]) + if legacy_key is not None: + self._retire_legacy_global(store, type, legacy_key) + self._state.legacy_global_keys.pop(type, None) + values = self._state.values.setdefault(type, {}) + envelopes = self._state.envelopes.setdefault(type, {}) + lookup_keys = self._state.lookup_keys.setdefault(type, {}) + for envelope, value, keys in admitted: + self._remove_lookups(type, envelope.key) + values[envelope.key] = value + envelopes[envelope.key] = envelope + lookup_keys[envelope.key] = keys + if keys.name is not None: + self._state.names[(type, keys.name)] = envelope.key + for index, index_value in keys.indexes.items(): + self._state.indexes[(type, index, index_value)] = envelope.key + + @staticmethod + def _retire_legacy_global(store: BackingStore, type: str, legacy_key: str) -> None: + """The first write of a global loaded from a legacy key removes that record in the same write. If + the removal fails, the new record is taken back out, so the store keeps only the legacy record.""" + try: + store.delete(type, legacy_key) + except BaseException: + try: + store.delete(type, GLOBAL_KEY) + except Exception: # noqa: BLE001 - the removal failure is the error reported + pass + raise + + @_mutating def put_global(self, document: DocumentDefinition[T], value: T) -> None: self._assert_global(document) - self.put(document, GLOBAL_KEY, value) + self._put(document, GLOBAL_KEY, value) + @_mutating def update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T: + return self._update(document, key, updater) + + def _update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T: current = self.get_required(document, key) updated = updater(current) - self.put(document, key, updated) + self._put(document, key, updated) return updated + @_mutating def update_global(self, document: DocumentDefinition[T], updater: Any) -> T: self._assert_global(document) - return self.update(document, GLOBAL_KEY, updater) + return self._update(document, GLOBAL_KEY, updater) + @_mutating def delete(self, document: DocumentDefinition[Any], key: str) -> None: - self._assert_registered(document) - for store in self._stores_for_type(document.type): - store.delete(document.type, key) - values = self._state.values.get(document.type) - old_value = None if values is None else values.pop(key, None) - if values == {}: - self._state.values.pop(document.type, None) - envelopes = self._state.envelopes.get(document.type) - if envelopes is not None: - envelopes.pop(key, None) - if envelopes == {}: - self._state.envelopes.pop(document.type, None) - if old_value is not None: - self._remove_value_indexes(document.type, key, old_value) + self._delete(document, key) + def _delete(self, document: DocumentDefinition[Any], key: str) -> None: + self._assert_registered(document) + store = self._store_for_type(document.type) + legacy_key = self._state.legacy_global_keys.get(document.type) if key == GLOBAL_KEY else None + if store is not None: + store.delete(document.type, key if legacy_key is None else legacy_key) + if legacy_key is not None: + del self._state.legacy_global_keys[document.type] + self._remove_lookups(document.type, key) + for by_type in (self._state.values, self._state.envelopes, self._state.lookup_keys): + held = by_type.get(document.type) + if held is not None: + held.pop(key, None) + if not held: + by_type.pop(document.type, None) + + @_mutating def delete_global(self, document: DocumentDefinition[Any]) -> None: self._assert_global(document) - self.delete(document, GLOBAL_KEY) + self._delete(document, GLOBAL_KEY) + # Snapshots iterate dicts a concurrent write would resize mid-iteration, so they hold the lock. + @_locked def snapshot(self) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} for type, values in self._state.values.items(): out[type] = dict(values) return out + @_locked def snapshot_envelopes(self) -> list[CultCacheEnvelope]: return [ envelope @@ -283,75 +506,70 @@ class CultCache: for envelope in envelopes.values() ] - def _stores_for_type(self, type: str) -> list[BackingStore]: - specific = self._state.stores_by_type.get(type) - return specific if specific else self._state.generic_stores + def _store_for_type(self, type: str) -> BackingStore | None: + """The store that claims the type, else the generic store. - def _all_specific_stores(self) -> list[BackingStore]: - stores: list[BackingStore] = [] - seen: set[int] = set() - for routed in self._state.stores_by_type.values(): - for store in routed: - marker = id(store) - if marker not in seen: - stores.append(store) - seen.add(marker) - return stores + Returns None only for a cache with no stores, which is an in-memory cache. + """ + store = self._state.stores_by_type.get(type, self._state.generic_store) + if store is None and self._state.stores_by_type: + raise CultCacheError(f"No backing store is home to document type: {type}") + return store - def _rebuild_indexes(self) -> None: - self._state.names.clear() - self._state.indexes.clear() - for type, values in self._state.values.items(): + @staticmethod + def _lookup_keys_for( + value: Any, name_extractor: str | Any | None, index_extractors: dict[str, str | Any] + ) -> _LookupKeys: + name = None if name_extractor is None else extract_value(value, name_extractor) + indexes: dict[str, str] = {} + for index, extractor in index_extractors.items(): + index_value = extract_value(value, extractor) + if index_value is not None: + indexes[index] = str(index_value) + return _LookupKeys(None if name is None else str(name), indexes) + + def _derive_lookups( + self, + values_by_type: dict[str, dict[str, Any]], + name_extractors: dict[str, str | Any], + index_extractors: dict[str, dict[str, str | Any]], + ) -> tuple[_Names, _Indexes, _LookupKeysByType]: + """Builds name and index lookups for the given values in fresh dicts, touching no cache state.""" + names: _Names = {} + indexes: _Indexes = {} + lookup_keys: _LookupKeysByType = {} + for type, values in values_by_type.items(): + name_extractor = name_extractors.get(type) + type_index_extractors = index_extractors.get(type, {}) for key, value in values.items(): - self._add_value_indexes(type, key, value) + keys = self._lookup_keys_for(value, name_extractor, type_index_extractors) + lookup_keys.setdefault(type, {})[key] = keys + if keys.name is not None: + names[(type, keys.name)] = key + for index, index_value in keys.indexes.items(): + indexes[(type, index, index_value)] = key + return names, indexes, lookup_keys - def _add_value_indexes(self, type: str, key: str, value: Any) -> None: - name_extractor = self._state.name_extractors.get(type) - if name_extractor is not None: - name = extract_value(value, name_extractor) - if name is not None: - self._state.names[(type, str(name))] = key - for index, extractor in self._state.index_extractors.get(type, {}).items(): - index_value = extract_value(value, extractor) - if index_value is not None: - self._state.indexes[(type, index, str(index_value))] = key - - def _remove_value_indexes(self, type: str, key: str, value: Any) -> None: - name_extractor = self._state.name_extractors.get(type) - if name_extractor is not None: - name = extract_value(value, name_extractor) - if name is not None: - self._remove_name_index(type, key, str(name)) - for index, extractor in self._state.index_extractors.get(type, {}).items(): - index_value = extract_value(value, extractor) - if index_value is not None: - self._remove_secondary_index(type, key, index, str(index_value)) - - def _remove_name_index(self, type: str, key: str, name: str) -> None: - lookup_key = (type, name) - if self._state.names.get(lookup_key) != key: + def _remove_lookups(self, type: str, key: str) -> None: + """Removes the lookups the held record produced at admission, handing a name or index value to + another held record that produced it too. Reads stored lookup keys only; runs no extractor.""" + held = self._state.lookup_keys.get(type, {}) + keys = held.get(key) + if keys is None: return - self._state.names.pop(lookup_key, None) - name_extractor = self._state.name_extractors.get(type) - if name_extractor is None: - return - for candidate_key, candidate in self._state.values.get(type, {}).items(): - candidate_name = extract_value(candidate, name_extractor) - if candidate_key != key and candidate_name is not None and str(candidate_name) == name: - self._state.names[lookup_key] = candidate_key - - def _remove_secondary_index(self, type: str, key: str, index: str, value: str) -> None: - lookup_key = (type, index, value) - if self._state.indexes.get(lookup_key) != key: - return - self._state.indexes.pop(lookup_key, None) - extractor = self._state.index_extractors.get(type, {}).get(index) - if extractor is None: - return - for candidate_key, candidate in self._state.values.get(type, {}).items(): - candidate_value = extract_value(candidate, extractor) - if candidate_key != key and candidate_value is not None and str(candidate_value) == value: - self._state.indexes[lookup_key] = candidate_key + if keys.name is not None and self._state.names.get((type, keys.name)) == key: + del self._state.names[(type, keys.name)] + for candidate_key, candidate in held.items(): + if candidate_key != key and candidate.name == keys.name: + self._state.names[(type, keys.name)] = candidate_key + for index, index_value in keys.indexes.items(): + lookup_key = (type, index, index_value) + if self._state.indexes.get(lookup_key) != key: + continue + del self._state.indexes[lookup_key] + for candidate_key, candidate in held.items(): + if candidate_key != key and candidate.indexes.get(index) == index_value: + self._state.indexes[lookup_key] = candidate_key def _assert_registered(self, document: DocumentDefinition[Any]) -> None: if self._state.documents.get(document.type) is not document: @@ -361,3 +579,9 @@ class CultCache: self._assert_registered(document) if not document.global_document: raise CultCacheError(f"Document type is not global: {document.type}") + + def _resolve_document_for_envelope(self, envelope: CultCacheEnvelope) -> DocumentDefinition[Any] | None: + document = self._state.documents.get(envelope.type) + if document is not None: + return document + return self._state.documents_by_schema_name.get(envelope.type) diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 3587e717..697882e0 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -178,12 +178,25 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None: raise ValueError("CultCache persisted records must declare storedAt") catalog_entry = catalog_by_schema_id.get(schema_id) if catalog_entry is None: - raise ValueError(f'CultCache persisted record "{key}" references missing schema id "{schema_id}"') + schema_version = _infer_schema_version_from_payload(_normalize_payload(payload)) + schema_name = _infer_schema_name(schema_version) if schema_version is not None else None + if schema_name is None: + raise ValueError(f'CultCache persisted record "{key}" references missing schema id "{schema_id}"') + catalog_entry = CultCacheSchemaCatalogEntry( + schema_id=schema_id, + schema_name=schema_name, + schema_version=schema_version, + content_hash=schema_id, + canonical_schema_json="", + compatible_schema_ids=(), + members=(), + ) + normalized_payload = _normalize_payload(payload) envelopes.append( CultCacheEnvelope( key=key, type=catalog_entry.schema_name, - payload=_normalize_payload(payload), + payload=normalized_payload, stored_at=stored_at, schema_id=schema_id, catalog_entry=catalog_entry, @@ -316,3 +329,27 @@ def _normalize_payload(payload: Any) -> bytes: if isinstance(payload, list) and all(isinstance(item, int) and 0 <= item <= 255 for item in payload): return bytes(payload) raise ValueError("CultCache record payload must be binary MessagePack bytes") + + +def _infer_schema_version_from_payload(payload: bytes) -> str | None: + try: + msgpack = SingleFileMessagePackBackingStore._msgpack() + decoded = msgpack.unpackb(payload, raw=False) + except Exception: + return None + if isinstance(decoded, list) and decoded and isinstance(decoded[0], str): + return decoded[0] + if isinstance(decoded, dict): + value = decoded.get("schemaVersion", decoded.get("schema_version")) + return value if isinstance(value, str) else None + return None + + +def _infer_schema_name(schema_version: str) -> str | None: + marker = schema_version.rfind(".v") + if marker <= 0: + return None + version = schema_version[marker + 2:] + if not version or not version.isdigit(): + return None + return schema_version[:marker] diff --git a/packages/cultcache-py/src/cultmesh_py/simulation.py b/packages/cultcache-py/src/cultmesh_py/simulation.py deleted file mode 100644 index 4b727fd5..00000000 --- a/packages/cultcache-py/src/cultmesh_py/simulation.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import UTC, datetime -import hashlib -from typing import Any - -from cultcache_py import define_database_entry_type -from cultcache_py.documents import DocumentDefinition - -from .node import CultMeshNode - -SIMULATION_FACT_DOCUMENT_TYPE = "gamecult.mesh.simulation_fact" -SIMULATION_FACT_SCHEMA_VERSION = "gamecult.mesh.simulation_fact.v1" - - -@dataclass(frozen=True) -class CultMeshSimulationFact: - fact_id: str - shard_id: str - shard_epoch: int - frame: int - subject_id: str - claim_kind: str - claim_hash: str - claim_summary: str | None - witness_count: int - support_weight: float - total_weight: float - confidence: float - committed_at: str - - @staticmethod - def compute_fact_id(candidate: dict[str, Any]) -> str: - canonical = "\x1f".join([ - _candidate_string(candidate, "shardId", "shard_id"), - str(_candidate_int(candidate, "shardEpoch", "shard_epoch")), - str(_candidate_int(candidate, "frame", "frame")), - _candidate_string(candidate, "subjectId", "subject_id"), - _candidate_string(candidate, "claimKind", "claim_kind"), - ]) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - @staticmethod - def create_record_key(candidate: dict[str, Any]) -> str: - return f"simulation:{CultMeshSimulationFact.compute_fact_id(candidate)}" - - @staticmethod - def from_candidate(candidate: dict[str, Any], *, committed_at: str | None = None) -> "CultMeshSimulationFact": - return CultMeshSimulationFact( - fact_id=CultMeshSimulationFact.compute_fact_id(candidate), - shard_id=_candidate_string(candidate, "shardId", "shard_id"), - shard_epoch=_candidate_int(candidate, "shardEpoch", "shard_epoch"), - frame=_candidate_int(candidate, "frame", "frame"), - subject_id=_candidate_string(candidate, "subjectId", "subject_id"), - claim_kind=_candidate_string(candidate, "claimKind", "claim_kind"), - claim_hash=_candidate_string(candidate, "claimHash", "claim_hash"), - claim_summary=_candidate_optional_string(candidate, "claimSummary", "claim_summary"), - witness_count=_candidate_int(candidate, "witnessCount", "witness_count"), - support_weight=_candidate_float(candidate, "supportWeight", "support_weight"), - total_weight=_candidate_float(candidate, "totalWeight", "total_weight"), - confidence=_candidate_float(candidate, "confidence", "confidence"), - committed_at=committed_at or datetime.now(UTC).isoformat(), - ) - - -simulation_fact_document: DocumentDefinition[CultMeshSimulationFact] = define_database_entry_type( - SIMULATION_FACT_DOCUMENT_TYPE, - [ - ("fact_id", 0), - ("shard_id", 1), - ("shard_epoch", 2), - ("frame", 3), - ("subject_id", 4), - ("claim_kind", 5), - ("claim_hash", 6), - ("claim_summary", 7, None), - ("witness_count", 8), - ("support_weight", 9), - ("total_weight", 10), - ("confidence", 11), - ("committed_at", 12), - ], - cls=CultMeshSimulationFact, - name="fact_id", - indexes={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"}, - schema_name=SIMULATION_FACT_DOCUMENT_TYPE, - schema_version=SIMULATION_FACT_SCHEMA_VERSION, -) - - -@dataclass(frozen=True) -class CultMeshSimulationFactCommit: - key: str - fact: CultMeshSimulationFact - - -@dataclass -class CultMeshSimulationFactCommitter: - node: CultMeshNode - document: DocumentDefinition[CultMeshSimulationFact] = simulation_fact_document - - def __post_init__(self) -> None: - if all(document.type != self.document.type for document in self.node.documents): - self.node.register_document(self.document) - - def commit(self, candidate: dict[str, Any], *, committed_at: str | None = None) -> CultMeshSimulationFactCommit: - if not bool(candidate.get("hasQuorum") if "hasQuorum" in candidate else candidate.get("has_quorum")): - raise ValueError("Simulation candidate cannot be committed before quorum") - key = CultMeshSimulationFact.create_record_key(candidate) - fact = CultMeshSimulationFact.from_candidate(candidate, committed_at=committed_at) - self.node.put(self.document, key, fact) - return CultMeshSimulationFactCommit(key=key, fact=fact) - - -def _candidate_string(candidate: dict[str, Any], camel: str, snake: str) -> str: - value = candidate.get(camel, candidate.get(snake, "")) - if value is None: - return "" - return str(value) - - -def _candidate_optional_string(candidate: dict[str, Any], camel: str, snake: str) -> str | None: - value = candidate.get(camel, candidate.get(snake)) - if value is None: - return None - return str(value) - - -def _candidate_int(candidate: dict[str, Any], camel: str, snake: str) -> int: - return int(candidate.get(camel, candidate.get(snake, 0))) - - -def _candidate_float(candidate: dict[str, Any], camel: str, snake: str) -> float: - return float(candidate.get(camel, candidate.get(snake, 0.0))) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 16d72d98..ac81cc6d 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -1,23 +1,12 @@ -from __future__ import annotations +from __future__ import annotations -import json -import os -import socket -import subprocess -import sys import tempfile import threading -import time import unittest -import hashlib -import hmac -import io -from dataclasses import asdict, dataclass -from datetime import UTC, datetime, timedelta +from dataclasses import asdict, dataclass, replace from pathlib import Path -from typing import Callable -from uuid import uuid4 - +from cultcache_py.backing_store import CultCacheEnvelope +from cultcache_py.cache import CultCacheError from cultcache_py import ( CultCache, JsonLinesBackingStore, @@ -25,154 +14,7 @@ from cultcache_py import ( define_database_entry_type, define_document_type, ) -from cultcache_py.benchmark import run_benchmark -from cultcache_py.compare_csharp import ( - DEFAULT_PARITY_THRESHOLD, - DEFAULT_SAMPLE_COUNT, - _compare_common_metrics, - _median_result, - _parity_status, -) -from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document, read_note, write_note -from cultcache_py.verify import verify -from cultnet_py.interop_peer import append_shard_log_put, build_state, raw_snapshot_response -from cultnet_py import ( - compute_simulation_claim_hash, - CultNetDatabaseChange, - CultNetClientAuthorityScope, - CultNetRawClient, - CultNetRawDocumentRecord, - CultNetRawSnapshotResponse, - CultNetSchemaCatalog, - CultNetSchemaDescriptor, - CultNetShardCatalog, - CultNetShardDescriptor, - CultNetShardLogEntry, - CultNetShardLogResponse, - CultNetFileShardReplicaCursorStore, - CultNetFileShardMutationLogStore, - CultNetInMemoryShardReplicaCursorStore, - CultNetSchemaWriteForwarder, - CultNetSchemaShardLogFetcher, - CultNetSchemaShardSnapshotFetcher, - CultNetPeerError, - CultNetClientSecurityOptions, - CultNetSecret, - CultNetServerSecurityOptions, - CultNetShardReplicator, - CultNetShardReplicatorOptions, - CultNetShardMutationLogStore, - CultNetShardWriteForwarder, - CultNetSimulationConsensusOptions, - CultNetSimulationConsensusCandidate, - CultNetSimulationObservation, - CultNetSimulationObservationHub, - CultNetRudpPacket, - CultNetRudpPacketType, - CultNetRudpSendOptions, - CultNetRudpSession, - CultNetRudpSessionOptions, - CultNetRudpSocketMode, - CultNetRudpReconnectLoop, - CultNetRudpSocketTransportConnection, - CultNetRudpSocketTransportOptions, - TcpFramedTransportConnection, - CultNetWitnessArtifactBundle, - apply_raw_document_record, - apply_raw_snapshot, - apply_shard_log_response, - create_rudp_transport_profile, - create_tcp_framed_transport_profile, - create_reconnect_policy, - CultNetReconnectController, - database_subscribe, - database_unsubscribe, - decode_frame, - decode_rudp_packet, - decode_witness_artifact_bundle_payload, - document_delete, - document_put_raw, - encode_frame, - encode_rudp_packet, - encode_witness_artifact_bundle_payload, - hello, - login, - login_success, - parse_message, - register, - schema_catalog_request, - schema_document_map, - shard_catalog_request, - shard_log_request, - simulation_observation, - snapshot_request, - verify_session, - wire_message_schema_catalog, - wire_message_schema_descriptors, - witness_artifact_bundle, - compute_reconnect_delay_ms, -) -from cultmesh_py import create_node -from cultmesh_py import ( - CultMesh, - CultMeshDatabase, - CultMeshDatabaseChange, - CultMeshGameSessionOptions, - CultMeshNodeOptions, - CultMeshPeerCard, - CultMeshPeerCatalog, - CultMeshSimulationFact, - CultMeshAuthorityLease, - CultMeshAuthorityLeaseCatalog, - CultMeshHmacAuthorityLeaseVerifier, - CultMeshDiscoveryClient, - CultMeshPeerExchangeClient, - CultMeshPeerHealthMonitor, - CultMeshSimulationObservationFanout, - CultMeshSnapshotFanout, - CultMeshStreamCatalog, - CultMeshStreamConsumerProfile, - CultMeshStreamDescriptor, - CultMeshStreamFrameHandle, - CultMeshVerseCatalog, - CultMeshVerseCompatibility, - CultMeshVerseDescriptor, - CultMeshVerseDiscoveryClient, - READY_SCHEMA_VERSION, - peer_exchange_request, - simulation_fact_document, - verse_catalog_request, -) - - -def bind_udp_socket() -> socket.socket: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind(("127.0.0.1", 0)) - sock.settimeout(0.02) - return sock - - -def pump_rudp_handshake( - client: CultNetRudpSocketTransportConnection, - server: CultNetRudpSocketTransportConnection, -) -> None: - for _ in range(20): - server.receive_once() - client.receive_once() - server.receive_once() - if client.connected and server.connected: - return - time.sleep(0.005) - raise AssertionError("RUDP socket handshake did not complete") - - -def receive_rudp_frame(transport: CultNetRudpSocketTransportConnection): - for _ in range(20): - frame = transport.receive_once() - if frame is not None: - return frame - time.sleep(0.005) - raise AssertionError("RUDP socket frame was not delivered") +from cultcache_py.interop import read_note, write_note @dataclass @@ -193,14 +35,6 @@ def item_doc(): class CultCacheTests(unittest.TestCase): - def _wait_until(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None: - deadline = time.monotonic() + timeout_seconds - while time.monotonic() < deadline: - if predicate(): - return - time.sleep(0.01) - self.fail("Timed out waiting for condition") - def test_round_trips_registered_documents(self) -> None: with tempfile.TemporaryDirectory() as tmp: document = item_doc() @@ -351,6 +185,53 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(raw[2][0][0], "app") self.assertEqual(raw[2][0][1], "settings") + def test_messagepack_store_recovers_schema_stamped_record_missing_catalog_entry(self) -> None: + try: + import msgpack # type: ignore + except ModuleNotFoundError: + self.skipTest("msgpack optional dependency is not installed") + + with tempfile.TemporaryDirectory() as tmp: + document = define_database_entry_type( + "runtime-policy", + [ + ("schema_version", 0), + ("name", 1), + ("value", 2), + ], + schema_id="schema-current", + schema_name="tests.schema_stamped_entry", + schema_version="tests.schema_stamped_entry.v1", + ) + store_path = Path(tmp) / "missing-catalog.msgpack" + store_path.write_bytes(msgpack.packb([ + "cultcache.store.v1", + [], + [[ + "record-1", + "sha256:stale-schema-id-from-cold-record", + "2026-06-25T12:00:00Z", + msgpack.packb([ + "tests.schema_stamped_entry.v1", + "schema-stamped", + "still readable", + ], use_bin_type=True), + ]], + ], use_bin_type=True)) + + cache = ( + CultCache.builder() + .register_document_type(document) + .add_generic_store(SingleFileMessagePackBackingStore(store_path)) + .build() + ) + + cache.pull_all_backing_stores() + self.assertEqual(cache.get_required(document, "record-1")["value"], "still readable") + envelope = cache.get_required_envelope(document, "record-1") + self.assertEqual(envelope.type, "runtime-policy") + self.assertEqual(envelope.schema_id, "sha256:stale-schema-id-from-cold-record") + def test_interop_cli_helpers_round_trip_v1_store(self) -> None: with tempfile.TemporaryDirectory() as tmp: store_path = str(Path(tmp) / "cache.msgpack") @@ -360,100 +241,549 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(loaded["authorRuntimeId"], "python-test") self.assertIn("interop", loaded["tags"]) - def test_benchmark_reports_core_hot_path_metrics(self) -> None: - result = run_benchmark(8) - metric_names = {metric["name"] for metric in result["metrics"]} - self.assertEqual(result["records"], 8) - self.assertEqual(result["wireMessageCount"], 8) - self.assertEqual(metric_names, { - "database_entry_encode", - "database_entry_decode", - "cultnet_frame_parse", - "raw_snapshot_apply", - "cache_upsert", - "cache_get", - }) - self.assertTrue(all(metric["opsPerSecond"] > 0 for metric in result["metrics"])) + def test_second_generic_store_rejected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cache = CultCache() + cache.add_generic_store(SingleFileMessagePackBackingStore(Path(tmp) / "a.cc")) + with self.assertRaisesRegex(CultCacheError, "second generic store"): + cache.add_generic_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc")) - def test_compare_csharp_median_result_summarizes_samples(self) -> None: - self.assertEqual(DEFAULT_SAMPLE_COUNT, 3) - self.assertEqual(DEFAULT_PARITY_THRESHOLD, 0.90) - samples = [ - { - "runtime": "python", - "records": 3, - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 30.0, "opsPerSecond": 100.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 60.0, "opsPerSecond": 50.0}, - ], - }, - { - "runtime": "python", - "records": 3, - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 10.0, "opsPerSecond": 300.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 20.0, "opsPerSecond": 150.0}, - ], - }, - { - "runtime": "python", - "records": 3, - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 20.0, "opsPerSecond": 200.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 40.0, "opsPerSecond": 75.0}, - ], - }, - ] + def test_type_claimed_twice_rejected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cache = CultCache() + cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "a.cc"), ["item"]) + with self.assertRaisesRegex(CultCacheError, "already routed"): + cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"), ["settings", "item"]) + # A refused registration claims nothing, so "settings" is still free. + cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "c.cc"), ["settings"]) - summarized = _median_result(samples) + def test_types_route_to_home_store(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + item = item_doc() + settings = define_database_entry_type("settings", [("theme", 0)]) + generic_path = Path(tmp) / "generic.cc" + settings_path = Path(tmp) / "settings.cc" - self.assertEqual(summarized["sampleCount"], 3) - self.assertEqual(summarized["metrics"][0]["name"], "cache_get") - self.assertEqual(summarized["metrics"][0]["elapsedMs"], 20.0) - self.assertEqual(summarized["metrics"][0]["opsPerSecond"], 200.0) - self.assertEqual(summarized["metrics"][1]["opsPerSecond"], 75.0) + def build() -> CultCache: + return ( + CultCache.builder() + .register_document_type(item) + .register_document_type(settings) + .add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"]) + .add_generic_store(SingleFileMessagePackBackingStore(generic_path)) + .build() + ) - def test_compare_csharp_marks_threshold_status_per_metric(self) -> None: - python_result = { - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 2.0, "opsPerSecond": 50.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 95.0}, - ], - } - csharp_result = { - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, - ], - } + cache = build() + cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50)) + cache.put(settings, "app", {"theme": "ash"}) - comparison = _compare_common_metrics(python_result, csharp_result, parity_threshold=0.90) - by_name = {item["name"]: item for item in comparison} + generic = SingleFileMessagePackBackingStore(generic_path).pull_all() + routed = SingleFileMessagePackBackingStore(settings_path).pull_all() + self.assertEqual([(e.key, e.type) for e in generic], [("item:potion", "item")]) + self.assertEqual([(e.key, e.type) for e in routed], [("app", "settings")]) - self.assertFalse(by_name["cache_get"]["meetsParityThreshold"]) - self.assertTrue(by_name["cache_upsert"]["meetsParityThreshold"]) - self.assertEqual(_parity_status(comparison, "ok"), "below-threshold") - self.assertEqual(_parity_status([by_name["cache_upsert"]], "ok"), "meets-threshold") - self.assertEqual(_parity_status(comparison, "failed"), "unknown") + cache.delete(settings, "app") + self.assertEqual(SingleFileMessagePackBackingStore(settings_path).pull_all(), []) + self.assertEqual(len(SingleFileMessagePackBackingStore(generic_path).pull_all()), 1) - def test_compare_csharp_marks_meets_threshold_when_all_common_metrics_pass(self) -> None: - comparison = _compare_common_metrics( - { - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 95.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 120.0}, - ], - }, - { - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, - ], - }, - parity_threshold=0.90, + reloaded = build() + reloaded.pull_all_backing_stores() + self.assertEqual(reloaded.get_required(item, "item:potion").value, 50) + self.assertIsNone(reloaded.get(settings, "app")) + + def test_attach_that_would_move_a_held_record_home_is_refused(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + settings = define_database_entry_type("settings", [("theme", 0)]) + generic_path = Path(tmp) / "generic.cc" + settings_path = Path(tmp) / "settings.cc" + cache = ( + CultCache.builder() + .register_document_type(settings) + .add_generic_store(SingleFileMessagePackBackingStore(generic_path)) + .build() + ) + cache.put(settings, "app", {"theme": "v1-generic"}) + + with self.assertRaisesRegex( + CultCacheError, "would move settings from the generic store to the store routed to settings" + ): + cache.add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"]) + # Nothing attached: the next write still lands in the generic store. + cache.put(settings, "app", {"theme": "v2-generic"}) + generic = SingleFileMessagePackBackingStore(generic_path).pull_all() + self.assertEqual([(e.key, e.type) for e in generic], [("app", "settings")]) + self.assertFalse(settings_path.exists()) + + def test_load_from_a_store_that_is_not_the_record_home_is_refused(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + settings = define_database_entry_type("settings", [("theme", 0)]) + generic_path = Path(tmp) / "generic.cc" + settings_path = Path(tmp) / "settings.cc" + writer = CultCache.builder().register_document_type(settings).add_generic_store( + SingleFileMessagePackBackingStore(generic_path) + ).build() + writer.put(settings, "app", {"theme": "stray"}) + generic_bytes = generic_path.read_bytes() + + cache = ( + CultCache.builder() + .register_document_type(settings) + .add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"]) + .add_generic_store(SingleFileMessagePackBackingStore(generic_path)) + .build() + ) + # The cache already holds a record, in its home store, before the refused load. + cache.put(settings, "held", {"theme": "home"}) + before = cache.snapshot_envelopes() + settings_bytes = settings_path.read_bytes() + with self.assertRaisesRegex( + CultCacheError, + "settings record app was loaded from the generic store, but its home is the store routed to settings", + ): + cache.pull_all_backing_stores() + self.assertIsNone(cache.get(settings, "app")) + self.assertEqual(cache.snapshot_envelopes(), before) + self.assertEqual(cache.get_required(settings, "held"), {"theme": "home"}) + self.assertEqual(generic_path.read_bytes(), generic_bytes) + self.assertEqual(settings_path.read_bytes(), settings_bytes) + + def test_pull_with_duplicate_global_leaves_cache_unchanged(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + item = item_doc() + settings = define_document_type("settings", global_document=True) + store_path = Path(tmp) / "generic.cc" + cache = ( + CultCache.builder() + .register_document_type(item) + .register_document_type(settings) + .add_generic_store(SingleFileMessagePackBackingStore(store_path)) + .build() + ) + cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50)) + cache.put_global(settings, {"theme": "ash"}) + before = cache.snapshot_envelopes() + + # A second record of the global type lands in the store file behind the cache's back. + stray = replace(cache.get_required_envelope(settings, CultCache.GLOBAL_KEY), key="stray") + SingleFileMessagePackBackingStore(store_path).push(stray) + + with self.assertRaisesRegex(CultCacheError, "Duplicate global document for type: settings"): + cache.pull_all_backing_stores() + self.assertEqual(cache.snapshot_envelopes(), before) + self.assertEqual(cache.get_required(item, "item:potion").value, 50) + self.assertEqual(cache.get_key_by_name(item, "Potion"), "item:potion") + self.assertEqual(cache.get_required_global(settings)["theme"], "ash") + + def test_pull_with_throwing_index_accessor_leaves_cache_unchanged(self) -> None: + def category(item: Item) -> str: + if item.name == "Bomb": + raise ValueError("category accessor refuses Bomb") + return item.category + + with tempfile.TemporaryDirectory() as tmp: + item = define_document_type( + "item", + encode=lambda value: asdict(value), + decode=lambda raw: Item(**raw), + name="name", + indexes={"category": category}, + ) + settings = define_document_type("settings", global_document=True) + store_path = Path(tmp) / "generic.cc" + cache = ( + CultCache.builder() + .register_document_type(item) + .register_document_type(settings) + .add_generic_store(SingleFileMessagePackBackingStore(store_path)) + .build() + ) + cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50)) + cache.put_global(settings, {"theme": "ash"}) + before_envelopes = cache.snapshot_envelopes() + before_values = cache.snapshot() + + # A record whose index accessor throws lands in the store file behind the cache's back. + bomb = replace( + cache.get_required_envelope(item, "item:potion"), + key="item:bomb", + payload=item.encode_payload(Item(name="Bomb", category="Explosive", value=1)), + ) + SingleFileMessagePackBackingStore(store_path).push(bomb) + + with self.assertRaisesRegex(ValueError, "category accessor refuses Bomb"): + cache.pull_all_backing_stores() + self.assertEqual(cache.snapshot_envelopes(), before_envelopes) + self.assertEqual(cache.snapshot(), before_values) + self.assertEqual(cache.get_required(item, "item:potion").value, 50) + self.assertIsNone(cache.get(item, "item:bomb")) + self.assertEqual(cache.get_key_by_name(item, "Potion"), "item:potion") + self.assertIsNone(cache.get_key_by_name(item, "Bomb")) + self.assertEqual(cache.get_key_by_index(item, "category", "Consumable"), "item:potion") + self.assertEqual(cache.get_required_global(settings)["theme"], "ash") + + def _store_state(self, path: Path) -> list[tuple[str, str, bytes]]: + return sorted((e.type, e.key, e.payload) for e in SingleFileMessagePackBackingStore(path).pull_all()) + + def test_put_with_throwing_name_extractor_changes_neither_store_nor_cache(self) -> None: + def name(value: dict) -> str: + if value["n"] == "bad": + raise ValueError("extractor refuses bad") + return value["n"] + + with tempfile.TemporaryDirectory() as tmp: + item = define_document_type("item", name=name) + path = Path(tmp) / "generic.cc" + cache = CultCache.builder().register_document_type(item).add_generic_store( + SingleFileMessagePackBackingStore(path) + ).build() + cache.put(item, "k", {"n": "good"}) + store_before, cache_before = self._store_state(path), cache.snapshot_envelopes() + + with self.assertRaisesRegex(ValueError, "extractor refuses bad"): + cache.put(item, "k", {"n": "bad"}) + self.assertEqual(self._store_state(path), store_before) + self.assertEqual(cache.snapshot_envelopes(), cache_before) + self.assertEqual(cache.get_required(item, "k"), {"n": "good"}) + self.assertEqual(cache.get_key_by_name(item, "good"), "k") + + def test_overwrite_under_a_registered_extractor_that_now_raises_changes_neither_store_nor_cache(self) -> None: + refusing = {"on": False} + + def category(value: dict) -> str: + if refusing["on"]: + raise ValueError("extractor refuses now") + return value["c"] + + with tempfile.TemporaryDirectory() as tmp: + item = define_document_type("item") + path = Path(tmp) / "generic.cc" + cache = CultCache.builder().register_document_type(item).add_generic_store( + SingleFileMessagePackBackingStore(path) + ).build() + cache.put(item, "k", {"c": "old"}) + cache.register_index(item, "category", category) + refusing["on"] = True + store_before, cache_before = self._store_state(path), cache.snapshot_envelopes() + + with self.assertRaisesRegex(ValueError, "extractor refuses now"): + cache.put(item, "k", {"c": "new"}) + self.assertEqual(self._store_state(path), store_before) + self.assertEqual(cache.snapshot_envelopes(), cache_before) + self.assertEqual(cache.get_key_by_index(item, "category", "old"), "k") + # Deleting runs no extractor: it uses the lookup keys stored when the record was admitted. + cache.delete(item, "k") + self.assertIsNone(cache.get_key_by_index(item, "category", "old")) + + def test_registering_an_extractor_that_raises_on_a_held_value_installs_nothing(self) -> None: + def name(value: dict) -> str: + if value["n"] == "bad": + raise ValueError("extractor refuses bad") + return value["n"] + + with tempfile.TemporaryDirectory() as tmp: + item = define_document_type("item") + path = Path(tmp) / "generic.cc" + cache = CultCache.builder().register_document_type(item).add_generic_store( + SingleFileMessagePackBackingStore(path) + ).build() + cache.put(item, "old", {"n": "bad"}) + with self.assertRaisesRegex(ValueError, "extractor refuses bad"): + cache.register_name_lookup(item, name) + cache.put(item, "old", {"n": "fine"}) + self.assertEqual(cache.get_required(item, "old"), {"n": "fine"}) + self.assertIsNone(cache.get_key_by_name(item, "fine")) + self.assertEqual([item.decode_payload(e.payload) for e in SingleFileMessagePackBackingStore(path).pull_all()], [{"n": "fine"}]) + + def test_put_envelopes_with_throwing_extractor_changes_neither_store_nor_cache(self) -> None: + def name(value: dict) -> str: + if value["n"] == "bad": + raise ValueError("extractor refuses bad") + return value["n"] + + with tempfile.TemporaryDirectory() as tmp: + item = define_document_type("item", name=name) + path = Path(tmp) / "generic.cc" + cache = CultCache.builder().register_document_type(item).add_generic_store( + SingleFileMessagePackBackingStore(path) + ).build() + cache.put(item, "seed", {"n": "seed"}) + seed = cache.get_required_envelope(item, "seed") + first = replace(seed, key="a", payload=item.encode_payload({"n": "a"})) + second = replace(seed, key="b", payload=item.encode_payload({"n": "bad"})) + store_before, cache_before = self._store_state(path), cache.snapshot_envelopes() + + with self.assertRaisesRegex(ValueError, "extractor refuses bad"): + cache.put_envelopes(item, [first, second]) + self.assertEqual(self._store_state(path), store_before) + self.assertEqual(cache.snapshot_envelopes(), cache_before) + self.assertIsNone(cache.get_key_by_name(item, "a")) + + def test_global_replace_with_failing_store_push_keeps_old_global(self) -> None: + class FailingPushStore(SingleFileMessagePackBackingStore): + fail = False + + def push(self, envelope): + if self.fail: + raise OSError("disk full") + super().push(envelope) + + with tempfile.TemporaryDirectory() as tmp: + settings = define_document_type("settings", global_document=True) + path = Path(tmp) / "generic.cc" + store = FailingPushStore(path) + cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build() + cache.put_global(settings, {"theme": "base"}) + store_before, cache_before = self._store_state(path), cache.snapshot_envelopes() + store.fail = True + + with self.assertRaisesRegex(OSError, "disk full"): + cache.put_global(settings, {"theme": "new"}) + with self.assertRaisesRegex(CultCacheError, "must use key __global__"): + cache.put(settings, "other", {"theme": "new"}) + self.assertEqual(self._store_state(path), store_before) + self.assertEqual(cache.snapshot_envelopes(), cache_before) + self.assertEqual(cache.get_required_global(settings), {"theme": "base"}) + + @staticmethod + def _pausing_store(path: Path, barrier: threading.Barrier, resume: threading.Event): + """A store whose first push writes, meets the other thread at the barrier, then waits until that + thread's call has returned (or a timeout: a locked cache makes the other thread wait on us).""" + + class PausingStore(SingleFileMessagePackBackingStore): + pushes = 0 + paused = False + other_reached_store_while_paused = False + + def push(self, envelope): + self.other_reached_store_while_paused |= self.paused + self.pushes += 1 + super().push(envelope) + if self.pushes == 1: + self.paused = True + barrier.wait(timeout=5) + resume.wait(timeout=0.5) + self.paused = False + + return PausingStore(path) + + @staticmethod + def _run_racing(first, second, resume: threading.Event) -> list[BaseException]: + errors: list[BaseException] = [] + + def run(action, signal): + try: + action() + except BaseException as error: # noqa: BLE001 - reported to the test + errors.append(error) + finally: + if signal: + resume.set() + + threads = [threading.Thread(target=run, args=(first, False)), threading.Thread(target=run, args=(second, True))] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + return errors + + def test_concurrent_put_and_attach_cannot_land_record_in_non_home_store(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + settings = define_database_entry_type("settings", [("theme", 0)]) + generic_path, settings_path = Path(tmp) / "generic.cc", Path(tmp) / "settings.cc" + barrier, resume = threading.Barrier(2), threading.Event() + generic = self._pausing_store(generic_path, barrier, resume) + cache = CultCache.builder().register_document_type(settings).add_generic_store(generic).build() + + def attach() -> None: + barrier.wait(timeout=5) + cache.add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"]) + + errors = self._run_racing(lambda: cache.put(settings, "app", {"theme": "t"}), attach, resume) + + # Either the attach was refused (the record is held, so its home cannot move) or it won; + # in every case each held record must be in the store that is its home. + home = SingleFileMessagePackBackingStore(generic_path if errors else settings_path) + self.assertEqual([(e.key, e.type) for e in home.pull_all()], [("app", "settings")]) + self.assertTrue(all(isinstance(error, CultCacheError) for error in errors), errors) + + def test_concurrent_global_puts_cannot_both_reach_disk(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + settings = define_document_type("settings", global_document=True) + path = Path(tmp) / "generic.cc" + barrier, resume = threading.Barrier(2), threading.Event() + store = self._pausing_store(path, barrier, resume) + cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build() + + def second() -> None: + barrier.wait(timeout=5) + cache.put_global(settings, {"theme": "second"}) + + errors = self._run_racing(lambda: cache.put_global(settings, {"theme": "first"}), second, resume) + + self.assertEqual(errors, []) + # While the first write was between its store push and its apply, the second never reached disk. + self.assertFalse(store.other_reached_store_while_paused) + on_disk = SingleFileMessagePackBackingStore(path).pull_all() + self.assertEqual(len(on_disk), 1) + self.assertEqual(on_disk[0].payload, cache.get_required_envelope(settings, CultCache.GLOBAL_KEY).payload) + self.assertEqual(cache.get_required_global(settings), {"theme": "second"}) + + @staticmethod + def _envelope(document, key: str, value) -> CultCacheEnvelope: + catalog_entry = document.catalog_entry() + return CultCacheEnvelope.create( + key=key, + type=document.type, + payload=document.encode_payload(value), + schema_id=catalog_entry.schema_id, + catalog_entry=catalog_entry, ) - self.assertEqual(_parity_status(comparison, "ok"), "meets-threshold") + def test_legacy_key_global_loads_without_writing_and_first_write_leaves_one_global(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + settings = define_document_type("settings", global_document=True) + path = Path(tmp) / "generic.cc" + + def build() -> CultCache: + return CultCache.builder().register_document_type(settings).add_generic_store( + SingleFileMessagePackBackingStore(path) + ).build() + + SingleFileMessagePackBackingStore(path).push(self._envelope(settings, "legacy", {"theme": "old"})) + stored_bytes = path.read_bytes() + + cache = build() + cache.pull_all_backing_stores() + self.assertEqual(cache.get_global(settings), {"theme": "old"}) + self.assertEqual(cache.get(settings, CultCache.GLOBAL_KEY), {"theme": "old"}) + self.assertEqual(path.read_bytes(), stored_bytes) + + cache.put_global(settings, {"theme": "new"}) + self.assertEqual([e.key for e in SingleFileMessagePackBackingStore(path).pull_all()], [CultCache.GLOBAL_KEY]) + reloaded = build() + reloaded.pull_all_backing_stores() + self.assertEqual(reloaded.get_required_global(settings), {"theme": "new"}) + + def test_deleting_a_legacy_key_global_removes_the_legacy_record(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + settings = define_document_type("settings", global_document=True) + path = Path(tmp) / "generic.cc" + SingleFileMessagePackBackingStore(path).push(self._envelope(settings, "legacy", {"theme": "old"})) + cache = CultCache.builder().register_document_type(settings).add_generic_store( + SingleFileMessagePackBackingStore(path) + ).build() + cache.pull_all_backing_stores() + + cache.delete_global(settings) + self.assertEqual(SingleFileMessagePackBackingStore(path).pull_all(), []) + self.assertIsNone(cache.get_global(settings)) + + def test_two_globals_of_one_type_on_disk_are_refused_under_any_keys(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + settings = define_document_type("settings", global_document=True) + for keys in (["one", "two"], ["legacy", CultCache.GLOBAL_KEY]): + path = Path(tmp) / f"{keys[0]}.cc" + store = SingleFileMessagePackBackingStore(path) + store.push_all([self._envelope(settings, key, {"theme": key}) for key in keys]) + cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build() + with self.assertRaisesRegex(CultCacheError, "Duplicate global document for type: settings"): + cache.pull_all_backing_stores() + self.assertIsNone(cache.get_global(settings)) + + def test_decoder_writing_during_pull_raises_and_changes_neither_store_nor_cache(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "generic.cc" + holder: dict[str, CultCache] = {} + + def decode(raw): + holder["cache"].put(item, "side", {"name": "side"}) + return raw + + item = define_document_type("item", decode=decode, encode=lambda value: value) + store = SingleFileMessagePackBackingStore(path) + store.push(self._envelope(item, "a", {"name": "a"})) + stored_bytes = path.read_bytes() + cache = holder["cache"] = CultCache.builder().register_document_type(item).add_generic_store(store).build() + + with self.assertRaisesRegex(CultCacheError, "re-entrant put"): + cache.pull_all_backing_stores() + self.assertEqual(path.read_bytes(), stored_bytes) + self.assertEqual(cache.snapshot_envelopes(), []) + self.assertIsNone(cache.get(item, "side")) + # The refusal released the cache: an ordinary write still works. + cache.put(item, "b", {"name": "b"}) + self.assertEqual(cache.get(item, "b"), {"name": "b"}) + + def test_index_extractor_writing_during_register_index_raises_and_installs_nothing(self) -> None: + thing = define_document_type("thing") + cache = CultCache.builder().register_document_type(thing).build() + cache.put(thing, "k1", {"n": "one", "cat": "x"}) + + def category(value: dict) -> str: + cache.put(thing, "k2", {"n": "two", "cat": "y"}) + return value["cat"] + + with self.assertRaisesRegex(CultCacheError, "re-entrant put"): + cache.register_index(thing, "cat", category) + self.assertIsNone(cache.get(thing, "k2")) + self.assertNotIn("thing", cache._state.index_extractors) + self.assertIsNone(cache.get_key_by_index(thing, "cat", "x")) + + def test_put_envelope_refuses_a_global_under_another_key(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + settings = define_document_type("settings", global_document=True) + path = Path(tmp) / "generic.cc" + cache = CultCache.builder().register_document_type(settings).add_generic_store( + SingleFileMessagePackBackingStore(path) + ).build() + cache.put_global(settings, {"theme": "base"}) + store_before, cache_before = self._store_state(path), cache.snapshot_envelopes() + stray = replace(cache.get_required_envelope(settings, CultCache.GLOBAL_KEY), key="stray") + + with self.assertRaisesRegex(CultCacheError, "must use key __global__"): + cache.put_envelope(settings, stray) + with self.assertRaisesRegex(CultCacheError, "must be non-empty"): + cache.put_envelope(settings, replace(stray, key="")) + self.assertEqual(self._store_state(path), store_before) + self.assertEqual(cache.snapshot_envelopes(), cache_before) + + def test_refused_put_leaves_no_empty_type_entry(self) -> None: + def refuse(value: dict) -> dict: + raise ValueError("encode refuses") + + with tempfile.TemporaryDirectory() as tmp: + other = define_document_type("other", encode=refuse) + path = Path(tmp) / "generic.cc" + cache = CultCache.builder().register_document_type(other).add_generic_store( + SingleFileMessagePackBackingStore(path) + ).build() + with self.assertRaisesRegex(ValueError, "encode refuses"): + cache.put(other, "x", {"a": 1}) + self.assertEqual(cache.snapshot(), {}) + self.assertFalse(path.exists()) + + def test_attach_with_empty_type_list_is_the_generic_store(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + settings = define_database_entry_type("settings", [("theme", 0)]) + store_path = Path(tmp) / "generic.cc" + cache = CultCache.builder().register_document_type(settings).build() + cache.add_backing_store(SingleFileMessagePackBackingStore(store_path), []) + cache.put(settings, "y", {"theme": "t"}) + self.assertEqual( + [(e.key, e.type) for e in SingleFileMessagePackBackingStore(store_path).pull_all()], + [("y", "settings")], + ) + with self.assertRaisesRegex(CultCacheError, "second generic store"): + cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"), []) + + def test_zero_store_cache_writes_and_deletes_in_memory(self) -> None: + settings = define_database_entry_type("settings", [("theme", 0)]) + cache = CultCache.builder().register_document_type(settings).build() + cache.put(settings, "x", {"theme": "mem"}) + self.assertEqual(cache.get_required(settings, "x"), {"theme": "mem"}) + cache.delete(settings, "x") + self.assertIsNone(cache.get(settings, "x")) def test_cache_put_without_backing_store_keeps_in_memory_value(self) -> None: document = define_database_entry_type( @@ -471,4768 +801,6 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(cache.get(document, "item:one"), {"name": "one", "value": 1}) self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one") - def test_verify_reports_python_runtime_surface_health(self) -> None: - result = verify(records=4) - - self.assertEqual(result["status"], "ok") - self.assertEqual( - {check["name"] for check in result["checks"]}, - { - "public_exports", - "typed_markers", - "local_cultmesh_wire_smoke", - "cultmesh_capability_truth", - "benchmark_sanity", - }, - ) - by_name = {check["name"]: check for check in result["checks"]} - self.assertEqual(by_name["public_exports"]["missing"], {}) - self.assertEqual(by_name["cultmesh_capability_truth"]["failures"], []) - - def test_cultnet_security_options_match_cultlib_defaults_and_environment_rules(self) -> None: - client = CultNetClientSecurityOptions.development() - server = CultNetServerSecurityOptions.development() - - self.assertEqual(client.connection_key, "gamecult-dev-connection-key") - self.assertEqual(server.connection_key, "gamecult-dev-connection-key") - self.assertTrue(server.is_development) - self.assertEqual(server.to_client_options().connection_key, client.connection_key) - self.assertEqual(client.encryption_key(), hashlib.sha256(b"gamecult-dev-connection-key").digest()) - self.assertEqual(server.session_signing_key(), hashlib.sha256(b"gamecult-dev-session-signing-secret").digest()) - self.assertEqual( - CultNetServerSecurityOptions.from_environment({}, allow_development_defaults=True), - server, - ) - with self.assertRaisesRegex(ValueError, "not configured"): - CultNetServerSecurityOptions.from_environment({}) - with self.assertRaisesRegex(ValueError, "GAMECULT_SESSION_SIGNING_SECRET"): - CultNetServerSecurityOptions.from_environment({"GAMECULT_CONNECTION_KEY": "key"}) - configured = CultNetServerSecurityOptions.from_environment({ - "GAMECULT_CONNECTION_KEY": "prod-key", - "GAMECULT_SESSION_SIGNING_SECRET": "prod-secret", - }) - self.assertEqual(configured.connection_key, "prod-key") - self.assertFalse(configured.is_development) - - def test_cultnet_secret_helpers_validate_csharp_versioned_session_tokens(self) -> None: - security = CultNetServerSecurityOptions("connection-key", "session-secret") - user_id = uuid4() - expires = datetime(2035, 1, 2, 3, 4, 5, tzinfo=UTC) - - token = CultNetSecret.create_session_token(user_id, expires, security, session_version=7) - payload_b64, signature_b64 = token.split(".") - self.assertEqual( - CultNetSecret.from_base64url(payload_b64).decode("utf-8"), - f"{user_id.hex}|{int(expires.timestamp())}|7", - ) - self.assertEqual( - CultNetSecret.from_base64url(signature_b64), - hmac.new( - hashlib.sha256(b"session-secret").digest(), - CultNetSecret.from_base64url(payload_b64), - hashlib.sha256, - ).digest(), - ) - - validated = CultNetSecret.try_validate_session_token( - token, - security, - now_utc=datetime(2035, 1, 2, 3, 4, 4, tzinfo=UTC), - ) - self.assertIsNotNone(validated) - assert validated is not None - self.assertEqual(validated.user_id, user_id) - self.assertEqual(validated.expires_at_utc, expires) - self.assertEqual(validated.session_version, 7) - - tampered_payload = CultNetSecret.to_base64url(CultNetSecret.from_base64url(payload_b64) + b"tamper") - tampered = f"{tampered_payload}.{signature_b64}" - self.assertIsNone(CultNetSecret.try_validate_session_token(tampered, security)) - self.assertIsNone(CultNetSecret.try_validate_session_token(token, security, now_utc=expires)) - - def test_cultnet_secret_helpers_validate_legacy_two_field_session_tokens(self) -> None: - security = CultNetServerSecurityOptions.development() - user_id = uuid4() - expires_at_seconds = int(datetime(2036, 1, 1, tzinfo=UTC).timestamp()) - payload = f"{user_id.hex}|{expires_at_seconds}".encode("utf-8") - signature = hmac.new(security.session_signing_key(), payload, hashlib.sha256).digest() - token = f"{CultNetSecret.to_base64url(payload)}.{CultNetSecret.to_base64url(signature)}" - - validated = CultNetSecret.try_validate_session_token( - token, - security, - now_utc=datetime(2035, 12, 31, tzinfo=UTC), - ) - - self.assertIsNotNone(validated) - assert validated is not None - self.assertEqual(validated.user_id, user_id) - self.assertEqual(validated.session_version, 0) - - def test_cultnet_secret_encryption_helpers_are_optional_without_crypto_dependency(self) -> None: - nonce = b"123456789012" - security = CultNetServerSecurityOptions.development() - try: - encrypted = CultNetSecret.encrypt_string("hello", nonce, security.to_client_options()) - except ImportError as exc: - self.assertIn("cryptography", str(exc)) - return - self.assertEqual(CultNetSecret.decrypt_string(encrypted, nonce, security), "hello") - - def test_cultnet_schema_message_frame_round_trip(self) -> None: - message = hello( - runtime_id="python-test", - supported_schema_versions=["cultnet.hello.v0"], - transport_profiles=[ - { - "schemaVersion": "cultnet.transport_profile.v0", - "runtimeId": "python-test", - "transports": [ - { - "transportId": "test", - "protocol": "tcp_framed", - "channels": [{"channelId": "schema", "delivery": "reliable", "ordering": "ordered"}], - } - ], - } - ], - ) - parsed = parse_message(decode_frame(encode_frame(message.to_bytes()))) - self.assertEqual(parsed.schema_version, "cultnet.hello.v0") - self.assertEqual(parsed.body["runtimeId"], "python-test") - self.assertEqual(parsed.body["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed") - - def test_cultnet_tcp_framed_transport_carries_schema_payloads_with_stats(self) -> None: - payload = b"cultnet-payload" - output = io.BytesIO() - sender = TcpFramedTransportConnection( - output, - profile=create_tcp_framed_transport_profile("sender", transport_id="test-tcp"), - ) - sender.send("schema", payload) - self.assertEqual(sender.stats.frames_sent, 1) - self.assertEqual(sender.stats.bytes_sent, len(payload) + 4) - with self.assertRaisesRegex(ValueError, "only supports the schema channel"): - sender.send("unreliable", b"") - - receiver = TcpFramedTransportConnection( - io.BytesIO(output.getvalue()), - profile=create_tcp_framed_transport_profile("receiver", transport_id="test-tcp"), - ) - frame = receiver.receive() - self.assertEqual(frame.channel_id, "schema") - self.assertEqual(frame.payload, payload) - self.assertEqual(receiver.stats.frames_received, 1) - self.assertEqual(receiver.stats.bytes_received, len(payload) + 4) - self.assertEqual(receiver.profile["transports"][0]["protocol"], "tcp_framed") - - def test_cultnet_raw_client_uses_schema_transport_factory(self) -> None: - import msgpack # type: ignore - - sent_messages: list[dict[str, object]] = [] - test_case = self - - class FakeSchemaTransport: - profile = create_tcp_framed_transport_profile("fake-client") - - def __enter__(self) -> "FakeSchemaTransport": - return self - - def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: - self.close() - - def send(self, channel_id: str, payload: bytes) -> None: - test_case.assertEqual(channel_id, "schema") - sent_messages.append(msgpack.unpackb(payload, raw=False)) - - def receive(self) -> object: - response = { - "schemaVersion": "cultnet.schema_catalog_response.v0", - "messageId": "transport-factory", - "schemas": [], - } - return type("Frame", (), {"channel_id": "schema", "payload": msgpack.packb(response, use_bin_type=True)})() - - def close(self) -> None: - pass - - client = CultNetRawClient( - "unused.example.test", - 1, - create_transport=FakeSchemaTransport, - ) - - response = client.fetch_schema_catalog(message_id="transport-factory") - - self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0") - self.assertEqual(sent_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0") - self.assertEqual(sent_messages[0]["messageId"], "transport-factory") - - def test_cultmesh_client_uses_rudp_schema_transport_endpoint(self) -> None: - import msgpack # type: ignore - - server_socket = bind_udp_socket() - server_host, server_port = server_socket.getsockname()[:2] - connection_id = 0x43554C54 - server = CultNetRudpSocketTransportConnection( - CultNetRudpSocketTransportOptions( - runtime_id="python-rudp-schema-server", - socket=server_socket, - mode=CultNetRudpSocketMode.SERVER, - connection_id=connection_id, - initial_sequence=100, - resend_delay_ms=25, - ) - ) - received_messages: list[dict[str, object]] = [] - - def serve_once() -> None: - deadline = time.monotonic() + 2.0 - try: - while time.monotonic() < deadline: - frame = server.receive_once() - server.poll_resends() - if frame is None: - time.sleep(0.005) - continue - received = msgpack.unpackb(frame.payload, raw=False) - received_messages.append(received) - response = { - "schemaVersion": "cultnet.schema_catalog_response.v0", - "messageId": received["messageId"], - "schemas": [], - } - server.send("schema", msgpack.packb(response, use_bin_type=True)) - return - raise AssertionError("RUDP schema server did not receive a request") - finally: - server.close() - - server_thread = threading.Thread(target=serve_once, daemon=True) - server_thread.start() - client = CultMesh.create_client( - endpoint=f"rudp://{server_host}:{server_port}", - timeout_seconds=2.0, - connection_id=connection_id, - runtime_id="python-rudp-schema-client", - ) - - response = client.fetch_schema_catalog(message_id="rudp-transport-factory") - server_thread.join(timeout=2.0) - - self.assertFalse(server_thread.is_alive()) - self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0") - self.assertEqual(response["messageId"], "rudp-transport-factory") - self.assertEqual(received_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0") - self.assertEqual(received_messages[0]["messageId"], "rudp-transport-factory") - - def test_cultnet_rudp_packet_codec_uses_deterministic_reliable_ordered_fixture(self) -> None: - encoded = encode_rudp_packet( - CultNetRudpPacket( - packet_type=CultNetRudpPacketType.DATA, - connection_id=0x01020304, - sequence=0x0000002A, - ack=0x00000029, - ack_mask=0x80000001, - channel_id="schema", - reliable=True, - ordered=True, - fragment_id=7, - fragment_index=1, - fragment_count=3, - payload=b"hello", - ) - ) - - self.assertEqual( - encoded.hex(), - "434e523000030b2a010203040000002a0000002980000001000700010003000000050600736368656d6168656c6c6f", - ) - - decoded = decode_rudp_packet(encoded) - self.assertEqual(decoded.packet_type, CultNetRudpPacketType.DATA) - self.assertEqual(decoded.connection_id, 0x01020304) - self.assertEqual(decoded.sequence, 0x0000002A) - self.assertEqual(decoded.ack, 0x00000029) - self.assertEqual(decoded.ack_mask, 0x80000001) - self.assertEqual(decoded.channel_id, "schema") - self.assertTrue(decoded.reliable) - self.assertTrue(decoded.ordered) - self.assertFalse(decoded.sequenced) - self.assertEqual(decoded.fragment_id, 7) - self.assertEqual(decoded.fragment_index, 1) - self.assertEqual(decoded.fragment_count, 3) - self.assertEqual(decoded.payload, b"hello") - - def test_cultnet_rudp_transport_profile_advertises_state_and_realtime_channels(self) -> None: - profile = create_rudp_transport_profile( - "python-rudp", - transport_id="public-rudp", - host="127.0.0.1", - port=7777, - max_payload_bytes=1200, - max_fragment_bytes=1000, - ) - - self.assertEqual(profile["transports"][0]["protocol"], "rudp") - self.assertEqual(profile["transports"][0]["reconnectPolicy"]["schemaVersion"], "cultnet.reconnect_policy.v0") - self.assertEqual(profile["transports"][0]["reconnectPolicy"]["baseDelayMs"], 1_000) - self.assertEqual( - [ - (channel["channelId"], channel["delivery"], channel["ordering"]) - for channel in profile["transports"][0]["channels"] - ], - [ - ("schema", "reliable", "ordered"), - ("latest", "unreliable", "sequenced"), - ("realtime", "unreliable", "unordered"), - ], - ) - - def test_cultnet_reconnect_policy_exposes_portable_delay_contract(self) -> None: - policy = create_reconnect_policy(policy_id="rudp-default", max_attempts=8) - - self.assertEqual(policy.schema_version, "cultnet.reconnect_policy.v0") - self.assertEqual(policy.policy_id, "rudp-default") - self.assertEqual(policy.max_attempts, 8) - self.assertEqual(policy.to_wire()["policyId"], "rudp-default") - self.assertEqual(policy.to_wire()["maxAttempts"], 8) - self.assertEqual(compute_reconnect_delay_ms(policy, 1), 1_000) - self.assertEqual(compute_reconnect_delay_ms(policy, 3, 17), 4_017) - self.assertEqual(compute_reconnect_delay_ms(policy, 9, 999), 30_250) - self.assertEqual(compute_reconnect_delay_ms(policy, 0, -5), 1_000) - - def test_cultnet_reconnect_controller_schedules_attempts_and_reset(self) -> None: - controller = CultNetReconnectController(create_reconnect_policy(max_attempts=2)) - - first = controller.record_failure(10_000) - self.assertEqual(first.attempt, 1) - self.assertTrue(first.should_retry) - self.assertEqual(first.delay_ms, 1_000) - self.assertEqual(first.next_attempt_at_ms, 11_000) - self.assertFalse(first.exhausted) - self.assertFalse(controller.can_attempt(10_999)) - self.assertTrue(controller.can_attempt(11_000)) - - second = controller.record_failure(11_000, 17) - self.assertEqual(second.attempt, 2) - self.assertEqual(second.delay_ms, 2_017) - self.assertEqual(second.next_attempt_at_ms, 13_017) - self.assertTrue(second.should_retry) - - exhausted = controller.record_failure(13_017) - self.assertEqual(exhausted.attempt, 2) - self.assertFalse(exhausted.should_retry) - self.assertEqual(exhausted.delay_ms, 0) - self.assertIsNone(exhausted.next_attempt_at_ms) - self.assertTrue(exhausted.exhausted) - self.assertFalse(controller.can_attempt(99_000)) - - controller.reset() - self.assertEqual(controller.attempt, 0) - self.assertIsNone(controller.next_attempt_at_ms) - self.assertFalse(controller.exhausted) - self.assertTrue(controller.can_attempt(99_000)) - - def test_cultnet_rudp_reconnect_loop_consumes_shared_controller(self) -> None: - class FakeRudpReconnectTransport: - def __init__(self) -> None: - self.connect_calls = 0 - self.close_calls = 0 - self.last_payload: bytes | None = None - - def connect(self, payload: bytes = b"") -> None: - self.connect_calls += 1 - self.last_payload = bytes(payload) - - def close(self) -> None: - self.close_calls += 1 - - now_ms = 10_000 - captured_callback: list[Callable[[], None]] = [] - transports: list[FakeRudpReconnectTransport] = [] - - def create_transport() -> FakeRudpReconnectTransport: - transport = FakeRudpReconnectTransport() - transports.append(transport) - return transport - - def scheduler(delay_ms: int, callback: Callable[[], None]) -> Callable[[], None]: - self.assertEqual(delay_ms, 1_017) - captured_callback[:] = [callback] - - def cancel() -> None: - captured_callback.clear() - - return cancel - - loop = CultNetRudpReconnectLoop( - create_transport, - reconnect_policy=create_reconnect_policy(max_attempts=2), - connect_payload=b"join", - now_ms=lambda: now_ms, - jitter_ms=lambda: 17, - scheduler=scheduler, - ) - - first = loop.start() - self.assertEqual(first.connect_calls, 1) - self.assertEqual(first.last_payload, b"join") - self.assertIs(loop.transport, first) - - decision = loop.handle_closed() - self.assertIsNotNone(decision) - self.assertEqual(decision.attempt, 1) - self.assertTrue(decision.should_retry) - self.assertEqual(decision.delay_ms, 1_017) - self.assertEqual(decision.next_attempt_at_ms, 11_017) - self.assertEqual(loop.reconnect_controller.attempt, 1) - self.assertEqual(loop.reconnect_controller.next_attempt_at_ms, 11_017) - self.assertEqual(len(captured_callback), 1) - - now_ms = 11_017 - captured_callback[0]() - self.assertEqual(len(transports), 2) - self.assertEqual(transports[1].connect_calls, 1) - self.assertIs(loop.transport, transports[1]) - - loop.mark_connected() - self.assertEqual(loop.reconnect_controller.attempt, 0) - - loop.stop() - self.assertEqual(transports[1].close_calls, 1) - self.assertIsNone(loop.transport) - - def test_cultnet_rudp_session_handshake_acks_reliable_connect_and_accept_packets(self) -> None: - client = CultNetRudpSession( - CultNetRudpSessionOptions(connection_id=0x0A0B0C0D, initial_sequence=1, resend_delay_ms=50) - ) - server = CultNetRudpSession( - CultNetRudpSessionOptions(connection_id=0x0A0B0C0D, initial_sequence=100, resend_delay_ms=50) - ) - - connect = client.create_connect(0, b"join") - self.assertEqual(connect.packet_type, CultNetRudpPacketType.CONNECT) - self.assertEqual(connect.sequence, 1) - self.assertEqual(client.pending_reliable_sequences, (1,)) - - accept = server.accept_connect(connect, 10, b"ok") - self.assertEqual(accept.packet_type, CultNetRudpPacketType.ACCEPT) - self.assertEqual(accept.ack, 1) - self.assertTrue(server.connected) - self.assertEqual(server.pending_reliable_sequences, (100,)) - - client.receive(accept, 20) - self.assertTrue(client.connected) - self.assertEqual(client.pending_reliable_sequences, ()) - - ack = client.create_ack() - self.assertEqual(ack.ack, 100) - server.receive(ack, 30) - self.assertEqual(server.pending_reliable_sequences, ()) - - def test_cultnet_rudp_session_computes_ack_masks_and_clears_pending_reliable_packets(self) -> None: - sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=7, initial_sequence=10, resend_delay_ms=100)) - receiver = CultNetRudpSession( - CultNetRudpSessionOptions(connection_id=7, initial_sequence=200, resend_delay_ms=100) - ) - sender.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 7, 1, 0, 0, "control") - ) - receiver.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 7, 2, 0, 0, "control") - ) - - first = sender.send( - "schema", - b"first", - CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0), - ) - second = sender.send( - "schema", - b"second", - CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0), - ) - third = sender.send( - "schema", - b"third", - CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0), - ) - self.assertEqual(sender.pending_reliable_sequences, (10, 11, 12)) - - receiver.receive(first) - receiver.receive(third) - ack_with_gap = receiver.create_ack() - self.assertEqual(ack_with_gap.ack, 12) - self.assertEqual(ack_with_gap.ack_mask, 0b10 | (1 << 9)) - sender.receive(ack_with_gap) - self.assertEqual(sender.pending_reliable_sequences, (11,)) - - receiver.receive(second) - full_ack = receiver.create_ack() - self.assertEqual(full_ack.ack, 12) - self.assertEqual(full_ack.ack_mask, 0b11 | (1 << 9)) - sender.receive(full_ack) - self.assertEqual(sender.pending_reliable_sequences, ()) - - def test_cultnet_rudp_session_schedules_reliable_resends_until_acked(self) -> None: - session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=99, initial_sequence=1, resend_delay_ms=100)) - session.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 99, 50, 0, 0, "control") - ) - sent = session.send( - "schema", - b"payload", - CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=10), - ) - - self.assertEqual(session.due_resends(90), ()) - self.assertEqual(tuple(packet.sequence for packet in session.due_resends(110)), (sent.sequence,)) - self.assertEqual(session.due_resends(150), ()) - - session.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACK, 99, 51, sent.sequence, 0, "control") - ) - self.assertEqual(session.due_resends(250), ()) - - def test_cultnet_rudp_session_pings_and_detects_receive_timeout(self) -> None: - client = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=101, initial_sequence=1)) - server = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=101, initial_sequence=100)) - connect = client.create_connect(0, b"join") - accept = server.accept_connect(connect, 10) - client.receive(accept, 20) - - ping = client.create_ping(b"pulse") - ping_result = server.receive(ping, 30) - self.assertIsNotNone(ping_result.reply) - self.assertEqual(ping_result.reply.packet_type, CultNetRudpPacketType.PONG) - self.assertEqual(ping_result.reply.payload, b"pulse") - - pong_result = client.receive(ping_result.reply, 40) - self.assertTrue(pong_result.pong) - self.assertEqual(pong_result.pong_payload, b"pulse") - self.assertFalse(client.check_timeout(90, 50)) - self.assertTrue(client.check_timeout(91, 50)) - self.assertFalse(client.connected) - - def test_cultnet_rudp_session_bounds_pending_reliable_packets_before_enqueue(self) -> None: - session = CultNetRudpSession( - CultNetRudpSessionOptions(connection_id=102, initial_sequence=1, max_pending_reliable_packets=2) - ) - session.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 102, 50, 0, 0, "control")) - session.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True)) - session.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True)) - with self.assertRaisesRegex(ValueError, "reliable send queue is full"): - session.send("schema", b"third", CultNetRudpSendOptions(reliable=True, ordered=True)) - self.assertEqual(session.pending_reliable_sequences, (1, 2)) - - fragmented = CultNetRudpSession( - CultNetRudpSessionOptions(connection_id=103, initial_sequence=1, max_pending_reliable_packets=3) - ) - fragmented.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 103, 50, 0, 0, "control")) - with self.assertRaisesRegex(ValueError, "reliable send queue is full"): - fragmented.send_many( - "schema", - b"fragment-me", - CultNetRudpSendOptions(reliable=True, ordered=True), - max_fragment_bytes=3, - ) - self.assertEqual(fragmented.pending_reliable_sequences, ()) - - def test_cultnet_rudp_session_suppresses_duplicates_and_delivers_reliable_ordered_payloads(self) -> None: - sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=123, initial_sequence=1)) - receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=123, initial_sequence=100)) - sender.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 123, 90, 0, 0, "control") - ) - receiver.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 123, 91, 0, 0, "control") - ) - - first = sender.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True)) - second = sender.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True)) - third = sender.send("schema", b"third", CultNetRudpSendOptions(reliable=True, ordered=True)) - - self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(first).delivered], ["first"]) - self.assertEqual(receiver.receive(third).delivered, ()) - self.assertEqual(receiver.receive(first).delivered, ()) - self.assertEqual( - [frame.payload.decode("utf-8") for frame in receiver.receive(second).delivered], - ["second", "third"], - ) - - def test_cultnet_rudp_session_skips_control_packets_while_ordering_schema_payloads(self) -> None: - sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=124, initial_sequence=1)) - receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=124, initial_sequence=100)) - sender.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 124, 90, 0, 0, "control") - ) - receiver.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 124, 91, 0, 0, "control") - ) - - first = sender.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True)) - control = sender.create_ack() - second = sender.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True)) - - self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(first).delivered], ["first"]) - self.assertEqual(receiver.receive(control).delivered, ()) - self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(second).delivered], ["second"]) - - def test_cultnet_rudp_session_fragments_and_reassembles_reliable_ordered_payloads(self) -> None: - sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=456, initial_sequence=1)) - receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=456, initial_sequence=100)) - sender.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 456, 90, 0, 0, "control") - ) - receiver.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 456, 91, 0, 0, "control") - ) - - packets = sender.send_many( - "schema", - b"fragment-me-please", - CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=10), - max_fragment_bytes=5, - ) - self.assertEqual(len(packets), 4) - self.assertEqual(tuple(packet.fragment_count for packet in packets), (4, 4, 4, 4)) - self.assertEqual(tuple(packet.fragment_index for packet in packets), (0, 1, 2, 3)) - self.assertTrue(all(packet.fragment_id == packets[0].fragment_id for packet in packets)) - - self.assertEqual(receiver.receive(packets[0]).delivered, ()) - self.assertEqual(receiver.receive(packets[1]).delivered, ()) - self.assertEqual(receiver.receive(packets[2]).delivered, ()) - delivered = receiver.receive(packets[3]).delivered - self.assertEqual(len(delivered), 1) - self.assertEqual(delivered[0].payload, b"fragment-me-please") - self.assertEqual(delivered[0].sequence, packets[0].sequence) - - def test_cultnet_rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames(self) -> None: - server_socket = bind_udp_socket() - client_socket = bind_udp_socket() - connection_id = 0x10203040 - server = CultNetRudpSocketTransportConnection( - CultNetRudpSocketTransportOptions( - runtime_id="python-rudp-server", - socket=server_socket, - mode=CultNetRudpSocketMode.SERVER, - connection_id=connection_id, - initial_sequence=100, - resend_delay_ms=25, - ) - ) - client = CultNetRudpSocketTransportConnection( - CultNetRudpSocketTransportOptions( - runtime_id="python-rudp-client", - socket=client_socket, - mode=CultNetRudpSocketMode.CLIENT, - remote_addr=server_socket.getsockname(), - connection_id=connection_id, - initial_sequence=1, - resend_delay_ms=25, - ) - ) - - try: - client.connect(b"join") - pump_rudp_handshake(client, server) - self.assertTrue(client.connected) - self.assertTrue(server.connected) - - client.send("schema", b"client-state") - server_frame = receive_rudp_frame(server) - self.assertEqual(server_frame.channel_id, "schema") - self.assertEqual(server_frame.payload, b"client-state") - - server.send("schema", b"server-state") - client_frame = receive_rudp_frame(client) - self.assertEqual(client_frame.channel_id, "schema") - self.assertEqual(client_frame.payload, b"server-state") - self.assertEqual(server.profile["transports"][0]["protocol"], "rudp") - self.assertEqual(client.stats.frames_sent, 1) - self.assertEqual(server.stats.frames_received, 1) - finally: - client.close() - server.close() - - def test_cultnet_rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames(self) -> None: - server_socket = bind_udp_socket() - client_socket = bind_udp_socket() - connection_id = 0x10203041 - server = CultNetRudpSocketTransportConnection( - CultNetRudpSocketTransportOptions( - runtime_id="python-rudp-fragment-server", - socket=server_socket, - mode=CultNetRudpSocketMode.SERVER, - connection_id=connection_id, - initial_sequence=100, - resend_delay_ms=25, - max_fragment_bytes=8, - ) - ) - client = CultNetRudpSocketTransportConnection( - CultNetRudpSocketTransportOptions( - runtime_id="python-rudp-fragment-client", - socket=client_socket, - mode=CultNetRudpSocketMode.CLIENT, - remote_addr=server_socket.getsockname(), - connection_id=connection_id, - initial_sequence=1, - resend_delay_ms=25, - max_fragment_bytes=8, - ) - ) - - try: - payload = b"this-schema-frame-is-larger-than-one-rudp-fragment" - client.connect(b"join") - pump_rudp_handshake(client, server) - client.send("schema", payload) - server_frame = receive_rudp_frame(server) - self.assertEqual(server_frame.channel_id, "schema") - self.assertEqual(server_frame.payload, payload) - self.assertEqual(client.stats.frames_sent, 1) - self.assertEqual(server.stats.frames_received, 1) - finally: - client.close() - server.close() - - def test_cultmesh_facade_creates_rudp_client_from_peer_endpoint(self) -> None: - connection_id = 0x10203042 - server = CultMesh.create_rudp_server( - "python-cultmesh-rudp-server", - connection_id, - initial_sequence=100, - resend_delay_ms=25, - max_fragment_bytes=1024, - max_pending_reliable_packets=16, - ) - server_host, server_port = server.socket.getsockname()[:2] - endpoint = CultMesh.parse_rudp_endpoint(f"rudp://{server_host}:{server_port}") - self.assertEqual(endpoint.host, server_host) - self.assertEqual(endpoint.port, server_port) - peer = CultMeshPeerCard( - peer_id="python-cultmesh-rudp-server", - verse_id="local", - endpoints=(endpoint.uri,), - roles=("schema",), - authority_lease_id="lease:python-cultmesh-rudp-server", - ) - peers = CultMesh.create_peer_catalog() - leases = CultMesh.create_authority_lease_catalog() - peers.upsert(peer) - with self.assertRaisesRegex(ValueError, "No authorized RUDP peer"): - CultMesh.create_rudp_client_for_authorized_peer( - "python-cultmesh-rudp-client", - connection_id, - peers, - leases, - "local", - "schema", - resend_delay_ms=25, - max_fragment_bytes=1024, - max_pending_reliable_packets=16, - ) - leases.upsert( - CultMeshAuthorityLease( - lease_id="lease:python-cultmesh-rudp-server", - verse_id="local", - peer_id="python-cultmesh-rudp-server", - roles=("schema",), - valid_from=datetime.now(UTC) - timedelta(seconds=1), - expires_at=datetime.now(UTC) + timedelta(seconds=30), - issuer_runtime_id="python-authority", - ) - ) - client = CultMesh.create_rudp_client_for_authorized_peer( - "python-cultmesh-rudp-client", - connection_id, - peers, - leases, - "local", - "schema", - resend_delay_ms=25, - max_fragment_bytes=1024, - max_pending_reliable_packets=16, - ) - - try: - client.connect(b"join") - pump_rudp_handshake(client, server) - self.assertTrue(client.connected) - self.assertTrue(server.connected) - - client.send("schema", b"client-state") - server_frame = receive_rudp_frame(server) - self.assertEqual(server_frame.channel_id, "schema") - self.assertEqual(server_frame.payload, b"client-state") - self.assertEqual(server.profile["transports"][0]["protocol"], "rudp") - self.assertEqual(client.profile["transports"][0]["protocol"], "rudp") - finally: - client.close() - server.close() - - def test_cultnet_database_subscription_helpers_match_schema_v0_shape(self) -> None: - subscribe = database_subscribe( - message_id="sub-message", - subscription_id="sub-1", - schema_ids=["schema-a"], - record_keys=["record-a"], - include_snapshot=False, - ).to_wire() - self.assertEqual(subscribe["schemaVersion"], "cultnet.database_subscribe.v0") - self.assertEqual(subscribe["messageId"], "sub-message") - self.assertEqual(subscribe["subscriptionId"], "sub-1") - self.assertEqual(subscribe["schemaIds"], ["schema-a"]) - self.assertEqual(subscribe["recordKeys"], ["record-a"]) - self.assertFalse(subscribe["includeSnapshot"]) - - unsubscribe = database_unsubscribe(message_id="unsub-message", subscription_id="sub-1").to_wire() - self.assertEqual(unsubscribe["schemaVersion"], "cultnet.database_unsubscribe.v0") - self.assertEqual(unsubscribe["messageId"], "unsub-message") - self.assertEqual(unsubscribe["subscriptionId"], "sub-1") - - def test_cultnet_database_change_parses_put_and_delete_shapes(self) -> None: - put_change = CultNetDatabaseChange.from_wire({ - "schemaVersion": "cultnet.database_change_raw.v0", - "messageId": "change-put", - "subscriptionId": "sub-1", - "changeKind": "added", - "document": { - "schemaId": "schema-note", - "recordKey": "note:1", - "payload": b"payload", - }, - }) - self.assertEqual(put_change.schema_id, "schema-note") - self.assertEqual(put_change.record_key, "note:1") - self.assertIsInstance(put_change.raw_document, CultNetRawDocumentRecord) - self.assertEqual(put_change.raw_document.payload, b"payload") - self.assertEqual(put_change.to_wire()["document"]["recordKey"], "note:1") - - delete_change = CultNetDatabaseChange.from_wire({ - "schemaVersion": "cultnet.database_change_raw.v0", - "messageId": "change-delete", - "subscriptionId": "sub-1", - "changeKind": "removed", - "schemaId": "schema-note", - "recordKey": "note:1", - }) - self.assertIsNone(delete_change.document) - self.assertIsNone(delete_change.raw_document) - self.assertEqual(delete_change.schema_id, "schema-note") - self.assertEqual(delete_change.to_wire()["recordKey"], "note:1") - - def test_cultnet_raw_put_helper_carries_message_id(self) -> None: - put = document_put_raw( - message_id="put-1", - key="record-a", - schema_id="schema-a", - stored_at="2026-06-13T00:00:00Z", - payload=b"payload", - source_runtime_id="python-test", - shard_id="interop", - shard_epoch=1, - ).to_wire() - self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0") - self.assertEqual(put["messageId"], "put-1") - self.assertEqual(put["document"]["recordKey"], "record-a") - self.assertEqual(put["document"]["sourceRuntimeId"], "python-test") - self.assertEqual(put["shardId"], "interop") - self.assertEqual(put["shardEpoch"], 1) - - def test_cultnet_catalog_and_snapshot_helpers_accept_filters(self) -> None: - catalog = schema_catalog_request( - message_id="catalog-1", - include_schema_json=True, - schema_ids=["schema-a"], - kinds=["wire_message"], - ).to_wire() - self.assertEqual(catalog["schemaVersion"], "cultnet.schema_catalog_request.v0") - self.assertEqual(catalog["messageId"], "catalog-1") - self.assertTrue(catalog["includeSchemaJson"]) - self.assertEqual(catalog["schemaIds"], ["schema-a"]) - self.assertEqual(catalog["kinds"], ["wire_message"]) - - snapshot = snapshot_request( - message_id="snapshot-1", - schema_ids=["schema-a"], - record_keys=["record-a"], - shard_id="interop", - shard_epoch=1, - ).to_wire() - self.assertEqual(snapshot["schemaVersion"], "cultnet.snapshot_request.v0") - self.assertEqual(snapshot["messageId"], "snapshot-1") - self.assertEqual(snapshot["schemaIds"], ["schema-a"]) - self.assertEqual(snapshot["recordKeys"], ["record-a"]) - self.assertEqual(snapshot["shardId"], "interop") - self.assertEqual(snapshot["shardEpoch"], 1) - - def test_cultnet_raw_snapshot_response_filters_and_roundtrips_records(self) -> None: - response = CultNetRawSnapshotResponse.from_wire({ - "schemaVersion": "cultnet.snapshot_response_raw.v0", - "messageId": "snapshot-1", - "shardId": "notes", - "shardEpoch": 3, - "shardLogSequence": 8, - "documents": [ - CultNetRawDocumentRecord( - schema_id="schema-note", - record_key="note:1", - stored_at="2026-06-14T00:00:00Z", - payload=b"note", - source_runtime_id="python-test", - tags=("snapshot",), - ).to_wire(), - { - "schemaId": "schema-fact", - "recordKey": "fact:1", - "payloadEncoding": "messagepack", - "payload": b"fact", - }, - ], - }) - self.assertEqual(response.shard_id, "notes") - self.assertEqual(response.shard_epoch, 3) - self.assertEqual(response.shard_log_sequence, 8) - self.assertEqual(response.documents[0].source_runtime_id, "python-test") - self.assertEqual(response.filter(schema_ids=["schema-note"])[0].record_key, "note:1") - self.assertEqual(response.filter(record_keys=["fact:1"])[0].schema_id, "schema-fact") - self.assertEqual(response.to_wire()["documents"][0]["tags"], ["snapshot"]) - - def test_cultnet_raw_client_fetches_schema_snapshot_and_shard_reads(self) -> None: - import msgpack # type: ignore - from cultnet_py import read_frame, write_frame - - received_versions: list[str] = [] - ready = threading.Event() - server_error: list[BaseException] = [] - port_holder: list[int] = [] - - def serve_requests() -> None: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", 0)) - port_holder.append(server.getsockname()[1]) - server.listen(4) - ready.set() - for _ in range(4): - connection, _ = server.accept() - with connection: - stream = connection.makefile("rwb") - request = msgpack.unpackb(read_frame(stream), raw=False) - received_versions.append(request["schemaVersion"]) - if request["schemaVersion"] == "cultnet.schema_catalog_request.v0": - self.assertEqual(request["kinds"], ["wire_message"]) - response = {"schemaVersion": "cultnet.schema_catalog_response.v0", "messageId": request["messageId"], "schemas": []} - elif request["schemaVersion"] == "cultnet.snapshot_request.v0": - self.assertEqual(request["shardId"], "interop") - response = {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": request["messageId"], "documents": []} - elif request["schemaVersion"] == "cultnet.shard_catalog_request.v0": - response = { - "schemaVersion": "cultnet.shard_catalog_response.v0", - "messageId": request["messageId"], - "shards": [{ - "shardId": "interop", - "ownerRuntimeId": "socket-test", - "epoch": 1, - "schemaIds": request["schemaIds"], - "keyPrefix": "record-", - "primaryEndpoints": ["cultnet://127.0.0.1:1"], - }], - } - elif request["schemaVersion"] == "cultnet.shard_log_request.v0": - self.assertEqual(request["afterSequence"], 7) - response = { - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": request["messageId"], - "shardId": request["shardId"], - "shardEpoch": request["shardEpoch"], - "entries": [], - "resyncRequired": False, - } - else: - raise AssertionError(f"unexpected request {request['schemaVersion']}") - write_frame(stream, msgpack.packb(response, use_bin_type=True)) - stream.flush() - except BaseException as error: - server_error.append(error) - ready.set() - - thread = threading.Thread(target=serve_requests, daemon=True) - thread.start() - self.assertTrue(ready.wait(2.0)) - self.assertFalse(server_error) - - client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) - self.assertEqual(client.fetch_schema_catalog(kinds=["wire_message"])["schemaVersion"], "cultnet.schema_catalog_response.v0") - typed_snapshot = client.fetch_snapshot_response(shard_id="interop", shard_epoch=1) - self.assertEqual(typed_snapshot.shard_id, None) - self.assertEqual(typed_snapshot.documents, ()) - shard_catalog = CultNetShardCatalog() - synced_shards = client.sync_shard_catalog(shard_catalog, schema_ids=["schema-a"]) - self.assertEqual(synced_shards[0].shard_id, "interop") - self.assertEqual(shard_catalog.get("interop"), synced_shards[0]) - shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=7) - self.assertEqual(shard_log.shard_id, "interop") - self.assertFalse(shard_log.resync_required) - self.assertEqual(shard_log.last_sequence, 0) - - thread.join(2.0) - self.assertFalse(server_error) - self.assertEqual(received_versions, [ - "cultnet.schema_catalog_request.v0", - "cultnet.snapshot_request.v0", - "cultnet.shard_catalog_request.v0", - "cultnet.shard_log_request.v0", - ]) - - def test_cultnet_raw_client_raises_typed_peer_error_response(self) -> None: - import msgpack # type: ignore - from cultnet_py import read_frame, write_frame - - ready = threading.Event() - port_holder: list[int] = [] - - def serve_error() -> None: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", 0)) - port_holder.append(server.getsockname()[1]) - server.listen(1) - ready.set() - connection, _ = server.accept() - with connection: - stream = connection.makefile("rwb") - read_frame(stream) - write_frame(stream, msgpack.packb({ - "schemaVersion": "cultnet.error.v0", - "messageId": "error-1", - "error": "Snapshot document limit exceeded", - }, use_bin_type=True)) - stream.flush() - - thread = threading.Thread(target=serve_error, daemon=True) - thread.start() - self.assertTrue(ready.wait(2.0)) - - client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) - with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded") as raised: - client.fetch_snapshot() - - self.assertEqual(raised.exception.peer_error, "Snapshot document limit exceeded") - self.assertEqual(raised.exception.response["schemaVersion"], "cultnet.error.v0") - thread.join(2.0) - - def test_cultnet_database_subscription_reads_snapshot_and_change(self) -> None: - import msgpack # type: ignore - from cultnet_py import read_frame, write_frame - - document = define_database_entry_type( - "sub.item", - [ - ("name", 0), - ("category", 1), - ("value", 2, 0), - ], - cls=Item, - ) - schema_id = document.catalog_entry().schema_id - ready = threading.Event() - server_error: list[BaseException] = [] - port_holder: list[int] = [] - received_versions: list[str] = [] - - def serve_subscription() -> None: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", 0)) - port_holder.append(server.getsockname()[1]) - server.listen(1) - ready.set() - connection, _ = server.accept() - with connection: - stream = connection.makefile("rwb") - subscribe = msgpack.unpackb(read_frame(stream), raw=False) - received_versions.append(subscribe["schemaVersion"]) - self.assertEqual(subscribe["subscriptionId"], "sub-1") - snapshot = { - "schemaVersion": "cultnet.snapshot_response_raw.v0", - "messageId": subscribe["messageId"], - "documents": [], - } - write_frame(stream, msgpack.packb(snapshot, use_bin_type=True)) - stream.flush() - - put = msgpack.unpackb(read_frame(stream), raw=False) - received_versions.append(put["schemaVersion"]) - change = { - "schemaVersion": "cultnet.database_change_raw.v0", - "messageId": "change-1", - "subscriptionId": "sub-1", - "changeKind": "added", - "document": put["document"], - } - write_frame(stream, msgpack.packb(change, use_bin_type=True)) - stream.flush() - - unsubscribe = msgpack.unpackb(read_frame(stream), raw=False) - received_versions.append(unsubscribe["schemaVersion"]) - except BaseException as error: - server_error.append(error) - ready.set() - - thread = threading.Thread(target=serve_subscription, daemon=True) - thread.start() - self.assertTrue(ready.wait(2.0)) - self.assertFalse(server_error) - - client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) - put = document_put_raw( - message_id="put-sub", - key="item:sub", - schema_id=schema_id, - stored_at="2026-06-14T00:00:00Z", - payload=document.encode_payload(Item("orb", "gear", 8)), - ) - with client.subscribe_database(subscription_id="sub-1", schema_ids=[schema_id]) as subscription: - snapshot = subscription.read_next_snapshot_response() - subscription.send(put) - change = subscription.read_next_change() - - thread.join(2.0) - self.assertFalse(server_error) - self.assertIsInstance(snapshot, CultNetRawSnapshotResponse) - self.assertEqual(snapshot.message_id, "cultnet-python-subscribe") - self.assertEqual(snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0") - self.assertEqual(change.change_kind, "added") - self.assertEqual(change.record_key, "item:sub") - self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord) - self.assertEqual(change.raw_document.record_key, "item:sub") - self.assertEqual(received_versions, [ - "cultnet.database_subscribe.v0", - "cultnet.document_put_raw.v0", - "cultnet.database_unsubscribe.v0", - ]) - - def test_cultnet_database_subscription_raises_typed_peer_error(self) -> None: - import msgpack # type: ignore - from cultnet_py import read_frame, write_frame - - ready = threading.Event() - port_holder: list[int] = [] - - def serve_subscription_error() -> None: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", 0)) - port_holder.append(server.getsockname()[1]) - server.listen(1) - ready.set() - connection, _ = server.accept() - with connection: - stream = connection.makefile("rwb") - read_frame(stream) - write_frame(stream, msgpack.packb({ - "schemaVersion": "cultnet.error.v0", - "messageId": "subscription-error", - "error": "subscription rejected", - }, use_bin_type=True)) - stream.flush() - - thread = threading.Thread(target=serve_subscription_error, daemon=True) - thread.start() - self.assertTrue(ready.wait(2.0)) - - client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) - with client.subscribe_database(subscription_id="sub-error") as subscription: - with self.assertRaisesRegex(CultNetPeerError, "subscription rejected") as raised: - subscription.read_next() - self.assertEqual(raised.exception.response["messageId"], "subscription-error") - thread.join(2.0) - - def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None: - document = define_database_entry_type( - "replica.item", - [ - ("name", 0), - ("category", 1), - ("value", 2, 0), - ], - cls=Item, - ) - cache = CultCache() - cache.register_document_type(document) - schema_id = document.catalog_entry().schema_id - - snapshot = { - "schemaVersion": "cultnet.snapshot_response_raw.v0", - "messageId": "snapshot-1", - "documents": [ - { - "schemaId": schema_id, - "recordKey": "item:1", - "storedAt": "2026-06-13T00:00:00Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload(Item("sword", "gear", 3)), - } - ], - } - applied_snapshot = apply_raw_snapshot(cache, [document], snapshot) - self.assertEqual(applied_snapshot[0].record_key, "item:1") - self.assertEqual(cache.get_required(document, "item:1").value, 3) - typed_snapshot = CultNetRawSnapshotResponse.from_wire(snapshot) - direct_applied = apply_raw_document_record(cache, schema_document_map([document]), typed_snapshot.documents[0]) - self.assertEqual(direct_applied.schema_id, schema_id) - self.assertEqual(direct_applied.record_key, "item:1") - typed_snapshot_applied = apply_raw_snapshot(cache, [document], typed_snapshot) - self.assertEqual(typed_snapshot_applied[0].record_key, "item:1") - - shard_log = { - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "log-1", - "shardId": "interop", - "shardEpoch": 1, - "resyncRequired": False, - "entries": [ - { - "sequence": 0, - "changeKind": "updated", - "put": { - "schemaVersion": "cultnet.document_put_raw.v0", - "messageId": "put-unknown", - "document": { - "schemaId": "replica.unknown.v1", - "recordKey": "unknown:1", - "storedAt": "2026-06-13T00:00:01Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload(Item("ignored", "unknown", 0)), - }, - "shardId": "interop", - "shardEpoch": 1, - }, - }, - { - "sequence": 1, - "changeKind": "updated", - "put": { - "schemaVersion": "cultnet.document_put_raw.v0", - "messageId": "put-1", - "document": { - "schemaId": schema_id, - "recordKey": "item:1", - "storedAt": "2026-06-13T00:00:01Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload(Item("sword", "gear", 12)), - }, - "shardId": "interop", - "shardEpoch": 1, - }, - }, - { - "sequence": 2, - "changeKind": "removed", - "delete": { - "schemaVersion": "cultnet.document_delete.v0", - "messageId": "delete-1", - "schemaId": schema_id, - "recordKey": "item:1", - "shardId": "interop", - "shardEpoch": 1, - }, - }, - ], - } - applied_log = apply_shard_log_response(cache, [document], shard_log) - self.assertEqual([change.change_kind for change in applied_log], ["updated", "removed"]) - self.assertIsNone(cache.get(document, "item:1")) - - cache.put(document, "item:1", Item("sword", "gear", 3)) - typed_shard_log = CultNetShardLogResponse.from_wire({ - **shard_log, - "entries": shard_log["entries"][1:], - }) - typed_applied_log = apply_shard_log_response(cache, [document], typed_shard_log) - self.assertEqual([change.change_kind for change in typed_applied_log], ["updated", "removed"]) - self.assertIsNone(cache.get(document, "item:1")) - - def test_cultmesh_node_syncs_snapshot_and_shard_log_through_cultnet_client(self) -> None: - import msgpack # type: ignore - from cultnet_py import read_frame, write_frame - - document = define_database_entry_type( - "mesh.sync_item", - [ - ("name", 0), - ("category", 1), - ("value", 2, 0), - ], - cls=Item, - ) - node = create_node(runtime_id="python-node") - node.register_document(document) - schema_id = document.catalog_entry().schema_id - ready = threading.Event() - server_error: list[BaseException] = [] - port_holder: list[int] = [] - - def serve_requests() -> None: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", 0)) - port_holder.append(server.getsockname()[1]) - server.listen(2) - ready.set() - for _ in range(2): - connection, _ = server.accept() - with connection: - stream = connection.makefile("rwb") - request = msgpack.unpackb(read_frame(stream), raw=False) - if request["schemaVersion"] == "cultnet.snapshot_request.v0": - response = { - "schemaVersion": "cultnet.snapshot_response_raw.v0", - "messageId": request["messageId"], - "documents": [ - { - "schemaId": schema_id, - "recordKey": "item:node", - "storedAt": "2026-06-13T00:00:00Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload(Item("node", "mesh", 1)), - } - ], - } - elif request["schemaVersion"] == "cultnet.shard_log_request.v0": - response = { - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": request["messageId"], - "shardId": request["shardId"], - "shardEpoch": request["shardEpoch"], - "resyncRequired": False, - "entries": [ - { - "sequence": 1, - "changeKind": "updated", - "put": { - "schemaVersion": "cultnet.document_put_raw.v0", - "messageId": "put-node", - "document": { - "schemaId": schema_id, - "recordKey": "item:node", - "storedAt": "2026-06-13T00:00:01Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload(Item("node", "mesh", 9)), - }, - "shardId": request["shardId"], - "shardEpoch": request["shardEpoch"], - }, - } - ], - } - else: - raise AssertionError(f"unexpected request {request['schemaVersion']}") - write_frame(stream, msgpack.packb(response, use_bin_type=True)) - stream.flush() - except BaseException as error: - server_error.append(error) - ready.set() - - thread = threading.Thread(target=serve_requests, daemon=True) - thread.start() - self.assertTrue(ready.wait(2.0)) - self.assertFalse(server_error) - - client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) - snapshot_changes = node.sync_snapshot(client, schema_ids=[schema_id]) - log_changes = node.sync_shard_log(client, shard_id="mesh", shard_epoch=1) - - thread.join(2.0) - self.assertFalse(server_error) - self.assertEqual(snapshot_changes[0].record_key, "item:node") - self.assertEqual(log_changes[0].change_kind, "updated") - self.assertEqual(node.get_required(document, "item:node").value, 9) - - def test_cultmesh_node_emits_raw_put_and_delete_messages_for_local_writes(self) -> None: - document = define_database_entry_type( - "mesh.emit_item", - [ - ("name", 0), - ("category", 1), - ("value", 2, 0), - ], - cls=Item, - ) - node = create_node(runtime_id="python-node") - node.register_document(document) - - put = node.put_raw_message( - document, - "item:emit", - Item("wand", "gear", 4), - message_id="put-emit", - shard_id="mesh", - shard_epoch=1, - ).to_wire() - self.assertEqual(node.get_required(document, "item:emit").name, "wand") - self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0") - self.assertEqual(put["messageId"], "put-emit") - self.assertEqual(put["document"]["schemaId"], document.catalog_entry().schema_id) - self.assertEqual(put["document"]["recordKey"], "item:emit") - self.assertEqual(put["document"]["sourceRuntimeId"], "python-node") - self.assertEqual(put["shardId"], "mesh") - self.assertEqual(put["shardEpoch"], 1) - - delete = node.delete_raw_message( - document, - "item:emit", - message_id="delete-emit", - shard_id="mesh", - shard_epoch=1, - ).to_wire() - self.assertIsNone(node.get(document, "item:emit")) - self.assertEqual(delete["schemaVersion"], "cultnet.document_delete.v0") - self.assertEqual(delete["messageId"], "delete-emit") - self.assertEqual(delete["schemaId"], document.catalog_entry().schema_id) - self.assertEqual(delete["recordKey"], "item:emit") - self.assertEqual(delete["shardId"], "mesh") - self.assertEqual(delete["shardEpoch"], 1) - - def test_cultnet_document_delete_helper_matches_schema_v0_shape(self) -> None: - message = document_delete( - message_id="delete-1", - schema_id="schema-a", - record_key="record-a", - shard_id="interop", - shard_epoch=1, - ).to_wire() - self.assertEqual(message["schemaVersion"], "cultnet.document_delete.v0") - self.assertEqual(message["messageId"], "delete-1") - self.assertEqual(message["schemaId"], "schema-a") - self.assertEqual(message["recordKey"], "record-a") - self.assertEqual(message["shardId"], "interop") - self.assertEqual(message["shardEpoch"], 1) - - def test_cultnet_auth_session_helpers_match_schema_v0_shape(self) -> None: - self.assertEqual( - login(nonce="nonce", auth="encrypted-auth", password="encrypted-password").to_wire(), - { - "schemaVersion": "cultnet.login.v0", - "nonce": "nonce", - "auth": "encrypted-auth", - "password": "encrypted-password", - }, - ) - self.assertEqual( - register( - nonce="nonce", - email="encrypted-email", - password="encrypted-password", - name="encrypted-name", - ).to_wire(), - { - "schemaVersion": "cultnet.register.v0", - "nonce": "nonce", - "email": "encrypted-email", - "password": "encrypted-password", - "name": "encrypted-name", - }, - ) - self.assertEqual( - verify_session(nonce="nonce", session="encrypted-session").to_wire(), - { - "schemaVersion": "cultnet.verify.v0", - "nonce": "nonce", - "session": "encrypted-session", - }, - ) - self.assertEqual( - login_success(nonce="nonce", session="encrypted-session").to_wire(), - { - "schemaVersion": "cultnet.login_success.v0", - "nonce": "nonce", - "session": "encrypted-session", - }, - ) - - def test_cultnet_wire_catalog_describes_python_handled_messages(self) -> None: - descriptors = wire_message_schema_descriptors(include_schema_json=True) - by_version = {descriptor["schemaVersion"]: descriptor for descriptor in descriptors} - for schema_version in [ - "cultnet.error.v0", - "cultnet.login.v0", - "cultnet.register.v0", - "cultnet.verify.v0", - "cultnet.login_success.v0", - "cultnet.transport_profile.v0", - "cultnet.document_delete.v0", - "cultnet.database_change_raw.v0", - "cultnet.shard_log_response.v0", - "cultnet.simulation_consensus_candidate.v0", - "cultmesh.peer_exchange_response.v0", - ]: - self.assertIn(schema_version, by_version) - expected_kind = "shared_contract" if schema_version == "cultnet.transport_profile.v0" else "wire_message" - self.assertEqual(by_version[schema_version]["kind"], expected_kind) - self.assertIn("cultnet.schema.v0", by_version[schema_version]["wireContracts"]) - self.assertIn(schema_version, by_version[schema_version]["schemaJson"]) - self.assertEqual(len(by_version[schema_version]["contentHash"]), 64) - - delete_schema = json.loads(by_version["cultnet.document_delete.v0"]["schemaJson"]) - self.assertEqual( - delete_schema["required"], - ["schemaVersion", "messageId", "schemaId", "recordKey"], - ) - self.assertEqual(delete_schema["properties"]["recordKey"]["type"], "string") - - error_schema = json.loads(by_version["cultnet.error.v0"]["schemaJson"]) - self.assertEqual(error_schema["required"], ["schemaVersion", "error"]) - self.assertEqual(error_schema["properties"]["error"]["type"], "string") - self.assertIn("details", error_schema["properties"]) - - login_schema = json.loads(by_version["cultnet.login.v0"]["schemaJson"]) - self.assertEqual(login_schema["required"], ["schemaVersion", "nonce", "auth", "password"]) - self.assertEqual(login_schema["properties"]["auth"]["type"], "string") - - register_schema = json.loads(by_version["cultnet.register.v0"]["schemaJson"]) - self.assertEqual(register_schema["required"], ["schemaVersion", "nonce", "email", "password", "name"]) - self.assertEqual(register_schema["properties"]["email"]["type"], "string") - - verify_schema = json.loads(by_version["cultnet.verify.v0"]["schemaJson"]) - self.assertEqual(verify_schema["required"], ["schemaVersion", "nonce", "session"]) - self.assertEqual(verify_schema["properties"]["session"]["type"], "string") - - success_schema = json.loads(by_version["cultnet.login_success.v0"]["schemaJson"]) - self.assertEqual(success_schema["required"], ["schemaVersion", "nonce", "session"]) - - transport_profile_schema = json.loads(by_version["cultnet.transport_profile.v0"]["schemaJson"]) - self.assertEqual(transport_profile_schema["required"], ["schemaVersion", "runtimeId", "transports"]) - transport = transport_profile_schema["properties"]["transports"]["items"] - self.assertEqual(transport["properties"]["protocol"]["enum"], ["tcp_framed", "litenetlib", "websocket", "rudp"]) - self.assertEqual( - transport["properties"]["reconnectPolicy"]["properties"]["schemaVersion"]["const"], - "cultnet.reconnect_policy.v0", - ) - channel = transport["properties"]["channels"]["items"] - self.assertEqual(channel["properties"]["ordering"]["enum"], ["ordered", "unordered", "sequenced"]) - - change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"]) - self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"]) - self.assertIn("document", change_schema["properties"]) - self.assertIn("schemaId", change_schema["properties"]) - self.assertIn("recordKey", change_schema["properties"]) - - log_schema = json.loads(by_version["cultnet.shard_log_response.v0"]["schemaJson"]) - self.assertIn("entries", log_schema["required"]) - self.assertIn("resyncRequired", log_schema["required"]) - self.assertIn("compactedThrough", log_schema["properties"]) - - hello_schema = json.loads(by_version["cultnet.hello.v0"]["schemaJson"]) - mutation_contract = hello_schema["properties"]["supportedMutationContracts"]["items"] - self.assertEqual(mutation_contract["type"], "object") - self.assertIn("operations", mutation_contract["properties"]) - self.assertEqual( - hello_schema["properties"]["transportProfiles"]["items"]["$ref"], - "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json", - ) - - consensus_schema = json.loads(by_version["cultnet.simulation_consensus_candidate.v0"]["schemaJson"]) - for required_field in ["witnessCount", "supportWeight", "totalWeight", "hasQuorum", "confidence"]: - self.assertIn(required_field, consensus_schema["required"]) - - def test_cultnet_schema_catalog_applies_filters_and_responses(self) -> None: - catalog = wire_message_schema_catalog(include_schema_json=True) - descriptor = catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json") - self.assertIsNotNone(descriptor) - assert descriptor is not None - self.assertEqual(descriptor.kind, "wire_message") - self.assertEqual(descriptor.schema_version, "cultnet.document_put_raw.v0") - self.assertIsNotNone(descriptor.schema_json) - - filtered = catalog.list(kinds=["wire_message"], schema_ids=[descriptor.schema_id]) - self.assertEqual(filtered, [descriptor]) - - response = catalog.create_response( - message_id="catalog-response", - include_schema_json=False, - schema_ids=[descriptor.schema_id], - kinds=["wire_message"], - ) - self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0") - self.assertNotIn("schemaJson", response["schemas"][0]) - - remote = CultNetSchemaCatalog() - applied = remote.apply_response({ - "schemaVersion": "cultnet.schema_catalog_response.v0", - "messageId": "remote", - "schemas": [ - CultNetSchemaDescriptor( - schema_id="schema:custom", - kind="shared_contract", - schema_version="custom.v0", - wire_contracts=("cultnet.schema.v0",), - content_hash="hash", - schema_json="{}", - ).to_wire() - ], - }) - self.assertEqual(applied[0].schema_id, "schema:custom") - self.assertEqual(remote.get("schema:custom"), applied[0]) - - def test_cultnet_shard_helpers_match_schema_v0_shape(self) -> None: - catalog = shard_catalog_request( - message_id="catalog-1", - schema_ids=["schema-a"], - record_keys=["record-a"], - ).to_wire() - self.assertEqual(catalog["schemaVersion"], "cultnet.shard_catalog_request.v0") - self.assertEqual(catalog["messageId"], "catalog-1") - self.assertEqual(catalog["schemaIds"], ["schema-a"]) - self.assertEqual(catalog["recordKeys"], ["record-a"]) - - log = shard_log_request( - message_id="log-1", - shard_id="interop", - shard_epoch=7, - after_sequence=3, - limit=2, - ).to_wire() - self.assertEqual(log["schemaVersion"], "cultnet.shard_log_request.v0") - self.assertEqual(log["messageId"], "log-1") - self.assertEqual(log["shardId"], "interop") - self.assertEqual(log["shardEpoch"], 7) - self.assertEqual(log["afterSequence"], 3) - self.assertEqual(log["limit"], 2) - - def test_cultnet_shard_catalog_applies_filters_and_responses(self) -> None: - catalog = CultNetShardCatalog() - primary = catalog.upsert(CultNetShardDescriptor( - shard_id="notes", - owner_runtime_id="python-runtime", - epoch=2, - is_primary=True, - schema_ids=("schema-note",), - key_prefix="note:", - primary_endpoints=("cultnet://127.0.0.1:3075",), - read_replica_endpoints=("cultnet://127.0.0.1:3075",), - region="local", - )) - catalog.upsert({ - "shardId": "facts", - "ownerRuntimeId": "python-runtime", - "epoch": 1, - "schemaIds": ["schema-fact"], - "keyPrefix": "fact:", - "primaryEndpoints": ["cultnet://127.0.0.1:3076"], - }) - - self.assertTrue(primary.serves(schema_id="schema-note", record_key="note:1")) - self.assertFalse(primary.serves(schema_id="schema-note", record_key="fact:1")) - self.assertEqual(catalog.list(schema_ids=["schema-note"]), [primary]) - self.assertEqual(catalog.list(record_keys=["note:1"]), [primary]) - - response = catalog.create_response( - message_id="shards", - schema_ids=["schema-note"], - record_keys=["note:1"], - ) - self.assertEqual(response["schemaVersion"], "cultnet.shard_catalog_response.v0") - self.assertEqual(response["shards"][0]["shardId"], "notes") - self.assertEqual(response["shards"][0]["ownerRuntimeId"], "python-runtime") - - remote = CultNetShardCatalog() - applied = remote.apply_response(response) - self.assertEqual(applied[0].shard_id, "notes") - self.assertEqual(remote.get("notes"), applied[0]) - - def test_cultnet_shard_log_response_tracks_cursor_and_resync_state(self) -> None: - response = CultNetShardLogResponse.from_wire({ - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "log", - "shardId": "notes", - "shardEpoch": 2, - "entries": [ - CultNetShardLogEntry( - sequence=2, - change_kind="updated", - put={"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "put", "document": {"schemaId": "schema-note", "recordKey": "note:1", "payload": b"p"}}, - committed_at="2026-06-14T00:00:00Z", - ).to_wire(), - {"sequence": 3, "changeKind": "removed", "delete": {"schemaId": "schema-note", "recordKey": "note:1"}}, - ], - "resyncRequired": False, - }) - self.assertEqual(response.last_sequence, 3) - self.assertFalse(response.resync_required) - self.assertEqual(response.entries[0].put["document"]["recordKey"], "note:1") - self.assertIsInstance(response.entries[0].raw_document, CultNetRawDocumentRecord) - self.assertEqual(response.entries[0].raw_document.record_key, "note:1") - self.assertEqual(response.entries[1].delete_schema_id, "schema-note") - self.assertEqual(response.entries[1].delete_record_key, "note:1") - self.assertEqual(response.to_wire()["entries"][1]["delete"]["recordKey"], "note:1") - self.assertIs(response.require_usable(), response) - - resync = CultNetShardLogResponse.from_wire({ - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "log-resync", - "shardId": "notes", - "shardEpoch": 2, - "entries": [], - "resyncRequired": True, - "reason": "compacted", - "compactedThrough": 12, - }) - self.assertEqual(resync.last_sequence, 12) - with self.assertRaisesRegex(ValueError, "compacted"): - resync.require_usable() - - def test_cultnet_simulation_observation_helper_matches_schema_v0_shape(self) -> None: - claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit") - self.assertEqual(len(claim_hash), 64) - message = simulation_observation( - message_id="obs-1", - witness_runtime_id="python-test", - shard_id="interop", - shard_epoch=1, - frame=42, - subject_id="player-1", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="player-1 hit target-a", - observed_at="2026-06-13T00:00:02Z", - ).to_wire() - self.assertEqual(message["schemaVersion"], "cultnet.simulation_observation.v0") - self.assertEqual(message["messageId"], "obs-1") - self.assertEqual(message["observation"]["witnessRuntimeId"], "python-test") - self.assertEqual(message["observation"]["claimHash"], claim_hash) - self.assertEqual(message["observation"]["weight"], 1.0) - observation = CultNetSimulationObservation.from_wire(message) - self.assertEqual(observation.witness_runtime_id, "python-test") - self.assertEqual(observation.claim_summary, "player-1 hit target-a") - self.assertEqual(observation.to_message_wire(message_id="obs-2")["observation"], message["observation"]) - - def test_cultnet_witness_artifact_bundle_uses_csharp_slot_order(self) -> None: - import msgpack # type: ignore - - bundle = witness_artifact_bundle( - bundle_id="bundle-1", - witness_kind="interop-proof", - captured_at="2026-06-13T00:00:03Z", - subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"}, - contracts=[{"role": "payload", "schemaId": "schema-a"}], - artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}], - timing_witnesses=[{"stage": "roundtrip", "startedAt": "2026-06-13T00:00:03Z", "completedAt": "2026-06-13T00:00:04Z", "latencyMs": 1.0}], - provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-test"}, - ) - payload = encode_witness_artifact_bundle_payload(bundle) - decoded = decode_witness_artifact_bundle_payload(payload) - typed = CultNetWitnessArtifactBundle.from_wire(bundle) - typed_payload = typed.to_payload() - typed_decoded = CultNetWitnessArtifactBundle.from_payload(typed_payload) - slots = msgpack.unpackb(payload, raw=False) - - self.assertEqual(payload, typed_payload) - self.assertEqual(slots[0], "bundle-1") - self.assertEqual(slots[1], "interop-proof") - self.assertEqual(slots[2], "2026-06-13T00:00:03Z") - self.assertEqual(slots[3]["subjectId"], "note:python") - self.assertEqual(slots[4][0]["schemaId"], "schema-a") - self.assertEqual(slots[5][0]["uri"], "cultcache://bundle-1/log") - self.assertEqual(slots[6][0]["latencyMs"], 1.0) - self.assertEqual(slots[7]["runtimeId"], "python-test") - self.assertEqual(decoded["bundleId"], "bundle-1") - self.assertEqual(decoded["witnessKind"], "interop-proof") - self.assertEqual(decoded["subject"]["subjectId"], "note:python") - self.assertEqual(decoded["contracts"][0]["schemaId"], "schema-a") - self.assertEqual(decoded["artifacts"][0]["mediaType"], "text/plain") - self.assertEqual(decoded["provenance"]["runtimeId"], "python-test") - self.assertEqual(typed.bundle_id, "bundle-1") - self.assertEqual(typed_decoded.to_wire(), bundle) - - def test_cultmesh_node_uses_cultcache_store(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - document = define_database_entry_type("mesh.note", [("body", 0)]) - store_path = Path(tmp) / "mesh.cc" - node = create_node(store_path, runtime_id="mesh-test") - self.assertIsInstance(node.database, CultMeshDatabase) - node.database.register_document(document) - node.database.put(document, "note:1", {"body": "hello"}) - self.assertEqual(node.get_required(document, "note:1")["body"], "hello") - - reopened = create_node(store_path, runtime_id="mesh-test") - reopened.database.register_document(document) - reopened.database.pull() - self.assertEqual(reopened.database.get(document, "note:1")["body"], "hello") - - def test_cultmesh_node_options_attach_default_durable_shard_log_store(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - document = define_database_entry_type( - "mesh.durable_option_note", - [("body", 0)], - schema_id="mesh.durable_option_note.v1", - ) - cache_path = Path(tmp) / "world.cc" - node = CultMesh.create_node( - cache_path, - runtime_id="durable-option-primary", - options=CultMeshNodeOptions(enable_durable_shard_logs=True), - ) - node.database.register_document(document) - node.database.put_raw_message(document, "note:1", {"body": "stored"}, shard_id="notes", shard_epoch=5) - - log_path = Path(tmp) / "world.cultmesh" / "shard-logs" - self.assertTrue(log_path.exists()) - - reopened = CultMesh.start_node( - cache_path, - runtime_id="durable-option-primary", - enable_durable_shard_logs=True, - ) - reopened.database.register_document(document) - response = reopened.database.build_shard_log_response(shard_id="notes", shard_epoch=5) - - self.assertEqual(reopened.database.shard_ids(), ["notes"]) - self.assertEqual([entry.sequence for entry in response.entries], [1]) - self.assertEqual(response.entries[0].raw_document.record_key, "note:1") - - def test_cultmesh_database_watchers_observe_local_changes_and_unsubscribe(self) -> None: - document = define_database_entry_type("mesh.watch", [("body", 0)]) - node = CultMesh.create_node(runtime_id="mesh-watch") - node.database.register_document(document) - changes: list[CultMeshDatabaseChange] = [] - all_changes: list[CultMeshDatabaseChange] = [] - - unsubscribe_record = node.database.watch_record(document, "note:1", changes.append) - unsubscribe_all = node.database.watch(all_changes.append, document=document) - - node.database.put(document, "note:1", {"body": "hello"}) - node.database.put(document, "note:2", {"body": "skip"}) - node.database.put(document, "note:1", {"body": "updated"}) - node.database.delete(document, "note:1") - unsubscribe_record() - node.database.put(document, "note:1", {"body": "after-unsubscribe"}) - unsubscribe_all() - - self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"]) - self.assertEqual([change.record_key for change in changes], ["note:1", "note:1", "note:1"]) - self.assertEqual(changes[1].previous_value, {"body": "hello"}) - self.assertIsNone(changes[2].value) - self.assertEqual([change.record_key for change in all_changes], ["note:1", "note:2", "note:1", "note:1", "note:1"]) - - def test_cultmesh_database_register_document_is_idempotent(self) -> None: - document = define_database_entry_type("mesh.idempotent", [("body", 0)]) - conflicting = define_database_entry_type("mesh.idempotent", [("title", 0)]) - node = CultMesh.create_node(runtime_id="mesh-idempotent") - - node.database.register_document(document) - node.database.register_document(document) - node.database.put(document, "note:1", {"body": "still registered once"}) - - self.assertEqual(node.database.get_required(document, "note:1")["body"], "still registered once") - self.assertEqual([registered.type for registered in node.documents], ["mesh.idempotent"]) - with self.assertRaises(ValueError): - node.database.register_document(conflicting) - - def test_cultmesh_database_global_document_facade_uses_singleton_key_and_watchers(self) -> None: - document = define_database_entry_type( - "mesh.global_settings", - [("theme", 0)], - global_document=True, - ) - node = CultMesh.create_node(runtime_id="mesh-global") - node.database.register_document(document) - changes: list[CultMeshDatabaseChange] = [] - - node.database.watch_global(document, changes.append) - node.database.put_global(document, {"theme": "ash"}) - node.put_global(document, {"theme": "ember"}) - node.delete_global(document) - - self.assertIsNone(node.database.get_global(document)) - self.assertEqual([change.record_key for change in changes], [node.cache.GLOBAL_KEY] * 3) - self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"]) - self.assertEqual(changes[1].previous_value, {"theme": "ash"}) - self.assertEqual(changes[2].previous_value, {"theme": "ember"}) - with self.assertRaises(Exception): - node.database.put(document, "settings:wrong", {"theme": "bad"}) - - def test_cultmesh_database_creates_raw_snapshot_response_from_envelopes(self) -> None: - document = define_database_entry_type( - "mesh.snapshot_note", - [("body", 0)], - schema_id="mesh.snapshot_note.v1", - ) - source = CultMesh.create_node(runtime_id="mesh-snapshot-source") - source.database.register_document(document) - source.database.put(document, "note:1", {"body": "include"}) - source.database.put(document, "note:2", {"body": "skip"}) - - typed_response = source.database.build_snapshot_response( - message_id="snapshot-1", - schema_ids=["mesh.snapshot_note.v1"], - record_keys=["note:1"], - shard_id="notes", - shard_epoch=4, - shard_log_sequence=7, - ) - response = source.database.create_snapshot_response( - message_id="snapshot-1", - schema_ids=["mesh.snapshot_note.v1"], - record_keys=["note:1"], - shard_id="notes", - shard_epoch=4, - shard_log_sequence=7, - ) - - self.assertIsInstance(typed_response, CultNetRawSnapshotResponse) - self.assertEqual(typed_response.message_id, "snapshot-1") - self.assertEqual(typed_response.documents[0].record_key, "note:1") - self.assertEqual(typed_response.to_wire(), response) - self.assertEqual(source.build_snapshot_response(schema_ids=["mesh.snapshot_note.v1"]).documents[0].record_key, "note:1") - self.assertEqual(response["schemaVersion"], "cultnet.snapshot_response_raw.v0") - self.assertEqual(response["messageId"], "snapshot-1") - self.assertEqual(response["shardId"], "notes") - self.assertEqual(response["shardEpoch"], 4) - self.assertEqual(response["shardLogSequence"], 7) - self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:1"]) - self.assertEqual(response["documents"][0]["schemaId"], "mesh.snapshot_note.v1") - self.assertEqual(response["documents"][0]["payloadEncoding"], "messagepack") - - target = CultMesh.create_node(runtime_id="mesh-snapshot-target") - target.database.register_document(document) - applied = target.database.apply_snapshot_response(response) - self.assertEqual([(record.schema_id, record.record_key) for record in applied], [("mesh.snapshot_note.v1", "note:1")]) - self.assertEqual(target.database.get_required(document, "note:1"), {"body": "include"}) - self.assertIsNone(target.database.get(document, "note:2")) - - typed_target = CultMesh.create_node(runtime_id="mesh-snapshot-typed-target") - typed_target.database.register_document(document) - typed_applied = typed_target.database.apply_snapshot_response(typed_response) - self.assertEqual([(record.schema_id, record.record_key) for record in typed_applied], [("mesh.snapshot_note.v1", "note:1")]) - self.assertEqual(typed_target.database.get_required(document, "note:1"), {"body": "include"}) - - def test_cultmesh_database_filters_snapshot_by_logged_shard_membership(self) -> None: - document = define_database_entry_type( - "mesh.sharded_snapshot_note", - [("body", 0)], - schema_id="mesh.sharded_snapshot_note.v1", - ) - source = CultMesh.create_node(runtime_id="mesh-sharded-snapshot-source") - source.database.register_document(document) - source.database.put_raw_message(document, "note:notes", {"body": "include"}, shard_id="notes", shard_epoch=2) - source.database.put_raw_message(document, "note:other", {"body": "skip"}, shard_id="other", shard_epoch=1) - - response = source.database.build_snapshot_response( - schema_ids=["mesh.sharded_snapshot_note.v1"], - shard_id="notes", - shard_epoch=2, - ) - - self.assertEqual(response.shard_id, "notes") - self.assertEqual(response.shard_epoch, 2) - self.assertEqual([record.record_key for record in response.documents], ["note:notes"]) - - def test_python_interop_peer_filters_shard_snapshot_by_logged_membership(self) -> None: - runtime_id = f"python-interop-test-{uuid4().hex}" - state = build_state( - runtime_id=runtime_id, - runtime_kind="python", - display_name="Python Interop Test", - agent_id="python-interop-test-agent", - schema_path=str(Path("packages/cultnet-ts/integration/contracts/cultnet.interop-note.schema.json")), - ) - binding = state.bindings["note"] - logged_value = { - "schemaVersion": "cultnet.interop_note.v0", - "documentId": "note:logged", - "authorRuntimeId": runtime_id, - "title": "Logged", - "body": "This record belongs to the shard log.", - "tags": ["interop", "logged"], - } - unlogged_value = { - **logged_value, - "documentId": "note:unlogged", - "title": "Unlogged", - "body": "This record is in cache but not in the shard log.", - } - state.cache.put(binding.document, "note:logged", logged_value) - state.cache.put(binding.document, "note:unlogged", unlogged_value) - logged_record = { - "schemaId": state.note_schema_id, - "recordKey": "note:logged", - "storedAt": "2026-06-14T00:00:00Z", - "payloadEncoding": "messagepack", - "payload": binding.document.encode_payload(logged_value), - } - append_shard_log_put( - state, - {"messageId": "logged-put"}, - logged_record, - ) - - response = raw_snapshot_response( - state, - { - "schemaVersion": "cultnet.snapshot_request.v0", - "messageId": "interop-shard-snapshot", - "schemaIds": [state.note_schema_id], - "shardId": state.shard_id, - }, - ) - - self.assertEqual(response["shardId"], state.shard_id) - self.assertEqual(response["shardLogSequence"], 1) - self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"]) - - def test_cultmesh_database_creates_shard_log_response_from_raw_mutations(self) -> None: - document = define_database_entry_type( - "mesh.log_note", - [("body", 0)], - schema_id="mesh.log_note.v1", - ) - source = CultMesh.create_node(runtime_id="mesh-log-source") - source.database.register_document(document) - source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="notes", shard_epoch=3) - source.database.put_raw_message(document, "note:1", {"body": "second"}, shard_id="notes", shard_epoch=3) - source.database.put_raw_message(document, "note:2", {"body": "other"}, shard_id="other", shard_epoch=1) - source.database.delete_raw_message(document, "note:1", shard_id="notes", shard_epoch=3) - - self.assertEqual(source.database.shard_ids(), ["notes", "other"]) - self.assertEqual(source.database.shard_schema_ids("notes"), ["mesh.log_note.v1"]) - - typed_response = source.database.build_shard_log_response( - message_id="log-1", - shard_id="notes", - shard_epoch=3, - after_sequence=1, - limit=2, - ) - response = source.database.create_shard_log_response( - message_id="log-1", - shard_id="notes", - shard_epoch=3, - after_sequence=1, - limit=2, - ) - - self.assertIsInstance(typed_response, CultNetShardLogResponse) - self.assertEqual(typed_response.message_id, "log-1") - self.assertEqual([entry.sequence for entry in typed_response.entries], [2, 3]) - self.assertEqual(typed_response.to_wire(), response) - self.assertEqual([entry.sequence for entry in source.build_shard_log_response(shard_id="notes").entries], [1, 2, 3]) - self.assertEqual(response["schemaVersion"], "cultnet.shard_log_response.v0") - self.assertEqual(response["messageId"], "log-1") - self.assertEqual(response["shardId"], "notes") - self.assertEqual(response["shardEpoch"], 3) - self.assertFalse(response["resyncRequired"]) - self.assertEqual([entry["sequence"] for entry in response["entries"]], [2, 3]) - self.assertEqual([entry["changeKind"] for entry in response["entries"]], ["updated", "removed"]) - self.assertEqual(response["entries"][0]["put"]["document"]["recordKey"], "note:1") - self.assertEqual(response["entries"][1]["delete"]["recordKey"], "note:1") - empty = source.database.create_shard_log_response(shard_id="missing") - self.assertEqual(empty["shardEpoch"], 0) - self.assertEqual(empty["entries"], []) - self.assertTrue(empty["resyncRequired"]) - self.assertEqual(empty["reason"], "unknown_shard") - stale = source.database.create_shard_log_response(shard_id="notes", shard_epoch=2) - self.assertEqual(stale["shardEpoch"], 3) - self.assertEqual(stale["entries"], []) - self.assertTrue(stale["resyncRequired"]) - self.assertEqual(stale["reason"], "stale_epoch") - with self.assertRaises(ValueError): - source.database.create_shard_log_response(shard_id="notes", after_sequence=-1) - with self.assertRaises(ValueError): - source.database.create_shard_log_response(shard_id="notes", limit=-1) - - target = CultMesh.create_node(runtime_id="mesh-log-target") - target.database.register_document(document) - target.database.put(document, "note:1", {"body": "first"}) - applied = target.database.apply_shard_log_response(response) - self.assertEqual([record.change_kind for record in applied], ["updated", "removed"]) - self.assertIsNone(target.database.get(document, "note:1")) - self.assertIsNone(target.database.get(document, "note:2")) - - typed_target = CultMesh.create_node(runtime_id="mesh-log-typed-target") - typed_target.database.register_document(document) - typed_target.database.put(document, "note:1", {"body": "first"}) - typed_applied = typed_target.database.apply_shard_log_response(typed_response) - self.assertEqual([record.change_kind for record in typed_applied], ["updated", "removed"]) - self.assertIsNone(typed_target.database.get(document, "note:1")) - - def test_cultnet_file_shard_mutation_log_store_persists_authoritative_entries(self) -> None: - document = define_database_entry_type( - "mesh.durable_log_note", - [("body", 0)], - schema_id="mesh.durable_log_note.v1", - ) - with tempfile.TemporaryDirectory() as tmp: - store: CultNetShardMutationLogStore = CultNetFileShardMutationLogStore(Path(tmp) / "logs") - source = CultMesh.create_node(runtime_id="durable-primary") - source.database.register_document(document) - source.database.use_shard_mutation_log_store(store) - source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="durable", shard_epoch=4) - source.database.put_raw_message(document, "note:2", {"body": "second"}, shard_id="durable", shard_epoch=4) - - reopened = CultMesh.create_node(runtime_id="durable-primary") - reopened.database.register_document(document) - reopened.database.use_shard_mutation_log_store(CultNetFileShardMutationLogStore(Path(tmp) / "logs")) - response = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4) - self.assertEqual(reopened.database.shard_ids(), ["durable"]) - self.assertEqual(reopened.database.shard_schema_ids("durable"), ["mesh.durable_log_note.v1"]) - self.assertEqual([entry.sequence for entry in response.entries], [1, 2]) - self.assertEqual(response.entries[0].raw_document.record_key, "note:1") - - store.compact_through("durable", 1) - compacted = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=0) - retained = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=1) - self.assertTrue(compacted.resync_required) - self.assertEqual(compacted.reason, "compacted_history") - self.assertEqual(compacted.compacted_through, 1) - self.assertEqual([entry.sequence for entry in retained.entries], [2]) - - def test_cultnet_shard_replicator_pulls_logs_and_resyncs_from_snapshot(self) -> None: - document = define_database_entry_type( - "mesh.replica_note", - [("body", 0)], - schema_id="mesh.replica_note.v1", - ) - source = CultMesh.create_node(runtime_id="primary") - source.database.register_document(document) - source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="notes", shard_epoch=3) - source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="notes", shard_epoch=3) - with tempfile.TemporaryDirectory() as tmp: - cursor_path = Path(tmp) / "replica-cursors.msgpack" - server = CultMesh.serve_node(source) - try: - shard = CultNetShardDescriptor( - shard_id="notes", - owner_runtime_id="primary", - epoch=3, - is_primary=False, - schema_ids=("mesh.replica_note.v1",), - primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",), - ) - target = CultMesh.create_node(runtime_id="replica") - target.database.register_document(document) - cursor_store = CultNetFileShardReplicaCursorStore(cursor_path) - replicator = CultNetShardReplicator( - target.database, - CultNetShardReplicatorOptions( - fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0), - snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0), - cursor_store=cursor_store, - batch_size=16, - ), - ) - - first_sequence = replicator.pull_once(shard) - restarted_replicator = CultNetShardReplicator( - target.database, - CultNetShardReplicatorOptions( - fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0), - snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0), - cursor_store=CultNetFileShardReplicaCursorStore(cursor_path), - batch_size=16, - ), - ) - second_sequence = restarted_replicator.pull_once(shard) - - self.assertEqual(first_sequence, 2) - self.assertEqual(second_sequence, 2) - self.assertEqual(target.get_required(document, "note:1")["body"], "one") - self.assertEqual(target.get_required(document, "note:2")["body"], "two") - cursor = CultNetFileShardReplicaCursorStore(cursor_path).read("notes") - self.assertIsNotNone(cursor) - assert cursor is not None - self.assertEqual(cursor.last_applied_sequence, 2) - self.assertEqual(cursor.shard_epoch, 3) - finally: - server.stop() - - resync_target = CultMesh.create_node(runtime_id="resync-replica") - resync_target.database.register_document(document) - snapshot = source.database.build_snapshot_response(shard_id="notes", shard_epoch=3, shard_log_sequence=2) - - class CompactingFetcher: - def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse: - return CultNetShardLogResponse( - message_id="compact", - shard_id=shard.shard_id, - shard_epoch=shard.epoch, - entries=(), - resync_required=True, - reason="compacted_history", - compacted_through=after_sequence, - ) - - class SnapshotFetcher: - def fetch(self, shard: CultNetShardDescriptor) -> CultNetRawSnapshotResponse: - return snapshot - - resync_replicator = CultNetShardReplicator( - resync_target.database, - CultNetShardReplicatorOptions( - fetcher=CompactingFetcher(), - snapshot_fetcher=SnapshotFetcher(), - cursor_store=CultNetInMemoryShardReplicaCursorStore(), - ), - ) - resync_sequence = resync_replicator.pull_once(CultNetShardDescriptor( - shard_id="notes", - owner_runtime_id="primary", - epoch=3, - is_primary=False, - schema_ids=("mesh.replica_note.v1",), - primary_endpoints=("cultnet://primary:3075",), - )) - - self.assertEqual(resync_sequence, 2) - self.assertEqual(resync_target.get_required(document, "note:1")["body"], "one") - - def test_cultnet_shard_replicator_background_loop_polls_non_primary_shards(self) -> None: - document = define_database_entry_type( - "mesh.background_replica_note", - [("body", 0)], - schema_id="mesh.background_replica_note.v1", - ) - source = CultMesh.create_node(runtime_id="background-primary") - source.database.register_document(document) - target = CultMesh.create_node(runtime_id="background-replica") - target.database.register_document(document) - cursor_store = CultNetInMemoryShardReplicaCursorStore() - errors: list[Exception] = [] - - class FlakySourceFetcher: - def __init__(self) -> None: - self.calls = 0 - - def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse: - self.calls += 1 - if self.calls == 1: - raise RuntimeError("transient fetch failure") - return source.database.build_shard_log_response( - shard_id=shard.shard_id, - shard_epoch=shard.epoch, - after_sequence=after_sequence, - limit=limit, - ) - - fetcher = FlakySourceFetcher() - shard = CultNetShardDescriptor( - shard_id="background", - owner_runtime_id="background-primary", - epoch=7, - is_primary=False, - schema_ids=("mesh.background_replica_note.v1",), - primary_endpoints=("cultnet://127.0.0.1:3075",), - ) - replicator = CultNetShardReplicator( - target.database, - CultNetShardReplicatorOptions( - fetcher=fetcher, - cursor_store=cursor_store, - batch_size=1, - poll_interval_seconds=0.01, - on_error=errors.append, - ), - ) - try: - source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="background", shard_epoch=7) - replicator.start([shard]) - self._wait_until(lambda: target.database.get(document, "note:1") is not None) - source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="background", shard_epoch=7) - self._wait_until(lambda: target.database.get(document, "note:2") is not None) - finally: - replicator.stop() - - cursor = cursor_store.read("background") - self.assertEqual(target.get_required(document, "note:1")["body"], "one") - self.assertEqual(target.get_required(document, "note:2")["body"], "two") - self.assertIsNotNone(cursor) - assert cursor is not None - self.assertEqual(cursor.last_applied_sequence, 2) - self.assertEqual(cursor.shard_epoch, 7) - self.assertEqual(len(errors), 1) - self.assertEqual(str(errors[0]), "transient fetch failure") - - def test_cultnet_schema_write_forwarder_sends_raw_writes_to_primary(self) -> None: - document = define_database_entry_type( - "mesh.forward_note", - [("body", 0)], - schema_id="mesh.forward_note.v1", - ) - primary = CultMesh.create_node(runtime_id="forward-primary") - primary.database.register_document(document) - server = CultMesh.serve_node(primary) - try: - shard = CultNetShardDescriptor( - shard_id="forward", - owner_runtime_id="forward-primary", - epoch=9, - is_primary=False, - schema_ids=("mesh.forward_note.v1",), - primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",), - ) - forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0) - put = document_put_raw( - message_id="forward-put", - key="note:forward", - schema_id=document.catalog_entry().schema_id, - stored_at="2026-06-14T00:00:00Z", - payload=document.encode_payload({"body": "forwarded"}), - ).to_wire() - delete = document_delete( - message_id="forward-delete", - schema_id=document.catalog_entry().schema_id, - record_key="note:forward", - ).to_wire() - - forwarder.forward_put(shard, put) - self._eventually(lambda: primary.database.get(document, "note:forward") is not None) - self.assertEqual(primary.database.get_required(document, "note:forward")["body"], "forwarded") - forwarder.forward_delete(shard, delete) - self._eventually(lambda: primary.database.get(document, "note:forward") is None) - - log = primary.database.build_shard_log_response(shard_id="forward", shard_epoch=9) - self.assertIsNone(primary.database.get(document, "note:forward")) - self.assertEqual([entry.change_kind for entry in log.entries], ["added", "removed"]) - self.assertEqual(log.entries[0].put["shardId"], "forward") - self.assertEqual(log.entries[1].delete["shardEpoch"], 9) - finally: - server.stop() - - def _eventually(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None: - deadline = time.monotonic() + timeout_seconds - while time.monotonic() < deadline: - if predicate(): - return - time.sleep(0.01) - self.assertTrue(predicate()) - - def test_cultmesh_database_watchers_observe_name_and_index_changes(self) -> None: - document = define_database_entry_type( - "mesh.named_watch", - [("name", 0), ("kind", 1)], - name="name", - indexes={"kind": "kind"}, - ) - node = CultMesh.create_node(runtime_id="mesh-named-watch") - node.database.register_document(document) - name_changes: list[CultMeshDatabaseChange] = [] - index_changes: list[CultMeshDatabaseChange] = [] - - node.database.watch_by_name(document, "Potion", name_changes.append) - node.database.watch_by_index(document, "kind", "consumable", index_changes.append) - - node.database.put(document, "item:1", {"name": "Potion", "kind": "consumable"}) - node.database.put(document, "item:2", {"name": "Sword", "kind": "weapon"}) - node.database.put(document, "item:1", {"name": "Elixir", "kind": "rare"}) - node.database.delete(document, "item:1") - - self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"]) - self.assertEqual([change.record_key for change in name_changes], ["item:1", "item:1"]) - self.assertEqual(name_changes[1].previous_value, {"name": "Potion", "kind": "consumable"}) - self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"]) - self.assertEqual([change.record_key for change in index_changes], ["item:1", "item:1"]) - self.assertEqual(index_changes[1].value, {"name": "Elixir", "kind": "rare"}) - - def test_cultmesh_database_watch_by_name_and_index_validate_lookup_shape(self) -> None: - document = define_database_entry_type("mesh.unnamed_watch", [("body", 0)]) - node = CultMesh.create_node(runtime_id="mesh-watch-validation") - node.database.register_document(document) - - with self.assertRaises(ValueError): - node.database.watch_by_name(document, "missing", lambda _: None) - with self.assertRaises(ValueError): - node.database.watch_by_index(document, "missing", "value", lambda _: None) - with self.assertRaises(ValueError): - node.database.watch_by_index(document, "", "value", lambda _: None) - with self.assertRaises(ValueError): - node.database.watch_by_index(document, "missing", "", lambda _: None) - - def test_cultmesh_facade_matches_peer_runtime_entrypoints(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - store_path = Path(tmp) / "mesh.cc" - node = CultMesh.start_node(store_path, runtime_id="mesh-facade") - - self.assertEqual(node.runtime_id, "mesh-facade") - self.assertIsInstance(CultMesh.create_node(), type(node)) - self.assertIsInstance(CultMesh.create_verse_catalog(), CultMeshVerseCatalog) - self.assertIsInstance(CultMesh.create_peer_catalog(), CultMeshPeerCatalog) - self.assertIsInstance(CultMesh.create_authority_lease_catalog(), CultMeshAuthorityLeaseCatalog) - self.assertIsInstance(CultMesh.create_stream_catalog(), CultMeshStreamCatalog) - self.assertIsInstance(CultMesh.create_schema_catalog(), CultNetSchemaCatalog) - self.assertIsInstance(CultMesh.create_builtin_schema_catalog(), CultNetSchemaCatalog) - self.assertIsInstance(CultMesh.create_shard_catalog(), CultNetShardCatalog) - builtins = CultMesh.create_builtin_schema_catalog( - include_schema_json=False, - kinds=["shared_contract"], - ) - self.assertEqual( - builtins.list()[0].schema_id, - "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json", - ) - self.assertIsNone(builtins.list()[0].schema_json) - peer_exchange_schema_id = ( - "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.peer-exchange-request.schema.json" - ) - filtered_builtin = CultMesh.create_builtin_schema_catalog( - include_schema_json=True, - schema_ids=[peer_exchange_schema_id], - ) - self.assertEqual( - filtered_builtin.list()[0].schema_version, - "cultmesh.peer_exchange_request.v0", - ) - self.assertIn( - "cultmesh.peer_exchange_request.v0", - filtered_builtin.list()[0].schema_json or "", - ) - shard_catalog = CultMesh.create_shard_catalog() - shard_catalog.upsert( - CultNetShardDescriptor( - shard_id="python-notes-a", - owner_runtime_id="mesh-facade", - epoch=7, - is_primary=True, - schema_ids=("python.note.v1",), - key_prefix="note:", - ) - ) - self.assertEqual( - [ - shard.shard_id - for shard in shard_catalog.list(schema_ids=["python.note.v1"], record_keys=["note:1"]) - ], - ["python-notes-a"], - ) - verse_client = CultMesh.create_verse_discovery_client("127.0.0.1", 4010, timeout_seconds=1.5) - peer_client = CultMesh.create_peer_exchange_client("127.0.0.1", 4011, timeout_seconds=1.5) - raw_client = CultMesh.create_client("127.0.0.1", 4012, timeout_seconds=1.5) - connected_client = CultMesh.connect_client("127.0.0.1", 4013, timeout_seconds=1.5) - self.assertIsInstance(verse_client, CultMeshVerseDiscoveryClient) - self.assertIsInstance(peer_client, CultMeshPeerExchangeClient) - self.assertIsInstance(verse_client, CultMeshDiscoveryClient) - self.assertIsInstance(peer_client, CultMeshDiscoveryClient) - self.assertIsInstance(raw_client, CultNetRawClient) - self.assertIsInstance(connected_client, CultNetRawClient) - self.assertEqual((verse_client.host, verse_client.port, verse_client.timeout_seconds), ("127.0.0.1", 4010, 1.5)) - self.assertEqual((peer_client.host, peer_client.port, peer_client.timeout_seconds), ("127.0.0.1", 4011, 1.5)) - self.assertEqual((raw_client.host, raw_client.port, raw_client.timeout_seconds), ("127.0.0.1", 4012, 1.5)) - self.assertEqual((connected_client.host, connected_client.port, connected_client.timeout_seconds), ("127.0.0.1", 4013, 1.5)) - - def test_cultmesh_simulation_fact_uses_csharp_slot_contract(self) -> None: - import msgpack # type: ignore - - candidate = { - "shardId": "arena", - "shardEpoch": 4, - "frame": 100, - "subjectId": "bob", - "claimKind": "hit", - "claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"), - "claimSummary": "alice shot bob first", - "witnessCount": 2, - "supportWeight": 2.0, - "totalWeight": 2.0, - "confidence": 1.0, - "hasQuorum": True, - } - - fact = CultMeshSimulationFact.from_candidate(candidate, committed_at="2026-06-13T00:00:00Z") - payload = simulation_fact_document.encode_payload(fact) - slots = msgpack.unpackb(payload, raw=False) - - self.assertEqual(CultMeshSimulationFact.create_record_key(candidate), f"simulation:{fact.fact_id}") - self.assertEqual(slots[0], fact.fact_id) - self.assertEqual(slots[1], "arena") - self.assertEqual(slots[2], 4) - self.assertEqual(slots[3], 100) - self.assertEqual(slots[4], "bob") - self.assertEqual(slots[5], "hit") - self.assertEqual(slots[6], candidate["claimHash"]) - self.assertEqual(slots[7], "alice shot bob first") - self.assertEqual(slots[8], 2) - self.assertEqual(slots[9], 2.0) - self.assertEqual(slots[10], 2.0) - self.assertEqual(slots[11], 1.0) - self.assertEqual(slots[12], "2026-06-13T00:00:00Z") - - def test_cultmesh_simulation_fact_committer_rejects_without_quorum_and_stores_fact(self) -> None: - candidate = { - "shardId": "arena", - "shardEpoch": 4, - "frame": 100, - "subjectId": "bob", - "claimKind": "hit", - "claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"), - "claimSummary": "alice shot bob first", - "witnessCount": 2, - "supportWeight": 2.0, - "totalWeight": 2.0, - "confidence": 1.0, - "hasQuorum": True, - } - - with tempfile.TemporaryDirectory() as tmp: - node = CultMesh.create_node(Path(tmp) / "facts.cc", runtime_id="mesh-facts") - committer = CultMesh.create_simulation_fact_committer(node) - - rejected = dict(candidate) - rejected["hasQuorum"] = False - with self.assertRaisesRegex(ValueError, "before quorum"): - committer.commit(rejected) - - committed = committer.commit(candidate, committed_at="2026-06-13T00:00:00Z") - stored = node.get_required(simulation_fact_document, committed.key) - - self.assertEqual(stored.claim_hash, candidate["claimHash"]) - self.assertEqual(stored.committed_at, "2026-06-13T00:00:00Z") - self.assertEqual(committed.fact.fact_id, stored.fact_id) - - def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None: - from cultnet_py import CultNetSimulationConsensus - - consensus = CultNetSimulationConsensus( - CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0) - ) - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") - observations = [ - { - "witnessRuntimeId": "watcher-1", - "shardId": "arena", - "shardEpoch": 4, - "frame": 100, - "subjectId": "bob", - "claimKind": "hit", - "claimHash": claim_hash, - "claimSummary": "alice shot bob first", - "weight": 1.0, - }, - { - "witnessRuntimeId": "watcher-1", - "shardId": "arena", - "shardEpoch": 4, - "frame": 100, - "subjectId": "bob", - "claimKind": "hit", - "claimHash": "stale-duplicate", - "weight": 0.5, - }, - { - "witnessRuntimeId": "watcher-2", - "shardId": "arena", - "shardEpoch": 4, - "frame": 100, - "subjectId": "bob", - "claimKind": "hit", - "claimHash": claim_hash, - "weight": 1.0, - }, - ] - - candidates = consensus.build_candidates(observations) - typed_candidates = consensus.build_candidate_objects([ - CultNetSimulationObservation.from_wire(observation) - for observation in observations - ]) - - self.assertEqual(len(candidates), 1) - self.assertEqual(candidates[0]["claimHash"], claim_hash) - self.assertEqual(candidates[0]["witnessCount"], 2) - self.assertEqual(candidates[0]["supportWeight"], 2.0) - self.assertTrue(candidates[0]["hasQuorum"]) - self.assertEqual(len(typed_candidates), 1) - self.assertIsInstance(typed_candidates[0], CultNetSimulationConsensusCandidate) - self.assertEqual(typed_candidates[0].claim_hash, claim_hash) - self.assertEqual(typed_candidates[0].to_wire(), candidates[0]) - self.assertEqual(CultNetSimulationConsensusCandidate.from_wire(candidates[0]), typed_candidates[0]) - - def test_cultmesh_game_session_submits_observations_and_commits_quorum_once(self) -> None: - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") - first = simulation_observation( - message_id="obs-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", - ).to_wire() - second = simulation_observation( - message_id="obs-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", - ).to_wire() - - with tempfile.TemporaryDirectory() as tmp: - node = CultMesh.create_node(Path(tmp) / "session.cc", runtime_id="mesh-session") - session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0) - ), - ) - seen_candidates: list[CultNetSimulationConsensusCandidate] = [] - seen_facts: list[CultMeshDatabaseChange] = [] - unsubscribe_candidates = session.watch_candidates(seen_candidates.append) - unsubscribe_facts = session.watch_simulation_facts(seen_facts.append) - - self.assertEqual(session.submit_and_commit(CultNetSimulationObservation.from_wire(first)), []) - typed_candidates = session.submit_observation_candidates(CultNetSimulationObservation.from_wire(second)) - commits = session.commit_quorum_candidate_objects(typed_candidates) - replay = session.commit_quorum_candidates(session.submit_observation(second)) - unsubscribe_candidates() - unsubscribe_facts() - - self.assertEqual(len(typed_candidates), 1) - self.assertTrue(typed_candidates[0].has_quorum) - self.assertEqual(len(commits), 1) - self.assertEqual(replay, []) - self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash, claim_hash]) - self.assertEqual(len(seen_facts), 1) - self.assertEqual(seen_facts[0].change_kind, "added") - self.assertEqual(seen_facts[0].value.claim_hash, claim_hash) - stored = node.get_required(simulation_fact_document, commits[0].key) - 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_can_disable_served_simulation_observations(self) -> None: - verse_catalog = CultMeshVerseCatalog() - verse_catalog.upsert(CultMeshVerseDescriptor( - verse_id="session-quiet", - display_name="Quiet Session", - authority_model="local", - compatibility=CultMeshVerseCompatibility("cultmesh.v0", "quiet-rules"), - )) - session = CultMesh.create_game_session( - CultMesh.create_node(runtime_id="quiet-session-peer"), - CultMeshGameSessionOptions( - verse_catalog=verse_catalog, - serve_simulation_observations=False, - ), - ) - server = session.serve(display_name="Quiet 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="quiet-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") - with self.assertRaisesRegex(CultNetPeerError, "Simulation observations are not enabled") as raised: - raw_client.request( - simulation_observation( - message_id="quiet-session-sim", - witness_runtime_id="watcher-1", - shard_id="arena", - shard_epoch=4, - frame=100, - subject_id="bob", - claim_kind="hit", - claim_hash=compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"), - ), - expected_schema_version="cultnet.simulation_consensus_candidate.v0", - ) - finally: - server.stop() - - self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) - self.assertEqual(verses[0].verse_id, "session-quiet") - self.assertEqual(raised.exception.response["code"], "simulation_observations_disabled") - - def test_cultmesh_game_session_prediction_requires_scope_and_reconciles_shard_log(self) -> None: - note_doc = define_database_entry_type( - "mesh.input", - [("body", 0)], - schema_id="mesh.input.v1", - ) - with tempfile.TemporaryDirectory() as tmp: - node = CultMesh.create_node(Path(tmp) / "prediction.cc", runtime_id="client-a") - node.register_document(note_doc) - session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - client_authority_scopes=( - CultNetClientAuthorityScope( - "client-a", - schema_ids=("mesh.input.v1",), - key_prefix="input:client-a", - ), - ) - ), - ) - - with self.assertRaisesRegex(ValueError, "does not have client prediction authority"): - session.predict(note_doc, "input:other", {"body": "nope"}) - - prediction = session.predict(note_doc, "input:client-a:move", {"body": "predicted"}) - put = document_put_raw( - message_id="authoritative", - key=prediction.key, - schema_id=prediction.schema_id, - stored_at="2026-06-13T00:00:00Z", - payload=note_doc.encode_payload({"body": "authoritative"}), - shard_id="inputs", - shard_epoch=1, - ) - authoritative_response = { - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "inputs-log", - "shardId": "inputs", - "shardEpoch": 1, - "entries": [ - { - "sequence": 1, - "committedAt": "2026-06-13T00:00:00Z", - "changeKind": "updated", - "put": put.to_wire(), - } - ], - "resyncRequired": False, - } - changes = session.apply_shard_log_response(CultNetShardLogResponse.from_wire(authoritative_response)) - - self.assertEqual(changes[0].change_kind, "reconciled") - self.assertEqual(node.get_required(note_doc, prediction.key)["body"], "authoritative") - self.assertEqual(session.pending_predictions(), ()) - self.assertEqual(session.resimulation_inputs(), ()) - - def test_cultmesh_game_session_rolls_back_pending_predictions(self) -> None: - note_doc = define_database_entry_type( - "mesh.rollback_input", - [("body", 0)], - schema_id="mesh.rollback_input.v1", - ) - node = CultMesh.create_node(runtime_id="client-rollback") - node.register_document(note_doc) - node.put(note_doc, "input:client-rollback:existing", {"body": "previous"}) - session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - client_authority_scopes=( - CultNetClientAuthorityScope( - "client-rollback", - schema_ids=("mesh.rollback_input.v1",), - key_prefix="input:client-rollback", - ), - ) - ), - ) - - existing = session.predict(note_doc, "input:client-rollback:existing", {"body": "predicted-existing"}) - created = session.predict(note_doc, "input:client-rollback:new", {"body": "predicted-new"}) - self.assertEqual( - [prediction.key for prediction in session.pending_predictions()], - ["input:client-rollback:existing", "input:client-rollback:new"], - ) - self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [ - "input:client-rollback:existing", - "input:client-rollback:new", - ]) - - restored = session.rollback_prediction(existing) - self.assertIsNotNone(restored) - assert restored is not None - self.assertEqual(restored.change_kind, "rolled_back") - self.assertEqual(restored.value, {"body": "previous"}) - self.assertEqual(node.get_required(note_doc, "input:client-rollback:existing")["body"], "previous") - - rolled_back = session.rollback_predictions() - self.assertEqual([(change.change_kind, change.record_key, change.value) for change in rolled_back], [ - ("rolled_back", created.key, None), - ]) - self.assertIsNone(node.get(note_doc, created.key)) - self.assertEqual(session.pending_predictions(), ()) - - def test_cultmesh_game_session_authoritative_delete_rolls_back_prediction(self) -> None: - note_doc = define_database_entry_type( - "mesh.deleted_input", - [("body", 0)], - schema_id="mesh.deleted_input.v1", - ) - node = CultMesh.create_node(runtime_id="client-delete") - node.register_document(note_doc) - session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - client_authority_scopes=( - CultNetClientAuthorityScope( - "client-delete", - schema_ids=("mesh.deleted_input.v1",), - key_prefix="input:client-delete", - ), - ) - ), - ) - - prediction = session.predict(note_doc, "input:client-delete:move", {"body": "predicted"}) - delete = document_delete( - message_id="authoritative-delete", - schema_id=prediction.schema_id, - record_key=prediction.key, - shard_id="inputs", - shard_epoch=1, - ) - changes = session.apply_shard_log_response({ - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "inputs-delete-log", - "shardId": "inputs", - "shardEpoch": 1, - "entries": [ - { - "sequence": 1, - "committedAt": "2026-06-13T00:00:00Z", - "changeKind": "removed", - "delete": delete.to_wire(), - } - ], - "resyncRequired": False, - }) - - self.assertEqual(changes[0].change_kind, "rolled_back") - self.assertIsNone(node.get(note_doc, prediction.key)) - self.assertEqual(session.pending_predictions(), ()) - - def test_cultmesh_database_watchers_observe_authoritative_shard_log_reconciliation(self) -> None: - note_doc = define_database_entry_type( - "mesh.watched_input", - [("body", 0)], - schema_id="mesh.watched_input.v1", - ) - node = CultMesh.create_node(runtime_id="client-watch") - node.database.register_document(note_doc) - seen: list[CultMeshDatabaseChange] = [] - node.database.watch_record(note_doc, "input:client-watch:move", seen.append) - session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - client_authority_scopes=( - CultNetClientAuthorityScope( - "client-watch", - schema_ids=("mesh.watched_input.v1",), - key_prefix="input:client-watch", - ), - ) - ), - ) - - prediction = session.predict(note_doc, "input:client-watch:move", {"body": "predicted"}) - put = document_put_raw( - message_id="authoritative-watch", - key=prediction.key, - schema_id=prediction.schema_id, - stored_at="2026-06-13T00:00:00Z", - payload=note_doc.encode_payload({"body": "authoritative"}), - shard_id="inputs", - shard_epoch=1, - ) - session_changes = session.apply_shard_log_response({ - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "inputs-watch-log", - "shardId": "inputs", - "shardEpoch": 1, - "entries": [ - { - "sequence": 1, - "committedAt": "2026-06-13T00:00:00Z", - "changeKind": "updated", - "put": put.to_wire(), - } - ], - "resyncRequired": False, - }) - - self.assertEqual([change.change_kind for change in seen], ["added", "updated"]) - self.assertEqual(seen[1].value, {"body": "authoritative"}) - self.assertEqual(seen[1].previous_value, {"body": "predicted"}) - self.assertEqual(session_changes[0].change_kind, "reconciled") - - def test_cultmesh_verse_catalog_response_matches_schema_v0_wire_shape(self) -> None: - import msgpack # type: ignore - - catalog = CultMeshVerseCatalog() - catalog.upsert( - CultMeshVerseDescriptor( - verse_id="aetheria-main", - display_name="Aetheria", - authority_model="OperatorCluster", - compatibility=CultMeshVerseCompatibility( - transport_version="cultmesh.v0", - rules_hash="rules", - compatible_verse_ids=("aetheria-modded",), - required_plugin_ids=("core",), - optional_plugin_ids=("skylands",), - ), - discovery_endpoints=("cultmesh://aetheria.example.test:3075",), - authority_runtime_ids=("runtime-a",), - description="main branch", - ) - ) - - response = catalog.create_response(verse_catalog_request("verses-1")) - decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False) - self.assertEqual(decoded["schemaVersion"], "cultmesh.verse_catalog_response.v0") - self.assertEqual(decoded["messageId"], "verses-1") - self.assertEqual(decoded["verses"][0]["verseId"], "aetheria-main") - self.assertEqual(decoded["verses"][0]["compatibility"]["requiredPluginIds"], ["core"]) - - def test_cultmesh_verse_catalog_watches_updates_and_finds_transfer_targets(self) -> None: - source = CultMeshVerseDescriptor( - verse_id="aetheria-main", - display_name="Aetheria", - authority_model="federated", - compatibility=CultMeshVerseCompatibility( - transport_version="cultmesh.v0", - rules_hash="rules-main", - ), - ) - compatible = CultMeshVerseDescriptor( - verse_id="aetheria-modded", - display_name="Aetheria Modded", - authority_model="federated", - compatibility=CultMeshVerseCompatibility( - transport_version="cultmesh.v0", - rules_hash="rules-modded", - compatible_verse_ids=("aetheria-main",), - ), - ) - incompatible = CultMeshVerseDescriptor( - verse_id="old-world", - display_name="Old World", - authority_model="solo", - compatibility=CultMeshVerseCompatibility( - transport_version="cultmesh.v0", - rules_hash="rules-old", - ), - ) - catalog = CultMeshVerseCatalog() - seen: list[str] = [] - unsubscribe = catalog.watch(lambda verse: seen.append(verse.verse_id)) - - catalog.upsert(source) - catalog.apply_response({ - "schemaVersion": "cultmesh.verse_catalog_response.v0", - "messageId": "verses-watch", - "verses": [compatible.to_wire(), incompatible.to_wire()], - }) - unsubscribe() - catalog.upsert(CultMeshVerseDescriptor( - verse_id="after-unsubscribe", - display_name="After", - authority_model="none", - compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-after"), - )) - - self.assertEqual(seen, ["aetheria-main", "aetheria-modded", "old-world"]) - self.assertEqual([verse.verse_id for verse in catalog.verses], ["aetheria-main", "aetheria-modded", "after-unsubscribe", "old-world"]) - self.assertEqual(catalog.get("aetheria-modded"), compatible) - self.assertEqual([verse.verse_id for verse in catalog.find_transfer_targets(source)], ["aetheria-modded"]) - - def test_cultmesh_peer_exchange_response_matches_schema_v0_wire_shape(self) -> None: - import msgpack # type: ignore - - catalog = CultMeshPeerCatalog() - catalog.upsert( - CultMeshPeerCard( - peer_id="peer-a", - verse_id="aetheria-main", - endpoints=("cultnet://peer-a.example.test:3075",), - roles=("discovery", "read-replica"), - shard_ids=("players",), - region="eu-west", - authority_lease_id="lease-1", - expires_at="2026-05-20T12:00:00.0000000Z", - signature="sig", - ) - ) - - response = catalog.create_response( - peer_exchange_request("pex-1", verse_id="aetheria-main", roles=["read-replica"]) - ) - decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False) - self.assertEqual(decoded["schemaVersion"], "cultmesh.peer_exchange_response.v0") - self.assertEqual(decoded["messageId"], "pex-1") - self.assertEqual(decoded["peers"][0]["peerId"], "peer-a") - self.assertIn("read-replica", decoded["peers"][0]["roles"]) - self.assertEqual(decoded["peers"][0]["authorityLeaseId"], "lease-1") - - def test_cultmesh_peer_catalog_watches_updates_and_gets_peers(self) -> None: - first = CultMeshPeerCard( - peer_id="peer-a", - verse_id="aetheria-main", - endpoints=("cultnet://peer-a.example.test:3075",), - roles=("discovery", "read-replica"), - ) - second = CultMeshPeerCard( - peer_id="peer-b", - verse_id="aetheria-main", - endpoints=("cultnet://peer-b.example.test:3075",), - roles=("shard-primary",), - ) - catalog = CultMeshPeerCatalog() - seen: list[str] = [] - unsubscribe = catalog.watch(lambda peer: seen.append(peer.peer_id)) - - catalog.upsert(first) - catalog.apply_response({ - "schemaVersion": "cultmesh.peer_exchange_response.v0", - "messageId": "peers-watch", - "peers": [second.to_wire()], - }) - unsubscribe() - catalog.upsert(CultMeshPeerCard( - peer_id="peer-c", - verse_id="aetheria-main", - endpoints=("cultnet://peer-c.example.test:3075",), - roles=("read-replica",), - )) - - self.assertEqual(seen, ["peer-a", "peer-b"]) - self.assertTrue(first.has_role("read-replica")) - self.assertEqual([peer.peer_id for peer in catalog.peers], ["peer-a", "peer-b", "peer-c"]) - self.assertEqual(catalog.get("peer-b"), second) - self.assertEqual([peer.peer_id for peer in catalog.find("aetheria-main", role="read-replica")], ["peer-a", "peer-c"]) - - def test_cultmesh_discovery_client_fetches_typed_catalogs_over_cultnet_frames(self) -> None: - import msgpack # type: ignore - from cultnet_py import read_frame, write_frame - - verses = CultMeshVerseCatalog() - verses.upsert( - CultMeshVerseDescriptor( - verse_id="aetheria-main", - display_name="Aetheria", - authority_model="federated", - compatibility=CultMeshVerseCompatibility( - transport_version="cultmesh.v0", - rules_hash="rules-1", - required_plugin_ids=("core",), - ), - discovery_endpoints=("cultmesh://aetheria.example.test:3075",), - authority_runtime_ids=("runtime-a",), - ) - ) - peers = CultMeshPeerCatalog() - peers.upsert( - CultMeshPeerCard( - peer_id="peer-a", - verse_id="aetheria-main", - endpoints=("cultnet://peer-a.example.test:3075",), - roles=("read-replica",), - shard_ids=("players",), - ) - ) - - ready = threading.Event() - server_error: list[BaseException] = [] - - def serve_requests() -> None: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", 0)) - port_holder.append(server.getsockname()[1]) - server.listen(8) - ready.set() - for _ in range(8): - connection, _ = server.accept() - with connection: - stream = connection.makefile("rwb") - request = msgpack.unpackb(read_frame(stream), raw=False) - if request["schemaVersion"] == "cultmesh.verse_catalog_request.v0": - response = verses.create_response(request) - elif request["schemaVersion"] == "cultmesh.peer_exchange_request.v0": - response = peers.create_response(request) - else: - raise AssertionError(f"unexpected request {request['schemaVersion']}") - write_frame(stream, msgpack.packb(response, use_bin_type=True)) - stream.flush() - except BaseException as error: - server_error.append(error) - ready.set() - - port_holder: list[int] = [] - thread = threading.Thread(target=serve_requests, daemon=True) - thread.start() - self.assertTrue(ready.wait(2.0)) - self.assertFalse(server_error) - - client = CultMeshDiscoveryClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) - fetched_verses = client.fetch_verses(transport_version="cultmesh.v0") - fetched_peers = client.fetch_peers(verse_id="aetheria-main", roles=["read-replica"]) - local_verses = CultMeshVerseCatalog() - local_peers = CultMeshPeerCatalog() - synced_verses = client.sync_verse_catalog(local_verses, transport_version="cultmesh.v0") - synced_peers = client.sync_peer_catalog(local_peers, verse_id="aetheria-main", roles=["read-replica"]) - discovered_verses = CultMeshVerseCatalog() - discovered_peers = CultMeshPeerCatalog() - endpoint = f"cultnet://127.0.0.1:{port_holder[0]}" - verse_discovery = CultMeshVerseDiscoveryClient.from_endpoint(endpoint, timeout_seconds=2.0) - peer_discovery = CultMeshPeerExchangeClient.from_endpoint(endpoint, timeout_seconds=2.0) - discovered_verse_count = verse_discovery.discover( - discovered_verses, - endpoints=[endpoint, endpoint, ""], - transport_version="cultmesh.v0", - ) - discovered_peer_count = peer_discovery.discover( - discovered_peers, - verse_id="aetheria-main", - endpoints=[endpoint, endpoint, ""], - roles=["read-replica"], - ) - fanout_verses = CultMeshVerseCatalog() - fanout_verses.upsert( - CultMeshVerseDescriptor( - verse_id="aetheria-main", - display_name="Known Aetheria", - authority_model="federated", - compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-1"), - discovery_endpoints=(endpoint, endpoint, ""), - ) - ) - fanout_peers = CultMeshPeerCatalog() - fanout_peers.upsert( - CultMeshPeerCard( - peer_id="known-peer", - verse_id="aetheria-main", - endpoints=(endpoint, endpoint, ""), - roles=("read-replica",), - ) - ) - fanout_verse_count = verse_discovery.fanout(fanout_verses, transport_version="cultmesh.v0") - fanout_peer_count = peer_discovery.fanout( - fanout_peers, - verse_id="aetheria-main", - roles=["read-replica"], - ) - - thread.join(2.0) - self.assertFalse(server_error) - self.assertEqual(fetched_verses[0].verse_id, "aetheria-main") - self.assertEqual(fetched_verses[0].compatibility.required_plugin_ids, ("core",)) - self.assertEqual(fetched_peers[0].peer_id, "peer-a") - self.assertEqual(fetched_peers[0].shard_ids, ("players",)) - self.assertEqual(synced_verses[0].verse_id, "aetheria-main") - self.assertEqual(local_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a") - self.assertEqual(synced_peers[0].roles, ("read-replica",)) - self.assertEqual(discovered_verse_count, 1) - self.assertEqual(discovered_peer_count, 1) - self.assertEqual(discovered_verses.get("aetheria-main").display_name, "Aetheria") - self.assertEqual(discovered_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a") - self.assertEqual(fanout_verse_count, 1) - self.assertEqual(fanout_peer_count, 1) - self.assertEqual(fanout_verses.get("aetheria-main").display_name, "Aetheria") - self.assertIn( - "peer-a", - {peer.peer_id for peer in fanout_peers.find("aetheria-main", role="read-replica")}, - ) - - def test_cultmesh_discovery_client_fans_out_simulation_observations_to_peers(self) -> None: - first_hub = CultNetSimulationObservationHub( - CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) - ) - second_hub = CultNetSimulationObservationHub( - CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) - ) - first_server = CultMesh.serve_node( - CultMesh.create_node(runtime_id="sim-peer-a"), - observation_hub=first_hub, - ) - second_server = CultMesh.serve_node( - CultMesh.create_node(runtime_id="sim-peer-b"), - observation_hub=second_hub, - ) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="sim-peer-a", - verse_id="arena", - endpoints=(f"cultnet://127.0.0.1:{first_server.port}",), - roles=("simulation-witness",), - )) - peers.upsert(CultMeshPeerCard( - peer_id="sim-peer-b", - verse_id="arena", - endpoints=(f"cultnet://127.0.0.1:{second_server.port}",), - roles=("simulation-witness",), - )) - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:200") - message = simulation_observation( - message_id="fanout-observation", - witness_runtime_id="python-fanout", - shard_id="arena", - shard_epoch=1, - frame=200, - subject_id="bob", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="alice hit bob", - ).to_wire() - - client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0) - candidates = client.fanout_simulation_observation( - peers, - message, - verse_id="arena", - roles=["simulation-witness"], - ) - finally: - first_server.stop() - second_server.stop() - - self.assertEqual([candidate["schemaVersion"] for candidate in candidates], [ - "cultnet.simulation_consensus_candidate.v0", - "cultnet.simulation_consensus_candidate.v0", - ]) - self.assertEqual({candidate["claimHash"] for candidate in candidates}, {claim_hash}) - self.assertEqual({candidate["messageId"] for candidate in candidates}, {"fanout-observation"}) - self.assertEqual({candidate["hasQuorum"] for candidate in candidates}, {True}) - - def test_cultmesh_simulation_observation_fanout_flushes_queued_messages(self) -> None: - hub = CultNetSimulationObservationHub( - CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) - ) - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-flush-peer"), observation_hub=hub) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="sim-flush-peer", - verse_id="arena", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("simulation-witness",), - )) - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:210") - seen_candidates: list[dict[str, object]] = [] - fanout = CultMeshSimulationObservationFanout( - CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), - peers, - verse_id="arena", - roles=["simulation-witness"], - on_candidate=seen_candidates.append, - ) - fanout.enqueue(simulation_observation( - message_id="queued-observation", - witness_runtime_id="python-fanout", - shard_id="arena", - shard_epoch=1, - frame=210, - subject_id="bob", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="alice hit bob", - ).to_wire()) - - candidates = fanout.flush() - finally: - server.stop() - - self.assertEqual(fanout.pending_count(), 0) - self.assertEqual([candidate["claimHash"] for candidate in candidates], [claim_hash]) - self.assertEqual(seen_candidates[0]["messageId"], "queued-observation") - - def test_cultmesh_simulation_observation_fanout_keeps_failed_messages_pending(self) -> None: - closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - closed_socket.bind(("127.0.0.1", 0)) - closed_port = closed_socket.getsockname()[1] - closed_socket.close() - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="closed-simulation-peer", - verse_id="arena", - endpoints=(f"cultnet://127.0.0.1:{closed_port}",), - roles=("simulation-witness",), - )) - errors: list[tuple[str, str]] = [] - fanout = CultMeshSimulationObservationFanout( - CultMeshDiscoveryClient("127.0.0.1", closed_port, timeout_seconds=0.2), - peers, - verse_id="arena", - roles=["simulation-witness"], - on_error=lambda endpoint, error: errors.append((endpoint, str(error))), - ) - fanout.enqueue(simulation_observation( - message_id="retry-observation", - witness_runtime_id="python-fanout", - shard_id="arena", - shard_epoch=1, - frame=212, - subject_id="bob", - claim_kind="miss", - claim_hash=compute_simulation_claim_hash("miss", "alice", "bob", "frame:212"), - claim_summary="alice missed bob", - ).to_wire()) - - candidates = fanout.flush() - - self.assertEqual(candidates, []) - self.assertEqual(fanout.pending_count(), 1) - self.assertEqual(len(errors), 1) - self.assertIn(f"127.0.0.1:{closed_port}", errors[0][0]) - - def test_cultmesh_simulation_observation_fanout_runs_background_loop(self) -> None: - hub = CultNetSimulationObservationHub( - CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) - ) - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-loop-peer"), observation_hub=hub) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="sim-loop-peer", - verse_id="arena", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("simulation-witness",), - )) - claim_hash = compute_simulation_claim_hash("block", "dana", "eve", "frame:211") - seen_candidates: list[dict[str, object]] = [] - fanout = CultMeshSimulationObservationFanout( - CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), - peers, - verse_id="arena", - roles=["simulation-witness"], - interval_seconds=0.05, - on_candidate=seen_candidates.append, - ) - fanout.start() - fanout.enqueue(simulation_observation( - message_id="loop-observation", - witness_runtime_id="python-fanout", - shard_id="arena", - shard_epoch=1, - frame=211, - subject_id="eve", - claim_kind="block", - claim_hash=claim_hash, - claim_summary="dana blocked eve", - ).to_wire()) - deadline = time.monotonic() + 2.0 - while not seen_candidates and time.monotonic() < deadline: - time.sleep(0.01) - fanout.stop() - finally: - server.stop() - - self.assertEqual(fanout.pending_count(), 0) - self.assertTrue(seen_candidates) - self.assertEqual(seen_candidates[0]["claimHash"], claim_hash) - self.assertEqual(seen_candidates[0]["messageId"], "loop-observation") - - def test_cultmesh_discovery_client_fans_out_snapshots_to_peers(self) -> None: - document = define_database_entry_type( - "mesh.snapshot_fanout_note", - [("body", 0)], - schema_id="mesh.snapshot_fanout_note.v1", - ) - first = CultMesh.create_node(runtime_id="snapshot-peer-a") - first.database.register_document(document) - first.database.put_raw_message(document, "note:a", {"body": "from-a"}, shard_id="notes", shard_epoch=1) - second = CultMesh.create_node(runtime_id="snapshot-peer-b") - second.database.register_document(document) - second.database.put_raw_message(document, "note:b", {"body": "from-b"}, shard_id="notes", shard_epoch=1) - target = CultMesh.create_node(runtime_id="snapshot-target") - target.database.register_document(document) - first_server = CultMesh.serve_node(first) - second_server = CultMesh.serve_node(second) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="snapshot-peer-a", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{first_server.port}", f"cultnet://127.0.0.1:{first_server.port}"), - roles=("read-replica",), - )) - peers.upsert(CultMeshPeerCard( - peer_id="snapshot-peer-b", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{second_server.port}",), - roles=("read-replica",), - )) - - client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0) - responses = client.fanout_snapshot_responses( - peers, - verse_id="mesh", - roles=["read-replica"], - schema_ids=["mesh.snapshot_fanout_note.v1"], - ) - applied = client.sync_snapshots( - target.database, - peers, - verse_id="mesh", - roles=["read-replica"], - schema_ids=["mesh.snapshot_fanout_note.v1"], - ) - finally: - first_server.stop() - second_server.stop() - - self.assertEqual(len(responses), 2) - self.assertTrue(all(isinstance(response, CultNetRawSnapshotResponse) for response in responses)) - self.assertEqual( - sorted(record.record_key for response in responses for record in response.documents), - ["note:a", "note:b"], - ) - self.assertEqual(sorted(record.record_key for record in applied), ["note:a", "note:b"]) - self.assertEqual(target.database.get_required(document, "note:a")["body"], "from-a") - self.assertEqual(target.database.get_required(document, "note:b")["body"], "from-b") - - def test_cultmesh_snapshot_fanout_syncs_once_and_reports_applied_records(self) -> None: - document = define_database_entry_type( - "mesh.snapshot_loop_note", - [("body", 0)], - schema_id="mesh.snapshot_loop_note.v1", - ) - source = CultMesh.create_node(runtime_id="snapshot-loop-source") - source.database.register_document(document) - source.database.put_raw_message(document, "note:sync-once", {"body": "from-source"}) - target = CultMesh.create_node(runtime_id="snapshot-loop-target") - target.database.register_document(document) - server = CultMesh.serve_node(source) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="snapshot-loop-source", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("read-replica",), - )) - seen: list[str] = [] - fanout = CultMeshSnapshotFanout( - CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), - target.database, - peers, - verse_id="mesh", - roles=["read-replica"], - schema_ids=["mesh.snapshot_loop_note.v1"], - on_applied=lambda record: seen.append(record.record_key), - ) - - applied = fanout.sync_once() - finally: - server.stop() - - self.assertEqual([record.record_key for record in applied], ["note:sync-once"]) - self.assertEqual(seen, ["note:sync-once"]) - self.assertEqual(target.database.get_required(document, "note:sync-once")["body"], "from-source") - - def test_cultmesh_snapshot_fanout_runs_background_loop(self) -> None: - document = define_database_entry_type( - "mesh.snapshot_background_note", - [("body", 0)], - schema_id="mesh.snapshot_background_note.v1", - ) - source = CultMesh.create_node(runtime_id="snapshot-background-source") - source.database.register_document(document) - source.database.put_raw_message(document, "note:background", {"body": "from-background"}) - target = CultMesh.create_node(runtime_id="snapshot-background-target") - target.database.register_document(document) - server = CultMesh.serve_node(source) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="snapshot-background-source", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("read-replica",), - )) - seen: list[str] = [] - fanout = CultMeshSnapshotFanout( - CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), - target.database, - peers, - verse_id="mesh", - roles=["read-replica"], - schema_ids=["mesh.snapshot_background_note.v1"], - interval_seconds=0.05, - on_applied=lambda record: seen.append(record.record_key), - ) - fanout.start() - deadline = time.monotonic() + 2.0 - while not seen and time.monotonic() < deadline: - time.sleep(0.01) - fanout.stop() - finally: - server.stop() - - self.assertTrue(seen) - self.assertEqual(target.database.get_required(document, "note:background")["body"], "from-background") - - def test_cultmesh_peer_health_monitor_probes_reachable_and_failed_endpoints(self) -> None: - document = define_database_entry_type( - "mesh.health_note", - [("body", 0)], - schema_id="mesh.health_note.v1", - ) - node = CultMesh.create_node(runtime_id="health-peer") - node.database.register_document(document) - server = CultMesh.serve_node(node) - closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - closed_socket.bind(("127.0.0.1", 0)) - closed_port = closed_socket.getsockname()[1] - closed_socket.close() - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="health-peer", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("read-replica",), - )) - peers.upsert(CultMeshPeerCard( - peer_id="closed-peer", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{closed_port}",), - roles=("read-replica",), - )) - seen: list[tuple[str, bool]] = [] - monitor = CultMeshPeerHealthMonitor( - runtime_id="health-prober", - timeout_seconds=0.2, - on_update=lambda health: seen.append((health.peer_id, health.is_reachable)), - ) - - results = monitor.probe_catalog(peers, verse_id="mesh", roles=["read-replica"]) - finally: - server.stop() - - by_peer = {result.peer_id: result for result in results} - self.assertTrue(by_peer["health-peer"].is_reachable) - self.assertEqual(by_peer["health-peer"].runtime_id, "health-peer") - self.assertEqual(by_peer["health-peer"].runtime_kind, "python") - self.assertEqual(by_peer["health-peer"].display_name, "health-peer") - self.assertEqual(by_peer["health-peer"].supported_document_types, ("mesh.health_note",)) - self.assertIn("cultnet.hello.v0", by_peer["health-peer"].supported_message_versions) - self.assertIn("cultnet.error.v0", by_peer["health-peer"].supported_message_versions) - self.assertEqual(by_peer["health-peer"].supported_mutation_contracts[0]["documentType"], "mesh.health_note") - self.assertIn("shardLog", by_peer["health-peer"].supported_mutation_contracts[0]["operations"]) - self.assertFalse(by_peer["closed-peer"].is_reachable) - self.assertIsNone(by_peer["closed-peer"].runtime_kind) - self.assertEqual(by_peer["closed-peer"].supported_document_types, ()) - self.assertEqual(by_peer["closed-peer"].supported_message_versions, ()) - self.assertEqual(by_peer["closed-peer"].supported_mutation_contracts, ()) - self.assertTrue(by_peer["closed-peer"].error) - self.assertEqual({peer_id for peer_id, _ in seen}, {"health-peer", "closed-peer"}) - self.assertEqual(len(monitor.latest()), 2) - - def test_cultmesh_peer_health_monitor_runs_background_probe_loop(self) -> None: - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="health-loop-peer")) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="health-loop-peer", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("read-replica",), - )) - seen: list[str] = [] - monitor = CultMeshPeerHealthMonitor( - runtime_id="health-loop-prober", - timeout_seconds=0.2, - interval_seconds=0.05, - on_update=lambda health: seen.append(health.peer_id), - ) - monitor.start(peers, verse_id="mesh", roles=["read-replica"]) - deadline = time.monotonic() + 2.0 - while time.monotonic() < deadline: - latest = monitor.latest() - if latest and latest[0].is_reachable: - break - time.sleep(0.01) - monitor.stop() - finally: - server.stop() - - self.assertTrue(seen) - latest = monitor.latest() - self.assertEqual(latest[0].peer_id, "health-loop-peer") - self.assertTrue(latest[0].is_reachable) - - def test_cultmesh_daemon_entrypoint_serves_framed_cultnet_hello(self) -> None: - with tempfile.TemporaryDirectory() as temp: - ready_path = Path(temp) / "ready.json" - package_src = Path(__file__).resolve().parents[1] / "src" - env = dict(os.environ) - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - process = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "daemon-test-peer", - "--display-name", - "Daemon Test Peer", - "--port", - "0", - "--ready-file", - str(ready_path), - "--seed-interop-note", - "--seed-shard-id", - "daemon-interop", - "--verse-id", - "daemon-verse", - "--verse-display-name", - "Daemon Verse", - "--role", - "shard-primary", - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - deadline = time.monotonic() + 5.0 - while not ready_path.exists() and time.monotonic() < deadline: - if process.poll() is not None: - break - time.sleep(0.05) - if not ready_path.exists(): - _, stderr = process.communicate(timeout=2.0) - self.fail(f"cultmesh daemon did not publish readiness: {stderr}") - ready = json.loads(ready_path.read_text(encoding="utf-8")) - self.assertEqual(ready["schemaVersion"], READY_SCHEMA_VERSION) - self.assertEqual(ready["runtimeId"], "daemon-test-peer") - self.assertEqual(ready["runtimeKind"], "python") - self.assertEqual(ready["displayName"], "Daemon Test Peer") - self.assertEqual(ready["endpoint"], f"cultnet://127.0.0.1:{ready['port']}") - self.assertEqual(ready["shardIds"], ["daemon-interop"]) - self.assertEqual(ready["supportedDocumentTypes"], ["cultcache.interop-note"]) - self.assertIn("cultnet.hello.v0", ready["supportedMessageVersions"]) - self.assertIn("cultnet.document_put_raw.v0", ready["supportedMessageVersions"]) - self.assertNotIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"]) - self.assertEqual(ready["supportedMutationContracts"][0]["documentType"], "cultcache.interop-note") - self.assertIn("shardLog", ready["supportedMutationContracts"][0]["operations"]) - self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": None}) - self.assertEqual(ready["verses"][0]["verseId"], "daemon-verse") - self.assertEqual(ready["verses"][0]["displayName"], "Daemon Verse") - self.assertEqual(ready["verses"][0]["discoveryEndpoints"], [ready["endpoint"]]) - self.assertEqual(ready["verses"][0]["authorityRuntimeIds"], ["daemon-test-peer"]) - self.assertEqual(ready["peers"][0]["peerId"], "daemon-test-peer") - self.assertEqual(ready["peers"][0]["endpoints"], [ready["endpoint"]]) - self.assertEqual(ready["peers"][0]["roles"], ["shard-primary"]) - self.assertEqual(ready["peers"][0]["shardIds"], ["daemon-interop"]) - - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - hello_response = client.request( - hello(runtime_id="daemon-test-prober"), - expected_schema_version="cultnet.hello.v0", - ) - - self.assertEqual(hello_response["runtimeId"], "daemon-test-peer") - self.assertEqual(hello_response["displayName"], "Daemon Test Peer") - self.assertIn("cultnet.hello.v0", hello_response["supportedMessageVersions"]) - self.assertIn("cultnet.schema_catalog_request.v0", hello_response["supportedMessageVersions"]) - self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) - self.assertEqual(hello_response["supportedDocumentTypes"], ["cultcache.interop-note"]) - self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed") - self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], int(ready["port"])) - self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"]) - self.assertEqual(ready["supportedMutationContracts"], hello_response["supportedMutationContracts"]) - self.assertEqual(ready["transportProfiles"], hello_response["transportProfiles"]) - - schema_response = client.fetch_schema_catalog(schema_ids=["cultcache.interop-note"], include_schema_json=True) - self.assertEqual(schema_response["schemas"][0]["schemaVersion"], INTEROP_SCHEMA_VERSION) - self.assertEqual(schema_response["schemas"][0]["documentType"], "cultcache.interop-note") - - snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) - self.assertEqual(snapshot_response.documents[0].record_key, "note:daemon-test-peer") - note = interop_note_document.decode_payload(snapshot_response.documents[0].payload) - self.assertEqual(note["authorRuntimeId"], "daemon-test-peer") - self.assertIn("daemon", note["tags"]) - - shard_catalog = client.fetch_shard_catalog(schema_ids=["cultcache.interop-note"]) - self.assertEqual(shard_catalog["shards"][0]["shardId"], "daemon-interop") - self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["cultcache.interop-note"]) - - shard_log = client.fetch_shard_log_response(shard_id="daemon-interop", shard_epoch=1) - self.assertFalse(shard_log.resync_required) - self.assertEqual(shard_log.entries[0].sequence, 1) - self.assertEqual(shard_log.entries[0].change_kind, "added") - self.assertIsNotNone(shard_log.entries[0].raw_document) - assert shard_log.entries[0].raw_document is not None - self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:daemon-test-peer") - - discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") - self.assertEqual(verses[0].verse_id, "daemon-verse") - self.assertEqual(verses[0].display_name, "Daemon Verse") - self.assertEqual(verses[0].discovery_endpoints, (ready["endpoint"],)) - self.assertEqual(verses[0].authority_runtime_ids, ("daemon-test-peer",)) - self.assertEqual([verse.to_wire() for verse in verses], ready["verses"]) - - peers = discovery_client.fetch_peers(verse_id="daemon-verse", roles=["shard-primary"]) - self.assertEqual(peers[0].peer_id, "daemon-test-peer") - self.assertEqual(peers[0].endpoints, (ready["endpoint"],)) - self.assertEqual(peers[0].roles, ("shard-primary",)) - self.assertEqual(peers[0].shard_ids, ("daemon-interop",)) - self.assertEqual([peer.to_wire() for peer in peers], ready["peers"]) - - monitor = CultMeshPeerHealthMonitor(runtime_id="daemon-health-prober", timeout_seconds=2.0) - health = monitor.probe_peer(peers[0])[0] - self.assertTrue(health.is_reachable) - self.assertEqual(health.runtime_id, "daemon-test-peer") - self.assertEqual(health.supported_document_types, ("cultcache.interop-note",)) - self.assertEqual(health.supported_mutation_contracts[0]["documentType"], "cultcache.interop-note") - finally: - process.terminate() - try: - process.communicate(timeout=5.0) - except subprocess.TimeoutExpired: - process.kill() - process.communicate(timeout=5.0) - - def test_cultmesh_daemon_restarts_with_persisted_cache_and_shard_log(self) -> None: - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - cache_path = root / "mesh.cc" - shard_log_path = root / "shard-log" - env = dict(os.environ) - package_src = Path(__file__).resolve().parents[1] / "src" - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - - first_ready = root / "first-ready.json" - first = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "durable-daemon-peer", - "--port", - "0", - "--cache-file", - str(cache_path), - "--enable-durable-shard-logs", - "--shard-log-file", - str(shard_log_path), - "--seed-interop-note", - "--seed-shard-id", - "durable-interop", - "--ready-file", - str(first_ready), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - self._wait_for_ready_file(first, first_ready) - finally: - self._terminate_process(first) - - second_ready = root / "second-ready.json" - second = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "durable-daemon-peer", - "--port", - "0", - "--cache-file", - str(cache_path), - "--enable-durable-shard-logs", - "--shard-log-file", - str(shard_log_path), - "--register-interop-note", - "--verse-id", - "durable-verse", - "--role", - "shard-primary", - "--ready-file", - str(second_ready), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - ready = self._wait_for_ready_file(second, second_ready) - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - - snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) - self.assertEqual(snapshot_response.documents[0].record_key, "note:durable-daemon-peer") - note = interop_note_document.decode_payload(snapshot_response.documents[0].payload) - self.assertEqual(note["authorRuntimeId"], "durable-daemon-peer") - - shard_log = client.fetch_shard_log_response(shard_id="durable-interop", shard_epoch=1) - self.assertFalse(shard_log.resync_required) - self.assertEqual(shard_log.entries[0].sequence, 1) - self.assertIsNotNone(shard_log.entries[0].raw_document) - assert shard_log.entries[0].raw_document is not None - self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:durable-daemon-peer") - - discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - peers = discovery_client.fetch_peers(verse_id="durable-verse", roles=["shard-primary"]) - self.assertEqual(peers[0].shard_ids, ("durable-interop",)) - finally: - self._terminate_process(second) - - def test_cultmesh_daemon_serves_live_database_subscription_mutations(self) -> None: - with tempfile.TemporaryDirectory() as temp: - ready_path = Path(temp) / "ready.json" - package_src = Path(__file__).resolve().parents[1] / "src" - env = dict(os.environ) - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - process = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "live-daemon-peer", - "--port", - "0", - "--seed-interop-note", - "--seed-shard-id", - "live-interop", - "--ready-file", - str(ready_path), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - ready = self._wait_for_ready_file(process, ready_path) - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - live_note = { - "schemaVersion": INTEROP_SCHEMA_VERSION, - "documentId": "note:daemon-live", - "authorRuntimeId": "daemon-live-client", - "title": "daemon live subscription note", - "body": "live subscription put from Python daemon smoke", - "tags": ["daemon", "subscription", "interop"], - } - - with client.subscribe_database( - subscription_id="daemon-live-sub", - schema_ids=["cultcache.interop-note"], - ) as subscription: - snapshot = subscription.read_next_snapshot_response() - subscription.send(document_put_raw( - message_id="daemon-live-put", - key="note:daemon-live", - schema_id="cultcache.interop-note", - stored_at="2026-06-14T00:00:00Z", - payload=interop_note_document.encode_payload(live_note), - source_runtime_id="daemon-live-client", - shard_id="live-interop", - shard_epoch=1, - )) - change = subscription.read_next_change() - - self.assertEqual(snapshot.documents[0].record_key, "note:live-daemon-peer") - self.assertEqual(change.change_kind, "added") - self.assertEqual(change.record_key, "note:daemon-live") - self.assertIsNotNone(change.raw_document) - assert change.raw_document is not None - self.assertEqual( - interop_note_document.decode_payload(change.raw_document.payload), - live_note, - ) - - shard_log = client.fetch_shard_log_response(shard_id="live-interop", shard_epoch=1) - self.assertFalse(shard_log.resync_required) - self.assertEqual([entry.sequence for entry in shard_log.entries], [1, 2]) - self.assertEqual(shard_log.entries[1].change_kind, "added") - self.assertIsNotNone(shard_log.entries[1].raw_document) - assert shard_log.entries[1].raw_document is not None - self.assertEqual(shard_log.entries[1].raw_document.record_key, "note:daemon-live") - finally: - self._terminate_process(process) - - def test_cultmesh_daemon_serves_compacted_shard_log_resync_boundary(self) -> None: - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - cache_path = root / "mesh.cc" - shard_log_path = root / "shard-log" - store = CultNetFileShardMutationLogStore(shard_log_path) - node = CultMesh.create_node( - cache_path, - runtime_id="compacted-daemon-peer", - enable_durable_shard_logs=True, - shard_log_path=shard_log_path, - ) - node.database.register_document(interop_note_document) - node.database.put_raw_message( - interop_note_document, - "note:compacted-one", - { - "schemaVersion": INTEROP_SCHEMA_VERSION, - "documentId": "note:compacted-one", - "authorRuntimeId": "compacted-daemon-peer", - "title": "compacted daemon note one", - "body": "first retained cache value", - "tags": ["daemon", "compaction"], - }, - message_id="compacted-put-1", - shard_id="compacted-interop", - shard_epoch=7, - ) - node.database.put_raw_message( - interop_note_document, - "note:compacted-two", - { - "schemaVersion": INTEROP_SCHEMA_VERSION, - "documentId": "note:compacted-two", - "authorRuntimeId": "compacted-daemon-peer", - "title": "compacted daemon note two", - "body": "second retained cache value", - "tags": ["daemon", "compaction"], - }, - message_id="compacted-put-2", - shard_id="compacted-interop", - shard_epoch=7, - ) - store.compact_through("compacted-interop", 1) - - ready_path = root / "ready.json" - package_src = Path(__file__).resolve().parents[1] / "src" - env = dict(os.environ) - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - process = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "compacted-daemon-peer", - "--port", - "0", - "--cache-file", - str(cache_path), - "--enable-durable-shard-logs", - "--shard-log-file", - str(shard_log_path), - "--register-interop-note", - "--ready-file", - str(ready_path), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - ready = self._wait_for_ready_file(process, ready_path) - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - - snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) - self.assertEqual( - {document.record_key for document in snapshot_response.documents}, - {"note:compacted-one", "note:compacted-two"}, - ) - - compacted = client.fetch_shard_log_response( - shard_id="compacted-interop", - shard_epoch=7, - after_sequence=0, - ) - retained = client.fetch_shard_log_response( - shard_id="compacted-interop", - shard_epoch=7, - after_sequence=1, - ) - - self.assertTrue(compacted.resync_required) - self.assertEqual(compacted.reason, "compacted_history") - self.assertEqual(compacted.compacted_through, 1) - self.assertEqual(compacted.entries, ()) - self.assertFalse(retained.resync_required) - self.assertEqual([entry.sequence for entry in retained.entries], [2]) - self.assertIsNotNone(retained.entries[0].raw_document) - assert retained.entries[0].raw_document is not None - self.assertEqual(retained.entries[0].raw_document.record_key, "note:compacted-two") - finally: - self._terminate_process(process) - - def test_cultmesh_daemon_accepts_schema_write_forwarder_mutations(self) -> None: - with tempfile.TemporaryDirectory() as temp: - ready_path = Path(temp) / "ready.json" - package_src = Path(__file__).resolve().parents[1] / "src" - env = dict(os.environ) - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - process = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "forwarder-daemon-peer", - "--port", - "0", - "--register-interop-note", - "--ready-file", - str(ready_path), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - ready = self._wait_for_ready_file(process, ready_path) - endpoint = f"cultnet://127.0.0.1:{ready['port']}" - shard = CultNetShardDescriptor( - shard_id="forwarded-interop", - owner_runtime_id="forwarder-daemon-peer", - epoch=3, - is_primary=False, - schema_ids=("cultcache.interop-note",), - primary_endpoints=(endpoint,), - ) - forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0) - note = { - "schemaVersion": INTEROP_SCHEMA_VERSION, - "documentId": "note:forwarded-daemon", - "authorRuntimeId": "forwarding-client", - "title": "forwarded daemon note", - "body": "forwarded through the schema write-forwarder", - "tags": ["daemon", "forwarded"], - } - - forwarder.forward_put(shard, document_put_raw( - message_id="forwarded-daemon-put", - key="note:forwarded-daemon", - schema_id="cultcache.interop-note", - stored_at="2026-06-14T00:00:00Z", - payload=interop_note_document.encode_payload(note), - source_runtime_id="forwarding-client", - ).to_wire()) - - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - self._eventually(lambda: bool(client.fetch_snapshot_response( - schema_ids=["cultcache.interop-note"], - record_keys=["note:forwarded-daemon"], - ).documents)) - snapshot = client.fetch_snapshot_response( - schema_ids=["cultcache.interop-note"], - record_keys=["note:forwarded-daemon"], - ) - self.assertEqual( - interop_note_document.decode_payload(snapshot.documents[0].payload), - note, - ) - - forwarder.forward_delete(shard, document_delete( - message_id="forwarded-daemon-delete", - schema_id="cultcache.interop-note", - record_key="note:forwarded-daemon", - ).to_wire()) - self._eventually(lambda: not client.fetch_snapshot_response( - schema_ids=["cultcache.interop-note"], - record_keys=["note:forwarded-daemon"], - ).documents) - - shard_log = client.fetch_shard_log_response(shard_id="forwarded-interop", shard_epoch=3) - self.assertEqual([entry.change_kind for entry in shard_log.entries], ["added", "removed"]) - self.assertEqual(shard_log.entries[0].put["shardId"], "forwarded-interop") - self.assertEqual(shard_log.entries[0].put["shardEpoch"], 3) - self.assertEqual(shard_log.entries[1].delete["shardId"], "forwarded-interop") - self.assertEqual(shard_log.entries[1].delete["shardEpoch"], 3) - finally: - self._terminate_process(process) - - def test_cultmesh_daemon_enforces_snapshot_document_limit_as_peer_error(self) -> None: - with tempfile.TemporaryDirectory() as temp: - ready_path = Path(temp) / "ready.json" - package_src = Path(__file__).resolve().parents[1] / "src" - env = dict(os.environ) - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - process = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "limited-daemon-peer", - "--port", - "0", - "--seed-interop-note", - "--max-snapshot-documents", - "0", - "--ready-file", - str(ready_path), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - ready = self._wait_for_ready_file(process, ready_path) - self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": 0}) - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - - with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded") as raised: - client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) - - self.assertEqual(raised.exception.response["schemaVersion"], "cultnet.error.v0") - self.assertEqual(raised.exception.response["code"], "snapshot_document_limit_exceeded") - self.assertEqual(raised.exception.response["details"]["documentCount"], 1) - self.assertEqual(raised.exception.response["details"]["maxSnapshotDocuments"], 0) - finally: - self._terminate_process(process) - - def test_cultmesh_daemon_serves_opt_in_simulation_observations(self) -> None: - with tempfile.TemporaryDirectory() as temp: - ready_path = Path(temp) / "ready.json" - package_src = Path(__file__).resolve().parents[1] / "src" - env = dict(os.environ) - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - process = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "simulation-daemon-peer", - "--port", - "0", - "--enable-simulation-observations", - "--simulation-minimum-witnesses", - "2", - "--simulation-quorum-ratio", - "1.0", - "--verse-id", - "simulation-verse", - "--ready-file", - str(ready_path), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - ready = self._wait_for_ready_file(process, ready_path) - self.assertIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"]) - self.assertIn("cultnet.simulation_consensus_candidate.v0", ready["supportedMessageVersions"]) - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - hello_response = client.request( - hello(runtime_id="simulation-daemon-prober"), - expected_schema_version="cultnet.hello.v0", - ) - self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"]) - discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - peers = discovery_client.fetch_peers(verse_id="simulation-verse", roles=["simulation-observer"]) - self.assertEqual(peers[0].peer_id, "simulation-daemon-peer") - self.assertIn("simulation-observer", peers[0].roles) - self.assertIn("read-replica", peers[0].roles) - - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") - first = client.request( - simulation_observation( - message_id="daemon-sim-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 = client.request( - simulation_observation( - message_id="daemon-sim-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", - ) - - self.assertEqual(first["messageId"], "daemon-sim-1") - self.assertFalse(first["hasQuorum"]) - self.assertEqual(first["witnessCount"], 1) - self.assertEqual(second["messageId"], "daemon-sim-2") - self.assertTrue(second["hasQuorum"]) - self.assertEqual(second["witnessCount"], 2) - self.assertEqual(second["claimHash"], claim_hash) - finally: - self._terminate_process(process) - - def _wait_for_ready_file(self, process: subprocess.Popen[str], ready_path: Path) -> dict[str, object]: - deadline = time.monotonic() + 5.0 - while not ready_path.exists() and time.monotonic() < deadline: - if process.poll() is not None: - break - time.sleep(0.05) - if not ready_path.exists(): - _, stderr = process.communicate(timeout=2.0) - self.fail(f"cultmesh daemon did not publish readiness: {stderr}") - return json.loads(ready_path.read_text(encoding="utf-8")) - - def _terminate_process(self, process: subprocess.Popen[str]) -> None: - process.terminate() - try: - process.communicate(timeout=5.0) - except subprocess.TimeoutExpired: - process.kill() - process.communicate(timeout=5.0) - - def test_cultmesh_local_server_serves_node_and_catalogs_over_clients(self) -> None: - document = define_database_entry_type( - "mesh.server_note", - [("body", 0)], - schema_id="mesh.server_note.v1", - ) - node = CultMesh.create_node(runtime_id="mesh-server") - node.database.register_document(document) - node.database.put_raw_message(document, "note:1", {"body": "served"}, shard_id="notes", shard_epoch=1) - observation_hub = CultNetSimulationObservationHub( - CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0) - ) - seen_candidates: list[CultNetSimulationConsensusCandidate] = [] - unsubscribe_candidates = observation_hub.watch_candidates(seen_candidates.append) - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") - verses = CultMeshVerseCatalog() - verses.upsert( - CultMeshVerseDescriptor( - verse_id="server-verse", - display_name="Server Verse", - authority_model="local", - compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules"), - ) - ) - peers = CultMeshPeerCatalog() - peers.upsert( - CultMeshPeerCard( - peer_id="mesh-server", - verse_id="server-verse", - endpoints=("cultnet://127.0.0.1:0",), - roles=("read-replica",), - ) - ) - - server = CultMesh.serve_node( - node, - verse_catalog=verses, - peer_catalog=peers, - observation_hub=observation_hub, - display_name="Mesh Server", - ) - try: - raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) - hello_response = raw_client.request(hello(runtime_id="probe"), expected_schema_version="cultnet.hello.v0") - schema_response = raw_client.fetch_schema_catalog(schema_ids=["mesh.server_note.v1"], include_schema_json=True) - rudp_client = CultMesh.create_client( - endpoint=f"rudp://127.0.0.1:{server.port}", - timeout_seconds=2.0, - connection_id=server.rudp_connection_id, - runtime_id="rudp-probe", - ) - rudp_hello_response = rudp_client.request( - hello(runtime_id="rudp-probe"), - expected_schema_version="cultnet.hello.v0", - ) - rudp_schema_response = rudp_client.fetch_schema_catalog( - schema_ids=["mesh.server_note.v1"], - include_schema_json=True, - ) - wire_schema_response = raw_client.fetch_schema_catalog(kinds=["wire_message"], include_schema_json=True) - synced_schema_catalog = CultNetSchemaCatalog() - synced_wire_descriptors = raw_client.sync_schema_catalog( - synced_schema_catalog, - kinds=["wire_message"], - include_schema_json=True, - ) - snapshot_response = raw_client.fetch_snapshot(schema_ids=["mesh.server_note.v1"]) - typed_snapshot_response = raw_client.fetch_snapshot_response(schema_ids=["mesh.server_note.v1"]) - shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.server_note.v1"]) - synced_shard_catalog = CultNetShardCatalog() - synced_shards = raw_client.sync_shard_catalog(synced_shard_catalog, schema_ids=["mesh.server_note.v1"]) - shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=1) - typed_shard_log = raw_client.fetch_shard_log_response(shard_id="notes", shard_epoch=1) - self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed") - self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], server.port) - stale_shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=0) - discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0) - fetched_verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") - fetched_peers = discovery_client.fetch_peers(verse_id="server-verse", roles=["read-replica"]) - first_candidate = raw_client.request( - simulation_observation( - message_id="obs-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", - ) - quorum_candidate = raw_client.request( - simulation_observation( - message_id="obs-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", - ) - with raw_client.subscribe_database(subscription_id="sub-server", schema_ids=["mesh.server_note.v1"]) as subscription: - subscription_snapshot = subscription.read_next_snapshot_response() - subscription.send(document_put_raw( - message_id="server-put", - key="note:2", - schema_id="mesh.server_note.v1", - stored_at="2026-06-14T00:00:00Z", - payload=document.encode_payload({"body": "subscribed"}), - shard_id="primary", - shard_epoch=1, - )) - subscription_change = subscription.read_next_change() - subscription.send(document_delete( - message_id="server-delete", - schema_id="mesh.server_note.v1", - record_key="note:2", - shard_id="primary", - shard_epoch=1, - )) - subscription_delete = subscription.read_next_change() - finally: - unsubscribe_candidates() - server.stop() - - self.assertEqual(hello_response["runtimeId"], "mesh-server") - self.assertEqual(hello_response["displayName"], "Mesh Server") - self.assertIn("cultnet.database_subscribe.v0", hello_response["supportedMessageVersions"]) - self.assertIn("cultnet.error.v0", hello_response["supportedMessageVersions"]) - self.assertIn("cultnet.document_put_raw.v0", hello_response["supportedMessageVersions"]) - self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) - self.assertEqual(hello_response["supportedMutationContracts"][0]["documentType"], "mesh.server_note") - self.assertIn("documentDelete", hello_response["supportedMutationContracts"][0]["operations"]) - self.assertIn("shardLog", hello_response["supportedMutationContracts"][0]["operations"]) - advertised_transports = { - transport["protocol"]: transport - for profile in hello_response["transportProfiles"] - for transport in profile["transports"] - } - self.assertEqual(advertised_transports["tcp_framed"]["port"], server.port) - self.assertEqual(advertised_transports["rudp"]["port"], server.port) - self.assertEqual(rudp_hello_response["runtimeId"], "mesh-server") - self.assertEqual(rudp_schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1") - self.assertEqual(schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1") - self.assertIn("schemaJson", schema_response["schemas"][0]) - self.assertIn("cultnet.document_delete.v0", schema_response["schemas"][0]["wireContracts"]) - self.assertIn("cultnet.shard_log_response.v0", schema_response["schemas"][0]["wireContracts"]) - wire_descriptors = {schema["schemaVersion"]: schema for schema in wire_schema_response["schemas"]} - self.assertEqual(wire_descriptors["cultnet.error.v0"]["kind"], "wire_message") - self.assertEqual(wire_descriptors["cultnet.document_put_raw.v0"]["kind"], "wire_message") - self.assertIn("schemaJson", wire_descriptors["cultnet.document_put_raw.v0"]) - self.assertIn("cultmesh.peer_exchange_response.v0", wire_descriptors) - self.assertIn("cultnet.error.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors}) - self.assertIn("cultnet.document_put_raw.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors}) - self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json")) - self.assertEqual(snapshot_response["documents"][0]["recordKey"], "note:1") - self.assertEqual(typed_snapshot_response.documents[0].record_key, "note:1") - self.assertEqual(typed_snapshot_response.documents[0].schema_id, "mesh.server_note.v1") - self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["mesh.server_note.v1"]) - self.assertEqual(shard_catalog["shards"][0]["epoch"], 1) - self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes") - self.assertEqual(synced_shards[0].shard_id, "notes") - self.assertEqual(synced_shards[0].epoch, 1) - self.assertEqual(synced_shard_catalog.get("notes"), synced_shards[0]) - self.assertEqual(shard_log["entries"][0]["changeKind"], "added") - self.assertTrue(stale_shard_log["resyncRequired"]) - self.assertEqual(stale_shard_log["reason"], "stale_epoch") - self.assertEqual(stale_shard_log["shardEpoch"], 1) - self.assertEqual(stale_shard_log["entries"], []) - self.assertEqual(typed_shard_log.last_sequence, 1) - self.assertEqual(typed_shard_log.entries[0].put["document"]["recordKey"], "note:1") - self.assertIsInstance(typed_shard_log.entries[0].raw_document, CultNetRawDocumentRecord) - self.assertEqual(typed_shard_log.entries[0].raw_document.record_key, "note:1") - self.assertEqual(fetched_verses[0].verse_id, "server-verse") - self.assertEqual(fetched_peers[0].peer_id, "mesh-server") - self.assertEqual(first_candidate["messageId"], "obs-1") - self.assertFalse(first_candidate["hasQuorum"]) - self.assertEqual(quorum_candidate["messageId"], "obs-2") - self.assertEqual(quorum_candidate["claimHash"], claim_hash) - self.assertTrue(quorum_candidate["hasQuorum"]) - self.assertEqual(quorum_candidate["witnessCount"], 2) - self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash]) - self.assertIsInstance(subscription_snapshot, CultNetRawSnapshotResponse) - self.assertEqual(subscription_snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0") - self.assertEqual(subscription_change.change_kind, "added") - self.assertEqual(subscription_change.record_key, "note:2") - self.assertEqual(subscription_change.document["recordKey"], "note:2") - self.assertIsInstance(subscription_change.raw_document, CultNetRawDocumentRecord) - self.assertEqual(subscription_change.raw_document.schema_id, "mesh.server_note.v1") - self.assertEqual(subscription_delete.change_kind, "removed") - self.assertEqual(subscription_delete.record_key, "note:2") - self.assertIsNone(subscription_delete.raw_document) - self.assertIsNone(node.database.get(document, "note:2")) - - def test_cultmesh_local_server_rejects_oversized_snapshot_responses(self) -> None: - document = define_database_entry_type( - "mesh.snapshot_limit_note", - [("body", 0)], - schema_id="mesh.snapshot_limit_note.v1", - ) - node = CultMesh.create_node(runtime_id="mesh-snapshot-limits") - node.database.register_document(document) - node.database.put_raw_message(document, "note:1", {"body": "first"}) - node.database.put_raw_message(document, "note:2", {"body": "second"}) - - server = CultMesh.serve_node(node, max_snapshot_documents=1) - try: - raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) - filtered_snapshot = raw_client.fetch_snapshot(record_keys=["note:1"]) - error = raw_client.request( - snapshot_request(schema_ids=["mesh.snapshot_limit_note.v1"]), - expected_schema_version="cultnet.error.v0", - ) - with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded"): - raw_client.fetch_snapshot(schema_ids=["mesh.snapshot_limit_note.v1"]) - finally: - server.stop() - - self.assertEqual(filtered_snapshot["schemaVersion"], "cultnet.snapshot_response_raw.v0") - self.assertEqual([document["recordKey"] for document in filtered_snapshot["documents"]], ["note:1"]) - self.assertEqual(error["schemaVersion"], "cultnet.error.v0") - self.assertIn("Snapshot document limit exceeded", error["error"]) - self.assertEqual(error["code"], "snapshot_document_limit_exceeded") - self.assertEqual(error["details"]["documentCount"], 2) - self.assertEqual(error["details"]["maxSnapshotDocuments"], 1) - - def test_cultmesh_local_server_returns_errors_for_bad_requests(self) -> None: - document = define_database_entry_type( - "mesh.error_note", - [("body", 0)], - schema_id="mesh.error_note.v1", - ) - node = CultMesh.create_node(runtime_id="mesh-error-server") - node.database.register_document(document) - - server = CultMesh.serve_node(node) - try: - raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) - unsupported = raw_client.request( - {"schemaVersion": "cultnet.nope.v0", "messageId": "bad-schema"}, - expected_schema_version="cultnet.error.v0", - ) - malformed_put = raw_client.request( - {"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "bad-put"}, - expected_schema_version="cultnet.error.v0", - ) - malformed_delete = raw_client.request( - {"schemaVersion": "cultnet.document_delete.v0", "messageId": "bad-delete", "schemaId": "mesh.error_note.v1"}, - expected_schema_version="cultnet.error.v0", - ) - finally: - server.stop() - - self.assertEqual(unsupported["messageId"], "bad-schema") - self.assertEqual(unsupported["code"], "unsupported_schema_version") - self.assertEqual(unsupported["details"]["schemaVersion"], "cultnet.nope.v0") - self.assertIn("Unsupported CultNet message schema", unsupported["error"]) - self.assertEqual(malformed_put["messageId"], "bad-put") - self.assertEqual(malformed_put["code"], "malformed_document_put") - self.assertEqual(malformed_delete["messageId"], "bad-delete") - self.assertEqual(malformed_delete["code"], "malformed_document_delete") - - def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None: - peer = CultMeshPeerCard( - peer_id="voidbot-local", - verse_id="local", - endpoints=("cultmesh://localhost",), - roles=("shard-primary",), - authority_lease_id="lease:voidbot-local", - ) - leases = CultMeshAuthorityLeaseCatalog() - peers = CultMeshPeerCatalog() - peers.upsert(peer) - now = datetime.now(UTC) - self.assertFalse(leases.is_authorized(peer, "shard-primary", at=now)) - self.assertEqual(peers.find_authorized("local", "shard-primary", leases, at=now), []) - - leases.upsert( - CultMeshAuthorityLease( - lease_id="lease:voidbot-local", - verse_id="local", - peer_id="voidbot-local", - roles=("shard-primary", "shard-primary", ""), - valid_from=now - timedelta(seconds=1), - expires_at=now + timedelta(seconds=1), - shard_ids=("shard-a",), - issuer_runtime_id="odin", - signature="signed", - ) - ) - - lease = leases.get("lease:voidbot-local") - self.assertIsNotNone(lease) - assert lease is not None - self.assertEqual(leases.leases, (lease,)) - self.assertEqual(lease.roles, ("shard-primary",)) - self.assertEqual(lease.issuer_runtime_id, "odin") - self.assertEqual(lease.signature, "signed") - self.assertTrue(lease.is_valid_at(now)) - self.assertTrue(lease.covers(peer, "shard-primary", shard_id="shard-a", at=now)) - self.assertFalse(lease.covers(peer, "shard-primary", shard_id="shard-b", at=now)) - self.assertTrue(leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) - self.assertEqual( - peers.find_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now), - [peer], - ) - self.assertEqual( - peers.first_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now), - peer, - ) - self.assertIsNone(peers.first_authorized("local", "read-replica", leases, at=now)) - self.assertFalse(leases.is_authorized(peer, "shard-primary", shard_id="shard-b", at=now)) - self.assertFalse(leases.is_authorized(peer, "read-replica", at=now)) - self.assertIsNone(leases.get("missing")) - - def test_cultmesh_authority_lease_catalog_can_require_verified_signatures(self) -> None: - peer = CultMeshPeerCard( - peer_id="voidbot-local", - verse_id="local", - endpoints=("cultmesh://localhost",), - roles=("shard-primary",), - shard_ids=("shard-a",), - authority_lease_id="lease:voidbot-local", - ) - now = datetime.now(UTC) - unsigned = CultMeshAuthorityLease( - lease_id="lease:voidbot-local", - verse_id="local", - peer_id="voidbot-local", - roles=("shard-primary",), - valid_from=now - timedelta(seconds=1), - expires_at=now + timedelta(seconds=30), - shard_ids=("shard-a",), - issuer_runtime_id="odin", - ) - verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"lease-secret"}) - signed = verifier.issue(unsigned) - strict_leases = CultMesh.create_authority_lease_catalog( - signature_verifier=verifier.verify, - require_verified_signatures=True, - ) - strict_leases.upsert(unsigned) - self.assertFalse(strict_leases.is_verified(unsigned.lease_id)) - self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) - - strict_leases.upsert(signed) - self.assertTrue(strict_leases.is_verified(signed.lease_id)) - self.assertTrue(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) - - wrong_key = CultMesh.create_authority_lease_catalog( - signature_verifier=CultMeshHmacAuthorityLeaseVerifier({"odin": b"wrong-secret"}).verify, - require_verified_signatures=True, - ) - wrong_key.upsert(signed) - self.assertFalse(wrong_key.is_verified(signed.lease_id)) - self.assertFalse(wrong_key.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) - - tampered = CultMeshAuthorityLease( - lease_id=signed.lease_id, - verse_id=signed.verse_id, - peer_id=signed.peer_id, - roles=("shard-primary", "read-replica"), - valid_from=signed.valid_from, - expires_at=signed.expires_at, - shard_ids=signed.shard_ids, - issuer_runtime_id=signed.issuer_runtime_id, - signature=signed.signature, - ) - strict_leases.upsert(tampered) - self.assertFalse(strict_leases.is_verified(tampered.lease_id)) - self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) - - def test_cultmesh_stream_catalog_negotiates_transport_and_latest_frame(self) -> None: - streams = CultMeshStreamCatalog() - streams.declare( - CultMeshStreamDescriptor( - stream_id="mimir:kiyo-pro", - verse_id="studio", - owner_peer_id="starfire", - kind="video", - label="Kiyo Pro", - clock={"clockDomainId": "starfire-qpc", "confidence": 0.25}, - video={"width": 1920, "height": 1080, "pixelFormat": "YUY2", "framesPerSecond": 30}, - preferred_transports=("shared-d3d12-texture", "shared-memory", "cultcache-page"), - max_in_flight_frames=3, - ) - ) - - negotiation = streams.negotiate( - "mimir:kiyo-pro", - CultMeshStreamConsumerProfile( - peer_id="fensalir", - verse_id="studio", - supported_transports=("shared-d3d12-texture", "cultcache-page"), - accepted_kinds=("video",), - can_import_gpu_handles=True, - max_in_flight_frames=2, - ), - ) - - self.assertEqual(negotiation.transport, "shared-d3d12-texture") - self.assertEqual(negotiation.max_in_flight_frames, 2) - self.assertEqual(negotiation.copy_budget, "zero-copy-target") - - streams.publish_frame( - CultMeshStreamFrameHandle( - stream_id="mimir:kiyo-pro", - sequence=42, - timestamp_ns=1_000_000_000, - duration_ns=33_333_334, - transport="shared-d3d12-texture", - native_handle="0xfeed", - fence_handle="0xbeef", - fence_value=7, - unavoidable_copy_count=0, - ) - ) - self.assertEqual(streams.latest_frame("mimir:kiyo-pro").sequence, 42) - if __name__ == "__main__": unittest.main() diff --git a/packages/cultcache-rs/Cargo.toml b/packages/cultcache-rs/Cargo.toml index b11a909e..23d53801 100644 --- a/packages/cultcache-rs/Cargo.toml +++ b/packages/cultcache-rs/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "cultcache-rs" -version = "0.1.0" +version = "0.2.0" edition = "2024" +license = "MIT" publish = false [workspace] @@ -17,6 +18,7 @@ chrono = { version = "0.4", features = ["serde"] } cultcache-rs-derive = { path = "cultcache-rs-derive" } fs2 = "0.4" rmp-serde = "1.3" +redb = "4.1.0" serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" serde_json = "1" @@ -25,3 +27,6 @@ uuid = { version = "1", features = ["v4"] } [dev-dependencies] pretty_assertions = "1.4" tempfile = "3" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index 2d75d94e..1ad01eb0 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -53,7 +53,7 @@ struct Settings { let mut cache = CultCache::new(); cache.register_entry_type::()?; -cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.msgpack")); +cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc")); cache.pull_all_backing_stores()?; cache.put("app", &Settings { @@ -136,7 +136,7 @@ pub struct PlayerData { let mut cache = CultCache::builder() .with_generated_entries(game_entries()) - .with_generic_store(SingleFileMessagePackBackingStore::new("cache.msgpack")) + .with_generic_store(SingleFileMessagePackBackingStore::new("cache.cc")) .build()?; cache.pull_all_backing_stores()?; @@ -173,39 +173,71 @@ without pretending Rust has runtime reflection hiding under the floorboards. - `get_envelope::` - `get_required_envelope::` - `get_all::` +- `get_all_with_keys::` +- `soa::` - `put::` - `put_envelope::` - `update::` - `delete::` - `snapshot` +- `snapshot_soa` +- `load_soa` - `SingleFileMessagePackBackingStore` +## Structure Of Arrays + +The C# CultCache hot path exposes CPU-local SoA scans as: + +```csharp +Span x = cache.Soa() + .Column(nameof(PlayerTransform.PositionX)) + .Span; +``` + +Rust cannot discover those columns through runtime reflection, so each domain +type declares its column extractors explicitly with `SoaDocument` or the +`cultcache_soa!` helper: + +```rust +cultcache_soa!(PlayerTransform { + "position_x" => |row: &PlayerTransform| row.position_x, + "health" => |row: &PlayerTransform| row.health, +}); + +let table = cache.soa::()?; +let health = table.column::("health")?; +``` + +This is the same public shape as the C# rite: callers ask the cache for a typed +table, then pull contiguous typed columns by name. The Rust version is explicit +about the extractor table because the closed-world schema boundary is a virtue, +not a missing reflection party trick. + ## Backing Store Routing Generic store: ```rust -cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.msgpack")); +cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc"))?; ``` Type-specific store: ```rust cache.add_backing_store( - SingleFileMessagePackBackingStore::new("players.msgpack"), + SingleFileMessagePackBackingStore::new("players.cc"), ["player"], -); +)?; ``` -When writing a `PlayerData`, the cache checks type-specific stores first. If none -match, it writes to the first generic store. Later matching stores are mirrors. +The contract, shared with every runtime, is one home store per document type +(`src/GameCult.Caching/Contracts/cultcache-store-composition.md` in CultLib): -This mirrors the C# behavior: - -- specific domain stores own their domain -- the first generic store is the primary generic write target -- later generic stores mirror writes -- this is not multi-master +- a type-specific store owns its types; otherwise the generic store is the home +- a second generic store is an error, and so is a type claimed by a second + store; both registrations return `Err` and attach nothing +- a write touches exactly one store; there are no mirrors +- a write whose type has no home store is an error ## Persistence Semantics diff --git a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs index f05a23f3..7d613194 100644 --- a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs +++ b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs @@ -75,6 +75,7 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { }; let mut key: Option = None; let mut default = false; + let mut bytes = false; for attribute in field .attrs .iter() @@ -88,6 +89,9 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { } else if meta.path.is_ident("default") { default = true; Ok(()) + } else if meta.path.is_ident("bytes") { + bytes = true; + Ok(()) } else { Err(meta.error("unsupported field cultcache attribute")) } @@ -117,6 +121,7 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { ident: field_ident, ty: field.ty, default, + bytes, }); } slots.sort_by_key(|slot| slot.key); @@ -125,8 +130,14 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { let serialize_elements = (0..=max_slot).map(|slot| { if let Some(field) = slots.iter().find(|field| field.key == slot) { let field_ident = &field.ident; - quote! { - tuple.serialize_element(&self.#field_ident)?; + if field.bytes { + quote! { + tuple.serialize_element(::serde_bytes::Bytes::new(&self.#field_ident))?; + } + } else { + quote! { + tuple.serialize_element(&self.#field_ident)?; + } } } else { quote! { @@ -138,8 +149,14 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { let option_declarations = slots.iter().map(|field| { let variable = format_ident!("slot_{}", field.key); let ty = &field.ty; - quote! { - let mut #variable: ::std::option::Option<#ty> = ::std::option::Option::None; + if field.bytes { + quote! { + let mut #variable: ::std::option::Option<::serde_bytes::ByteBuf> = ::std::option::Option::None; + } + } else { + quote! { + let mut #variable: ::std::option::Option<#ty> = ::std::option::Option::None; + } } }); @@ -147,9 +164,17 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { if let Some(field) = slots.iter().find(|field| field.key == slot) { let variable = format_ident!("slot_{}", field.key); let ty = &field.ty; - quote! { - #slot => { - #variable = seq.next_element::<#ty>()?; + if field.bytes { + quote! { + #slot => { + #variable = seq.next_element::<::serde_bytes::ByteBuf>()?; + } + } + } else { + quote! { + #slot => { + #variable = seq.next_element::<#ty>()?; + } } } } else { @@ -164,7 +189,16 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { let build_fields = slots.iter().map(|field| { let field_ident = &field.ident; let variable = format_ident!("slot_{}", field.key); - if field.default { + if field.bytes && field.default { + quote! { + #field_ident: #variable.unwrap_or_default().into_vec() + } + } else if field.bytes { + let field_name = field_ident.to_string(); + quote! { + #field_ident: #variable.ok_or_else(|| ::serde::de::Error::missing_field(#field_name))?.into_vec() + } + } else if field.default { quote! { #field_ident: #variable.unwrap_or_default() } @@ -240,4 +274,5 @@ struct CultCacheSlot { ident: syn::Ident, ty: Type, default: bool, + bytes: bool, } diff --git a/packages/cultcache-rs/examples/cultcache_interop.rs b/packages/cultcache-rs/examples/cultcache_interop.rs index 4049acf8..e625db9e 100644 --- a/packages/cultcache-rs/examples/cultcache_interop.rs +++ b/packages/cultcache-rs/examples/cultcache_interop.rs @@ -74,7 +74,7 @@ fn read_note(file: &str) -> Result<()> { fn build_cache(file: &str) -> Result { let mut cache = CultCache::new(); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file)); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?; Ok(cache) } diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index cb56b8a8..ed9cae32 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1,15 +1,22 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; +use anyhow::bail; +use anyhow::ensure; use serde::Serialize; use serde::de::DeserializeOwned; +use std::any::Any; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::fs; use std::fs::File; use std::fs::OpenOptions; +use std::marker::PhantomData; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; + +use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; extern crate self as cultcache_rs; @@ -24,6 +31,83 @@ pub trait CultCacheRegistry { fn register_entries(&self, cache: &mut CultCache) -> Result<()>; } +pub trait SoaDocument: DatabaseEntry { + fn soa_columns(rows: &[Self]) -> BTreeMap<&'static str, CultSoaColumnValues>; +} + +pub struct CultSoaColumnValues { + type_name: &'static str, + values: Box, +} + +impl CultSoaColumnValues { + pub fn new(values: Vec) -> Self { + Self { + type_name: std::any::type_name::(), + values: Box::new(values), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CultSoaColumn { + name: String, + values: Vec, +} + +impl CultSoaColumn { + pub fn name(&self) -> &str { + &self.name + } + + pub fn values(&self) -> &[TValue] { + &self.values + } +} + +pub struct CultSoaTable { + keys: Vec, + documents: Vec, + columns: BTreeMap, + _document: PhantomData, +} + +impl CultSoaTable { + pub fn keys(&self) -> &[String] { + &self.keys + } + + pub fn documents(&self) -> &[TDocument] { + &self.documents + } + + pub fn len(&self) -> usize { + self.keys.len() + } + + pub fn is_empty(&self) -> bool { + self.keys.is_empty() + } + + pub fn column(&self, name: &str) -> Result> { + let Some(column) = self.columns.get(name) else { + return Err(anyhow!("SoA table has no column {name:?}")); + }; + let Some(values) = column.values.downcast_ref::>() else { + return Err(anyhow!( + "SoA table column {:?} stores {}, not {}", + name, + column.type_name, + std::any::type_name::() + )); + }; + Ok(CultSoaColumn { + name: name.to_string(), + values: values.clone(), + }) + } +} + #[macro_export] macro_rules! cultcache_registry { ($name:ident { $($entry:ty),* $(,)? }) => { @@ -41,6 +125,22 @@ macro_rules! cultcache_registry { }; } +#[macro_export] +macro_rules! cultcache_soa { + ($entry:ty { $($column:literal => $extractor:expr),* $(,)? }) => { + impl $crate::SoaDocument for $entry { + fn soa_columns(rows: &[Self]) -> ::std::collections::BTreeMap<&'static str, $crate::CultSoaColumnValues> { + let mut columns = ::std::collections::BTreeMap::new(); + $( + let values = rows.iter().map($extractor).collect::>(); + columns.insert($column, $crate::CultSoaColumnValues::new(values)); + )* + columns + } + } + }; +} + #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct CultCacheEnvelope { @@ -54,6 +154,93 @@ pub struct CultCacheEnvelope { pub schema_id: Option, } +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultCacheSoaTable { + pub keys: Vec, + pub types: Vec, + pub payloads: Vec>, + pub stored_ats: Vec, + pub schema_ids: Vec>, +} + +impl CultCacheSoaTable { + pub fn from_envelopes(entries: impl IntoIterator) -> Self { + let mut table = Self::default(); + for entry in entries { + table.keys.push(entry.key); + table.types.push(entry.r#type); + table.payloads.push(entry.payload); + table.stored_ats.push(entry.stored_at); + table.schema_ids.push(entry.schema_id); + } + table + } + + pub fn len(&self) -> usize { + self.keys.len() + } + + pub fn is_empty(&self) -> bool { + self.keys.is_empty() + } + + pub fn validate(&self) -> Result<()> { + let len = self.keys.len(); + for (name, candidate) in [ + ("types", self.types.len()), + ("payloads", self.payloads.len()), + ("storedAts", self.stored_ats.len()), + ("schemaIds", self.schema_ids.len()), + ] { + if candidate != len { + return Err(anyhow!( + "CultCache SoA column {name} has length {candidate}, expected {len}" + )); + } + } + for (index, key) in self.keys.iter().enumerate() { + if key.trim().is_empty() { + return Err(anyhow!("CultCache SoA row {index} has an empty key")); + } + if self.types[index].trim().is_empty() { + return Err(anyhow!("CultCache SoA row {index} has an empty type")); + } + if self.stored_ats[index].trim().is_empty() { + return Err(anyhow!("CultCache SoA row {index} has an empty stored_at")); + } + } + Ok(()) + } + + pub fn into_envelopes(self) -> Result> { + self.validate()?; + let Self { + keys, + types, + payloads, + stored_ats, + schema_ids, + } = self; + Ok(keys + .into_iter() + .zip(types) + .zip(payloads) + .zip(stored_ats) + .zip(schema_ids) + .map( + |((((key, r#type), payload), stored_at), schema_id)| CultCacheEnvelope { + key, + r#type, + payload, + stored_at, + schema_id, + }, + ) + .collect()) + } +} + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] struct PersistedStoreSnapshot( String, @@ -97,21 +284,56 @@ pub struct PushAllOptions { pub soft: bool, } +/// One per-entry expectation for [`SingleFileMessagePackBackingStore::compare_exchange`]. +/// `current` is the envelope the caller believes is stored, or `None` when it +/// believes the identity is absent. +#[derive(Clone, Debug, PartialEq)] +pub struct CultCacheExpectedEnvelope { + pub key: String, + pub r#type: String, + pub current: Option, +} + +/// Result of one nonblocking whole-snapshot compare-and-exchange. Contention is +/// reported rather than waited on, so the caller keeps retry and scheduling +/// authority instead of blocking inside the store. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TryCompareExchangeSnapshotOutcome { + Exchanged, + Mismatch, + LockContended, +} + pub trait CacheBackingStore: Send { fn pull_all(&self) -> Result>; fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()>; fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()>; - fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> { - let existing = self.pull_all()?; - for entry in existing { - self.delete(&entry)?; - } - for entry in entries { - self.push(entry)?; - } - Ok(()) - } + /// Replaces everything the store holds with `entries` in one atomic step: + /// on `Err` the store still holds exactly what it held before. A cache + /// batch (`put_prepared_batch`) is all-or-nothing only through this. There + /// is no default, because a replace assembled from `delete` and `push` + /// calls leaves the store emptied or partial when one of them fails; a + /// store without an atomic replace does not compile: + /// + /// ```compile_fail,E0046 + /// use cultcache_rs::{CacheBackingStore, CultCacheEnvelope}; + /// + /// struct PerRecordStore; + /// + /// impl CacheBackingStore for PerRecordStore { + /// fn pull_all(&self) -> anyhow::Result> { + /// Ok(Vec::new()) + /// } + /// fn push(&mut self, _entry: &CultCacheEnvelope) -> anyhow::Result<()> { + /// Ok(()) + /// } + /// fn delete(&mut self, _entry: &CultCacheEnvelope) -> anyhow::Result<()> { + /// Ok(()) + /// } + /// } + /// ``` + fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> Result<()>; } #[derive(Clone, Debug, PartialEq, Eq)] @@ -128,6 +350,305 @@ impl SingleFileMessagePackBackingStore { &self.path } + /// Holds the pre-created sibling lock shared while a read-only consumer + /// evaluates and acts on one snapshot. The reader never creates or writes + /// either file. Writers using the sibling lock cannot replace the snapshot + /// until `action` returns. + /// + /// Releasing the lock is RAII cleanup. A release failure therefore cannot + /// rewrite the completed action's result; consumers that need to observe + /// that cleanup failure can use + /// [`Self::with_read_only_shared_snapshot_and_unlock_diagnostic`]. + pub fn with_read_only_shared_snapshot( + &self, + action: impl FnOnce(Vec) -> Result, + ) -> Result { + self.with_read_only_shared_snapshot_and_unlock_diagnostic(action, |_| {}) + } + + /// The diagnostic-bearing form of + /// [`Self::with_read_only_shared_snapshot`]. `on_unlock_failure` observes a + /// cleanup failure separately and never changes the action result. + pub fn with_read_only_shared_snapshot_and_unlock_diagnostic( + &self, + action: impl FnOnce(Vec) -> Result, + on_unlock_failure: impl FnOnce(anyhow::Error), + ) -> Result { + self.with_read_only_shared_snapshot_using( + action, + |lock| { + fs2::FileExt::unlock(lock) + .with_context(|| format!("failed to unlock {}", self.lock_path().display())) + }, + on_unlock_failure, + ) + } + + fn with_read_only_shared_snapshot_using( + &self, + action: impl FnOnce(Vec) -> Result, + unlock: U, + on_unlock_failure: D, + ) -> Result + where + U: FnOnce(&File) -> Result<()>, + D: FnOnce(anyhow::Error), + { + let lock_path = self.lock_path(); + let lock = OpenOptions::new() + .read(true) + .open(&lock_path) + .with_context(|| format!("failed to open pre-created {}", lock_path.display()))?; + fs2::FileExt::lock_shared(&lock) + .with_context(|| format!("failed to lock {}", lock_path.display()))?; + let guard = ReadOnlySharedLockGuard::new(lock, unlock, on_unlock_failure); + let result = self.read_all_unlocked().and_then(action); + drop(guard); + result + } + + /// Replaces one exact envelope under the store's cross-process exclusive + /// lock. A changed or missing expected envelope returns false without a write. + pub fn compare_and_swap_entry( + &self, + expected: &CultCacheEnvelope, + replacement: CultCacheEnvelope, + ) -> Result { + self.with_exclusive_lock(|| { + let mut entries = self.read_all_unlocked()?; + let Some(index) = entries + .iter() + .position(|candidate| entry_id(candidate) == entry_id(expected)) + else { + return Ok(false); + }; + if entries[index] != *expected { + return Ok(false); + } + entries[index] = replacement; + entries.sort_by_key(entry_id); + self.write_all_unlocked(&entries)?; + Ok(true) + }) + } + + /// Inserts one envelope only when its polymorphic type/key identity is + /// absent, under the same cross-process lock used by all single-file writes. + pub fn insert_entry_if_absent(&self, entry: CultCacheEnvelope) -> Result { + self.with_exclusive_lock(|| { + let mut entries = self.read_all_unlocked()?; + if entries + .iter() + .any(|candidate| entry_id(candidate) == entry_id(&entry)) + { + return Ok(false); + } + entries.push(entry); + entries.sort_by_key(entry_id); + self.write_all_unlocked(&entries)?; + Ok(true) + }) + } + + /// Atomically replaces an exact set of existing envelopes and inserts any + /// additional companion envelopes. Every precondition is checked beneath + /// the same cross-process lock as the single snapshot write. + pub fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + if replacements.is_empty() { + return Err(anyhow!( + "conditional batch requires a non-empty replacement set" + )); + } + let expected_ids = unique_batch_ids(expected, "expected")?; + let replacement_ids = unique_batch_ids(&replacements, "replacement")?; + if !expected_ids.is_subset(&replacement_ids) { + return Err(anyhow!( + "conditional batch must replace every expected identity" + )); + } + + self.with_exclusive_lock(|| { + let mut entries = self.read_all_unlocked()?; + for expected_entry in expected { + let Some(current) = entries + .iter() + .find(|candidate| entry_id(candidate) == entry_id(expected_entry)) + else { + return Ok(false); + }; + if current != expected_entry { + return Ok(false); + } + } + for replacement in &replacements { + let identity = entry_id(replacement); + if !expected_ids.contains(&identity) + && entries + .iter() + .any(|candidate| entry_id(candidate) == identity) + { + return Ok(false); + } + } + + entries.retain(|candidate| !expected_ids.contains(&entry_id(candidate))); + entries.extend(replacements); + entries.sort_by_key(entry_id); + self.write_all_unlocked(&entries)?; + Ok(true) + }) + } + + /// Atomically appends companions only when the complete backing-store + /// snapshot is byte-for-byte unchanged. Unlike identity-scoped CAS, this + /// fences predicate cardinality: a concurrently inserted unknown identity + /// makes the expected snapshot stale. + pub fn append_if_snapshot_unchanged( + &self, + expected_snapshot: &[CultCacheEnvelope], + additions: Vec, + ) -> Result { + if additions.is_empty() { + return Err(anyhow!("conditional snapshot append requires additions")); + } + unique_batch_ids(expected_snapshot, "expected snapshot")?; + let addition_ids = unique_batch_ids(&additions, "snapshot additions")?; + self.with_exclusive_lock(|| { + let mut current = self.read_all_unlocked()?; + let mut expected = expected_snapshot.to_vec(); + current.sort_by_key(entry_id); + expected.sort_by_key(entry_id); + if current != expected { + return Ok(false); + } + if current + .iter() + .any(|row| addition_ids.contains(&entry_id(row))) + { + return Ok(false); + } + current.extend(additions); + current.sort_by_key(entry_id); + self.write_all_unlocked(¤t)?; + Ok(true) + }) + } + + /// Atomically deletes an exact set of envelopes. Any changed or missing + /// member refuses the entire deletion without a partial write. + pub fn delete_batch_if_unchanged(&self, expected: &[CultCacheEnvelope]) -> Result { + if expected.is_empty() { + return Err(anyhow!( + "conditional delete requires a non-empty expected set" + )); + } + let expected_ids = unique_batch_ids(expected, "delete expected")?; + self.with_exclusive_lock(|| { + let mut entries = self.read_all_unlocked()?; + for expected_entry in expected { + let Some(current) = entries + .iter() + .find(|candidate| entry_id(candidate) == entry_id(expected_entry)) + else { + return Ok(false); + }; + if current != expected_entry { + return Ok(false); + } + } + entries.retain(|candidate| !expected_ids.contains(&entry_id(candidate))); + self.write_all_unlocked(&entries)?; + Ok(true) + }) + } + + /// Atomically replaces existing rows and appends companion rows only while + /// the complete backing-store snapshot remains byte-exact. + pub fn replace_and_append_if_snapshot_unchanged( + &self, + expected_snapshot: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + if replacements.is_empty() { + return Err(anyhow!( + "conditional snapshot replacement requires replacements" + )); + } + unique_batch_ids(expected_snapshot, "expected snapshot")?; + let replacement_ids = unique_batch_ids(&replacements, "snapshot replacements")?; + self.with_exclusive_lock(|| { + let mut current = self.read_all_unlocked()?; + let mut expected = expected_snapshot.to_vec(); + current.sort_by_key(entry_id); + expected.sort_by_key(entry_id); + if current != expected { + return Ok(false); + } + current.retain(|row| !replacement_ids.contains(&entry_id(row))); + current.extend(replacements); + current.sort_by_key(entry_id); + self.write_all_unlocked(¤t)?; + Ok(true) + }) + } + + /// Atomically replaces and deletes rows only while the complete store + /// snapshot remains byte-exact. This is the single-file compaction + /// primitive: summary publication and history deletion share one write. + pub fn replace_and_delete_if_snapshot_unchanged( + &self, + expected_snapshot: &[CultCacheEnvelope], + replacements: Vec, + deletions: &[CultCacheEnvelope], + ) -> Result { + if replacements.is_empty() || deletions.is_empty() { + return Err(anyhow!( + "conditional compaction requires replacements and deletions" + )); + } + let expected_ids = unique_batch_ids(expected_snapshot, "expected snapshot")?; + let replacement_ids = unique_batch_ids(&replacements, "replacement")?; + let deletion_ids = unique_batch_ids(deletions, "deletion")?; + if !deletion_ids.is_subset(&expected_ids) { + return Err(anyhow!( + "compaction deletion is absent from expected snapshot" + )); + } + if !deletion_ids.is_disjoint(&replacement_ids) { + return Err(anyhow!("compaction cannot replace and delete one identity")); + } + self.with_exclusive_lock(|| { + let mut current = self.read_all_unlocked()?; + let mut expected = expected_snapshot.to_vec(); + current.sort_by_key(entry_id); + expected.sort_by_key(entry_id); + if current != expected { + return Ok(false); + } + for row in &replacements { + if !expected_ids.contains(&entry_id(row)) + && current + .iter() + .any(|candidate| entry_id(candidate) == entry_id(row)) + { + return Ok(false); + } + } + current.retain(|row| !deletion_ids.contains(&entry_id(row))); + for row in replacements { + current.retain(|candidate| entry_id(candidate) != entry_id(&row)); + current.push(row); + } + current.sort_by_key(entry_id); + self.write_all_unlocked(¤t)?; + Ok(true) + }) + } + fn read_all_unlocked(&self) -> Result> { if !self.path.exists() { return Ok(Vec::new()); @@ -137,10 +658,21 @@ impl SingleFileMessagePackBackingStore { if bytes.is_empty() { return Ok(Vec::new()); } - decode_store_snapshot(&bytes).or_else(|_| { - rmp_serde::from_slice(&bytes) - .with_context(|| format!("failed to decode MessagePack {}", self.path.display())) - }) + decode_store_snapshot(&bytes) + .or_else(|_| rmp_serde::from_slice(&bytes)) + .with_context(|| format!("failed to decode MessagePack {}", self.path.display())) + } + + /// Reads one filesystem snapshot without creating or opening the sibling + /// CultCache lock file. + /// + /// This is deliberately narrower than `pull_all`: use it only at a + /// provider-owned, read-only crossing where the producer replaces the + /// complete file atomically and the consumer has no authority to mutate + /// the provider directory. An absent file is an empty snapshot. Writers + /// and locally owned stores must continue to use the locked APIs. + pub fn pull_all_read_only_snapshot(&self) -> Result> { + self.read_all_unlocked() } fn write_all_unlocked(&self, entries: &[CultCacheEnvelope]) -> Result<()> { @@ -148,22 +680,25 @@ impl SingleFileMessagePackBackingStore { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } - let bytes = rmp_serde::to_vec(&encode_store_snapshot(entries)) + remove_abandoned_staging_files(&self.path)?; + let bytes = rmp_serde::to_vec(&encode_store_snapshot(entries)?) .context("failed to encode MessagePack")?; let tmp_path = temporary_path_for(&self.path); - fs::write(&tmp_path, bytes) + let mut staged = OpenOptions::new() + .create_new(true) + .write(true) + .open(&tmp_path) + .with_context(|| format!("failed to create {}", tmp_path.display()))?; + use std::io::Write; + staged + .write_all(&bytes) .with_context(|| format!("failed to write {}", tmp_path.display()))?; - if self.path.exists() { - fs::remove_file(&self.path) - .with_context(|| format!("failed to replace {}", self.path.display()))?; - } - fs::rename(&tmp_path, &self.path).with_context(|| { - format!( - "failed to move {} to {}", - tmp_path.display(), - self.path.display() - ) - })?; + staged + .sync_all() + .with_context(|| format!("failed to sync {}", tmp_path.display()))?; + drop(staged); + replace_file_atomically(&tmp_path, &self.path)?; + sync_parent_directory(&self.path)?; Ok(()) } @@ -187,6 +722,126 @@ impl SingleFileMessagePackBackingStore { result } + /// Replaces the named entries only if each still matches its expectation. + /// Unlike the snapshot form this constrains only the identities named, so + /// unrelated concurrent writes do not lose the race. + pub fn compare_exchange( + &self, + expected: &[CultCacheExpectedEnvelope], + replacements: &[CultCacheEnvelope], + ) -> Result { + self.with_exclusive_lock(|| { + let mut entries = self.read_all_unlocked()?; + for condition in expected { + let current = entries + .iter() + .find(|entry| entry.r#type == condition.r#type && entry.key == condition.key); + if current != condition.current.as_ref() { + return Ok(false); + } + if let Some(envelope) = condition.current.as_ref() + && (envelope.r#type != condition.r#type || envelope.key != condition.key) + { + return Err(anyhow!( + "CultCache compare-exchange expectation identity differs from its envelope" + )); + } + } + let mut replacement_ids = BTreeSet::new(); + for replacement in replacements { + if replacement.key.trim().is_empty() || replacement.r#type.trim().is_empty() { + return Err(anyhow!( + "CultCache compare-exchange replacement identity is empty" + )); + } + if !replacement_ids.insert(entry_id(replacement)) { + return Err(anyhow!( + "CultCache compare-exchange contains a duplicate replacement" + )); + } + } + entries.retain(|entry| !replacement_ids.contains(&entry_id(entry))); + entries.extend_from_slice(replacements); + entries.sort_by_key(entry_id); + self.write_all_unlocked(&entries)?; + Ok(true) + }) + } + + /// Replaces the whole snapshot only if it still equals `expected`, waiting + /// for the exclusive lock. Returns false when another writer has moved the + /// snapshot on, which is a lost race rather than an error. + pub fn compare_exchange_snapshot( + &self, + expected: &[CultCacheEnvelope], + replacements: &[CultCacheEnvelope], + ) -> Result { + self.with_exclusive_lock(|| self.compare_exchange_snapshot_unlocked(expected, replacements)) + } + + /// Makes one nonblocking attempt at the same exchange. Once the lock is + /// held, comparison, validation, ordering and persistence are identical to + /// [`Self::compare_exchange_snapshot`]. + pub fn try_compare_exchange_snapshot( + &self, + expected: &[CultCacheEnvelope], + replacements: &[CultCacheEnvelope], + ) -> Result { + let Some(exchanged) = self.try_with_exclusive_lock(|| { + self.compare_exchange_snapshot_unlocked(expected, replacements) + })? + else { + return Ok(TryCompareExchangeSnapshotOutcome::LockContended); + }; + Ok(if exchanged { + TryCompareExchangeSnapshotOutcome::Exchanged + } else { + TryCompareExchangeSnapshotOutcome::Mismatch + }) + } + + fn compare_exchange_snapshot_unlocked( + &self, + expected: &[CultCacheEnvelope], + replacements: &[CultCacheEnvelope], + ) -> Result { + let current = self.read_all_unlocked()?; + if current != expected { + return Ok(false); + } + let mut replacement_ids = BTreeSet::new(); + for replacement in replacements { + if replacement.key.trim().is_empty() || replacement.r#type.trim().is_empty() { + return Err(anyhow!("CultCache snapshot replacement identity is empty")); + } + if !replacement_ids.insert(entry_id(replacement)) { + return Err(anyhow!( + "CultCache snapshot replacement contains a duplicate identity" + )); + } + } + let mut ordered = replacements.to_vec(); + ordered.sort_by_key(entry_id); + self.write_all_unlocked(&ordered)?; + Ok(true) + } + + fn try_with_exclusive_lock(&self, action: impl FnOnce() -> Result) -> Result> { + let lock = self.open_lock_file()?; + match fs2::FileExt::try_lock_exclusive(&lock) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(None), + Err(error) => { + return Err(error) + .with_context(|| format!("failed to lock {}", self.lock_path().display())); + } + } + let result = action(); + fs2::FileExt::unlock(&lock) + .with_context(|| format!("failed to unlock {}", self.lock_path().display()))?; + result.map(Some) + } + fn open_lock_file(&self) -> Result { let lock_path = self.lock_path(); if let Some(parent) = lock_path.parent() { @@ -207,14 +862,71 @@ impl SingleFileMessagePackBackingStore { .path .file_name() .map(|value| value.to_os_string()) - .unwrap_or_else(|| "cultcache.msgpack".into()); + .unwrap_or_else(|| "cultcache.cc".into()); lock_name.push(".lock"); self.path.with_file_name(lock_name) } } +struct ReadOnlySharedLockGuard +where + U: FnOnce(&File) -> Result<()>, + D: FnOnce(anyhow::Error), +{ + lock: Option, + unlock: Option, + on_unlock_failure: Option, +} + +impl ReadOnlySharedLockGuard +where + U: FnOnce(&File) -> Result<()>, + D: FnOnce(anyhow::Error), +{ + fn new(lock: File, unlock: U, on_unlock_failure: D) -> Self { + Self { + lock: Some(lock), + unlock: Some(unlock), + on_unlock_failure: Some(on_unlock_failure), + } + } +} + +impl Drop for ReadOnlySharedLockGuard +where + U: FnOnce(&File) -> Result<()>, + D: FnOnce(anyhow::Error), +{ + fn drop(&mut self) { + let (Some(lock), Some(unlock)) = (self.lock.as_ref(), self.unlock.take()) else { + return; + }; + if let Err(error) = unlock(lock) + && let Some(on_unlock_failure) = self.on_unlock_failure.take() + { + on_unlock_failure(error); + } + } +} + +fn unique_batch_ids( + entries: &[CultCacheEnvelope], + label: &str, +) -> Result> { + let ids = entries.iter().map(entry_id).collect::>(); + if ids.len() != entries.len() { + return Err(anyhow!( + "conditional batch {label} set contains duplicate identities" + )); + } + Ok(ids) +} + impl CacheBackingStore for SingleFileMessagePackBackingStore { fn pull_all(&self) -> Result> { + if !self.path.exists() && !self.lock_path().exists() { + return Ok(Vec::new()); + } self.with_shared_lock(|| self.read_all_unlocked()) } @@ -245,6 +957,947 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { } } +const REDB_ENVELOPES: TableDefinition<&[u8], &[u8]> = TableDefinition::new("cultcache_envelopes"); + +/// Transactional keyed CultCache storage. Each polymorphic `(type, key)` +/// identity is an independent redb row whose value is the exact MessagePack +/// serialization of its `CultCacheEnvelope`. +#[derive(Clone)] +pub struct RedbMessagePackBackingStore { + path: PathBuf, +} + +impl std::fmt::Debug for RedbMessagePackBackingStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RedbMessagePackBackingStore") + .field("path", &self.path) + .finish_non_exhaustive() + } +} + +impl RedbMessagePackBackingStore { + pub fn new(path: impl Into) -> Result { + let path = path.into(); + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let store = Self { path }; + store.with_database(|database| { + let write = database.begin_write()?; + { + write.open_table(REDB_ENVELOPES)?; + } + write.commit()?; + Ok(()) + })?; + Ok(store) + } + + pub fn path(&self) -> &Path { + &self.path + } + + fn open_database(&self) -> Result { + Database::create(&self.path) + .with_context(|| format!("failed to open redb CultCache {}", self.path.display())) + } + + /// redb permits one open writable database handle per path. The external + /// CultCache lock therefore owns the complete open/transaction/close + /// interval, preserving the fresh-handle and cross-process construction + /// model used by the other backing store. + fn with_database(&self, action: impl FnOnce(&Database) -> Result) -> Result { + let lock_path = self.lock_path(); + if let Some(parent) = lock_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let lock = OpenOptions::new() + .create(true) + .read(true) + .truncate(false) + .write(true) + .open(&lock_path) + .with_context(|| format!("failed to open {}", lock_path.display()))?; + fs2::FileExt::lock_exclusive(&lock) + .with_context(|| format!("failed to lock {}", lock_path.display()))?; + let result = self.open_database().and_then(|database| action(&database)); + fs2::FileExt::unlock(&lock) + .with_context(|| format!("failed to unlock {}", lock_path.display()))?; + result + } + + fn lock_path(&self) -> PathBuf { + let mut lock_name = self + .path + .file_name() + .map(|value| value.to_os_string()) + .unwrap_or_else(|| "cultcache.cc".into()); + lock_name.push(".lock"); + self.path.with_file_name(lock_name) + } + + pub fn compare_and_swap_entry( + &self, + expected: &CultCacheEnvelope, + replacement: CultCacheEnvelope, + ) -> Result { + if entry_id(expected) != entry_id(&replacement) { + return Err(anyhow!("entry CAS replacement must preserve identity")); + } + self.with_database(|database| { + let write = database.begin_write()?; + let matched = { + let mut table = write.open_table(REDB_ENVELOPES)?; + if read_redb_entry(&table, expected)? != Some(expected.clone()) { + false + } else { + insert_redb_entry(&mut table, &replacement)?; + true + } + }; + if matched { + write.commit()?; + } else { + write.abort()?; + } + Ok(matched) + }) + } + + pub fn insert_entry_if_absent(&self, entry: CultCacheEnvelope) -> Result { + self.with_database(|database| { + let write = database.begin_write()?; + let inserted = { + let mut table = write.open_table(REDB_ENVELOPES)?; + let key = redb_identity(&entry)?; + if table.get(key.as_slice())?.is_some() { + false + } else { + insert_redb_entry(&mut table, &entry)?; + true + } + }; + if inserted { + write.commit()?; + } else { + write.abort()?; + } + Ok(inserted) + }) + } + + pub fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + if replacements.is_empty() { + return Err(anyhow!( + "conditional batch requires a non-empty replacement set" + )); + } + let expected_ids = unique_batch_ids(expected, "expected")?; + let replacement_ids = unique_batch_ids(&replacements, "replacement")?; + if !expected_ids.is_subset(&replacement_ids) { + return Err(anyhow!( + "conditional batch must replace every expected identity" + )); + } + self.with_database(|database| { + let write = database.begin_write()?; + let matched = { + let mut table = write.open_table(REDB_ENVELOPES)?; + let mut valid = true; + for row in expected { + if read_redb_entry(&table, row)? != Some(row.clone()) { + valid = false; + break; + } + } + if valid { + for row in &replacements { + if !expected_ids.contains(&entry_id(row)) + && read_redb_entry(&table, row)?.is_some() + { + valid = false; + break; + } + } + } + if valid { + for row in &replacements { + insert_redb_entry(&mut table, row)?; + } + } + valid + }; + if matched { + write.commit()?; + } else { + write.abort()?; + } + Ok(matched) + }) + } + + pub fn append_if_snapshot_unchanged( + &self, + expected_snapshot: &[CultCacheEnvelope], + additions: Vec, + ) -> Result { + if additions.is_empty() { + return Err(anyhow!("conditional snapshot append requires additions")); + } + unique_batch_ids(expected_snapshot, "expected snapshot")?; + let addition_ids = unique_batch_ids(&additions, "snapshot additions")?; + self.with_database(|database| { + let write = database.begin_write()?; + let matched = { + let mut table = write.open_table(REDB_ENVELOPES)?; + let mut current = read_all_redb(&table)?; + let mut expected = expected_snapshot.to_vec(); + current.sort_by_key(entry_id); + expected.sort_by_key(entry_id); + let valid = current == expected + && !current + .iter() + .any(|row| addition_ids.contains(&entry_id(row))); + if valid { + for row in &additions { + insert_redb_entry(&mut table, row)?; + } + } + valid + }; + if matched { + write.commit()?; + } else { + write.abort()?; + } + Ok(matched) + }) + } + + pub fn replace_and_append_if_snapshot_unchanged( + &self, + expected_snapshot: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + if replacements.is_empty() { + return Err(anyhow!( + "conditional snapshot replacement requires replacements" + )); + } + unique_batch_ids(expected_snapshot, "expected snapshot")?; + unique_batch_ids(&replacements, "snapshot replacements")?; + self.with_database(|database| { + let write = database.begin_write()?; + let matched = { + let mut table = write.open_table(REDB_ENVELOPES)?; + let mut current = read_all_redb(&table)?; + let mut expected = expected_snapshot.to_vec(); + current.sort_by_key(entry_id); + expected.sort_by_key(entry_id); + let valid = current == expected; + if valid { + for row in &replacements { + insert_redb_entry(&mut table, row)?; + } + } + valid + }; + if matched { + write.commit()?; + } else { + write.abort()?; + } + Ok(matched) + }) + } + + /// Atomically replaces and deletes rows only while the complete store + /// snapshot remains byte-exact. This is the compaction primitive for a + /// non-owning keyed handle: a stale reader cannot summarize one history + /// while deleting another. + pub fn replace_and_delete_if_snapshot_unchanged( + &self, + expected_snapshot: &[CultCacheEnvelope], + replacements: Vec, + deletions: &[CultCacheEnvelope], + ) -> Result { + if replacements.is_empty() || deletions.is_empty() { + return Err(anyhow!( + "conditional compaction requires replacements and deletions" + )); + } + let expected_ids = unique_batch_ids(expected_snapshot, "expected snapshot")?; + let replacement_ids = unique_batch_ids(&replacements, "replacement")?; + let deletion_ids = unique_batch_ids(deletions, "deletion")?; + if !deletion_ids.is_subset(&expected_ids) { + return Err(anyhow!( + "compaction deletion is absent from expected snapshot" + )); + } + if !deletion_ids.is_disjoint(&replacement_ids) { + return Err(anyhow!("compaction cannot replace and delete one identity")); + } + self.with_database(|database| { + let write = database.begin_write()?; + let matched = { + let mut table = write.open_table(REDB_ENVELOPES)?; + let mut current = read_all_redb(&table)?; + let mut expected = expected_snapshot.to_vec(); + current.sort_by_key(entry_id); + expected.sort_by_key(entry_id); + let mut valid = current == expected; + if valid { + for row in &replacements { + if !expected_ids.contains(&entry_id(row)) + && read_redb_entry(&table, row)?.is_some() + { + valid = false; + break; + } + } + } + if valid { + for identity in deletion_ids { + let key = rmp_serde::to_vec(&identity) + .context("failed to encode CultCache identity")?; + table.remove(key.as_slice())?; + } + for row in &replacements { + insert_redb_entry(&mut table, row)?; + } + } + valid + }; + if matched { + write.commit()?; + } else { + write.abort()?; + } + Ok(matched) + }) + } + + pub fn delete_batch_if_unchanged(&self, expected: &[CultCacheEnvelope]) -> Result { + if expected.is_empty() { + return Err(anyhow!( + "conditional delete requires a non-empty expected set" + )); + } + unique_batch_ids(expected, "delete expected")?; + self.with_database(|database| { + let write = database.begin_write()?; + let matched = { + let mut table = write.open_table(REDB_ENVELOPES)?; + let mut valid = true; + for row in expected { + if read_redb_entry(&table, row)? != Some(row.clone()) { + valid = false; + break; + } + } + if valid { + for row in expected { + let key = redb_identity(row)?; + table.remove(key.as_slice())?; + } + } + valid + }; + if matched { + write.commit()?; + } else { + write.abort()?; + } + Ok(matched) + }) + } +} + +struct OwnedRedbInner { + database: Database, + _owner_lock: File, + file_identity: String, +} + +/// A pinned redb store for one long-lived service owner. Unlike the transient +/// redb store, this handle holds the CultCache external lock, an open file +/// handle, and the redb database for its entire lifetime. Clones share that +/// exact ownership authority. +#[derive(Clone)] +pub struct OwnedRedbMessagePackBackingStore { + path: PathBuf, + inner: Arc, +} + +impl std::fmt::Debug for OwnedRedbMessagePackBackingStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OwnedRedbMessagePackBackingStore") + .field("path", &self.path) + .field("file_identity", &self.inner.file_identity) + .finish_non_exhaustive() + } +} + +impl OwnedRedbMessagePackBackingStore { + pub fn new(path: impl Into) -> Result { + let path = path.into(); + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let lock_path = redb_lock_path(&path); + let owner_lock = OpenOptions::new() + .create(true) + .read(true) + .truncate(false) + .write(true) + .open(&lock_path) + .with_context(|| format!("failed to open {}", lock_path.display()))?; + fs2::FileExt::try_lock_exclusive(&owner_lock).with_context(|| { + format!( + "redb CultCache {} already has an active owner", + path.display() + ) + })?; + + // Give redb ownership of the already pinned file, structurally closing + // path substitution during database creation. The post-open identity + // comparison separately verifies that the pathname still names it. + let database_file = OpenOptions::new() + .create(true) + .read(true) + .truncate(false) + .write(true) + .open(&path) + .with_context(|| format!("failed to pin redb CultCache {}", path.display()))?; + let held_identity = file_identity_from_file(&database_file)?; + let database = Database::builder() + .create_file(database_file) + .with_context(|| format!("failed to open redb CultCache {}", path.display()))?; + let path_identity_file = File::open(&path)?; + let path_identity = file_identity_from_file(&path_identity_file)?; + if held_identity != path_identity { + bail!( + "redb CultCache path identity changed while opening: held {held_identity}, path {path_identity}" + ); + } + let write = database.begin_write()?; + { + write.open_table(REDB_ENVELOPES)?; + } + write.commit()?; + Ok(Self { + path, + inner: Arc::new(OwnedRedbInner { + database, + _owner_lock: owner_lock, + file_identity: held_identity, + }), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn file_identity(&self) -> &str { + &self.inner.file_identity + } + + pub fn require_file_identity(&self, expected: &str) -> Result<()> { + if expected != self.file_identity() { + bail!( + "redb CultCache file identity mismatch: expected {expected}, owned {}", + self.file_identity() + ); + } + Ok(()) + } + + pub fn validate_path_identity(&self) -> Result<()> { + let current_file = File::open(&self.path) + .with_context(|| format!("owned redb path {} is missing", self.path.display()))?; + let current = file_identity_from_file(¤t_file)?; + self.require_file_identity(¤t).with_context(|| { + format!( + "owned redb path {} no longer names the pinned file", + self.path.display() + ) + }) + } + + pub fn compare_and_swap_entry( + &self, + expected: &CultCacheEnvelope, + replacement: CultCacheEnvelope, + ) -> Result { + if entry_id(expected) != entry_id(&replacement) { + return Err(anyhow!("entry CAS replacement must preserve identity")); + } + let write = self.inner.database.begin_write()?; + let matched = { + let mut table = write.open_table(REDB_ENVELOPES)?; + if read_redb_entry(&table, expected)? != Some(expected.clone()) { + false + } else { + insert_redb_entry(&mut table, &replacement)?; + true + } + }; + if matched { + write.commit()?; + } else { + write.abort()?; + } + Ok(matched) + } + + pub fn insert_entry_if_absent(&self, entry: CultCacheEnvelope) -> Result { + let write = self.inner.database.begin_write()?; + let inserted = { + let mut table = write.open_table(REDB_ENVELOPES)?; + let key = redb_identity(&entry)?; + if table.get(key.as_slice())?.is_some() { + false + } else { + insert_redb_entry(&mut table, &entry)?; + true + } + }; + if inserted { + write.commit()?; + } else { + write.abort()?; + } + Ok(inserted) + } + + pub fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + if replacements.is_empty() { + return Err(anyhow!( + "conditional batch requires a non-empty replacement set" + )); + } + let expected_ids = unique_batch_ids(expected, "expected")?; + let replacement_ids = unique_batch_ids(&replacements, "replacement")?; + if !expected_ids.is_subset(&replacement_ids) { + return Err(anyhow!( + "conditional batch must replace every expected identity" + )); + } + let write = self.inner.database.begin_write()?; + let matched = { + let mut table = write.open_table(REDB_ENVELOPES)?; + let mut valid = true; + for row in expected { + if read_redb_entry(&table, row)? != Some(row.clone()) { + valid = false; + break; + } + } + if valid { + for row in &replacements { + if !expected_ids.contains(&entry_id(row)) + && read_redb_entry(&table, row)?.is_some() + { + valid = false; + break; + } + } + } + if valid { + for row in &replacements { + insert_redb_entry(&mut table, row)?; + } + } + valid + }; + if matched { + write.commit()?; + } else { + write.abort()?; + } + Ok(matched) + } + + pub fn append_if_snapshot_unchanged( + &self, + expected_snapshot: &[CultCacheEnvelope], + additions: Vec, + ) -> Result { + if additions.is_empty() { + return Err(anyhow!("conditional snapshot append requires additions")); + } + unique_batch_ids(expected_snapshot, "expected snapshot")?; + let addition_ids = unique_batch_ids(&additions, "snapshot additions")?; + let write = self.inner.database.begin_write()?; + let matched = { + let mut table = write.open_table(REDB_ENVELOPES)?; + let mut current = read_all_redb(&table)?; + let mut expected = expected_snapshot.to_vec(); + current.sort_by_key(entry_id); + expected.sort_by_key(entry_id); + let valid = current == expected + && !current + .iter() + .any(|row| addition_ids.contains(&entry_id(row))); + if valid { + for row in &additions { + insert_redb_entry(&mut table, row)?; + } + } + valid + }; + if matched { + write.commit()?; + } else { + write.abort()?; + } + Ok(matched) + } + + pub fn replace_and_append_if_snapshot_unchanged( + &self, + expected_snapshot: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + if replacements.is_empty() { + return Err(anyhow!( + "conditional snapshot replacement requires replacements" + )); + } + unique_batch_ids(expected_snapshot, "expected snapshot")?; + unique_batch_ids(&replacements, "snapshot replacements")?; + let write = self.inner.database.begin_write()?; + let matched = { + let mut table = write.open_table(REDB_ENVELOPES)?; + let mut current = read_all_redb(&table)?; + let mut expected = expected_snapshot.to_vec(); + current.sort_by_key(entry_id); + expected.sort_by_key(entry_id); + let valid = current == expected; + if valid { + for row in &replacements { + insert_redb_entry(&mut table, row)?; + } + } + valid + }; + if matched { + write.commit()?; + } else { + write.abort()?; + } + Ok(matched) + } + + pub fn delete_batch_if_unchanged(&self, expected: &[CultCacheEnvelope]) -> Result { + if expected.is_empty() { + return Err(anyhow!( + "conditional delete requires a non-empty expected set" + )); + } + let expected_ids = unique_batch_ids(expected, "delete expected")?; + let write = self.inner.database.begin_write()?; + let matched = { + let mut table = write.open_table(REDB_ENVELOPES)?; + let mut valid = true; + for row in expected { + if read_redb_entry(&table, row)? != Some(row.clone()) { + valid = false; + break; + } + } + if valid { + for identity in expected_ids { + let key = rmp_serde::to_vec(&identity) + .context("failed to encode CultCache identity")?; + table.remove(key.as_slice())?; + } + } + valid + }; + if matched { + write.commit()?; + } else { + write.abort()?; + } + Ok(matched) + } + + /// Atomically replaces and deletes rows only while the complete store + /// snapshot remains byte-exact. This is the compaction primitive: a stale + /// reader cannot summarize one history while deleting another. + pub fn replace_and_delete_if_snapshot_unchanged( + &self, + expected_snapshot: &[CultCacheEnvelope], + replacements: Vec, + deletions: &[CultCacheEnvelope], + ) -> Result { + if replacements.is_empty() || deletions.is_empty() { + return Err(anyhow!( + "conditional compaction requires replacements and deletions" + )); + } + let expected_ids = unique_batch_ids(expected_snapshot, "expected snapshot")?; + let replacement_ids = unique_batch_ids(&replacements, "replacement")?; + let deletion_ids = unique_batch_ids(deletions, "deletion")?; + if !deletion_ids.is_subset(&expected_ids) { + return Err(anyhow!( + "compaction deletion is absent from expected snapshot" + )); + } + if !deletion_ids.is_disjoint(&replacement_ids) { + return Err(anyhow!("compaction cannot replace and delete one identity")); + } + let write = self.inner.database.begin_write()?; + let matched = { + let mut table = write.open_table(REDB_ENVELOPES)?; + let mut current = read_all_redb(&table)?; + let mut expected = expected_snapshot.to_vec(); + current.sort_by_key(entry_id); + expected.sort_by_key(entry_id); + let mut valid = current == expected; + if valid { + for row in &replacements { + if !expected_ids.contains(&entry_id(row)) + && read_redb_entry(&table, row)?.is_some() + { + valid = false; + break; + } + } + } + if valid { + for identity in deletion_ids { + let key = rmp_serde::to_vec(&identity) + .context("failed to encode CultCache identity")?; + table.remove(key.as_slice())?; + } + for row in &replacements { + insert_redb_entry(&mut table, row)?; + } + } + valid + }; + if matched { + write.commit()?; + } else { + write.abort()?; + } + Ok(matched) + } +} + +impl CacheBackingStore for OwnedRedbMessagePackBackingStore { + fn pull_all(&self) -> Result> { + let read = self.inner.database.begin_read()?; + let table = read.open_table(REDB_ENVELOPES)?; + read_all_redb(&table) + } + + fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + let write = self.inner.database.begin_write()?; + { + let mut table = write.open_table(REDB_ENVELOPES)?; + insert_redb_entry(&mut table, entry)?; + } + write.commit()?; + Ok(()) + } + + fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + let write = self.inner.database.begin_write()?; + { + let mut table = write.open_table(REDB_ENVELOPES)?; + let key = redb_identity(entry)?; + table.remove(key.as_slice())?; + } + write.commit()?; + Ok(()) + } + + fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> { + unique_batch_ids(entries, "push all")?; + let write = self.inner.database.begin_write()?; + { + let mut table = write.open_table(REDB_ENVELOPES)?; + let keys = table + .iter()? + .map(|row| Ok(row?.0.value().to_vec())) + .collect::>>()?; + for key in keys { + table.remove(key.as_slice())?; + } + for entry in entries { + insert_redb_entry(&mut table, entry)?; + } + } + write.commit()?; + Ok(()) + } +} + +fn redb_lock_path(path: &Path) -> PathBuf { + let mut lock_name = path + .file_name() + .map(|value| value.to_os_string()) + .unwrap_or_else(|| "cultcache.cc".into()); + lock_name.push(".lock"); + path.with_file_name(lock_name) +} + +#[cfg(unix)] +fn file_identity_from_file(file: &File) -> Result { + use std::os::unix::fs::MetadataExt; + let metadata = file.metadata()?; + Ok(format!( + "unix:{:016x}:{:016x}", + metadata.dev(), + metadata.ino() + )) +} + +#[cfg(windows)] +fn file_identity_from_file(file: &File) -> Result { + use std::mem::zeroed; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { zeroed() }; + let success = + unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut information) }; + if success == 0 { + return Err(std::io::Error::last_os_error()) + .context("failed to read Windows file identity"); + } + let index = ((information.nFileIndexHigh as u64) << 32) | information.nFileIndexLow as u64; + Ok(format!( + "windows:{:08x}:{index:016x}", + information.dwVolumeSerialNumber + )) +} + +fn redb_identity(entry: &CultCacheEnvelope) -> Result> { + rmp_serde::to_vec(&entry_id(entry)).context("failed to encode CultCache identity") +} + +fn read_redb_entry( + table: &impl ReadableTable<&'static [u8], &'static [u8]>, + identity: &CultCacheEnvelope, +) -> Result> { + let key = redb_identity(identity)?; + table + .get(key.as_slice())? + .map(|value| { + rmp_serde::from_slice(value.value()).context("failed to decode redb CultCache envelope") + }) + .transpose() +} + +fn insert_redb_entry( + table: &mut redb::Table<&[u8], &[u8]>, + entry: &CultCacheEnvelope, +) -> Result<()> { + let key = redb_identity(entry)?; + let value = rmp_serde::to_vec(entry).context("failed to encode redb CultCache envelope")?; + table.insert(key.as_slice(), value.as_slice())?; + Ok(()) +} + +fn read_all_redb( + table: &impl ReadableTable<&'static [u8], &'static [u8]>, +) -> Result> { + let mut entries = table + .iter()? + .map(|row| { + let (_, value) = row?; + rmp_serde::from_slice(value.value()).context("failed to decode redb CultCache envelope") + }) + .collect::>>()?; + entries.sort_by_key(entry_id); + Ok(entries) +} + +impl CacheBackingStore for RedbMessagePackBackingStore { + fn pull_all(&self) -> Result> { + self.with_database(|database| { + let read = database.begin_read()?; + let table = read.open_table(REDB_ENVELOPES)?; + read_all_redb(&table) + }) + } + + fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + self.with_database(|database| { + let write = database.begin_write()?; + { + let mut table = write.open_table(REDB_ENVELOPES)?; + insert_redb_entry(&mut table, entry)?; + } + write.commit()?; + Ok(()) + }) + } + + fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + self.with_database(|database| { + let write = database.begin_write()?; + { + let mut table = write.open_table(REDB_ENVELOPES)?; + let key = redb_identity(entry)?; + table.remove(key.as_slice())?; + } + write.commit()?; + Ok(()) + }) + } + + fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> { + unique_batch_ids(entries, "push all")?; + self.with_database(|database| { + let write = database.begin_write()?; + { + let mut table = write.open_table(REDB_ENVELOPES)?; + let keys = table + .iter()? + .map(|row| Ok(row?.0.value().to_vec())) + .collect::>>()?; + for key in keys { + table.remove(key.as_slice())?; + } + for entry in entries { + insert_redb_entry(&mut table, entry)?; + } + } + write.commit()?; + Ok(()) + }) + } +} + struct CultCacheStoreRegistration { store: Box, types: BTreeSet, @@ -252,7 +1905,8 @@ struct CultCacheStoreRegistration { pub struct CultCache { definitions: BTreeMap, - entries: BTreeMap, + schema_name_definitions: BTreeMap, + entries: BTreeMap<(String, String), CultCacheEnvelope>, stores: Vec, } @@ -260,6 +1914,7 @@ impl CultCache { pub fn new() -> Self { Self { definitions: BTreeMap::new(), + schema_name_definitions: BTreeMap::new(), entries: BTreeMap::new(), stores: Vec::new(), } @@ -279,7 +1934,18 @@ impl CultCache { T::TYPE )); } + if let Some(existing_type) = self.schema_name_definitions.get(T::SCHEMA_NAME) + && existing_type != T::TYPE + { + return Err(anyhow!( + "CultCache schema name {:?} is already registered for type {:?}", + T::SCHEMA_NAME, + existing_type + )); + } self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME); + self.schema_name_definitions + .insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string()); Ok(()) } @@ -287,43 +1953,113 @@ impl CultCache { self.register_entry_type::() } + pub fn registered_entry_types(&self) -> Vec { + self.definitions.keys().cloned().collect() + } + pub fn register_registry(&mut self, registry: R) -> Result<&mut Self> { registry.register_entries(self)?; Ok(self) } + /// Makes the store home to `types`, or the generic store when `types` is + /// empty. A type has exactly one home store: a second generic store or a + /// type already claimed by another store is an error and attaches nothing. pub fn add_backing_store( &mut self, store: impl CacheBackingStore + 'static, types: impl IntoIterator>, - ) { + ) -> Result<()> { + let types: BTreeSet = types.into_iter().map(Into::into).collect(); + if types.is_empty() && self.stores.iter().any(|registration| registration.types.is_empty()) { + return Err(anyhow!( + "Backing store would be a second generic store; name the types it is home to" + )); + } + if let Some(claimed) = types + .iter() + .find(|type_id| self.stores.iter().any(|registration| registration.types.contains(*type_id))) + { + return Err(anyhow!( + "CultCache type {:?} is already routed to another backing store; it cannot also route to this one", + claimed + )); + } self.stores.push(CultCacheStoreRegistration { store: Box::new(store), - types: types.into_iter().map(Into::into).collect(), + types, }); + // A record's home cannot change after it is admitted. + let candidate = self.stores.len() - 1; + let held: BTreeSet = self.entries.keys().map(|(type_id, _)| type_id.clone()).collect(); + for type_id in &held { + let type_id = type_id.as_str(); + let after = self.resolve_route_index(type_id); + let before = self.stores[..candidate] + .iter() + .position(|registration| registration.types.contains(type_id)) + .or_else(|| self.stores[..candidate].iter().position(|registration| registration.types.is_empty())); + if before != after { + let error = anyhow!( + "Attaching this backing store would move {:?} from {} to {}; attach routed stores before the generic store", + type_id, + self.describe_store(before), + self.describe_store(after) + ); + self.stores.pop(); + return Err(error); + } + } + Ok(()) } - pub fn add_generic_backing_store(&mut self, store: impl CacheBackingStore + 'static) { - self.add_backing_store(store, Vec::::new()); + pub fn add_generic_backing_store(&mut self, store: impl CacheBackingStore + 'static) -> Result<()> { + self.add_backing_store(store, Vec::::new()) } + /// Reads every store into a staged image and replaces the cache's view + /// only when all of them loaded, so a refused load admits nothing. pub fn pull_all_backing_stores(&mut self) -> Result<()> { - self.entries.clear(); + let mut loaded = BTreeMap::new(); let known_types: BTreeSet = self.definitions.keys().cloned().collect(); - for registration in &mut self.stores { - for entry in registration.store.pull_all()? { - if !known_types.contains(&entry.r#type) { + let schema_name_definitions = self.schema_name_definitions.clone(); + for (index, registration) in self.stores.iter().enumerate() { + for mut entry in registration.store.pull_all()? { + let Some(canonical_type) = resolve_registered_type( + &known_types, + &schema_name_definitions, + &entry.r#type, + ) else { return Err(anyhow!( "No schema is registered for persisted entry type {:?}", entry.r#type )); + }; + let home = self.resolve_route_index(&canonical_type); + if home != Some(index) { + return Err(anyhow!( + "CultCache {:?} record {:?} was loaded from {}, but its home is {}", + canonical_type, + entry.key, + self.describe_store(Some(index)), + self.describe_store(home) + )); } - self.entries.insert(entry_id(&entry), entry); + entry.r#type = canonical_type; + loaded.insert(entry_id(&entry), entry); } } + self.entries = loaded; Ok(()) } + /// Returns the exact envelopes in the currently loaded cache image. + /// Callers that need optimistic concurrency can validate typed documents + /// and capture CAS expectations from one coherent read. + pub fn snapshot_envelopes(&self) -> Vec { + self.entries.values().cloned().collect() + } + pub fn get(&self, key: &str) -> Result> { self.require_entry_type::()?; let Some(entry) = self.entries.get(&entry_id_parts(T::TYPE, key)) else { @@ -374,7 +2110,57 @@ impl CultCache { Ok(values) } + pub fn get_all_with_keys(&self) -> Result> { + self.require_entry_type::()?; + let mut values = Vec::new(); + for entry in self.entries.values() { + if entry.r#type != T::TYPE { + continue; + } + values.push(( + entry.key.clone(), + rmp_serde::from_slice(&entry.payload).with_context(|| { + format!( + "failed to decode CultCache entry {:?} at key {:?} as {}", + T::TYPE, + entry.key, + T::SCHEMA_NAME + ) + })?, + )); + } + Ok(values) + } + + pub fn soa(&self) -> Result> { + let keyed_rows = self.get_all_with_keys::()?; + let (keys, documents): (Vec<_>, Vec<_>) = keyed_rows.into_iter().unzip(); + let columns = T::soa_columns(&documents) + .into_iter() + .map(|(name, values)| (name.to_string(), values)) + .collect(); + Ok(CultSoaTable { + keys, + documents, + columns, + _document: PhantomData, + }) + } + pub fn put(&mut self, key: impl Into, value: &T) -> Result { + let (entry, parsed) = self.prepare_entry(key, value)?; + if let Some(home) = self.home_index(T::TYPE)? { + self.stores[home].store.push(&entry)?; + } + self.entries.insert(entry_id(&entry), entry); + Ok(parsed) + } + + pub fn prepare_entry( + &self, + key: impl Into, + value: &T, + ) -> Result<(CultCacheEnvelope, T)> { self.require_entry_type::()?; let key = key.into(); let payload = rmp_serde::to_vec(value).with_context(|| { @@ -385,6 +2171,36 @@ impl CultCache { T::SCHEMA_NAME ) })?; + self.finish_prepared_entry::(key, payload) + } + + /// Prepares a typed entry while encoding ordinary nested structs as + /// MessagePack maps. DatabaseEntry itself remains the stable numeric-slot + /// tuple; named nested fields prevent serde's omitted optional fields from + /// shifting later values into the wrong positions. + pub fn prepare_entry_named( + &self, + key: impl Into, + value: &T, + ) -> Result<(CultCacheEnvelope, T)> { + self.require_entry_type::()?; + let key = key.into(); + let payload = rmp_serde::to_vec_named(value).with_context(|| { + format!( + "failed to encode named CultCache entry {:?} at key {:?} as {}", + T::TYPE, + key, + T::SCHEMA_NAME + ) + })?; + self.finish_prepared_entry::(key, payload) + } + + fn finish_prepared_entry( + &self, + key: String, + payload: Vec, + ) -> Result<(CultCacheEnvelope, T)> { let parsed: T = rmp_serde::from_slice(&payload).with_context(|| { format!( "failed to validate CultCache entry {:?} at key {:?} as {}", @@ -400,22 +2216,106 @@ impl CultCache { stored_at: now_utc_second(), schema_id: Some(T::TYPE.to_string()), }; - let route = self.resolve_route_indices(T::TYPE); - let Some(primary_index) = route.first().copied() else { - return Err(anyhow!( - "No backing store is registered for entry type {:?}", - T::TYPE - )); - }; - self.stores[primary_index].store.push(&entry)?; - for mirror_index in route.iter().skip(1).copied() { - self.stores[mirror_index].store.push(&entry)?; + Ok((entry, parsed)) + } + + pub fn put_prepared_batch(&mut self, staged: Vec) -> Result<()> { + if staged.is_empty() { + return Err(anyhow!("CultCache batch must contain at least one entry")); + } + // Validate every staged record before any store is touched. + let mut store_index = None; + for entry in &staged { + if !self.definitions.contains_key(&entry.r#type) { + return Err(anyhow!( + "CultCache batch entry type {:?} is not registered", + entry.r#type + )); + } + if entry.key.trim().is_empty() { + return Err(anyhow!( + "CultCache envelope keys for type {:?} must be non-empty", + entry.r#type + )); + } + if entry.stored_at.trim().is_empty() { + return Err(anyhow!( + "CultCache envelope stored_at for type {:?} must be non-empty", + entry.r#type + )); + } + let Some(home) = self.home_index(&entry.r#type)? else { + continue; + }; + match store_index { + Some(expected) if expected != home => { + return Err(anyhow!("CultCache atomic batch cannot span backing stores")); + } + None => store_index = Some(home), + _ => {} + } + } + let mut next = self.entries.clone(); + for entry in &staged { + next.insert(entry_id(entry), entry.clone()); + } + if let Some(store_index) = store_index { + // The home store receives its own records only: those it already + // holds in this cache plus the staged ones. + let snapshot: Vec<_> = next + .values() + .filter(|entry| self.resolve_route_index(&entry.r#type) == Some(store_index)) + .cloned() + .collect(); + self.stores[store_index] + .store + .push_all(&snapshot, PushAllOptions::default())?; + } + self.entries = next; + Ok(()) + } + + pub fn put_envelope(&mut self, entry: CultCacheEnvelope) -> Result { + let parsed = self.validate_envelope::(&entry)?; + if let Some(home) = self.home_index(T::TYPE)? { + self.stores[home].store.push(&entry)?; } self.entries.insert(entry_id(&entry), entry); Ok(parsed) } - pub fn put_envelope(&mut self, entry: CultCacheEnvelope) -> Result { + /// Publishes an envelope for a registered dynamic document type without + /// claiming compile-time knowledge of its payload schema. This is the + /// CultMesh/schema-registry boundary: callers own payload validation before + /// admission; CultCache still enforces registered type, identity, timestamp, + /// routing, and persistence. + pub fn put_raw_envelope(&mut self, entry: CultCacheEnvelope) -> Result<()> { + if !self.definitions.contains_key(&entry.r#type) { + return Err(anyhow!( + "No entry type registered for CultCache envelope type {:?}", + entry.r#type + )); + } + if entry.key.trim().is_empty() { + return Err(anyhow!( + "CultCache envelope keys for type {:?} must be non-empty", + entry.r#type + )); + } + if entry.stored_at.trim().is_empty() { + return Err(anyhow!( + "CultCache envelope stored_at for type {:?} must be non-empty", + entry.r#type + )); + } + if let Some(home) = self.home_index(&entry.r#type)? { + self.stores[home].store.push(&entry)?; + } + self.entries.insert(entry_id(&entry), entry); + Ok(()) + } + + fn validate_envelope(&self, entry: &CultCacheEnvelope) -> Result { self.require_entry_type::()?; if entry.r#type != T::TYPE { return Err(anyhow!( @@ -437,27 +2337,14 @@ impl CultCache { )); } - let parsed: T = rmp_serde::from_slice(&entry.payload).with_context(|| { + rmp_serde::from_slice(&entry.payload).with_context(|| { format!( "failed to validate CultCache envelope {:?} at key {:?} as {}", T::TYPE, entry.key, T::SCHEMA_NAME ) - })?; - let route = self.resolve_route_indices(T::TYPE); - let Some(primary_index) = route.first().copied() else { - return Err(anyhow!( - "No backing store is registered for entry type {:?}", - T::TYPE - )); - }; - self.stores[primary_index].store.push(&entry)?; - for mirror_index in route.iter().skip(1).copied() { - self.stores[mirror_index].store.push(&entry)?; - } - self.entries.insert(entry_id(&entry), entry); - Ok(parsed) + }) } pub fn update(&mut self, key: &str, updater: F) -> Result @@ -475,16 +2362,8 @@ impl CultCache { let Some(entry) = self.entries.get(&id).cloned() else { return Ok(false); }; - let route = self.resolve_route_indices(T::TYPE); - let Some(primary_index) = route.first().copied() else { - return Err(anyhow!( - "No backing store is registered for entry type {:?}", - T::TYPE - )); - }; - self.stores[primary_index].store.delete(&entry)?; - for mirror_index in route.iter().skip(1).copied() { - self.stores[mirror_index].store.delete(&entry)?; + if let Some(home) = self.home_index(T::TYPE)? { + self.stores[home].store.delete(&entry)?; } self.entries.remove(&id); Ok(true) @@ -494,6 +2373,30 @@ impl CultCache { self.entries.values().cloned().collect() } + pub fn snapshot_soa(&self) -> CultCacheSoaTable { + CultCacheSoaTable::from_envelopes(self.snapshot()) + } + + /// Replaces the cache's view with the table's records. Every record is + /// checked (registered type, and a home store once any store is attached) + /// into a local image first, so a refused load admits nothing and leaves + /// the prior view intact. Loading writes no store. + pub fn load_soa(&mut self, table: CultCacheSoaTable) -> Result<()> { + let mut loaded = BTreeMap::new(); + for entry in table.into_envelopes()? { + if !self.definitions.contains_key(&entry.r#type) { + return Err(anyhow!( + "No schema is registered for SoA entry type {:?}", + entry.r#type + )); + } + self.home_index(&entry.r#type)?; + loaded.insert(entry_id(&entry), entry); + } + self.entries = loaded; + Ok(()) + } + fn require_entry_type(&self) -> Result<()> { match self.definitions.get(T::TYPE) { Some(schema_name) if *schema_name == T::SCHEMA_NAME => Ok(()), @@ -504,23 +2407,39 @@ impl CultCache { } } - fn resolve_route_indices(&self, type_id: &str) -> Vec { - let type_specific: Vec = self - .stores - .iter() - .enumerate() - .filter_map(|(index, registration)| { - registration.types.contains(type_id).then_some(index) - }) - .collect(); - if !type_specific.is_empty() { - return type_specific; - } + /// The store that claims the type, else the generic store. Registration + /// guarantees at most one of each. + fn resolve_route_index(&self, type_id: &str) -> Option { self.stores .iter() - .enumerate() - .filter_map(|(index, registration)| registration.types.is_empty().then_some(index)) - .collect() + .position(|registration| registration.types.contains(type_id)) + .or_else(|| { + self.stores + .iter() + .position(|registration| registration.types.is_empty()) + }) + } + + /// `None` for a cache with no stores, which is in memory. Once any store + /// is attached, a type with no home store is an error. + fn home_index(&self, type_id: &str) -> Result> { + if self.stores.is_empty() { + return Ok(None); + } + self.resolve_route_index(type_id) + .map(Some) + .ok_or_else(|| anyhow!("No backing store is registered for entry type {:?}", type_id)) + } + + fn describe_store(&self, index: Option) -> String { + match index { + None => "memory".to_string(), + Some(index) if self.stores[index].types.is_empty() => "the generic store".to_string(), + Some(index) => format!( + "the store routed to {:?}", + self.stores[index].types.iter().collect::>() + ), + } } } @@ -530,35 +2449,40 @@ impl Default for CultCache { } } -fn entry_id(entry: &CultCacheEnvelope) -> String { +fn entry_id(entry: &CultCacheEnvelope) -> (String, String) { entry_id_parts(&entry.r#type, &entry.key) } -fn entry_id_parts(r#type: &str, key: &str) -> String { - format!("{type}::{key}", type = r#type) +fn entry_id_parts(r#type: &str, key: &str) -> (String, String) { + (r#type.to_string(), key.to_string()) } fn now_utc_second() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } -fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> PersistedStoreSnapshot { - let mut schema_names = BTreeSet::::new(); +fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> Result { + let mut schema_types = BTreeMap::::new(); for entry in entries { - schema_names.insert( - entry - .schema_id - .clone() - .unwrap_or_else(|| entry.r#type.clone()), - ); + let schema_id = entry + .schema_id + .clone() + .unwrap_or_else(|| entry.r#type.clone()); + if let Some(existing_type) = schema_types.insert(schema_id.clone(), entry.r#type.clone()) { + ensure!( + existing_type == entry.r#type, + "CultCache schema {schema_id:?} cannot identify both {existing_type:?} and {:?}", + entry.r#type + ); + } } - let catalog = schema_names + let catalog = schema_types .into_iter() - .map(|schema_id| { + .map(|(schema_id, document_type)| { PersistedSchemaCatalogEntry( schema_id.clone(), - schema_id.clone(), + document_type, format!("{schema_id}.v1"), schema_id.clone(), format!( @@ -586,7 +2510,11 @@ fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> PersistedStoreSnapsho }) .collect(); - PersistedStoreSnapshot("cultcache.store.v1".to_string(), catalog, records) + Ok(PersistedStoreSnapshot( + "cultcache.store.v1".to_string(), + catalog, + records, + )) } fn decode_store_snapshot(bytes: &[u8]) -> Result> { @@ -605,13 +2533,32 @@ fn decode_store_snapshot(bytes: &[u8]) -> Result> { .2 .into_iter() .map(|record| { - let r#type = catalog.get(&record.1).cloned().ok_or_else(|| { - anyhow!( - "CultCache record {:?} references missing schema {:?}", - record.0, - record.1 - ) - })?; + let r#type = match catalog.get(&record.1) { + Some(schema_name) => schema_name.clone(), + None => { + // A record can outlive its catalog entry when another runtime + // wrote it, or when the record is older than the catalog it + // was stamped against. The payload is a MessagePack array + // whose first field is the schema version, so the type can be + // recovered from the record itself rather than failing the + // whole snapshot. + let schema_version = + infer_schema_version_from_payload(&record.3).ok_or_else(|| { + anyhow!( + "CultCache record {:?} references missing schema {:?}", + record.0, + record.1 + ) + })?; + infer_schema_name(&schema_version).ok_or_else(|| { + anyhow!( + "CultCache record {:?} references missing schema {:?}", + record.0, + record.1 + ) + })? + } + }; Ok(CultCacheEnvelope { key: record.0, r#type, @@ -623,6 +2570,83 @@ fn decode_store_snapshot(bytes: &[u8]) -> Result> { .collect() } +/// A record recovered from its payload carries a schema name, while the +/// registry is keyed by entry type. Accept either, so a snapshot whose catalog +/// entry is gone still resolves to the registered definition. +fn resolve_registered_type( + known_types: &BTreeSet, + schema_name_definitions: &BTreeMap, + persisted_type: &str, +) -> Option { + if known_types.contains(persisted_type) { + return Some(persisted_type.to_string()); + } + + schema_name_definitions.get(persisted_type).cloned() +} + +fn infer_schema_version_from_payload(payload: &[u8]) -> Option { + let mut offset = 0usize; + read_array_header(payload, &mut offset)?; + read_string(payload, &mut offset) +} + +fn read_array_header(payload: &[u8], offset: &mut usize) -> Option { + let marker = *payload.get(*offset)?; + *offset += 1; + match marker { + 0x90..=0x9f => Some((marker & 0x0f) as u32), + 0xdc => { + let bytes = payload.get(*offset..(*offset + 2))?; + *offset += 2; + Some(u16::from_be_bytes([bytes[0], bytes[1]]) as u32) + } + 0xdd => { + let bytes = payload.get(*offset..(*offset + 4))?; + *offset += 4; + Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } + _ => None, + } +} + +fn read_string(payload: &[u8], offset: &mut usize) -> Option { + let marker = *payload.get(*offset)?; + *offset += 1; + let length = match marker { + 0xa0..=0xbf => (marker & 0x1f) as usize, + 0xd9 => { + let length = *payload.get(*offset)? as usize; + *offset += 1; + length + } + 0xda => { + let bytes = payload.get(*offset..(*offset + 2))?; + *offset += 2; + u16::from_be_bytes([bytes[0], bytes[1]]) as usize + } + 0xdb => { + let bytes = payload.get(*offset..(*offset + 4))?; + *offset += 4; + u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize + } + _ => return None, + }; + + let bytes = payload.get(*offset..(*offset + length))?; + std::str::from_utf8(bytes).ok().map(str::to_string) +} + +fn infer_schema_name(schema_version: &str) -> Option { + let marker = schema_version.rfind(".v")?; + let version = schema_version.get((marker + 2)..)?; + if marker == 0 || version.is_empty() || !version.bytes().all(|value| value.is_ascii_digit()) { + return None; + } + + Some(schema_version[..marker].to_string()) +} + fn escape_json_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } @@ -631,15 +2655,200 @@ fn temporary_path_for(path: &Path) -> PathBuf { let mut file_name = path .file_name() .map(|value| value.to_os_string()) - .unwrap_or_else(|| "cultcache.msgpack".into()); + .unwrap_or_else(|| "cultcache.cc".into()); file_name.push(format!(".{}.tmp", uuid::Uuid::new_v4())); path.with_file_name(file_name) } +fn remove_abandoned_staging_files(destination: &Path) -> Result<()> { + let Some(parent) = destination.parent() else { + return Ok(()); + }; + let Some(destination_name) = destination.file_name().and_then(|value| value.to_str()) else { + return Ok(()); + }; + let prefix = format!("{destination_name}."); + for entry in fs::read_dir(parent).with_context(|| { + format!( + "failed to inspect {} for abandoned staging files", + parent.display() + ) + })? { + let entry = entry?; + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let Some(candidate) = name + .strip_prefix(&prefix) + .and_then(|value| value.strip_suffix(".tmp")) + else { + continue; + }; + if uuid::Uuid::parse_str(candidate).is_err() || !entry.file_type()?.is_file() { + continue; + } + fs::remove_file(entry.path()).with_context(|| { + format!( + "failed to remove abandoned staging file {}", + entry.path().display() + ) + })?; + } + Ok(()) +} + +#[cfg(unix)] +fn replace_file_atomically(staged: &Path, destination: &Path) -> Result<()> { + fs::rename(staged, destination).with_context(|| { + format!( + "failed to atomically replace {} with {}", + destination.display(), + staged.display() + ) + }) +} + +#[cfg(windows)] +fn replace_file_atomically(staged: &Path, destination: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let staged_wide = staged + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination_wide = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // A concurrent reader of the destination (another process opening the + // store, an indexer, an antivirus scan) makes the replace fail with a + // sharing or access error for as long as it holds the handle. That is + // transient by construction; a daemon that treats it as fatal dies on + // the first overlap (Muninn serve did, 2026-09-11). Retry briefly. + const ERROR_ACCESS_DENIED: i32 = 5; + const ERROR_SHARING_VIOLATION: i32 = 32; + const ERROR_LOCK_VIOLATION: i32 = 33; + const ATTEMPTS: u32 = 25; + const BACKOFF: std::time::Duration = std::time::Duration::from_millis(20); + let mut attempt = 0; + loop { + let success = unsafe { + MoveFileExW( + staged_wide.as_ptr(), + destination_wide.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if success != 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + attempt += 1; + let transient = matches!( + error.raw_os_error(), + Some(ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION) + ); + if !transient || attempt >= ATTEMPTS { + return Err(error).with_context(|| { + format!( + "failed to atomically replace {} with {} after {attempt} attempt(s)", + destination.display(), + staged.display() + ) + }); + } + std::thread::sleep(BACKOFF); + } +} + +#[cfg(all(test, windows))] +mod windows_replace_tests { + use super::replace_file_atomically; + use std::os::windows::fs::OpenOptionsExt; + + /// A reader holding the destination open with no sharing makes the + /// replace fail for as long as it holds on; it must succeed once the + /// reader lets go, not report the first refusal as the outcome. + #[test] + fn a_replace_waits_out_a_reader_that_holds_the_destination() { + let dir = std::env::temp_dir().join(format!("cultcache-replace-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let destination = dir.join("store.cc"); + let staged = dir.join("store.cc.tmp"); + std::fs::write(&destination, b"old").unwrap(); + std::fs::write(&staged, b"new").unwrap(); + let held = std::fs::OpenOptions::new() + .read(true) + .share_mode(0) + .open(&destination) + .unwrap(); + let releaser = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(120)); + drop(held); + }); + replace_file_atomically(&staged, &destination).expect("the replace outlasts the reader"); + releaser.join().unwrap(); + assert_eq!(std::fs::read(&destination).unwrap(), b"new"); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[cfg(unix)] +fn sync_parent_directory(path: &Path) -> Result<()> { + let Some(parent) = path.parent() else { + return Ok(()); + }; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .with_context(|| format!("failed to sync {}", parent.display())) +} + +#[cfg(windows)] +fn sync_parent_directory(_path: &Path) -> Result<()> { + // MoveFileExW with MOVEFILE_WRITE_THROUGH owns publication durability. + Ok(()) +} + #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::mpsc; + use std::time::Duration; + + struct RefusingBatchStore { + inner: SingleFileMessagePackBackingStore, + refuse_batch: Arc, + } + + impl CacheBackingStore for RefusingBatchStore { + fn pull_all(&self) -> Result> { + self.inner.pull_all() + } + fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + self.inner.push(entry) + } + fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + self.inner.delete(entry) + } + fn push_all( + &mut self, + entries: &[CultCacheEnvelope], + options: PushAllOptions, + ) -> Result<()> { + if self.refuse_batch.load(Ordering::SeqCst) { + return Err(anyhow!("injected batch refusal")); + } + self.inner.push_all(entries, options) + } + } #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache(type = "settings")] @@ -659,12 +2868,123 @@ mod tests { body: String, } + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = "binary-record")] + struct BinaryRecord { + #[cultcache(key = 0, bytes)] + required: Vec, + #[cultcache(key = 1, bytes, default)] + optional: Vec, + } + + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + struct NestedOptionalValue { + label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + symbol: Option, + } + + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = "runtime-policy", schema = "tests.schema_stamped_entry")] + struct SchemaStamped { + #[cultcache(key = 0)] + schema_version: String, + #[cultcache(key = 1)] + name: String, + #[cultcache(key = 2)] + value: String, + } + + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = "nested-note")] + struct NestedNote { + #[cultcache(key = 0)] + value: NestedOptionalValue, + } + + cultcache_soa!(Settings { + "theme" => |row: &Settings| row.theme.clone(), + "retries" => |row: &Settings| row.retries, + }); + cultcache_registry!(TestEntries { Settings, Note }); + #[test] + fn captured_snapshot_cannot_outlive_a_shared_consequence_gate() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("authority.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&store_path); + let released = CultCacheEnvelope { + key: "brake".into(), + r#type: "brake".into(), + payload: b"released".to_vec(), + stored_at: "2026-07-20T00:00:00Z".into(), + schema_id: Some("brake".into()), + }; + store.push(&released)?; + + let writer_path = store_path.clone(); + let (started_tx, started_rx) = mpsc::channel(); + let (finished_tx, finished_rx) = mpsc::channel(); + store.with_read_only_shared_snapshot(|captured| { + assert_eq!(captured, vec![released.clone()]); + std::thread::spawn(move || { + started_tx.send(()).unwrap(); + let engaged = CultCacheEnvelope { + payload: b"engaged".to_vec(), + ..released + }; + let mut writer = SingleFileMessagePackBackingStore::new(writer_path); + writer.push(&engaged).unwrap(); + finished_tx.send(()).unwrap(); + }); + started_rx.recv_timeout(Duration::from_secs(1))?; + assert!( + finished_rx + .recv_timeout(Duration::from_millis(100)) + .is_err() + ); + Ok(()) + })?; + finished_rx.recv_timeout(Duration::from_secs(1))?; + assert_eq!(store.pull_all()?[0].payload, b"engaged"); + Ok(()) + } + + #[test] + fn post_action_unlock_failure_is_diagnostic_not_action_result() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("authority.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&store_path); + store.push(&CultCacheEnvelope { + key: "brake".into(), + r#type: "brake".into(), + payload: b"released".to_vec(), + stored_at: "2026-07-20T00:00:00Z".into(), + schema_id: Some("brake".into()), + })?; + + let mut diagnostic = None; + let result = store.with_read_only_shared_snapshot_using( + |_| Ok(42), + |_| Err(anyhow!("injected post-action unlock failure")), + |error| diagnostic = Some(error.to_string()), + ); + + assert_eq!(result?, 42); + assert_eq!( + diagnostic.as_deref(), + Some("injected post-action unlock failure") + ); + Ok(()) + } + #[test] fn familiar_cultcache_flow_persists_and_reloads_typed_documents() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.msgpack"); + let store_path = temp.path().join("cache.cc"); let settings = Settings { theme: "ash".to_string(), retries: 3, @@ -672,14 +2992,14 @@ mod tests { let mut cache = CultCache::new(); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; cache.pull_all_backing_stores()?; cache.put("app", &settings)?; assert_eq!(cache.get_required::("app")?, settings); let mut reloaded = CultCache::new(); reloaded.register_entry_type::()?; - reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); + reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; reloaded.pull_all_backing_stores()?; assert_eq!(reloaded.get_required::("app")?, settings); Ok(()) @@ -688,10 +3008,10 @@ mod tests { #[test] fn entry_identity_is_polymorphic_by_type_and_key() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.msgpack"); + let store_path = temp.path().join("cache.cc"); let mut cache = CultCache::new(); cache.register_registry(TestEntries)?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; cache.put( "shared", @@ -714,19 +3034,759 @@ mod tests { Ok(()) } + #[test] + fn prepared_batch_publishes_heterogeneous_entries_in_one_snapshot() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("batch.cc"); + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + + let (settings, _) = cache.prepare_entry( + "app", + &Settings { + theme: "iron".to_string(), + retries: 4, + }, + )?; + let (note, _) = cache.prepare_entry( + "receipt", + &Note { + title: "committed".to_string(), + body: "same snapshot".to_string(), + }, + )?; + cache.put_prepared_batch(vec![settings, note])?; + + let mut reloaded = CultCache::new(); + reloaded.register_registry(TestEntries)?; + reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + reloaded.pull_all_backing_stores()?; + assert_eq!(reloaded.get_required::("app")?.theme, "iron"); + assert_eq!(reloaded.get_required::("receipt")?.title, "committed"); + Ok(()) + } + + #[test] + fn named_preparation_preserves_omitted_nested_optional_slots() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("named.cc"); + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; + let note = NestedNote { + value: NestedOptionalValue { + label: "nested".into(), + count: None, + symbol: Some("kept-in-its-own-field".into()), + }, + }; + assert!(cache.prepare_entry("compact", ¬e).is_err()); + let (entry, parsed) = cache.prepare_entry_named("named", ¬e)?; + assert_eq!(parsed, note); + cache.put_prepared_batch(vec![entry])?; + assert_eq!(cache.get_required::("named")?, note); + Ok(()) + } + + #[test] + fn pulling_an_absent_store_does_not_create_its_parent_or_lock() -> Result<()> { + let temp = tempfile::tempdir()?; + let missing_parent = temp.path().join("missing-body"); + let store_path = missing_parent.join("cache.cc"); + let store = SingleFileMessagePackBackingStore::new(&store_path); + + assert!(store.pull_all()?.is_empty()); + assert!(!missing_parent.exists()); + assert!(!store_path.with_file_name("cache.cc.lock").exists()); + Ok(()) + } + + #[test] + fn provider_snapshot_read_does_not_create_or_open_a_lock() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("provider.cc"); + let lock_path = temp.path().join("provider.cc.lock"); + let mut store = SingleFileMessagePackBackingStore::new(&store_path); + let expected = test_envelope("provider.delivery", "one", b"payload"); + store.push(&expected)?; + std::fs::remove_file(&lock_path)?; + + assert_eq!(store.pull_all_read_only_snapshot()?, vec![expected]); + assert!(!lock_path.exists()); + Ok(()) + } + + #[test] + fn next_exclusive_write_removes_only_exact_abandoned_staging_files() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("cache.cc"); + let abandoned = temporary_path_for(&store_path); + let unknown = temp.path().join("cache.cc.not-a-uuid.tmp"); + let foreign = temp + .path() + .join("other.cc.00000000-0000-0000-0000-000000000000.tmp"); + fs::write(&abandoned, b"partial staging bytes")?; + fs::write(&unknown, b"operator-owned unknown file")?; + fs::write(&foreign, b"foreign store staging")?; + + let mut store = SingleFileMessagePackBackingStore::new(&store_path); + let expected = test_envelope("model", "current", b"one"); + store.push(&expected)?; + + assert!(!abandoned.exists()); + assert_eq!(fs::read(&unknown)?, b"operator-owned unknown file"); + assert_eq!(fs::read(&foreign)?, b"foreign store staging"); + assert_eq!(store.pull_all()?, vec![expected]); + Ok(()) + } + + #[test] + fn failed_snapshot_publication_preserves_the_committed_destination() -> Result<()> { + let temp = tempfile::tempdir()?; + let destination = temp.path().join("cache.cc"); + let absent_staging = temp.path().join("absent.tmp"); + let committed = b"committed snapshot"; + fs::write(&destination, committed)?; + + assert!(replace_file_atomically(&absent_staging, &destination).is_err()); + assert_eq!(fs::read(&destination)?, committed); + Ok(()) + } + + #[test] + fn snapshot_publication_atomically_replaces_an_existing_destination() -> Result<()> { + let temp = tempfile::tempdir()?; + let destination = temp.path().join("cache.cc"); + let staged = temp.path().join("cache.cc.staged"); + fs::write(&destination, b"old snapshot")?; + fs::write(&staged, b"new snapshot")?; + + replace_file_atomically(&staged, &destination)?; + + assert_eq!(fs::read(&destination)?, b"new snapshot"); + assert!(!staged.exists()); + Ok(()) + } + + #[test] + #[ignore = "operator SIGKILL probe; requires CULTCACHE_INTERRUPTION_STORE"] + fn snapshot_publication_interruption_probe_writer() -> Result<()> { + let store_path = std::env::var_os("CULTCACHE_INTERRUPTION_STORE") + .map(PathBuf::from) + .ok_or_else(|| anyhow!("CULTCACHE_INTERRUPTION_STORE is required"))?; + let marker_path = std::env::var_os("CULTCACHE_INTERRUPTION_MARKER") + .map(PathBuf::from) + .ok_or_else(|| anyhow!("CULTCACHE_INTERRUPTION_MARKER is required"))?; + let mut store = SingleFileMessagePackBackingStore::new(store_path); + for sequence in 0_u64.. { + store.push(&test_envelope( + "interruption.probe", + "current", + &rmp_serde::to_vec(&sequence)?, + ))?; + fs::write(&marker_path, sequence.to_string())?; + } + unreachable!("the interruption writer is stopped by the operator") + } + + #[test] + #[ignore = "operator SIGKILL probe; requires CULTCACHE_INTERRUPTION_STORE"] + fn snapshot_publication_interruption_probe_reader() -> Result<()> { + let store_path = std::env::var_os("CULTCACHE_INTERRUPTION_STORE") + .map(PathBuf::from) + .ok_or_else(|| anyhow!("CULTCACHE_INTERRUPTION_STORE is required"))?; + let rows = SingleFileMessagePackBackingStore::new(store_path).pull_all()?; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].r#type, "interruption.probe"); + assert_eq!(rows[0].key, "current"); + let _: u64 = rmp_serde::from_slice(&rows[0].payload)?; + Ok(()) + } + + fn test_envelope(r#type: &str, key: &str, payload: &[u8]) -> CultCacheEnvelope { + CultCacheEnvelope { + key: key.to_string(), + r#type: r#type.to_string(), + payload: payload.to_vec(), + stored_at: "2026-07-13T00:00:00Z".to_string(), + schema_id: Some(r#type.to_string()), + } + } + + #[test] + fn redb_store_crud_and_reopen_preserve_polymorphic_rows() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("keyed.cc"); + let model = test_envelope("model", "shared", b"one"); + let receipt = test_envelope("receipt", "shared", b"two"); + { + let mut store = RedbMessagePackBackingStore::new(&path)?; + store.push(&model)?; + store.push(&receipt)?; + assert_eq!(store.pull_all()?.len(), 2); + store.delete(&receipt)?; + } + let reopened = RedbMessagePackBackingStore::new(&path)?; + assert_eq!(reopened.pull_all()?, vec![model]); + Ok(()) + } + + #[test] + fn redb_exact_cas_refuses_stale_value_without_mutation() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut store = RedbMessagePackBackingStore::new(temp.path().join("cas.cc"))?; + let current = test_envelope("model", "current", b"one"); + store.push(¤t)?; + let stale = test_envelope("model", "current", b"stale"); + let replacement = test_envelope("model", "current", b"two"); + assert!(!store.compare_and_swap_entry(&stale, replacement.clone())?); + assert_eq!(store.pull_all()?, vec![current.clone()]); + assert!(store.compare_and_swap_entry(¤t, replacement.clone())?); + assert_eq!(store.pull_all()?, vec![replacement]); + Ok(()) + } + + #[test] + fn redb_batch_is_atomic_and_stale_member_refuses_every_write() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut store = RedbMessagePackBackingStore::new(temp.path().join("batch.cc"))?; + let first = test_envelope("model", "first", b"one"); + let second = test_envelope("model", "second", b"one"); + store.push(&first)?; + store.push(&second)?; + let stale_second = test_envelope("model", "second", b"stale"); + let first_two = test_envelope("model", "first", b"two"); + let second_two = test_envelope("model", "second", b"two"); + let receipt = test_envelope("receipt", "batch", b"committed"); + assert!(!store.compare_and_swap_batch( + &[first.clone(), stale_second], + vec![first_two.clone(), second_two.clone(), receipt.clone()], + )?); + assert_eq!(store.pull_all()?, vec![first.clone(), second.clone()]); + assert!(store.compare_and_swap_batch( + &[first, second], + vec![first_two.clone(), second_two.clone(), receipt.clone()], + )?); + assert_eq!(store.pull_all()?, vec![first_two, second_two, receipt]); + Ok(()) + } + + #[test] + fn redb_compaction_refuses_stale_snapshot_and_atomically_replaces_and_deletes() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut store = RedbMessagePackBackingStore::new(temp.path().join("compact.cc"))?; + let old_head = test_envelope("retention", "current", b"one"); + let retired = test_envelope("receipt", "old", b"terminal"); + store.push(&old_head)?; + store.push(&retired)?; + let stale = store.pull_all()?; + let concurrent = test_envelope("receipt", "running", b"active"); + assert!(store.insert_entry_if_absent(concurrent.clone())?); + let new_head = test_envelope("retention", "current", b"two"); + assert!(!store.replace_and_delete_if_snapshot_unchanged( + &stale, + vec![new_head.clone()], + std::slice::from_ref(&retired), + )?); + assert!(store.pull_all()?.contains(&retired)); + let exact = store.pull_all()?; + assert!(store.replace_and_delete_if_snapshot_unchanged( + &exact, + vec![new_head.clone()], + &[retired], + )?); + assert_eq!(store.pull_all()?, vec![concurrent, new_head]); + Ok(()) + } + + #[test] + fn redb_entry_cas_does_not_depend_on_unrelated_snapshot_changes() -> Result<()> { + let temp = tempfile::tempdir()?; + let store = RedbMessagePackBackingStore::new(temp.path().join("independent.cc"))?; + let current = test_envelope("model", "current", b"one"); + assert!(store.insert_entry_if_absent(current.clone())?); + let unrelated = test_envelope("event", "later", b"noise"); + assert!(store.insert_entry_if_absent(unrelated.clone())?); + let replacement = test_envelope("model", "current", b"two"); + assert!(store.compare_and_swap_entry(¤t, replacement.clone())?); + assert_eq!(store.pull_all()?, vec![unrelated, replacement]); + Ok(()) + } + + #[test] + fn redb_fresh_handles_share_one_database_for_the_same_cultcache_path() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("shared.cc"); + let mut first = RedbMessagePackBackingStore::new(&path)?; + let second = RedbMessagePackBackingStore::new(&path)?; + let initial = test_envelope("model", "current", b"one"); + first.push(&initial)?; + assert_eq!(second.pull_all()?, vec![initial.clone()]); + let replacement = test_envelope("model", "current", b"two"); + assert!(second.compare_and_swap_entry(&initial, replacement.clone())?); + assert_eq!(first.pull_all()?, vec![replacement]); + Ok(()) + } + + #[test] + fn owned_redb_clones_share_authority_and_fresh_owner_is_refused() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("owned.cc"); + let owner = OwnedRedbMessagePackBackingStore::new(&path)?; + let clone = owner.clone(); + assert_eq!(owner.file_identity(), clone.file_identity()); + assert!(OwnedRedbMessagePackBackingStore::new(&path).is_err()); + drop(owner); + assert!(OwnedRedbMessagePackBackingStore::new(&path).is_err()); + drop(clone); + assert!(OwnedRedbMessagePackBackingStore::new(&path).is_ok()); + Ok(()) + } + + #[test] + fn owned_redb_file_identity_is_stable_across_writes_and_reopen() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("identity.cc"); + let identity = { + let mut owner = OwnedRedbMessagePackBackingStore::new(&path)?; + let identity = owner.file_identity().to_string(); + owner.push(&test_envelope("model", "current", b"one"))?; + owner.validate_path_identity()?; + owner.require_file_identity(&identity)?; + identity + }; + let reopened = OwnedRedbMessagePackBackingStore::new(&path)?; + assert_eq!(reopened.file_identity(), identity); + assert_eq!(reopened.pull_all()?.len(), 1); + Ok(()) + } + + #[test] + fn owned_redb_identity_helper_refuses_mismatch() -> Result<()> { + let temp = tempfile::tempdir()?; + let owner = OwnedRedbMessagePackBackingStore::new(temp.path().join("mismatch.cc"))?; + assert!(owner.require_file_identity("not-the-owned-file").is_err()); + owner.require_file_identity(owner.file_identity())?; + Ok(()) + } + + #[test] + fn owned_redb_batch_refuses_stale_member_and_commits_success_atomically() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut owner = OwnedRedbMessagePackBackingStore::new(temp.path().join("batch-owned.cc"))?; + let first = test_envelope("model", "first", b"one"); + let second = test_envelope("model", "second", b"one"); + owner.push(&first)?; + owner.push(&second)?; + let first_two = test_envelope("model", "first", b"two"); + let second_two = test_envelope("model", "second", b"two"); + let companion = test_envelope("receipt", "batch", b"committed"); + let stale_second = test_envelope("model", "second", b"stale"); + assert!(!owner.compare_and_swap_batch( + &[first.clone(), stale_second], + vec![first_two.clone(), second_two.clone(), companion.clone()], + )?); + assert_eq!(owner.pull_all()?, vec![first.clone(), second.clone()]); + assert!(owner.compare_and_swap_batch( + &[first, second], + vec![first_two.clone(), second_two.clone(), companion.clone()], + )?); + assert_eq!(owner.pull_all()?, vec![first_two, second_two, companion]); + Ok(()) + } + + #[test] + fn owned_redb_compaction_refuses_stale_snapshot_and_atomically_replaces_and_deletes() + -> Result<()> { + let temp = tempfile::tempdir()?; + let mut owner = + OwnedRedbMessagePackBackingStore::new(temp.path().join("compact-owned.cc"))?; + let old_head = test_envelope("retention", "current", b"one"); + let retired_a = test_envelope("claim", "a", b"terminal"); + let retired_b = test_envelope("receipt", "a", b"terminal"); + owner.push(&old_head)?; + owner.push(&retired_a)?; + owner.push(&retired_b)?; + let stale = owner.pull_all()?; + let concurrent = test_envelope("claim", "concurrent", b"running"); + assert!(owner.insert_entry_if_absent(concurrent.clone())?); + let new_head = test_envelope("retention", "current", b"two"); + assert!(!owner.replace_and_delete_if_snapshot_unchanged( + &stale, + vec![new_head.clone()], + &[retired_a.clone(), retired_b.clone()], + )?); + assert!(owner.pull_all()?.contains(&retired_a)); + let exact = owner.pull_all()?; + assert!(owner.replace_and_delete_if_snapshot_unchanged( + &exact, + vec![new_head.clone()], + &[retired_a, retired_b], + )?); + assert_eq!(owner.pull_all()?, vec![concurrent, new_head]); + Ok(()) + } + + #[test] + fn owned_redb_path_replacement_cannot_redirect_pinned_authority() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("pinned.cc"); + let displaced = temp.path().join("displaced.cc"); + let mut owner = OwnedRedbMessagePackBackingStore::new(&path)?; + let identity = owner.file_identity().to_string(); + owner.push(&test_envelope("model", "before", b"one"))?; + fs::rename(&path, &displaced)?; + File::create(&path)?; + assert!(owner.validate_path_identity().is_err()); + assert_eq!(owner.file_identity(), identity); + owner.push(&test_envelope("model", "after", b"two"))?; + assert_eq!(owner.pull_all()?.len(), 2); + assert_ne!( + file_identity_from_file(&File::open(&path)?)?, + owner.file_identity() + ); + Ok(()) + } + + #[test] + fn redb_overlapping_fresh_handles_serialize_the_database_open_interval() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("contended.cc"); + let first = RedbMessagePackBackingStore::new(&path)?; + let second = RedbMessagePackBackingStore::new(&path)?; + let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); + let first_barrier = barrier.clone(); + let first_thread = std::thread::spawn(move || -> Result<()> { + first_barrier.wait(); + for index in 0..32 { + first.insert_entry_if_absent(test_envelope( + "event", + &format!("first-{index}"), + b"first", + ))?; + } + Ok(()) + }); + let second_barrier = barrier.clone(); + let second_thread = std::thread::spawn(move || -> Result<()> { + second_barrier.wait(); + for index in 0..32 { + second.insert_entry_if_absent(test_envelope( + "event", + &format!("second-{index}"), + b"second", + ))?; + } + Ok(()) + }); + barrier.wait(); + first_thread.join().expect("first redb writer")?; + second_thread.join().expect("second redb writer")?; + assert_eq!( + RedbMessagePackBackingStore::new(path)?.pull_all()?.len(), + 64 + ); + Ok(()) + } + + #[test] + fn redb_accepts_a_bare_relative_cultcache_path() -> Result<()> { + let name = format!("cultcache-redb-relative-{}.cc", uuid::Uuid::new_v4()); + let path = PathBuf::from(&name); + let lock_path = PathBuf::from(format!("{name}.lock")); + let store = RedbMessagePackBackingStore::new(&path)?; + assert!(store.insert_entry_if_absent(test_envelope("model", "current", b"one"))?); + assert_eq!(store.pull_all()?.len(), 1); + fs::remove_file(path)?; + fs::remove_file(lock_path)?; + Ok(()) + } + + #[test] + fn conditional_batch_replaces_expected_and_inserts_companion_atomically() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("conditional-batch.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&store_path); + let current = test_envelope("model", "current", b"revision-1"); + store.push(¤t)?; + let replacement = test_envelope("model", "current", b"revision-2"); + let companion = test_envelope("receipt", "migration-1", b"applied"); + + assert!(store.compare_and_swap_batch( + std::slice::from_ref(¤t), + vec![replacement.clone(), companion.clone()], + )?); + assert_eq!(store.pull_all()?, vec![replacement, companion]); + Ok(()) + } + + #[test] + fn conditional_batch_allows_atomic_first_import_when_identities_are_absent() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("conditional-import.cc"); + let store = SingleFileMessagePackBackingStore::new(&store_path); + let model = test_envelope("model", "current", b"revision-1"); + let receipt = test_envelope("receipt", "migration-1", b"applied"); + + assert!(store.compare_and_swap_batch(&[], vec![model.clone(), receipt.clone()])?); + assert_eq!(store.pull_all()?, vec![model, receipt]); + Ok(()) + } + + #[test] + fn conditional_batch_stale_expected_and_companion_collision_preserve_exact_bytes() -> Result<()> + { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("conditional-refusal.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&store_path); + let current = test_envelope("model", "current", b"revision-1"); + let occupied = test_envelope("receipt", "migration-1", b"someone-else"); + store.push(¤t)?; + store.push(&occupied)?; + let before = fs::read(&store_path)?; + + let stale = test_envelope("model", "current", b"stale-copy"); + let replacement = test_envelope("model", "current", b"revision-2"); + let fresh_companion = test_envelope("receipt", "migration-2", b"applied"); + assert!( + !store.compare_and_swap_batch(&[stale], vec![replacement.clone(), fresh_companion],)? + ); + assert_eq!(fs::read(&store_path)?, before); + + assert!(!store.compare_and_swap_batch( + &[current], + vec![ + replacement, + test_envelope("receipt", "migration-1", b"collision") + ], + )?); + assert_eq!(fs::read(&store_path)?, before); + Ok(()) + } + + #[test] + fn conditional_batch_rejects_empty_replacements_and_duplicate_identities() -> Result<()> { + let temp = tempfile::tempdir()?; + let store = SingleFileMessagePackBackingStore::new(temp.path().join("invalid-batch.cc")); + let entry = test_envelope("model", "current", b"one"); + + assert!( + store + .compare_and_swap_batch(&[entry.clone()], Vec::new()) + .is_err() + ); + assert!( + store + .compare_and_swap_batch(&[], vec![entry.clone(), entry.clone()]) + .is_err() + ); + assert!( + store + .compare_and_swap_batch( + &[entry.clone(), entry.clone()], + vec![test_envelope("model", "current", b"two")], + ) + .is_err() + ); + assert!(!store.path().exists()); + Ok(()) + } + + #[test] + fn full_snapshot_replacement_and_append_refuses_concurrent_rows() -> Result<()> { + let temp = tempfile::tempdir()?; + let snapshot_path = temp.path().join("snapshot-replace-append.cc"); + let mut snapshot_store = SingleFileMessagePackBackingStore::new(&snapshot_path); + let current = test_envelope("session", "current", b"active"); + snapshot_store.push(¤t)?; + let stale_snapshot = snapshot_store.pull_all()?; + let concurrent = test_envelope("event", "concurrent", b"live"); + snapshot_store.push(&concurrent)?; + let completed = test_envelope("session", "current", b"completed"); + let receipt = test_envelope("receipt", "terminal", b"done"); + assert!(!snapshot_store.replace_and_append_if_snapshot_unchanged( + &stale_snapshot, + vec![completed.clone(), receipt.clone()], + )?); + assert_eq!( + snapshot_store.pull_all()?, + vec![concurrent.clone(), current.clone()] + ); + let exact_snapshot = snapshot_store.pull_all()?; + assert!(snapshot_store.replace_and_append_if_snapshot_unchanged( + &exact_snapshot, + vec![completed.clone(), receipt.clone()], + )?); + assert_eq!( + snapshot_store.pull_all()?, + vec![concurrent.clone(), receipt.clone(), completed.clone()] + ); + + let keyed_path = temp.path().join("keyed-replace-append.redb"); + let mut keyed_store = RedbMessagePackBackingStore::new(&keyed_path)?; + keyed_store.push(¤t)?; + let stale_snapshot = keyed_store.pull_all()?; + keyed_store.push(&concurrent)?; + assert!(!keyed_store.replace_and_append_if_snapshot_unchanged( + &stale_snapshot, + vec![completed.clone(), receipt.clone()], + )?); + let exact_snapshot = keyed_store.pull_all()?; + assert!(keyed_store.replace_and_append_if_snapshot_unchanged( + &exact_snapshot, + vec![completed.clone(), receipt.clone()], + )?); + assert_eq!( + keyed_store.pull_all()?, + vec![concurrent, receipt, completed] + ); + Ok(()) + } + + #[test] + fn single_file_compaction_atomically_replaces_summary_and_deletes_history() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("conditional-compaction.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&store_path); + let history = test_envelope("history", "old", b"closed"); + let head = test_envelope("retention", "head", b"revision-1"); + store.push(&history)?; + store.push(&head)?; + let snapshot = store.pull_all()?; + let next_head = test_envelope("retention", "head", b"revision-2"); + + assert!(store.replace_and_delete_if_snapshot_unchanged( + &snapshot, + vec![next_head.clone()], + std::slice::from_ref(&history), + )?); + assert_eq!(store.pull_all()?, vec![next_head]); + + let before = fs::read(&store_path)?; + assert!(!store.replace_and_delete_if_snapshot_unchanged( + &snapshot, + vec![test_envelope("retention", "head", b"revision-3")], + std::slice::from_ref(&history), + )?); + assert_eq!(fs::read(&store_path)?, before); + Ok(()) + } + + #[test] + fn snapshot_append_fences_concurrent_unknown_identity() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("snapshot-append.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&store_path); + let model = test_envelope("model", "current", b"revision-1"); + store.push(&model)?; + let expected = store.pull_all()?; + let duplicate_admission = test_envelope("admission", "unexpected", b"also-current"); + store.push(&duplicate_admission)?; + let before = fs::read(&store_path)?; + + assert!(!store.append_if_snapshot_unchanged( + &expected, + vec![test_envelope("readiness", "proof", b"ready")], + )?); + assert_eq!(fs::read(&store_path)?, before); + Ok(()) + } + + #[test] + fn snapshot_append_is_order_insensitive_and_refuses_collisions() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("snapshot-order.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&store_path); + let a = test_envelope("a", "one", b"1"); + let b = test_envelope("b", "two", b"2"); + store.push(&a)?; + store.push(&b)?; + assert!(store.append_if_snapshot_unchanged( + &[b.clone(), a.clone()], + vec![test_envelope("proof", "three", b"3")], + )?); + let current = store.pull_all()?; + assert!(!store.append_if_snapshot_unchanged( + ¤t, + vec![test_envelope("a", "one", b"collision")], + )?); + assert!( + store + .append_if_snapshot_unchanged(¤t, Vec::new()) + .is_err() + ); + Ok(()) + } + + #[test] + fn type_and_key_identity_is_not_delimiter_ambiguous() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("delimiter-identity.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&store_path); + let left = test_envelope("a::b", "c", b"left"); + let right = test_envelope("a", "b::c", b"right"); + store.push(&left)?; + store.push(&right)?; + let rows = store.pull_all()?; + assert_eq!(rows.len(), 2); + assert!(rows.contains(&left)); + assert!(rows.contains(&right)); + Ok(()) + } + + #[test] + fn refused_batch_preserves_prior_snapshot() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("refused-batch.cc"); + let refusal = Arc::new(AtomicBool::new(false)); + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.add_generic_backing_store(RefusingBatchStore { + inner: SingleFileMessagePackBackingStore::new(&store_path), + refuse_batch: refusal.clone(), + })?; + cache.put( + "app", + &Settings { + theme: "before".to_string(), + retries: 1, + }, + )?; + let (note, _) = cache.prepare_entry( + "receipt", + &Note { + title: "after".to_string(), + body: "must not land".to_string(), + }, + )?; + refusal.store(true, Ordering::SeqCst); + assert!(cache.put_prepared_batch(vec![note]).is_err()); + assert!(cache.get::("receipt")?.is_none()); + + let mut reloaded = CultCache::new(); + reloaded.register_registry(TestEntries)?; + reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + reloaded.pull_all_backing_stores()?; + assert_eq!(reloaded.get_required::("app")?.theme, "before"); + assert!(reloaded.get::("receipt")?.is_none()); + Ok(()) + } + #[test] fn type_specific_store_routes_before_generic_store() -> Result<()> { let temp = tempfile::tempdir()?; - let generic_path = temp.path().join("generic.msgpack"); - let settings_path = temp.path().join("settings.msgpack"); + let generic_path = temp.path().join("generic.cc"); + let settings_path = temp.path().join("settings.cc"); let mut cache = CultCache::new(); cache.register_entry_type::()?; cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path)); cache.add_backing_store( SingleFileMessagePackBackingStore::new(&settings_path), ["settings"], - ); + )?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; cache.put( "app", @@ -751,12 +3811,231 @@ mod tests { } #[test] - fn update_and_delete_follow_the_cache_api() -> Result<()> { + fn second_generic_store_is_rejected() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.msgpack"); + let mut cache = CultCache::new(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("a.cc")))?; + let error = cache + .add_generic_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("b.cc"))) + .unwrap_err(); + assert!(error.to_string().contains("second generic store"), "{error}"); + assert_eq!(cache.stores.len(), 1); + Ok(()) + } + + #[test] + fn type_claimed_twice_is_rejected() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut cache = CultCache::new(); + cache.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("a.cc")), ["settings"])?; + let error = cache + .add_backing_store( + SingleFileMessagePackBackingStore::new(temp.path().join("b.cc")), + ["note", "settings"], + ) + .unwrap_err(); + assert!(error.to_string().contains("already routed"), "{error}"); + // A refused registration claims nothing, so "note" is still free. + cache.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("c.cc")), ["note"])?; + assert_eq!(cache.stores.len(), 2); + Ok(()) + } + + #[test] + fn attach_that_would_move_a_held_record_home_is_refused() -> Result<()> { + let temp = tempfile::tempdir()?; + let generic_path = temp.path().join("generic.cc"); + let settings_path = temp.path().join("settings.cc"); let mut cache = CultCache::new(); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; + cache.put("app", &Settings { theme: "v1-generic".to_string(), retries: 1 })?; + + let error = cache + .add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), ["settings"]) + .unwrap_err(); + assert!( + error + .to_string() + .contains("would move \"settings\" from the generic store to the store routed to [\"settings\"]"), + "{error}" + ); + assert_eq!(cache.stores.len(), 1); + // Nothing attached: the next write still lands in the generic store. + cache.put("app", &Settings { theme: "v2-generic".to_string(), retries: 2 })?; + let generic_entries = SingleFileMessagePackBackingStore::new(&generic_path).pull_all()?; + assert_eq!(generic_entries.len(), 1); + assert_eq!(generic_entries[0].r#type, "settings"); + assert!(!settings_path.exists()); + Ok(()) + } + + #[test] + fn load_from_a_store_that_is_not_the_record_home_is_refused() -> Result<()> { + let temp = tempfile::tempdir()?; + let generic_path = temp.path().join("generic.cc"); + let settings_path = temp.path().join("settings.cc"); + let mut writer = CultCache::new(); + writer.register_entry_type::()?; + writer.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; + writer.put("app", &Settings { theme: "stray".to_string(), retries: 0 })?; + let generic_bytes = fs::read(&generic_path)?; + + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), ["settings"])?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; + // The cache already holds a record, in its home store, before the refused load. + cache.put("held", &Settings { theme: "home".to_string(), retries: 0 })?; + let before = cache.snapshot(); + let settings_bytes = fs::read(&settings_path)?; + let error = cache.pull_all_backing_stores().unwrap_err(); + assert!( + error.to_string().contains( + "\"settings\" record \"app\" was loaded from the generic store, but its home is the store routed to [\"settings\"]" + ), + "{error}" + ); + assert_eq!(cache.snapshot(), before); + assert_eq!(cache.get_required::("held")?.theme, "home"); + assert_eq!(fs::read(&generic_path)?, generic_bytes); + assert_eq!(fs::read(&settings_path)?, settings_bytes); + Ok(()) + } + + #[test] + fn refused_pull_leaves_prior_view_intact() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("generic.cc"); + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.put("app", &Settings { theme: "before".to_string(), retries: 1 })?; + let before = cache.snapshot(); + + // A record of a type this cache never registered lands in the store behind its back. + SingleFileMessagePackBackingStore::new(&store_path).push(&CultCacheEnvelope { + key: "stray".into(), + r#type: "unregistered".into(), + payload: rmp_serde::to_vec(&("x",))?, + stored_at: "2026-09-14T00:00:00Z".into(), + schema_id: Some("unregistered".into()), + })?; + let error = cache.pull_all_backing_stores().unwrap_err(); + assert!(error.to_string().contains("\"unregistered\""), "{error}"); + assert_eq!(cache.snapshot(), before); + assert_eq!(cache.get_required::("app")?.theme, "before"); + Ok(()) + } + + #[test] + fn refused_soa_load_leaves_prior_view_intact() -> Result<()> { + let mut source = CultCache::new(); + source.register_registry(TestEntries)?; + source.put("app", &Settings { theme: "ash".to_string(), retries: 3 })?; + source.put("memo", &Note { title: "machine".to_string(), body: "awake".to_string() })?; + let table = source.snapshot_soa(); + + // A type with no registration. + let mut unregistered = CultCache::new(); + unregistered.register_entry_type::()?; + unregistered.put("held", &Settings { theme: "held".to_string(), retries: 0 })?; + let before = unregistered.snapshot(); + let error = unregistered.load_soa(table.clone()).unwrap_err(); + assert!(error.to_string().contains("\"note\""), "{error}"); + assert_eq!(unregistered.snapshot(), before); + + // A registered type with no home once a store is attached. + let temp = tempfile::tempdir()?; + let mut homeless = CultCache::new(); + homeless.register_registry(TestEntries)?; + homeless.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("settings.cc")), ["settings"])?; + homeless.put("held", &Settings { theme: "held".to_string(), retries: 0 })?; + let before = homeless.snapshot(); + let error = homeless.load_soa(table).unwrap_err(); + assert!(error.to_string().contains("No backing store is registered for entry type \"note\""), "{error}"); + assert_eq!(homeless.snapshot(), before); + Ok(()) + } + + #[test] + fn single_home_batch_is_not_refused_by_other_homes_records() -> Result<()> { + let temp = tempfile::tempdir()?; + let generic_path = temp.path().join("generic.cc"); + let settings_path = temp.path().join("settings.cc"); + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), ["settings"])?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; + cache.put("memo", &Note { title: "generic".to_string(), body: "home".to_string() })?; + cache.put("old", &Settings { theme: "kept".to_string(), retries: 0 })?; + + let (staged, _) = cache.prepare_entry("app", &Settings { theme: "batch".to_string(), retries: 1 })?; + cache.put_prepared_batch(vec![staged])?; + + let mut settings_keys: Vec<_> = SingleFileMessagePackBackingStore::new(&settings_path) + .pull_all()? + .into_iter() + .map(|entry| format!("{}:{}", entry.r#type, entry.key)) + .collect(); + settings_keys.sort(); + assert_eq!(settings_keys, vec!["settings:app".to_string(), "settings:old".to_string()]); + let generic_keys: Vec<_> = SingleFileMessagePackBackingStore::new(&generic_path) + .pull_all()? + .into_iter() + .map(|entry| format!("{}:{}", entry.r#type, entry.key)) + .collect(); + assert_eq!(generic_keys, vec!["note:memo".to_string()]); + assert_eq!(cache.get_required::("app")?.theme, "batch"); + Ok(()) + } + + #[test] + fn batch_with_empty_key_or_stored_at_is_refused_before_any_store() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("generic.cc"); + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.put("app", &Settings { theme: "before".to_string(), retries: 1 })?; + let before = cache.snapshot(); + let store_bytes = fs::read(&store_path)?; + + let (empty_key, _) = cache.prepare_entry("", &Note { title: "t".to_string(), body: "b".to_string() })?; + let error = cache.put_prepared_batch(vec![empty_key]).unwrap_err(); + assert!(error.to_string().contains("keys for type \"note\" must be non-empty"), "{error}"); + + let (mut empty_stored_at, _) = cache.prepare_entry("n", &Note { title: "t".to_string(), body: "b".to_string() })?; + empty_stored_at.stored_at = " ".to_string(); + let error = cache.put_prepared_batch(vec![empty_stored_at]).unwrap_err(); + assert!(error.to_string().contains("stored_at for type \"note\" must be non-empty"), "{error}"); + + assert_eq!(cache.snapshot(), before); + assert_eq!(fs::read(&store_path)?, store_bytes); + Ok(()) + } + + #[test] + fn zero_store_cache_writes_and_deletes_in_memory() -> Result<()> { + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.put("x", &Settings { theme: "mem".to_string(), retries: 0 })?; + assert_eq!(cache.get_required::("x")?.theme, "mem"); + let (staged, _) = cache.prepare_entry("y", &Settings { theme: "batch".to_string(), retries: 0 })?; + cache.put_prepared_batch(vec![staged])?; + assert_eq!(cache.get_required::("y")?.theme, "batch"); + assert!(cache.delete::("x")?); + assert!(cache.get::("x")?.is_none()); + Ok(()) + } + + #[test] + fn update_and_delete_follow_the_cache_api() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("cache.cc"); + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; let updated = cache.update::("app", |current| { let mut current = current.unwrap_or(Settings { @@ -775,7 +4054,7 @@ mod tests { #[test] fn pull_rejects_unregistered_persisted_entry_type() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.msgpack"); + let store_path = temp.path().join("cache.cc"); let mut store = SingleFileMessagePackBackingStore::new(&store_path); store.push(&CultCacheEnvelope { key: "unknown".to_string(), @@ -786,7 +4065,7 @@ mod tests { })?; let mut cache = CultCache::new(); - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; let error = cache.pull_all_backing_stores().unwrap_err(); assert!( error @@ -799,10 +4078,10 @@ mod tests { #[test] fn payload_is_binary_messagepack_not_json_value() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.msgpack"); + let store_path = temp.path().join("cache.cc"); let mut cache = CultCache::new(); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; cache.put( "app", &Settings { @@ -818,10 +4097,23 @@ mod tests { Ok(()) } + #[test] + fn byte_slots_use_messagepack_binary_and_round_trip() -> Result<()> { + let record = BinaryRecord { + required: vec![1, 2, 3], + optional: Vec::new(), + }; + + let payload = rmp_serde::to_vec(&record)?; + assert_eq!(payload, vec![0x92, 0xc4, 3, 1, 2, 3, 0xc4, 0]); + assert_eq!(rmp_serde::from_slice::(&payload)?, record); + Ok(()) + } + #[test] fn corrupted_payload_fails_during_typed_retrieval() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.msgpack"); + let store_path = temp.path().join("cache.cc"); let mut store = SingleFileMessagePackBackingStore::new(&store_path); store.push(&CultCacheEnvelope { key: "app".to_string(), @@ -833,7 +4125,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; cache.pull_all_backing_stores()?; let error = cache.get_required::("app").unwrap_err(); assert!( @@ -847,12 +4139,12 @@ mod tests { #[test] fn put_envelope_reuses_existing_messagepack_payload() -> Result<()> { let temp = tempfile::tempdir()?; - let origin_store = temp.path().join("origin.msgpack"); - let target_store = temp.path().join("target.msgpack"); + let origin_store = temp.path().join("origin.cc"); + let target_store = temp.path().join("target.cc"); let mut origin = CultCache::new(); origin.register_entry_type::()?; - origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store)); + origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?; origin.put( "app", &Settings { @@ -865,7 +4157,7 @@ mod tests { let mut target = CultCache::new(); target.register_entry_type::()?; - target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store)); + target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; let applied = target.put_envelope::(envelope.clone())?; assert_eq!( @@ -882,4 +4174,403 @@ mod tests { ); Ok(()) } + + #[test] + fn raw_envelope_path_preserves_a_registered_dynamic_payload() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("dynamic.cc"); + let envelope = CultCacheEnvelope { + key: "app".to_string(), + r#type: Settings::TYPE.to_string(), + payload: vec![0xc1], + stored_at: now_utc_second(), + schema_id: Some("dynamic.settings.v1".to_string()), + }; + + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.put_raw_envelope(envelope.clone())?; + + assert_eq!(cache.snapshot(), vec![envelope.clone()]); + assert_eq!( + SingleFileMessagePackBackingStore::new(&store_path).pull_all()?, + vec![envelope] + ); + assert!(cache.get_required::("app").is_err()); + let before_collision = fs::read(&store_path)?; + assert!( + cache + .put_raw_envelope(CultCacheEnvelope { + key: "note".to_string(), + r#type: Note::TYPE.to_string(), + payload: Vec::new(), + stored_at: now_utc_second(), + schema_id: Some("dynamic.settings.v1".to_string()), + }) + .is_err() + ); + assert_eq!(fs::read(&store_path)?, before_collision); + assert!( + cache + .put_raw_envelope(CultCacheEnvelope { + key: "foreign".to_string(), + r#type: "unregistered".to_string(), + payload: Vec::new(), + stored_at: now_utc_second(), + schema_id: None, + }) + .is_err() + ); + Ok(()) + } + + #[test] + fn soa_snapshot_round_trips_registered_entries() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("cache.cc"); + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.put( + "app", + &Settings { + theme: "ash".to_string(), + retries: 3, + }, + )?; + cache.put( + "memo", + &Note { + title: "machine".to_string(), + body: "awake".to_string(), + }, + )?; + + let soa = cache.snapshot_soa(); + assert_eq!(soa.len(), 2); + assert_eq!(soa.keys.len(), soa.payloads.len()); + + let mut restored = CultCache::new(); + restored.register_registry(TestEntries)?; + restored.add_generic_backing_store(SingleFileMessagePackBackingStore::new( + temp.path().join("restored.cc"), + ))?; + restored.load_soa(soa)?; + assert_eq!(restored.get_required::("app")?.theme, "ash"); + assert_eq!(restored.get_required::("memo")?.body, "awake"); + Ok(()) + } + + #[test] + fn typed_soa_columns_match_csharp_cache_ergonomics() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("cache.cc"); + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.put( + "settings:a", + &Settings { + theme: "ash".to_string(), + retries: 3, + }, + )?; + cache.put( + "settings:b", + &Settings { + theme: "bone".to_string(), + retries: 5, + }, + )?; + + let table = cache.soa::()?; + assert_eq!( + table.keys(), + &["settings:a".to_string(), "settings:b".to_string()] + ); + assert_eq!( + table.column::("theme")?.values(), + &["ash".to_string(), "bone".to_string()] + ); + assert_eq!(table.column::("retries")?.values(), &[3, 5]); + + let error = table.column::("retries").unwrap_err(); + assert!(error.to_string().contains("not alloc::string::String")); + Ok(()) + } + + #[test] + fn soa_rejects_column_length_drift() { + let table = CultCacheSoaTable { + keys: vec!["app".to_string()], + types: Vec::new(), + payloads: Vec::new(), + stored_ats: Vec::new(), + schema_ids: Vec::new(), + }; + let error = table.validate().unwrap_err(); + assert!(error.to_string().contains("column types has length 0")); + } + + #[test] + fn messagepack_store_recovers_schema_stamped_records_missing_catalog_entries() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("missing-catalog.msgpack"); + let expected = SchemaStamped { + schema_version: "tests.schema_stamped_entry.v1".to_string(), + name: "schema-stamped".to_string(), + value: "still readable".to_string(), + }; + // An empty catalog with a stale schema id is what a record written by + // another runtime, or one older than its catalog, looks like on disk. + let snapshot = PersistedStoreSnapshot( + "cultcache.store.v1".to_string(), + Vec::new(), + vec![PersistedRecord( + "record-1".to_string(), + "sha256:stale-schema-id-from-cold-record".to_string(), + "2026-06-25T12:00:00Z".to_string(), + rmp_serde::to_vec(&expected)?, + )], + ); + std::fs::write(&store_path, rmp_serde::to_vec(&snapshot)?)?; + + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; + + assert_eq!(cache.get_required::("record-1")?, expected); + assert_eq!( + cache + .get_required_envelope::("record-1")? + .schema_id, + Some("sha256:stale-schema-id-from-cold-record".to_string()) + ); + Ok(()) + } + + #[test] + fn abandoned_staging_cleanup_removes_only_canonical_candidates() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("store.cc"); + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.put( + "primary", + &Settings { + theme: "dark".to_string(), + retries: 2, + }, + )?; + + // A crashed writer leaves a canonically named candidate behind. + let abandoned = temp + .path() + .join(format!("store.cc.{}.tmp", uuid::Uuid::new_v4())); + std::fs::write(&abandoned, b"partial snapshot")?; + // These only look like candidates. Cleanup must not touch them: the + // staging name is the store's own file name plus a canonical UUID, and + // anything else is somebody's file. + let lookalike = temp.path().join("store.cc.not-a-uuid.tmp"); + std::fs::write(&lookalike, b"keep me")?; + let bare_suffix = temp.path().join("store.cc.tmp"); + std::fs::write(&bare_suffix, b"keep me too")?; + let neighbour = temp.path().join("other.cc.00000000-0000-0000-0000-000000000000.tmp"); + std::fs::write(&neighbour, b"not this store")?; + + cache.put( + "primary", + &Settings { + theme: "light".to_string(), + retries: 3, + }, + )?; + + assert!(!abandoned.exists(), "canonical abandoned candidate survived"); + assert!(lookalike.exists(), "non-UUID lookalike was deleted"); + assert!(bare_suffix.exists(), "bare .tmp suffix was deleted"); + assert!(neighbour.exists(), "another store's candidate was deleted"); + + let mut reloaded = CultCache::new(); + reloaded.register_entry_type::()?; + reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + reloaded.pull_all_backing_stores()?; + assert_eq!(reloaded.get_required::("primary")?.theme, "light"); + Ok(()) + } + + fn snapshot_envelope(key: &str, payload: &[u8]) -> CultCacheEnvelope { + CultCacheEnvelope { + key: key.to_string(), + r#type: "snapshot.record".to_string(), + payload: payload.to_vec(), + stored_at: "2026-01-01T00:00:00Z".to_string(), + // The store stamps the schema id from the type on write, so a + // fixture that omits it would not survive its own round trip. + schema_id: Some("snapshot.record".to_string()), + } + } + + #[test] + fn snapshot_exchange_commits_only_against_the_observed_snapshot() -> Result<()> { + let temp = tempfile::tempdir()?; + let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc")); + + let first = vec![snapshot_envelope("admitted", b"one")]; + assert!(store.compare_exchange_snapshot(&[], &first)?); + assert_eq!(store.pull_all_read_only_snapshot()?, first); + + // A writer holding a stale view loses the race instead of clobbering it. + let stale = vec![snapshot_envelope("admitted", b"stale")]; + assert!(!store.compare_exchange_snapshot(&[], &stale)?); + assert_eq!(store.pull_all_read_only_snapshot()?, first); + + let second = vec![snapshot_envelope("admitted", b"two")]; + assert!(store.compare_exchange_snapshot(&first, &second)?); + assert_eq!(store.pull_all_read_only_snapshot()?, second); + Ok(()) + } + + #[test] + fn try_snapshot_exchange_separates_a_lost_race_from_a_commit() -> Result<()> { + let temp = tempfile::tempdir()?; + let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc")); + + let first = vec![snapshot_envelope("admitted", b"one")]; + assert_eq!( + store.try_compare_exchange_snapshot(&[], &first)?, + TryCompareExchangeSnapshotOutcome::Exchanged + ); + assert_eq!( + store.try_compare_exchange_snapshot(&[], &first)?, + TryCompareExchangeSnapshotOutcome::Mismatch, + "a stale expectation is a lost race, not contention" + ); + assert_eq!(store.pull_all_read_only_snapshot()?, first); + Ok(()) + } + + #[test] + fn snapshot_exchange_refuses_replacements_that_cannot_be_addressed() -> Result<()> { + let temp = tempfile::tempdir()?; + let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc")); + + assert!( + store + .compare_exchange_snapshot(&[], &[snapshot_envelope(" ", b"one")]) + .is_err(), + "an unaddressable record must not reach the store" + ); + assert!( + store + .compare_exchange_snapshot( + &[], + &[ + snapshot_envelope("admitted", b"one"), + snapshot_envelope("admitted", b"two"), + ], + ) + .is_err(), + "a duplicate identity would make the snapshot ambiguous" + ); + assert_eq!(store.pull_all_read_only_snapshot()?, Vec::new()); + Ok(()) + } + + #[test] + fn per_entry_exchange_constrains_only_the_identities_it_names() -> Result<()> { + let temp = tempfile::tempdir()?; + let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc")); + + let admitted = snapshot_envelope("admitted", b"one"); + let unrelated = snapshot_envelope("unrelated", b"other"); + assert!(store.compare_exchange( + &[ + CultCacheExpectedEnvelope { + key: "admitted".into(), + r#type: "snapshot.record".into(), + current: None, + }, + CultCacheExpectedEnvelope { + key: "unrelated".into(), + r#type: "snapshot.record".into(), + current: None, + }, + ], + &[admitted.clone(), unrelated.clone()], + )?); + + // The snapshot moved, but an expectation naming only `admitted` still + // holds. A whole-snapshot exchange would have lost this race. + let next = snapshot_envelope("admitted", b"two"); + assert!(store.compare_exchange( + &[CultCacheExpectedEnvelope { + key: "admitted".into(), + r#type: "snapshot.record".into(), + current: Some(admitted.clone()), + }], + &[next.clone()], + )?); + assert_eq!( + store.pull_all_read_only_snapshot()?, + vec![next.clone(), unrelated.clone()] + ); + + // A stale expectation for that identity now loses. + assert!(!store.compare_exchange( + &[CultCacheExpectedEnvelope { + key: "admitted".into(), + r#type: "snapshot.record".into(), + current: Some(admitted), + }], + &[snapshot_envelope("admitted", b"three")], + )?); + assert_eq!(store.pull_all_read_only_snapshot()?, vec![next, unrelated]); + Ok(()) + } + + #[test] + fn per_entry_exchange_distinguishes_absent_from_stored() -> Result<()> { + let temp = tempfile::tempdir()?; + let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc")); + let admitted = snapshot_envelope("admitted", b"one"); + + // Expecting a stored value where nothing is stored must not commit. + assert!(!store.compare_exchange( + &[CultCacheExpectedEnvelope { + key: "admitted".into(), + r#type: "snapshot.record".into(), + current: Some(admitted.clone()), + }], + &[admitted.clone()], + )?); + assert_eq!(store.pull_all_read_only_snapshot()?, Vec::new()); + + // Expecting absence commits the first writer. + assert!(store.compare_exchange( + &[CultCacheExpectedEnvelope { + key: "admitted".into(), + r#type: "snapshot.record".into(), + current: None, + }], + &[admitted.clone()], + )?); + + // And the second writer expecting absence now loses, which is what + // makes this usable as a create-once claim. + assert!(!store.compare_exchange( + &[CultCacheExpectedEnvelope { + key: "admitted".into(), + r#type: "snapshot.record".into(), + current: None, + }], + &[snapshot_envelope("admitted", b"two")], + )?); + assert_eq!(store.pull_all_read_only_snapshot()?, vec![admitted]); + Ok(()) + } } diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index 064cc91b..d1730958 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -53,7 +53,7 @@ import { SingleFileMessagePackBackingStore, defineDocumentRegistry, defineDocumentType, -} from "cultcache-ts"; +} from "@gamecult/cultcache-ts"; const itemDocument = defineDocumentType({ type: "item", @@ -142,6 +142,25 @@ Document definitions may also carry persistence metadata for cross-language stores: `schemaId`, `schemaName`, `schemaVersion`, `contentHash`, `canonicalSchemaJson`, `compatibleSchemaIds`, and slot `members`. +## Backing Store Routing + +```ts +const cache = CultCache.builder() + .withRegistry(registry) + .withBackingStore(new SingleFileMessagePackBackingStore("settings.cc"), settingsDocument) + .withGenericStore(new SingleFileMessagePackBackingStore("cache.cc")) + .build(); +``` + +A document type has exactly one home store +(`src/GameCult.Caching/Contracts/cultcache-store-composition.md` in CultLib): + +- a store added with types owns those types; otherwise the generic store is the home +- a second generic store throws, and so does a type claimed by a second store; + a refused registration attaches nothing +- `put`, `putEnvelope` and `delete` touch exactly one store; nothing is mirrored +- a write whose type has no home store throws + ## Name, Index, and Global Semantics `name` and `indexes` can be declared on the document definition: @@ -161,11 +180,19 @@ const playerDocument = defineDocumentType({ You can also register them later if you feel like changing the machine after boot: ```ts -cache.registerNameLookup(playerDocument, "displayName"); -cache.registerIndex(playerDocument, "faction", "faction"); +await cache.registerNameLookup(playerDocument, "displayName"); +await cache.registerIndex(playerDocument, "faction", "faction"); ``` -Global documents are singleton-style per type: +Registration, `addBackingStore`, `pullAllBackingStores` and every write and +delete run one at a time through a per-cache queue and return promises; reads +are synchronous. A write validates the whole record (home store, global key, +name and index accessors) before any store is touched, so a refused write +changes neither store nor cache. Registering an accessor that throws on a held +record installs nothing. + +Global documents are singleton-style per type, always stored under +`CultCache.GLOBAL_KEY` (`__global__`); writing one under any other key throws: ```ts await cache.putGlobal(settingsDocument, { theme: "ash", retries: 3 }); diff --git a/packages/cultcache-ts/package-lock.json b/packages/cultcache-ts/package-lock.json deleted file mode 100644 index 4b3cf3f6..00000000 --- a/packages/cultcache-ts/package-lock.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "name": "cultcache-ts", - "version": "0.13.5", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "cultcache-ts", - "version": "0.13.5", - "license": "MIT", - "dependencies": { - "@msgpack/msgpack": "^3.1.3", - "zod": "^3.25.76" - }, - "devDependencies": { - "@types/node": "^24.0.0", - "typescript": "^5.9.0" - } - }, - "node_modules/@msgpack/msgpack": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", - "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", - "license": "ISC", - "engines": { - "node": ">= 18" - } - }, - "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/packages/cultcache-ts/package.json b/packages/cultcache-ts/package.json index 60731b8f..67f8d5ed 100644 --- a/packages/cultcache-ts/package.json +++ b/packages/cultcache-ts/package.json @@ -1,6 +1,6 @@ { - "name": "cultcache-ts", - "version": "0.13.5", + "name": "@gamecult/cultcache-ts", + "version": "0.14.0", "description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -20,9 +20,10 @@ "dist" ], "scripts": { - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --pretty false", - "test": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/cult-cache.test.js" + "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", + "prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", + "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false", + "test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/cult-cache.test.js" }, "keywords": [ "messagepack", @@ -34,6 +35,18 @@ ], "author": "metacratic", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/GameCult/CultLib.git", + "directory": "packages/cultcache-ts" + }, + "bugs": { + "url": "https://github.com/GameCult/CultLib/issues" + }, + "homepage": "https://github.com/GameCult/CultLib/tree/main/packages/cultcache-ts#readme", + "engines": { + "node": ">=18" + }, "dependencies": { "@msgpack/msgpack": "^3.1.3", "zod": "^3.25.76" diff --git a/packages/cultcache-ts/src/cult-cache-inspector.ts b/packages/cultcache-ts/src/cult-cache-inspector.ts index da91da41..1301fe47 100644 --- a/packages/cultcache-ts/src/cult-cache-inspector.ts +++ b/packages/cultcache-ts/src/cult-cache-inspector.ts @@ -74,13 +74,31 @@ function inspectV1Snapshot(filePath: string, fileSizeBytes: number, decoded: unk catalogBySchemaId.set(compatibleSchemaId, entry); } } + const records = recordsRaw.map((record) => decodeV1Record(record, catalogBySchemaId)); + for (const record of records) { + if (!catalog.some((entry) => entry.schemaId === record.schemaId)) { + const schemaVersion = inferSchemaVersionFromPayloadPreview(record.payloadPreview); + const schemaName = schemaVersion ? inferSchemaName(schemaVersion) : undefined; + if (schemaName) { + catalog.push({ + schemaId: record.schemaId, + schemaName, + schemaVersion: schemaVersion!, + contentHash: record.schemaId, + canonicalSchemaJson: "", + compatibleSchemaIds: [], + members: [], + }); + } + } + } return { filePath, fileSizeBytes, format: STORE_FORMAT_VERSION, catalog, - records: recordsRaw.map((record) => decodeV1Record(record, catalogBySchemaId)), + records, }; } @@ -184,8 +202,8 @@ function decodeV1Record(value: unknown, catalogBySchemaId: Map string | undefined; type RegisteredDefinition = { readonly definition: AnyCultCacheDocumentDefinition; readonly global: boolean; readonly formatter: CultCacheDocumentFormatter; readonly catalogEntry: CultCacheSchemaCatalogEntry; - nameAccessor?: (value: unknown) => string | undefined; - readonly indexAccessors: Map string | undefined>; + nameAccessor?: Accessor; + indexAccessors: ReadonlyMap; +}; + +// The name and index values a record produced when it was admitted. Removing the record +// reads these and runs no accessor, so removal has no fallible step. +type EntryLookups = { + readonly name?: string; + readonly indexes: ReadonlyMap; }; type HydratedEntry = CultCacheEnvelope & { value: unknown; + lookups: EntryLookups; }; +// Everything a pull replaces. Built whole, then swapped in with one assignment. +type HydratedState = { + readonly entries: Map; + readonly typeEntryIds: Map>; + readonly nameToKeyMaps: Map>; + readonly indexToKeyMaps: Map>; + readonly globalKeys: Map; + // Load compatibility shim: a global persisted under a key other than GLOBAL_KEY is held as that + // type's global under GLOBAL_KEY, and its persisted envelope is kept here so the first write or + // delete of that global removes it from the home store. Remove once no store holds a global under + // a key other than GLOBAL_KEY. + readonly legacyGlobals: Map; +}; + +// Lookups for every held record of one type under candidate accessors, derived before anything is installed. +type DerivedTypeLookups = { + readonly entryLookups: Array<[HydratedEntry, EntryLookups]>; + readonly nameMap?: Map; + readonly indexMaps: Map>; +}; + +function emptyHydratedState(): HydratedState { + return { + entries: new Map(), + typeEntryIds: new Map(), + nameToKeyMaps: new Map(), + indexToKeyMaps: new Map(), + globalKeys: new Map(), + legacyGlobals: new Map(), + }; +} + +function getOrCreate(map: Map, key: K, create: () => V): V { + let value = map.get(key); + if (value === undefined) { + value = create(); + map.set(key, value); + } + return value; +} + type BackingStoreTypeReference = string | AnyCultCacheDocumentDefinition; type AccessorInput = string | ((value: any) => unknown); +// Assigned in CultCache's static block. The builder's cache is unreachable until build(), so no +// queued operation can be in flight and registration and attach may run synchronously. +let registerNow!: (cache: CultCache, definition: AnyCultCacheDocumentDefinition) => void; +let attachNow!: (cache: CultCache, store: CacheBackingStore, types: BackingStoreTypeReference[]) => void; + export class CultCacheBuilder { readonly #cache = new CultCache(); + #built = false; withDocumentType(definition: TDefinition): this { - this.#cache.registerDocumentType(definition); + registerNow(this.#unbuilt(), definition); return this; } @@ -47,7 +99,10 @@ export class CultCacheBuilder { | CultCacheDocumentRegistry | Iterable, ): this { - this.#cache.registerRegistry(registry); + const cache = this.#unbuilt(); + for (const definition of registryDefinitions(registry)) { + registerNow(cache, definition); + } return this; } @@ -55,158 +110,193 @@ export class CultCacheBuilder { store: CacheBackingStore, ...types: BackingStoreTypeReference[] ): this { - this.#cache.addBackingStore(store, ...types); + attachNow(this.#unbuilt(), store, types); return this; } withGenericStore(store: CacheBackingStore): this { - this.#cache.addGenericBackingStore(store); + attachNow(this.#unbuilt(), store, []); return this; } build(): CultCache { + this.#built = true; return this.#cache; } + + #unbuilt(): CultCache { + if (this.#built) { + throw new Error("CultCacheBuilder has already built its cache; configure the cache through its own methods."); + } + return this.#cache; + } +} + +function registryDefinitions( + registry: CultCacheDocumentRegistry | Iterable, +): Iterable { + return Symbol.iterator in Object(registry) && !("definitions" in Object(registry)) + ? registry as Iterable + : (registry as CultCacheDocumentRegistry).definitions; } export class CultCache { static readonly GLOBAL_KEY = "__global__"; + static { + registerNow = (cache, definition) => cache.#registerNow(definition); + attachNow = (cache, store, types) => cache.#attachNow(store, types); + } + readonly #definitions = new Map(); readonly #schemaIdDefinitions = new Map(); - readonly #entries = new Map(); - readonly #typeEntryIds = new Map>(); - readonly #nameToKeyMaps = new Map>(); - readonly #indexToKeyMaps = new Map>(); - readonly #globalKeys = new Map(); + readonly #schemaNameDefinitions = new Map(); + #hydrated = emptyHydratedState(); readonly #stores: CultCacheStoreRegistration[] = []; + #tail: Promise = Promise.resolve(); static builder(): CultCacheBuilder { return new CultCacheBuilder(); } + // Attach, pull, registration and every write and delete run one at a time through this chain, + // so validation, the store call and applying to the cache never interleave with another + // mutation of this cache. Reads are not queued. Observer delivery is not scheduled here. + // The chain advances on its own promise; the caller gets the operation's promise, untouched, so + // an unawaited failure is still an unhandled rejection. A call made from inside an operation + // queues behind it; a store call that waits for that call waits on itself and never finishes. + #serial(operation: () => T | Promise): Promise { + let advance!: () => void; + const previous = this.#tail; + this.#tail = new Promise((resolve) => { + advance = resolve; + }); + return previous.then(async () => { + try { + return await operation(); + } finally { + advance(); + } + }); + } + registerDocumentType( definition: TDefinition, - ): TDefinition { - const existing = this.#definitions.get(definition.type); - if (existing && existing.definition !== definition) { - throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`); - } - - const registered = existing ?? { - definition, - global: definition.global === true, - formatter: this.#createFormatter(definition), - catalogEntry: this.#createCatalogEntry(definition), - nameAccessor: undefined, - indexAccessors: new Map string | undefined>(), - }; - - registered.nameAccessor = definition.name - ? this.#compileAccessor(definition.type, "name", definition.name) - : undefined; - registered.indexAccessors.clear(); - for (const [indexName, accessor] of this.#enumerateDefinitionIndexes(definition)) { - registered.indexAccessors.set( - indexName, - this.#compileAccessor(definition.type, `index "${indexName}"`, accessor), - ); - } - - this.#definitions.set(definition.type, registered); - for (const schemaId of registered.catalogEntry.compatibleSchemaIds ?? [registered.catalogEntry.schemaId]) { - const existingBySchema = this.#schemaIdDefinitions.get(schemaId); - if (existingBySchema && existingBySchema !== registered) { - throw new Error(`CultCache schema id "${schemaId}" is already registered for type "${existingBySchema.definition.type}".`); - } - this.#schemaIdDefinitions.set(schemaId, registered); - } - this.#rebuildDefinitionLookups(registered); - return definition; + ): Promise { + return this.#serial(() => { + this.#registerNow(definition); + return definition; + }); } registerRegistry( registry: | CultCacheDocumentRegistry | Iterable, - ): this { - const definitions = Symbol.iterator in Object(registry) && !("definitions" in Object(registry)) - ? registry as Iterable - : (registry as CultCacheDocumentRegistry).definitions; - - for (const definition of definitions) { - this.registerDocumentType(definition); - } - - return this; + ): Promise { + return this.#serial(() => { + for (const definition of registryDefinitions(registry)) { + this.#registerNow(definition); + } + }); } registerNameLookup( definition: TDefinition, accessor: CultCacheDocumentAccessor>, - ): TDefinition { - const registered = this.#requireDefinition(definition); - registered.nameAccessor = this.#compileAccessor(definition.type, "name", accessor); - this.#rebuildNameLookup(registered); - return definition; + ): Promise { + return this.#serial(() => { + const registered = this.#requireDefinition(definition); + const nameAccessor = this.#compileAccessor(definition.type, "name", accessor); + const derived = this.#deriveTypeLookups(definition.type, nameAccessor, registered.indexAccessors); + this.#installAccessors(registered, nameAccessor, registered.indexAccessors, derived); + return definition; + }); } registerIndex( definition: TDefinition, indexName: string, accessor: CultCacheDocumentAccessor>, - ): TDefinition { - if (!indexName || indexName.trim().length === 0) { - throw new Error(`CultCache index names for type "${definition.type}" must be non-empty.`); - } + ): Promise { + return this.#serial(() => { + if (!indexName || indexName.trim().length === 0) { + throw new Error(`CultCache index names for type "${definition.type}" must be non-empty.`); + } - const registered = this.#requireDefinition(definition); - registered.indexAccessors.set( - indexName, - this.#compileAccessor(definition.type, `index "${indexName}"`, accessor), - ); - this.#rebuildIndexLookup(registered, indexName); - return definition; + const registered = this.#requireDefinition(definition); + const indexAccessors = new Map(registered.indexAccessors); + indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor)); + const derived = this.#deriveTypeLookups(definition.type, registered.nameAccessor, indexAccessors); + this.#installAccessors(registered, registered.nameAccessor, indexAccessors, derived); + return definition; + }); } addBackingStore( store: CacheBackingStore, ...types: BackingStoreTypeReference[] - ): void { - this.#stores.push({ - store, - types: [...new Set(types.map((value) => (typeof value === "string" ? value : value.type)))], - }); + ): Promise { + return this.#serial(() => this.#attachNow(store, types)); } - addGenericBackingStore(store: CacheBackingStore): void { - this.addBackingStore(store); + addGenericBackingStore(store: CacheBackingStore): Promise { + return this.addBackingStore(store); } - async pullAllBackingStores(): Promise { - this.#resetHydratedState(); + // The complete next state (entries, globals, name and index lookups) is built into a fresh + // HydratedState, running every check and every user accessor, and replaces the cache's state + // in one assignment only once the build finishes. A refused load admits nothing. + pullAllBackingStores(): Promise { + return this.#serial(async () => { + const next = emptyHydratedState(); + const persistedGlobalKeys = new Map(); - for (const registration of this.#stores) { - const entries = await registration.store.pullAll(); + for (const registration of this.#stores) { + const entries = await registration.store.pullAll(); - for (const entry of entries) { - const registered = this.#resolveDefinitionForEnvelope(entry); - if (!registered) { - throw new Error( - entry.schemaId - ? `No schema is registered for persisted schema id "${entry.schemaId}".` - : `No schema is registered for persisted document type "${entry.type}".`, - ); + for (const entry of entries) { + const registered = this.#resolveDefinitionForEnvelope(entry); + if (!registered) { + throw new Error( + entry.schemaId + ? `No schema is registered for persisted schema id "${entry.schemaId}".` + : `No schema is registered for persisted document type "${entry.type}".`, + ); + } + + const type = registered.definition.type; + const home = this.#resolveRegistration(type); + if (home?.store !== registration.store) { + throw new Error( + `CultCache "${type}" record "${entry.key}" was loaded from ${this.#describe(registration)}, ` + + `but its home is ${this.#describe(home)}.`, + ); + } + + const payload = this.#cloneBytes(entry.payload); + const value = registered.formatter.decode(payload); + let admitted: CultCacheEnvelope = { ...entry, type, payload }; + if (registered.global) { + const persistedKey = persistedGlobalKeys.get(type); + if (persistedKey !== undefined) { + throw new Error( + `CultCache global document type "${type}" has multiple persisted entries: "${persistedKey}" and "${entry.key}".`, + ); + } + persistedGlobalKeys.set(type, entry.key); + if (entry.key !== CultCache.GLOBAL_KEY) { + next.legacyGlobals.set(type, admitted); + admitted = { ...admitted, key: CultCache.GLOBAL_KEY }; + } + } + const hydrated = this.#admitEntry(next, registered, admitted, value); + this.#installEntry(next, registered, hydrated); } - - const payload = this.#cloneBytes(entry.payload); - const value = registered.formatter.decode(payload); - this.#applyHydratedEntry(registered, { - ...entry, - payload, - }, value, "pull"); } - } + + this.#hydrated = next; + }); } get( @@ -214,7 +304,7 @@ export class CultCache { key: string, ): CultCacheDocumentValue | undefined { this.#requireDefinition(definition); - const entry = this.#entries.get(this.#entryId(definition.type, key)); + const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key)); return entry ? (entry.value as CultCacheDocumentValue) : undefined; } @@ -235,7 +325,7 @@ export class CultCache { key: string, ): CultCacheEnvelope | undefined { this.#requireDefinition(definition); - const entry = this.#entries.get(this.#entryId(definition.type, key)); + const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key)); return entry ? this.#toEnvelope(entry) : undefined; } @@ -254,12 +344,8 @@ export class CultCache { getGlobal( definition: TDefinition, ): CultCacheDocumentValue | undefined { - const registered = this.#requireDefinition(definition); - if (!registered.global) { - throw new Error(`CultCache document type "${definition.type}" is not marked as global.`); - } - - const globalKey = this.#globalKeys.get(definition.type); + this.#requireGlobal(definition); + const globalKey = this.#hydrated.globalKeys.get(definition.type); return globalKey ? this.get(definition, globalKey) : undefined; } @@ -277,12 +363,8 @@ export class CultCache { getGlobalEnvelope( definition: TDefinition, ): CultCacheEnvelope | undefined { - const registered = this.#requireDefinition(definition); - if (!registered.global) { - throw new Error(`CultCache document type "${definition.type}" is not marked as global.`); - } - - const globalKey = this.#globalKeys.get(definition.type); + this.#requireGlobal(definition); + const globalKey = this.#hydrated.globalKeys.get(definition.type); return globalKey ? this.getEnvelope(definition, globalKey) : undefined; } @@ -290,21 +372,7 @@ export class CultCache { definition: TDefinition, ): CultCacheDocumentValue[] { this.#requireDefinition(definition); - const values: CultCacheDocumentValue[] = []; - const entryIds = this.#typeEntryIds.get(definition.type); - - if (!entryIds) { - return values; - } - - for (const entryId of entryIds) { - const entry = this.#entries.get(entryId); - if (entry) { - values.push(entry.value as CultCacheDocumentValue); - } - } - - return values; + return this.#entriesForType(definition.type).map((entry) => entry.value as CultCacheDocumentValue); } getKeyByName( @@ -316,7 +384,7 @@ export class CultCache { return undefined; } - return this.#nameToKeyMaps.get(definition.type)?.get(name); + return this.#hydrated.nameToKeyMaps.get(definition.type)?.get(name); } getIdByName( @@ -340,7 +408,7 @@ export class CultCache { value: string, ): string | undefined { this.#requireDefinition(definition); - return this.#indexToKeyMaps.get(this.#indexId(definition.type, indexName))?.get(value); + return this.#hydrated.indexToKeyMaps.get(this.#indexId(definition.type, indexName))?.get(value); } getIdByIndex( @@ -360,12 +428,180 @@ export class CultCache { return key ? this.get(definition, key) : undefined; } - async put( + put( + definition: TDefinition, + key: string, + value: CultCacheDocumentValue, + ): Promise> { + return this.#serial(() => this.#putNow(definition, key, value)); + } + + putEnvelope( + definition: TDefinition, + envelope: CultCacheEnvelope, + ): Promise> { + return this.#serial(async () => { + const registered = this.#requireDefinition(definition); + if (envelope.type !== definition.type) { + throw new Error( + `CultCache envelope type "${envelope.type}" does not match definition "${definition.type}".`, + ); + } + if (!envelope.key || envelope.key.trim().length === 0) { + throw new Error(`CultCache envelope key for type "${definition.type}" must be non-empty.`); + } + if (!envelope.storedAt || envelope.storedAt.trim().length === 0) { + throw new Error(`CultCache envelope storedAt for type "${definition.type}" must be non-empty.`); + } + this.#requireGlobalKey(registered, envelope.key); + + const payload = this.#cloneBytes(envelope.payload); + const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue; + const entry: CultCacheEnvelope = { + key: envelope.key, + type: envelope.type, + payload, + storedAt: envelope.storedAt, + schemaId: envelope.schemaId ?? registered.catalogEntry.schemaId, + catalogEntry: envelope.catalogEntry ?? registered.catalogEntry, + }; + await this.#writeNow(registered, entry, parsed); + return parsed; + }); + } + + putGlobal( + definition: TDefinition, + value: CultCacheDocumentValue, + ): Promise> { + return this.#serial(() => { + this.#requireGlobal(definition); + return this.#putNow(definition, CultCache.GLOBAL_KEY, value); + }); + } + + update( + definition: TDefinition, + key: string, + updater: ( + current: CultCacheDocumentValue | undefined, + ) => CultCacheDocumentValue, + ): Promise> { + return this.#serial(() => this.#putNow(definition, key, updater(this.get(definition, key)))); + } + + updateGlobal( + definition: TDefinition, + updater: ( + current: CultCacheDocumentValue | undefined, + ) => CultCacheDocumentValue, + ): Promise> { + return this.#serial(() => + this.#putNow(definition, CultCache.GLOBAL_KEY, updater(this.getGlobal(definition))), + ); + } + + delete( + definition: TDefinition, + key: string, + ): Promise { + return this.#serial(() => this.#deleteNow(definition, key)); + } + + deleteGlobal( + definition: TDefinition, + ): Promise { + return this.#serial(async () => { + this.#requireGlobal(definition); + const globalKey = this.#hydrated.globalKeys.get(definition.type); + return globalKey ? this.#deleteNow(definition, globalKey) : false; + }); + } + + snapshot(): CultCacheEnvelope[] { + return [...this.#hydrated.entries.values()].map((entry) => this.#toEnvelope(entry)); + } + + #registerNow(definition: AnyCultCacheDocumentDefinition): void { + const existing = this.#definitions.get(definition.type); + if (existing && existing.definition !== definition) { + throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`); + } + + const registered: RegisteredDefinition = existing ?? { + definition, + global: definition.global === true, + formatter: this.#createFormatter(definition), + catalogEntry: this.#createCatalogEntry(definition), + nameAccessor: undefined, + indexAccessors: new Map(), + }; + + const nameAccessor = definition.name + ? this.#compileAccessor(definition.type, "name", definition.name) + : undefined; + const indexAccessors = new Map(); + for (const [indexName, accessor] of this.#enumerateDefinitionIndexes(definition)) { + indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor)); + } + + const schemaIds = registered.catalogEntry.compatibleSchemaIds ?? [registered.catalogEntry.schemaId]; + for (const schemaId of schemaIds) { + const existingBySchema = this.#schemaIdDefinitions.get(schemaId); + if (existingBySchema && existingBySchema !== registered) { + throw new Error(`CultCache schema id "${schemaId}" is already registered for type "${existingBySchema.definition.type}".`); + } + } + const existingBySchemaName = this.#schemaNameDefinitions.get(registered.catalogEntry.schemaName); + if (existingBySchemaName && existingBySchemaName !== registered) { + throw new Error( + `CultCache schema name "${registered.catalogEntry.schemaName}" is already registered for type "${existingBySchemaName.definition.type}".`, + ); + } + const derived = this.#deriveTypeLookups(definition.type, nameAccessor, indexAccessors); + + // Every check and accessor has run; install. + this.#definitions.set(definition.type, registered); + for (const schemaId of schemaIds) { + this.#schemaIdDefinitions.set(schemaId, registered); + } + this.#schemaNameDefinitions.set(registered.catalogEntry.schemaName, registered); + this.#installAccessors(registered, nameAccessor, indexAccessors, derived); + } + + #attachNow(store: CacheBackingStore, types: BackingStoreTypeReference[]): void { + const claimed = [...new Set(types.map((value) => (typeof value === "string" ? value : value.type)))]; + if (claimed.length === 0 && this.#stores.some((registration) => registration.types.length === 0)) { + throw new Error("Backing store would be a second generic store; name the types it is home to."); + } + for (const type of claimed) { + if (this.#stores.some((registration) => registration.types.includes(type))) { + throw new Error(`CultCache type "${type}" is already routed to another backing store; it cannot also route to this one.`); + } + } + const candidate: CultCacheStoreRegistration = { store, types: claimed }; + for (const type of this.#hydrated.typeEntryIds.keys()) { + const before = this.#resolveRegistration(type); + this.#stores.push(candidate); + const after = this.#resolveRegistration(type); + this.#stores.pop(); + if (before !== after) { + throw new Error( + `Attaching this backing store would move "${type}" from ${this.#describe(before)} to ${this.#describe(after)}; ` + + "attach routed stores before the generic store.", + ); + } + } + this.#stores.push(candidate); + } + + async #putNow( definition: TDefinition, key: string, value: CultCacheDocumentValue, ): Promise> { const registered = this.#requireDefinition(definition); + this.#requireGlobalKey(registered, key); const payload = registered.formatter.encode(value); const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue; const entry: CultCacheEnvelope = { @@ -376,140 +612,67 @@ export class CultCache { schemaId: registered.catalogEntry.schemaId, catalogEntry: registered.catalogEntry, }; - - const route = this.#resolveRoute(definition.type); - if (!route.primary) { - throw new Error(`No backing store is registered for document type "${definition.type}".`); - } - - if (registered.global) { - const existingGlobalKey = this.#globalKeys.get(definition.type); - if (existingGlobalKey && existingGlobalKey !== key) { - await this.delete(definition, existingGlobalKey); - } - } - - await route.primary.push(entry); - await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry))); - this.#applyHydratedEntry(registered, entry, parsed, "put"); + await this.#writeNow(registered, entry, parsed); return parsed; } - async putEnvelope( - definition: TDefinition, - envelope: CultCacheEnvelope, - ): Promise> { - const registered = this.#requireDefinition(definition); - if (envelope.type !== definition.type) { - throw new Error( - `CultCache envelope type "${envelope.type}" does not match definition "${definition.type}".`, - ); + // Validates the whole write (home, global singleton, name and index accessors) before the store + // is touched; once the store accepts, applying to the cache has no fallible step. + async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown): Promise { + const home = this.#homeStore(entry.type); + const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value); + const legacy = this.#hydrated.legacyGlobals.get(entry.type); + await home?.push(entry); + if (home && legacy) { + await this.#retireLegacyGlobal(home, legacy, entry); } - if (!envelope.key || envelope.key.trim().length === 0) { - throw new Error(`CultCache envelope key for type "${definition.type}" must be non-empty.`); - } - if (!envelope.storedAt || envelope.storedAt.trim().length === 0) { - throw new Error(`CultCache envelope storedAt for type "${definition.type}" must be non-empty.`); - } - - const payload = this.#cloneBytes(envelope.payload); - const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue; - const entry: CultCacheEnvelope = { - key: envelope.key, - type: envelope.type, - payload, - storedAt: envelope.storedAt, - schemaId: envelope.schemaId ?? registered.catalogEntry.schemaId, - catalogEntry: envelope.catalogEntry ?? registered.catalogEntry, - }; - - const route = this.#resolveRoute(definition.type); - if (!route.primary) { - throw new Error(`No backing store is registered for document type "${definition.type}".`); - } - - if (registered.global) { - const existingGlobalKey = this.#globalKeys.get(definition.type); - if (existingGlobalKey && existingGlobalKey !== entry.key) { - await this.delete(definition, existingGlobalKey); - } - } - - await route.primary.push(entry); - await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry))); - this.#applyHydratedEntry(registered, entry, parsed, "put"); - return parsed; + this.#hydrated.legacyGlobals.delete(entry.type); + this.#installEntry(this.#hydrated, registered, hydrated); } - async putGlobal( - definition: TDefinition, - value: CultCacheDocumentValue, - ): Promise> { - const registered = this.#requireDefinition(definition); - if (!registered.global) { - throw new Error(`CultCache document type "${definition.type}" is not marked as global.`); + // The first write of a global loaded from a legacy key removes that record in the same write. If the + // removal fails, the new record is taken back out, so the store keeps only the legacy record. + async #retireLegacyGlobal(home: CacheBackingStore, legacy: CultCacheEnvelope, written: CultCacheEnvelope): Promise { + try { + await home.delete(legacy); + } catch (error) { + await home.delete(written).catch(() => undefined); + throw error; } - - return this.put(definition, CultCache.GLOBAL_KEY, value); } - async update( - definition: TDefinition, - key: string, - updater: ( - current: CultCacheDocumentValue | undefined, - ) => CultCacheDocumentValue, - ): Promise> { - const current = this.get(definition, key); - return this.put(definition, key, updater(current)); - } - - async updateGlobal( - definition: TDefinition, - updater: ( - current: CultCacheDocumentValue | undefined, - ) => CultCacheDocumentValue, - ): Promise> { - const current = this.getGlobal(definition); - return this.putGlobal(definition, updater(current)); - } - - async delete( + async #deleteNow( definition: TDefinition, key: string, ): Promise { const registered = this.#requireDefinition(definition); - const entry = this.#entries.get(this.#entryId(definition.type, key)); + const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key)); if (!entry) { return false; } - const route = this.#resolveRoute(definition.type); - if (!route.primary) { - throw new Error(`No backing store is registered for document type "${definition.type}".`); - } - - const envelope = this.#toEnvelope(entry); - await route.primary.delete(envelope); - await Promise.all(route.mirrors.map(async (mirror) => mirror.delete(envelope))); - this.#removeHydratedEntry(registered, entry); + const home = this.#homeStore(definition.type); + await home?.delete(this.#hydrated.legacyGlobals.get(definition.type) ?? this.#toEnvelope(entry)); + this.#hydrated.legacyGlobals.delete(definition.type); + this.#removeEntry(this.#hydrated, registered, entry); return true; } - async deleteGlobal( - definition: TDefinition, - ): Promise { + #requireGlobal(definition: AnyCultCacheDocumentDefinition): RegisteredDefinition { const registered = this.#requireDefinition(definition); if (!registered.global) { throw new Error(`CultCache document type "${definition.type}" is not marked as global.`); } - - const globalKey = this.#globalKeys.get(definition.type); - return globalKey ? this.delete(definition, globalKey) : false; + return registered; } - snapshot(): CultCacheEnvelope[] { - return [...this.#entries.values()].map((entry) => this.#toEnvelope(entry)); + // A global is only ever stored under GLOBAL_KEY; any other key is refused before a store is touched. + #requireGlobalKey(registered: RegisteredDefinition, key: string): void { + if (registered.global && key !== CultCache.GLOBAL_KEY) { + throw new Error( + `CultCache global document type "${registered.definition.type}" must use key "${CultCache.GLOBAL_KEY}", not "${key}".`, + ); + } } #createFormatter(definition: AnyCultCacheDocumentDefinition): CultCacheDocumentFormatter { @@ -594,7 +757,7 @@ export class CultCache { type: string, label: string, accessor: AccessorInput, - ): (value: unknown) => string | undefined { + ): Accessor { if (typeof accessor === "function") { return (value: unknown) => this.#normalizeIndexValue(type, label, accessor(value)); } @@ -643,165 +806,203 @@ export class CultCache { #resolveDefinitionForEnvelope(entry: CultCacheEnvelope): RegisteredDefinition | undefined { if (entry.schemaId) { - return this.#schemaIdDefinitions.get(entry.schemaId) ?? this.#definitions.get(entry.type); + return this.#schemaIdDefinitions.get(entry.schemaId) + ?? this.#schemaNameDefinitions.get(entry.type) + ?? this.#definitions.get(entry.type); } - return this.#definitions.get(entry.type); + return this.#schemaNameDefinitions.get(entry.type) ?? this.#definitions.get(entry.type); } - #resolveRoute(type: string): StoreRoute { - const typeSpecific = this.#stores.filter((registration) => registration.types.includes(type)); - if (typeSpecific.length > 0) { - return { - primary: typeSpecific[0]?.store, - mirrors: typeSpecific.slice(1).map((registration) => registration.store), - }; + // The registration that claims the type, else the generic one. Registration guarantees at most one of each. + #resolveRegistration(type: string): CultCacheStoreRegistration | undefined { + return this.#stores.find((registration) => registration.types.includes(type)) + ?? this.#stores.find((registration) => registration.types.length === 0); + } + + // A cache with no stores is in memory: undefined. Once any store is attached, a type with no home is an error. + #homeStore(type: string): CacheBackingStore | undefined { + if (this.#stores.length === 0) { + return undefined; } - - const generic = this.#stores.filter((registration) => registration.types.length === 0); - return { - primary: generic[0]?.store, - mirrors: generic.slice(1).map((registration) => registration.store), - }; + const home = this.#resolveRegistration(type); + if (!home) { + throw new Error(`No backing store is registered for document type "${type}".`); + } + return home.store; } - #applyHydratedEntry( + #describe(registration: CultCacheStoreRegistration | undefined): string { + if (!registration) { + return "memory"; + } + return registration.types.length === 0 + ? "the generic store" + : `the store routed to ${registration.types.join(", ")}`; + } + + #lookupsFor( + nameAccessor: Accessor | undefined, + indexAccessors: ReadonlyMap, + value: unknown, + ): EntryLookups { + const name = nameAccessor?.(value); + const indexes = new Map(); + for (const [indexName, accessor] of indexAccessors) { + const indexValue = accessor(value); + if (indexValue !== undefined) { + indexes.set(indexName, indexValue); + } + } + return { name, indexes }; + } + + // Fallible half of admission: the global singleton check and every user accessor. Touches no state. + #admitEntry( + state: HydratedState, registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown, - source: "pull" | "put", - ): void { - const entryId = this.#entryId(entry.type, entry.key); - const existing = this.#entries.get(entryId); - if (existing) { - this.#removeHydratedEntry(registered, existing); - } - + ): HydratedEntry { if (registered.global) { - const currentGlobalKey = this.#globalKeys.get(entry.type); - if (currentGlobalKey && currentGlobalKey !== entry.key) { + const currentGlobalKey = state.globalKeys.get(entry.type); + if (currentGlobalKey !== undefined && currentGlobalKey !== entry.key) { throw new Error( - source === "pull" - ? `CultCache global document type "${entry.type}" has multiple persisted entries.` - : `CultCache global document type "${entry.type}" already has a different key "${currentGlobalKey}".`, + `CultCache global document type "${entry.type}" has multiple persisted entries: "${currentGlobalKey}" and "${entry.key}".`, ); } } - const hydrated: HydratedEntry = { + return { ...entry, payload: this.#cloneBytes(entry.payload), schemaId: entry.schemaId ?? registered.catalogEntry.schemaId, catalogEntry: entry.catalogEntry ?? registered.catalogEntry, value, + lookups: this.#lookupsFor(registered.nameAccessor, registered.indexAccessors, value), }; - this.#entries.set(entryId, hydrated); - this.#getOrCreateTypeEntrySet(entry.type).add(entryId); + } + // Infallible half of admission: replaces any held record at the same key and installs the admitted one. + #installEntry(state: HydratedState, registered: RegisteredDefinition, hydrated: HydratedEntry): void { + const entryId = this.#entryId(hydrated.type, hydrated.key); + const existing = state.entries.get(entryId); + if (existing) { + this.#removeEntry(state, registered, existing); + } + + state.entries.set(entryId, hydrated); + getOrCreate(state.typeEntryIds, hydrated.type, () => new Set()).add(entryId); if (registered.global) { - this.#globalKeys.set(entry.type, entry.key); + state.globalKeys.set(hydrated.type, hydrated.key); } - - if (registered.nameAccessor) { - const name = registered.nameAccessor(value); - if (name !== undefined) { - this.#getOrCreateNameMap(entry.type).set(name, entry.key); - } + if (hydrated.lookups.name !== undefined) { + getOrCreate(state.nameToKeyMaps, hydrated.type, () => new Map()).set(hydrated.lookups.name, hydrated.key); } - - for (const [indexName, accessor] of registered.indexAccessors) { - const indexValue = accessor(value); - if (indexValue !== undefined) { - this.#getOrCreateIndexMap(entry.type, indexName).set(indexValue, entry.key); - } + for (const [indexName, indexValue] of hydrated.lookups.indexes) { + getOrCreate(state.indexToKeyMaps, this.#indexId(hydrated.type, indexName), () => new Map()) + .set(indexValue, hydrated.key); } } - #removeHydratedEntry( + // Reads the lookups stored at admission; runs no accessor. + #removeEntry( + state: HydratedState, registered: RegisteredDefinition, entry: HydratedEntry, ): void { const entryId = this.#entryId(entry.type, entry.key); - this.#entries.delete(entryId); + state.entries.delete(entryId); - const typeEntryIds = this.#typeEntryIds.get(entry.type); + const typeEntryIds = state.typeEntryIds.get(entry.type); if (typeEntryIds) { typeEntryIds.delete(entryId); if (typeEntryIds.size === 0) { - this.#typeEntryIds.delete(entry.type); + state.typeEntryIds.delete(entry.type); } } - if (registered.global && this.#globalKeys.get(entry.type) === entry.key) { - this.#globalKeys.delete(entry.type); + if (registered.global && state.globalKeys.get(entry.type) === entry.key) { + state.globalKeys.delete(entry.type); } - if (registered.nameAccessor) { - const name = registered.nameAccessor(entry.value); - if (name !== undefined) { - this.#nameToKeyMaps.get(entry.type)?.delete(name); + if (entry.lookups.name !== undefined) { + const names = state.nameToKeyMaps.get(entry.type); + if (names?.get(entry.lookups.name) === entry.key) { + names.delete(entry.lookups.name); } } - - for (const [indexName, accessor] of registered.indexAccessors) { - const value = accessor(entry.value); - if (value !== undefined) { - this.#indexToKeyMaps.get(this.#indexId(entry.type, indexName))?.delete(value); + for (const [indexName, indexValue] of entry.lookups.indexes) { + const index = state.indexToKeyMaps.get(this.#indexId(entry.type, indexName)); + if (index?.get(indexValue) === entry.key) { + index.delete(indexValue); } } } - #rebuildDefinitionLookups(registered: RegisteredDefinition): void { - this.#rebuildNameLookup(registered); + // Runs the candidate accessors over every held record of the type into local maps. + #deriveTypeLookups( + type: string, + nameAccessor: Accessor | undefined, + indexAccessors: ReadonlyMap, + ): DerivedTypeLookups { + const entryLookups: Array<[HydratedEntry, EntryLookups]> = []; + const nameMap = nameAccessor ? new Map() : undefined; + const indexMaps = new Map>(); + for (const indexName of indexAccessors.keys()) { + indexMaps.set(indexName, new Map()); + } + for (const entry of this.#entriesForType(type)) { + const lookups = this.#lookupsFor(nameAccessor, indexAccessors, entry.value); + entryLookups.push([entry, lookups]); + if (lookups.name !== undefined) { + nameMap?.set(lookups.name, entry.key); + } + for (const [indexName, indexValue] of lookups.indexes) { + indexMaps.get(indexName)?.set(indexValue, entry.key); + } + } + + return { entryLookups, nameMap, indexMaps }; + } + + // Installs accessors and their derived lookups together. Nothing here can throw. + #installAccessors( + registered: RegisteredDefinition, + nameAccessor: Accessor | undefined, + indexAccessors: ReadonlyMap, + derived: DerivedTypeLookups, + ): void { + const type = registered.definition.type; for (const indexName of registered.indexAccessors.keys()) { - this.#rebuildIndexLookup(registered, indexName); - } - } - - #rebuildNameLookup(registered: RegisteredDefinition): void { - this.#nameToKeyMaps.delete(registered.definition.type); - - if (!registered.nameAccessor) { - return; + this.#hydrated.indexToKeyMaps.delete(this.#indexId(type, indexName)); } - const map = this.#getOrCreateNameMap(registered.definition.type); - for (const entry of this.#entriesForType(registered.definition.type)) { - const name = registered.nameAccessor(entry.value); - if (name !== undefined) { - map.set(name, entry.key); - } + registered.nameAccessor = nameAccessor; + registered.indexAccessors = indexAccessors; + for (const [entry, lookups] of derived.entryLookups) { + entry.lookups = lookups; } - } - - #rebuildIndexLookup(registered: RegisteredDefinition, indexName: string): void { - const lookupId = this.#indexId(registered.definition.type, indexName); - this.#indexToKeyMaps.delete(lookupId); - - const accessor = registered.indexAccessors.get(indexName); - if (!accessor) { - return; + if (derived.nameMap) { + this.#hydrated.nameToKeyMaps.set(type, derived.nameMap); + } else { + this.#hydrated.nameToKeyMaps.delete(type); } - - const map = this.#getOrCreateIndexMap(registered.definition.type, indexName); - for (const entry of this.#entriesForType(registered.definition.type)) { - const value = accessor(entry.value); - if (value !== undefined) { - map.set(value, entry.key); - } + for (const [indexName, map] of derived.indexMaps) { + this.#hydrated.indexToKeyMaps.set(this.#indexId(type, indexName), map); } } #entriesForType(type: string): HydratedEntry[] { - const entryIds = this.#typeEntryIds.get(type); + const entryIds = this.#hydrated.typeEntryIds.get(type); if (!entryIds) { return []; } const entries: HydratedEntry[] = []; for (const entryId of entryIds) { - const entry = this.#entries.get(entryId); + const entry = this.#hydrated.entries.get(entryId); if (entry) { entries.push(entry); } @@ -810,34 +1011,6 @@ export class CultCache { return entries; } - #getOrCreateTypeEntrySet(type: string): Set { - let set = this.#typeEntryIds.get(type); - if (!set) { - set = new Set(); - this.#typeEntryIds.set(type, set); - } - return set; - } - - #getOrCreateNameMap(type: string): Map { - let map = this.#nameToKeyMaps.get(type); - if (!map) { - map = new Map(); - this.#nameToKeyMaps.set(type, map); - } - return map; - } - - #getOrCreateIndexMap(type: string, indexName: string): Map { - const lookupId = this.#indexId(type, indexName); - let map = this.#indexToKeyMaps.get(lookupId); - if (!map) { - map = new Map(); - this.#indexToKeyMaps.set(lookupId, map); - } - return map; - } - #toEnvelope(entry: HydratedEntry): CultCacheEnvelope { return { key: entry.key, @@ -849,14 +1022,6 @@ export class CultCache { }; } - #resetHydratedState(): void { - this.#entries.clear(); - this.#typeEntryIds.clear(); - this.#nameToKeyMaps.clear(); - this.#indexToKeyMaps.clear(); - this.#globalKeys.clear(); - } - #entryId(type: string, key: string): string { return `${type}::${key}`; } diff --git a/packages/cultcache-ts/src/index.ts b/packages/cultcache-ts/src/index.ts index 97687de8..92f7b481 100644 --- a/packages/cultcache-ts/src/index.ts +++ b/packages/cultcache-ts/src/index.ts @@ -27,3 +27,8 @@ export type { InspectedCatalogMember, InspectedRecord, } from "./cult-cache-inspector"; +export { + defineSwarmDocuments, + type DefineDocumentType, + type SwarmDocumentCatalog, +} from "./swarm-documents"; diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 3ed89214..4d2bb4f9 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -52,7 +52,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { const snapshot = decodeSnapshot(decoded); if (snapshot) { return snapshot.records.map((record) => { - const catalogEntry = snapshot.catalogBySchemaId.get(record.schemaId); + const catalogEntry = resolveCatalogEntryForRecord(record, snapshot.catalogBySchemaId); if (!catalogEntry) { throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`); } @@ -166,7 +166,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { try { await writeFile(tempPath, encode(encodeSnapshot(entries))); - await rename(tempPath, this.filePath); + await renameWithRetry(tempPath, this.filePath); } catch (error) { await rm(tempPath, { force: true }).catch(() => undefined); throw error; @@ -174,6 +174,32 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { } } +async function renameWithRetry(source: string, destination: string): Promise { + let delayMs = 10; + let lastError: unknown; + for (let attempt = 0; attempt < 10; attempt++) { + try { + await rename(source, destination); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") { + throw error; + } + + lastError = error; + await delay(delayMs); + delayMs *= 2; + } + } + + throw lastError; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + type PersistedRecord = { key: string; schemaId: string; @@ -282,6 +308,59 @@ function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined { return { catalogBySchemaId, records }; } +function resolveCatalogEntryForRecord( + record: PersistedRecord, + catalogBySchemaId: Map, +): CultCacheSchemaCatalogEntry | undefined { + const existing = catalogBySchemaId.get(record.schemaId); + if (existing) { + return existing; + } + + const schemaVersion = inferSchemaVersionFromPayload(record.payload); + if (!schemaVersion) { + return undefined; + } + + const schemaName = inferSchemaName(schemaVersion); + if (!schemaName) { + return undefined; + } + + return { + schemaId: record.schemaId, + schemaName, + schemaVersion, + contentHash: record.schemaId, + canonicalSchemaJson: "", + compatibleSchemaIds: [], + members: [], + }; +} + +function inferSchemaVersionFromPayload(payload: Uint8Array): string | undefined { + try { + const decoded = decode(payload); + if (Array.isArray(decoded) && typeof decoded[0] === "string") { + return decoded[0]; + } + + if (isObject(decoded)) { + const schemaVersion = decoded.schemaVersion ?? decoded.schema_version; + return typeof schemaVersion === "string" ? schemaVersion : undefined; + } + + return undefined; + } catch { + return undefined; + } +} + +function inferSchemaName(schemaVersion: string): string | undefined { + const match = /^(.*)\.v\d+$/.exec(schemaVersion); + return match?.[1]; +} + function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry { if (!Array.isArray(value)) { throw new Error("CultCache schema catalog entries must be MessagePack arrays."); diff --git a/packages/cultcache-ts/src/swarm-documents.ts b/packages/cultcache-ts/src/swarm-documents.ts new file mode 100644 index 00000000..b1c0b40d --- /dev/null +++ b/packages/cultcache-ts/src/swarm-documents.ts @@ -0,0 +1,561 @@ +// GameCult swarm document-type catalog. +// +// These are CultCache document definitions for documents that cross service +// boundaries: Eve surfaces and provider advertisements, Ghostlight, Heimdall, +// Idunn, Muninn, Sleipnir, Aetheria, VoidBot, Stonks. The catalog lived in Odin +// because Odin was written first, not because Odin owns it; every service that +// decodes another service documents needs the same definitions, and a second +// copy would be a second definition of one wire format. +// +// defineDocumentType is injected rather than imported. That is how it worked in +// Odin, it keeps this file clear of the generic signature in ./document, and it +// lets a caller supply a runtime other than this package. + +/** An undecoded document payload. These definitions predate typing and name fields defensively. */ +export type DocumentValue = any; + +export type DefineDocumentType = (definition: Record) => unknown; +export type SwarmDocumentCatalog = Record; + +function parseObjectDocument(label: string) { + return { + parse(value: unknown) { + if (!value || typeof value !== "object") { + throw new Error(`${label} must be an object`); + } + return value; + }, + }; +} + +export function defineSwarmDocuments(defineDocumentType?: DefineDocumentType | null): SwarmDocumentCatalog { + if (!defineDocumentType) { + return { + interfaceBindingDefinition: null, + evePluginAdvertisementDefinition: null, + ghostlightSchemaCatalogDefinition: null, + heimdallCommandBoundaryDefinition: null, + heimdallTransportProfileDefinition: null, + idunnCommandBoundaryDefinition: null, + idunnDaemonHealthDefinition: null, + idunnTransportProfileDefinition: null, + interfaceLayoutDefinition: null, + muninnCaptureStreamCommandDefinition: null, + muninnCaptureStreamDefinition: null, + muninnCommandBoundaryDefinition: null, + muninnMediaAudioPacketDefinition: null, + muninnMediaReceiverFeedbackDefinition: null, + muninnMediaVideoAccessUnitDefinition: null, + muninnHidControllerStateDefinition: null, + muninnMoveControllerStateDefinition: null, + muninnMoveIdentityDefinition: null, + muninnMoveLightCommandDefinition: null, + muninnMoveMarkerCandidateDefinition: null, + muninnObsStreamCatalogDefinition: null, + muninnQuestAccessDefinition: null, + muninnTelemetrySurfaceDefinition: null, + muninnTransportProfileDefinition: null, + SleipnirInputMappingDefinition: null, + aetheriaAssetManifestDefinition: null, + aetheriaGravityViewportDefinition: null, + aetheriaObjectsViewportDefinition: null, + aetheriaRenderSplatsViewportDefinition: null, + operatorStateDefinition: null, + providerAdvertisementDefinition: null, + voidbotProviderCatalogDefinition: null, + stonksCommandBoundaryDefinition: null, + stonksMarketSnapshotDefinition: null, + stonksRequestEventDefinition: null, + stonksTransportProfileDefinition: null, + streamPixelsCommandBoundaryDefinition: null, + streamPixelsTransportProfileDefinition: null, + surfaceDefinition: null, + viliCommandBoundaryDefinition: null, + viliTransportProfileDefinition: null, + weksaCommandBoundaryDefinition: null, + weksaOperatorStateDefinition: null, + weksaTransportProfileDefinition: null, + voidbotSwarmSnapshotDefinition: null, + }; + } + + const surfaceDefinition = defineDocumentType({ + type: "gamecult.eve.surface_state", + schemaName: "gamecult.eve.surface_state", + schemaId: "gamecult.eve.surface_state.v1", + schemaVersion: "gamecult.eve.surface_state.v1", + global: false, + name: (value: DocumentValue) => value?.providerId || "surface", + schema: { parse: (value: DocumentValue) => value }, + members: [ + { slot: 0, memberName: "providerId", typeName: "string", isName: true }, + { slot: 1, memberName: "title", typeName: "string" }, + { slot: 2, memberName: "version", typeName: "long" }, + { slot: 3, memberName: "updatedAt", typeName: "string" }, + { slot: 4, memberName: "surface", typeName: "object" }, + ], + }); + const interfaceBindingDefinition = defineDocumentType({ + type: "gamecult.eve.interface_binding", + schemaName: "gamecult.eve.interface_binding", + schemaId: "gamecult.eve.interface_binding.v1", + schemaVersion: "gamecult.eve.interface_binding.v1", + global: false, + name: (value: DocumentValue) => value?.bindingId || value?.providerId || "interface", + schema: parseObjectDocument("Eve interface binding"), + }); + const providerAdvertisementDefinition = defineDocumentType({ + type: "gamecult.eve.provider_advertisement", + schemaName: "gamecult.eve.provider_advertisement", + schemaId: "gamecult.eve.provider_advertisement.v1", + schemaVersion: "gamecult.eve.provider_advertisement.v1", + global: false, + name: (value: DocumentValue) => value?.providerId || value?.provider?.id || "provider", + schema: parseObjectDocument("Eve provider advertisement"), + }); + const evePluginAdvertisementDefinition = defineDocumentType({ + type: "gamecult.eve.plugin_advertisement", + schemaName: "gamecult.eve.plugin_advertisement", + schemaId: "gamecult.eve.plugin_advertisement.v1", + schemaVersion: "gamecult.eve.plugin_advertisement.v1", + global: false, + name: (value: DocumentValue) => value?.pluginId || value?.plugin?.id || "plugin", + schema: parseObjectDocument("Eve plugin advertisement"), + }); + const ghostlightSchemaCatalogDefinition = defineDocumentType({ + type: "ghostlight.schema_catalog", + schemaName: "ghostlight.schema_catalog", + schemaId: "ghostlight.schema_catalog.v1", + schemaVersion: "ghostlight.schema_catalog.v1", + global: false, + name: (value: DocumentValue) => value?.providerId || "gamecult.ghostlight.dungeon", + schema: parseObjectDocument("Ghostlight public schema catalog"), + }); + const heimdallCommandBoundaryDefinition = defineDocumentType({ + type: "heimdall.command_boundary", + schemaName: "heimdall.command_boundary", + schemaId: "heimdall.command_boundary.v1", + schemaVersion: "heimdall.command_boundary.v1", + global: false, + name: (value: DocumentValue) => value?.boundaryId || value?.daemonId || "heimdall", + schema: parseObjectDocument("Heimdall command boundary"), + }); + const heimdallTransportProfileDefinition = defineDocumentType({ + type: "heimdall.transport_profile", + schemaName: "heimdall.transport_profile", + schemaId: "heimdall.transport_profile.v1", + schemaVersion: "heimdall.transport_profile.v1", + global: false, + name: (value: DocumentValue) => value?.profileId || value?.daemonId || "heimdall", + schema: parseObjectDocument("Heimdall transport profile"), + }); + const interfaceLayoutDefinition = defineDocumentType({ + type: "odin.interface_layout", + schemaName: "odin.interface_layout", + schemaId: "odin.interface_layout.v1", + schemaVersion: "odin.interface_layout.v1", + global: false, + name: (value: DocumentValue) => value?.layoutId || value?.providerId || "odin.providers", + schema: parseObjectDocument("Odin interface layout"), + members: [ + { slot: 0, memberName: "layoutId", typeName: "string", isName: true }, + { slot: 1, memberName: "updatedAt", typeName: "string" }, + { slot: 2, memberName: "tiles", typeName: "object" }, + ], + }); + const idunnDaemonHealthDefinition = defineDocumentType({ + type: "idunn.daemon_health", + schemaName: "idunn.daemon_health", + schemaId: "idunn.daemon_health", + schemaVersion: "idunn.daemon_health.v1", + global: false, + name: (value: DocumentValue) => value?.daemonId || value?.daemon_id || "daemon", + schema: parseObjectDocument("Idunn daemon health"), + }); + const idunnCommandBoundaryDefinition = defineDocumentType({ + type: "idunn.command_boundary", + schemaName: "idunn.command_boundary", + schemaId: "idunn.command_boundary.v1", + schemaVersion: "idunn.command_boundary.v1", + global: false, + name: (value: DocumentValue) => value?.boundary_id || value?.boundaryId || value?.daemon_id || value?.daemonId || "daemon", + schema: parseObjectDocument("Idunn command boundary"), + }); + const idunnTransportProfileDefinition = defineDocumentType({ + type: "idunn.daemon_transport_profile", + schemaName: "idunn.daemon_transport_profile", + schemaId: "idunn.daemon_transport_profile.v1", + schemaVersion: "idunn.daemon_transport_profile.v1", + global: false, + name: (value: DocumentValue) => value?.profile_id || value?.profileId || value?.daemon_id || value?.daemonId || "daemon", + schema: parseObjectDocument("Idunn daemon transport profile"), + }); + const muninnCaptureStreamDefinition = defineDocumentType({ + type: "muninn.capture_stream", + schemaName: "muninn.capture_stream", + schemaId: "muninn.capture_stream.v1", + schemaVersion: "muninn.capture_stream.v1", + global: false, + name: (value: DocumentValue) => value?.stream_id || value?.streamId || "capture-stream", + schema: parseObjectDocument("Muninn capture stream"), + }); + const muninnCaptureStreamCommandDefinition = defineDocumentType({ + type: "muninn.capture_stream_command", + schemaName: "muninn.capture_stream_command", + schemaId: "muninn.capture_stream_command.v1", + schemaVersion: "muninn.capture_stream_command.v1", + global: false, + name: (value: DocumentValue) => value?.command_id || value?.commandId || value?.stream_id || value?.streamId || "capture-stream-command", + schema: parseObjectDocument("Muninn capture stream command"), + }); + const muninnCommandBoundaryDefinition = defineDocumentType({ + type: "muninn.command_boundary", + schemaName: "muninn.command_boundary", + schemaId: "muninn.command_boundary.v1", + schemaVersion: "muninn.command_boundary.v1", + global: false, + name: (value: DocumentValue) => value?.boundary_id || value?.boundaryId || value?.daemon_id || value?.daemonId || "muninn", + schema: parseObjectDocument("Muninn command boundary"), + }); + const muninnMediaVideoAccessUnitDefinition = defineDocumentType({ + type: "muninn.media_video_access_unit", + schemaName: "muninn.media_video_access_unit", + schemaId: "muninn.media_video_access_unit.v1", + schemaVersion: "muninn.media_video_access_unit.v1", + global: false, + name: (value: DocumentValue) => { + const streamId = value?.stream_id || value?.streamId || "stream"; + const sessionId = value?.session_id || value?.sessionId || "session"; + const frameId = value?.frame_id ?? value?.frameId ?? "frame"; + const chunkIndex = value?.chunk_index ?? value?.chunkIndex ?? "chunk"; + return `${streamId}:${sessionId}:video:${frameId}:${chunkIndex}`; + }, + schema: parseObjectDocument("Muninn media video access unit"), + }); + const muninnMediaAudioPacketDefinition = defineDocumentType({ + type: "muninn.media_audio_packet", + schemaName: "muninn.media_audio_packet", + schemaId: "muninn.media_audio_packet.v1", + schemaVersion: "muninn.media_audio_packet.v1", + global: false, + name: (value: DocumentValue) => { + const streamId = value?.stream_id || value?.streamId || "stream"; + const sessionId = value?.session_id || value?.sessionId || "session"; + const packetId = value?.packet_id ?? value?.packetId ?? "packet"; + return `${streamId}:${sessionId}:audio:${packetId}`; + }, + schema: parseObjectDocument("Muninn media audio packet"), + }); + const muninnMediaReceiverFeedbackDefinition = defineDocumentType({ + type: "muninn.media_receiver_feedback", + schemaName: "muninn.media_receiver_feedback", + schemaId: "muninn.media_receiver_feedback.v1", + schemaVersion: "muninn.media_receiver_feedback.v1", + global: false, + name: (value: DocumentValue) => { + const streamId = value?.stream_id || value?.streamId || "stream"; + const sessionId = value?.session_id || value?.sessionId || "session"; + const receiverId = value?.receiver_id || value?.receiverId || "receiver"; + return `${streamId}:${sessionId}:feedback:${receiverId}`; + }, + schema: parseObjectDocument("Muninn media receiver feedback"), + }); + const muninnMoveControllerStateDefinition = defineDocumentType({ + type: "muninn.move_controller_state", + schemaName: "muninn.move_controller_state", + schemaId: "muninn.move_controller_state.v1", + schemaVersion: "muninn.move_controller_state.v1", + global: false, + name: (value: DocumentValue) => value?.stream_id || value?.streamId || value?.move_id || value?.moveId || "move-controller-state", + schema: parseObjectDocument("Muninn Move controller state"), + }); + const muninnHidControllerStateDefinition = defineDocumentType({ + type: "muninn.hid_controller_state", + schemaName: "muninn.hid_controller_state", + schemaId: "muninn.hid_controller_state.v1", + schemaVersion: "muninn.hid_controller_state.v1", + global: false, + name: (value: DocumentValue) => value?.stream_id || value?.streamId || value?.device_id || value?.deviceId || "hid-controller-state", + schema: parseObjectDocument("Muninn HID controller state"), + }); + const muninnMoveIdentityDefinition = defineDocumentType({ + type: "muninn.move_identity", + schemaName: "muninn.move_identity", + schemaId: "muninn.move_identity.v1", + schemaVersion: "muninn.move_identity.v1", + global: false, + name: (value: DocumentValue) => value?.identity_id || value?.identityId || value?.move_id || value?.moveId || "move-identity", + schema: parseObjectDocument("Muninn Move identity"), + }); + const muninnMoveLightCommandDefinition = defineDocumentType({ + type: "muninn.move_light_command", + schemaName: "muninn.move_light_command", + schemaId: "muninn.move_light_command.v1", + schemaVersion: "muninn.move_light_command.v1", + global: false, + name: (value: DocumentValue) => value?.command_id || value?.commandId || value?.move_id || value?.moveId || "move-light-command", + schema: parseObjectDocument("Muninn Move light command"), + }); + const muninnMoveMarkerCandidateDefinition = defineDocumentType({ + type: "muninn.move_marker_candidate", + schemaName: "muninn.move_marker_candidate", + schemaId: "muninn.move_marker_candidate.v1", + schemaVersion: "muninn.move_marker_candidate.v1", + global: false, + name: (value: DocumentValue) => value?.stream_id || value?.streamId || value?.camera_id || value?.cameraId || "move-marker-candidate", + schema: parseObjectDocument("Muninn Move marker candidate"), + }); + const muninnObsStreamCatalogDefinition = defineDocumentType({ + type: "muninn.obs_stream_catalog", + schemaName: "muninn.obs_stream_catalog", + schemaId: "muninn.obs_stream_catalog.v1", + schemaVersion: "muninn.obs_stream_catalog.v1", + global: false, + name: (value: DocumentValue) => value?.catalog_id || value?.catalogId || value?.host_id || value?.hostId || "muninn", + schema: parseObjectDocument("Muninn OBS stream catalog"), + }); + const muninnQuestAccessDefinition = defineDocumentType({ + type: "muninn.quest_access", + schemaName: "muninn.quest_access", + schemaId: "muninn.quest_access.v1", + schemaVersion: "muninn.quest_access.v1", + global: false, + name: (value: DocumentValue) => value?.access_id || value?.accessId || value?.serial || "quest-access", + schema: parseObjectDocument("Muninn Quest access"), + }); + const muninnTelemetrySurfaceDefinition = defineDocumentType({ + type: "muninn.telemetry_surface", + schemaName: "muninn.telemetry_surface", + schemaId: "muninn.telemetry_surface.v1", + schemaVersion: "muninn.telemetry_surface.v1", + global: false, + name: (value: DocumentValue) => value?.surface_id || value?.surfaceId || value?.host_id || value?.hostId || "muninn-telemetry", + schema: parseObjectDocument("Muninn telemetry surface"), + }); + const muninnTransportProfileDefinition = defineDocumentType({ + type: "muninn.transport_profile", + schemaName: "muninn.transport_profile", + schemaId: "muninn.transport_profile.v1", + schemaVersion: "muninn.transport_profile.v1", + global: false, + name: (value: DocumentValue) => value?.profile_id || value?.profileId || value?.daemon_id || value?.daemonId || "muninn", + schema: parseObjectDocument("Muninn transport profile"), + }); + const SleipnirInputMappingDefinition = defineDocumentType({ + type: "sleipnir.input_mapping", + schemaName: "sleipnir.input_mapping", + schemaId: "sleipnir.input_mapping.v1", + schemaVersion: "sleipnir.input_mapping.v1", + global: false, + name: (value: DocumentValue) => value?.providerId || value?.provider_id || "sleipnir", + schema: parseObjectDocument("Sleipnir input mapping"), + }); + const aetheriaAssetManifestDefinition = defineDocumentType({ + type: "gamecult.aetheria.asset_manifest", + schemaName: "gamecult.aetheria.asset_manifest", + schemaId: "gamecult.aetheria.asset_manifest.v1", + schemaVersion: "gamecult.aetheria.asset_manifest.v1", + global: false, + name: (value: DocumentValue) => value?.runId || value?.run_id || "aetheria-assets", + schema: parseObjectDocument("Aetheria asset manifest"), + }); + const aetheriaGravityViewportDefinition = defineDocumentType({ + type: "gamecult.fields.gravity", + schemaName: "gamecult.fields.gravity", + schemaId: "gamecult.fields.gravity.v1", + schemaVersion: "gamecult.fields.gravity.v1", + global: false, + name: (value: DocumentValue) => value?.viewportId || value?.viewport_id || "aetheria-gravity", + schema: parseObjectDocument("Aetheria gravity viewport"), + }); + const aetheriaObjectsViewportDefinition = defineDocumentType({ + type: "gamecult.fields.objects", + schemaName: "gamecult.fields.objects", + schemaId: "gamecult.fields.objects.v1", + schemaVersion: "gamecult.fields.objects.v1", + global: false, + name: (value: DocumentValue) => value?.viewportId || value?.viewport_id || "aetheria-objects", + schema: parseObjectDocument("Aetheria objects viewport"), + }); + const aetheriaRenderSplatsViewportDefinition = defineDocumentType({ + type: "gamecult.fields.splats", + schemaName: "gamecult.fields.splats", + schemaId: "gamecult.fields.splats.v1", + schemaVersion: "gamecult.fields.splats.v1", + global: false, + name: (value: DocumentValue) => value?.viewportId || value?.viewport_id || "aetheria-render-splats", + schema: parseObjectDocument("Aetheria render splats viewport"), + }); + const operatorStateDefinition = defineDocumentType({ + type: "gamecult.vili.operator_state", + schemaName: "gamecult.vili.operator_state", + schemaId: "gamecult.vili.operator_state.v1", + schemaVersion: "gamecult.vili.operator_state.v1", + global: false, + name: (value: DocumentValue) => value?.service || value?.providerId || "operator", + schema: parseObjectDocument("Vili operator state"), + }); + const viliCommandBoundaryDefinition = defineDocumentType({ + type: "gamecult.vili.command_boundary", + schemaName: "gamecult.vili.command_boundary", + schemaId: "gamecult.vili.command_boundary.v1", + schemaVersion: "gamecult.vili.command_boundary.v1", + global: false, + name: (value: DocumentValue) => value?.boundaryId || value?.daemonId || "vili", + schema: parseObjectDocument("Vili command boundary"), + }); + const viliTransportProfileDefinition = defineDocumentType({ + type: "gamecult.vili.transport_profile", + schemaName: "gamecult.vili.transport_profile", + schemaId: "gamecult.vili.transport_profile.v1", + schemaVersion: "gamecult.vili.transport_profile.v1", + global: false, + name: (value: DocumentValue) => value?.profileId || value?.daemonId || "vili", + schema: parseObjectDocument("Vili transport profile"), + }); + const weksaOperatorStateDefinition = defineDocumentType({ + type: "weksa.operator_state", + schemaName: "weksa.operator_state", + schemaId: "weksa.operator_state.v0", + schemaVersion: "weksa.operator_state.v0", + global: false, + name: (value: DocumentValue) => value?.daemon_id || value?.daemonId || value?.provider_id || value?.providerId || "weksa", + schema: parseObjectDocument("Weksa operator state"), + }); + const weksaCommandBoundaryDefinition = defineDocumentType({ + type: "weksa.command_boundary", + schemaName: "weksa.command_boundary", + schemaId: "weksa.command_boundary.v1", + schemaVersion: "weksa.command_boundary.v1", + global: false, + name: (value: DocumentValue) => value?.boundary_id || value?.boundaryId || value?.daemon_id || value?.daemonId || "weksa", + schema: parseObjectDocument("Weksa command boundary"), + }); + const weksaTransportProfileDefinition = defineDocumentType({ + type: "weksa.transport_profile", + schemaName: "weksa.transport_profile", + schemaId: "weksa.transport_profile.v1", + schemaVersion: "weksa.transport_profile.v1", + global: false, + name: (value: DocumentValue) => value?.profile_id || value?.profileId || value?.daemon_id || value?.daemonId || "weksa", + schema: parseObjectDocument("Weksa transport profile"), + }); + const stonksRequestEventDefinition = defineDocumentType({ + type: "stonks.request_event", + schemaName: "stonks.request_event", + schemaId: "stonks.request_event.v1", + schemaVersion: "stonks.request_event.v1", + global: false, + name: (value: DocumentValue) => value?.id || "request", + schema: parseObjectDocument("Stonks request event"), + }); + const stonksMarketSnapshotDefinition = defineDocumentType({ + type: "stonks.market_snapshot", + schemaName: "stonks.market_snapshot", + schemaId: "stonks.market_snapshot.v1", + schemaVersion: "stonks.market_snapshot.v1", + global: true, + schema: parseObjectDocument("Stonks market snapshot"), + }); + const stonksCommandBoundaryDefinition = defineDocumentType({ + type: "stonks.command_boundary", + schemaName: "stonks.command_boundary", + schemaId: "stonks.command_boundary.v1", + schemaVersion: "stonks.command_boundary.v1", + global: false, + name: (value: DocumentValue) => value?.boundaryId || value?.daemonId || "stonks", + schema: parseObjectDocument("Stonks command boundary"), + }); + const stonksTransportProfileDefinition = defineDocumentType({ + type: "stonks.transport_profile", + schemaName: "stonks.transport_profile", + schemaId: "stonks.transport_profile.v1", + schemaVersion: "stonks.transport_profile.v1", + global: false, + name: (value: DocumentValue) => value?.profileId || value?.daemonId || "stonks", + schema: parseObjectDocument("Stonks transport profile"), + }); + const streamPixelsCommandBoundaryDefinition = defineDocumentType({ + type: "streampixels.command_boundary", + schemaName: "streampixels.command_boundary", + schemaId: "streampixels.command_boundary.v1", + schemaVersion: "streampixels.command_boundary.v1", + global: false, + name: (value: DocumentValue) => value?.boundaryId || value?.daemonId || "streampixels", + schema: parseObjectDocument("StreamPixels command boundary"), + }); + const streamPixelsTransportProfileDefinition = defineDocumentType({ + type: "streampixels.transport_profile", + schemaName: "streampixels.transport_profile", + schemaId: "streampixels.transport_profile.v1", + schemaVersion: "streampixels.transport_profile.v1", + global: false, + name: (value: DocumentValue) => value?.profileId || value?.daemonId || "streampixels", + schema: parseObjectDocument("StreamPixels transport profile"), + }); + const voidbotSwarmSnapshotDefinition = defineDocumentType({ + type: "voidbot.swarm_state_snapshot", + schemaName: "voidbot.swarm_state_snapshot", + schemaId: "voidbot.swarm_state_snapshot.v1", + schemaVersion: "voidbot.swarm_state_snapshot.v1", + global: true, + schema: parseObjectDocument("VoidBot swarm snapshot"), + }); + const voidbotProviderCatalogDefinition = defineDocumentType({ + type: "voidbot.provider_advertisement_catalog", + schemaName: "voidbot.provider_advertisement_catalog", + schemaId: "voidbot.provider_advertisement_catalog.v0", + schemaVersion: "voidbot.provider_advertisement_catalog.v0", + global: true, + schema: parseObjectDocument("VoidBot provider advertisement catalog"), + }); + + return { + interfaceBindingDefinition, + evePluginAdvertisementDefinition, + ghostlightSchemaCatalogDefinition, + heimdallCommandBoundaryDefinition, + heimdallTransportProfileDefinition, + interfaceLayoutDefinition, + idunnCommandBoundaryDefinition, + idunnDaemonHealthDefinition, + idunnTransportProfileDefinition, + muninnCaptureStreamCommandDefinition, + muninnCaptureStreamDefinition, + muninnCommandBoundaryDefinition, + muninnMediaAudioPacketDefinition, + muninnMediaReceiverFeedbackDefinition, + muninnMediaVideoAccessUnitDefinition, + muninnHidControllerStateDefinition, + muninnMoveControllerStateDefinition, + muninnMoveIdentityDefinition, + muninnMoveLightCommandDefinition, + muninnMoveMarkerCandidateDefinition, + muninnObsStreamCatalogDefinition, + muninnQuestAccessDefinition, + muninnTelemetrySurfaceDefinition, + muninnTransportProfileDefinition, + SleipnirInputMappingDefinition, + aetheriaAssetManifestDefinition, + aetheriaGravityViewportDefinition, + aetheriaObjectsViewportDefinition, + aetheriaRenderSplatsViewportDefinition, + operatorStateDefinition, + providerAdvertisementDefinition, + stonksCommandBoundaryDefinition, + stonksMarketSnapshotDefinition, + stonksRequestEventDefinition, + stonksTransportProfileDefinition, + streamPixelsCommandBoundaryDefinition, + streamPixelsTransportProfileDefinition, + surfaceDefinition, + viliCommandBoundaryDefinition, + viliTransportProfileDefinition, + weksaCommandBoundaryDefinition, + weksaOperatorStateDefinition, + weksaTransportProfileDefinition, + voidbotProviderCatalogDefinition, + voidbotSwarmSnapshotDefinition, + }; +} + diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 1067eecd..5932b2f8 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -1,9 +1,10 @@ import assert from "node:assert/strict"; import { exec, execFile } from "node:child_process"; +import { EventEmitter } from "node:events"; import { existsSync } from "node:fs"; import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join, resolve, delimiter } from "node:path"; import { test } from "node:test"; import { promisify } from "node:util"; @@ -24,7 +25,9 @@ const pythonCommand = process.env.PYTHON ?? "python"; const cultCacheTsRoot = resolve(__dirname, "../.."); const cultLibRoot = findAncestor(cultCacheTsRoot, "CultLib.sln") ?? resolve(cultCacheTsRoot, "..", "CultLib"); const cultcachePyRoot = resolve(cultLibRoot, "packages", "cultcache-py"); -const cultcachePySrc = resolve(cultcachePyRoot, "src"); +const cultcachePySrc = ["cultcache-py", "cultnet-py", "cultmesh-py"] + .map((name) => resolve(cultLibRoot, "packages", name, "src")) + .join(delimiter); const cultcacheRsRoot = existsSync(resolve(cultLibRoot, "packages", "cultcache-rs")) ? resolve(cultLibRoot, "packages", "cultcache-rs") : resolve(cultCacheTsRoot, "..", "cultcache-rs"); @@ -161,8 +164,8 @@ test("CultCache can register name and index lookups after entries already exist" body: "world", }); - cache.registerNameLookup(noteDocument, "title"); - cache.registerIndex(noteDocument, "author", "author"); + await cache.registerNameLookup(noteDocument, "title"); + await cache.registerIndex(noteDocument, "author", "author"); assert.equal(cache.getIdByName(noteDocument, "Hello"), "note:hello"); assert.equal(cache.getIdByIndex(noteDocument, "author", "ari"), "note:hello"); @@ -437,6 +440,58 @@ test("SingleFileMessagePackBackingStore reads CultCache v1 snapshots by schema i assert.equal(cache.getRequiredEnvelope(noteDocument, "record-1").schemaId, "schema-1"); }); +test("SingleFileMessagePackBackingStore recovers schema-stamped records missing catalog entries", async () => { + const stampedDocument = defineDocumentType({ + type: "runtime-policy", + schema: z.tuple([ + z.literal("tests.schema_stamped_entry.v1"), + z.string(), + z.string(), + ]), + schemaId: "schema-current", + schemaName: "tests.schema_stamped_entry", + schemaVersion: "tests.schema_stamped_entry.v1", + compatibleSchemaIds: ["schema-current"], + }); + + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "missing-catalog.msgpack"); + await writeFile( + storePath, + encode([ + "cultcache.store.v1", + [], + [ + [ + "record-1", + "sha256:stale-schema-id-from-cold-record", + "2026-06-25T12:00:00Z", + encode([ + "tests.schema_stamped_entry.v1", + "schema-stamped", + "still readable", + ]), + ], + ], + ]), + ); + + const cache = CultCache.builder() + .withDocumentType(stampedDocument) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + + await cache.pullAllBackingStores(); + assert.deepEqual(cache.getRequired(stampedDocument, "record-1"), [ + "tests.schema_stamped_entry.v1", + "schema-stamped", + "still readable", + ]); + assert.equal( + cache.getRequiredEnvelope(stampedDocument, "record-1").schemaId, + "sha256:stale-schema-id-from-cold-record", + ); +}); + test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was persisted as an object", async () => { const noteDocument = defineDocumentType({ type: "note", @@ -479,6 +534,536 @@ test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was assert.ok(records[0]?.[3] instanceof Uint8Array); }); +test("CultCache rejects a second generic backing store", async () => { + const cache = new CultCache(); + await cache.addGenericBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-a.cc"))); + await assert.rejects( + cache.addGenericBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-b.cc"))), + /second generic store/u, + ); +}); + +test("CultCache rejects a type registered to two stores", async () => { + const cache = new CultCache(); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-a.cc")), "item"); + await assert.rejects( + cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-b.cc")), "settings", "item"), + /"item" is already routed/u, + ); + // A refused registration claims nothing, so "settings" is still free. + await cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-c.cc")), "settings"); +}); + +test("CultCache routes each type to its home store", async () => { + const itemDocument = defineDocumentType({ + type: "item", + schema: z.object({ name: z.string() }), + }); + const settingsDocument = defineDocumentType({ + type: "settings", + schema: z.object({ theme: z.string() }), + }); + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-routes-")); + const genericPath = join(tempDir, "generic.cc"); + const settingsPath = join(tempDir, "settings.cc"); + const build = () => CultCache.builder() + .withRegistry(defineDocumentRegistry(itemDocument, settingsDocument)) + .withBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument) + .withGenericStore(new SingleFileMessagePackBackingStore(genericPath)) + .build(); + const keysIn = async (path: string) => + (await new SingleFileMessagePackBackingStore(path).pullAll()).map((entry) => `${entry.type}:${entry.key}`); + + const cache = build(); + await cache.put(itemDocument, "potion", { name: "Potion" }); + await cache.put(settingsDocument, "app", { theme: "ash" }); + assert.deepEqual(await keysIn(genericPath), ["item:potion"]); + assert.deepEqual(await keysIn(settingsPath), ["settings:app"]); + + await cache.delete(settingsDocument, "app"); + assert.deepEqual(await keysIn(settingsPath), []); + assert.deepEqual(await keysIn(genericPath), ["item:potion"]); + + const reloaded = build(); + await reloaded.pullAllBackingStores(); + assert.deepEqual(reloaded.getRequired(itemDocument, "potion"), { name: "Potion" }); + assert.equal(reloaded.get(settingsDocument, "app"), undefined); +}); + +test("CultCache refuses an attach that would move a held record's home", async () => { + const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ theme: z.string() }) }); + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-home-move-")); + const genericPath = join(tempDir, "generic.cc"); + const settingsPath = join(tempDir, "settings.cc"); + const cache = CultCache.builder() + .withRegistry(defineDocumentRegistry(settingsDocument)) + .withGenericStore(new SingleFileMessagePackBackingStore(genericPath)) + .build(); + await cache.put(settingsDocument, "app", { theme: "v1-generic" }); + + await assert.rejects( + async () => cache.addBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument), + /would move "settings" from the generic store to the store routed to settings/u, + ); + // Nothing attached: the next write still lands in the generic store, and the refused store stays untouched. + await cache.put(settingsDocument, "app", { theme: "v2-generic" }); + const keysIn = async (path: string) => + (await new SingleFileMessagePackBackingStore(path).pullAll()).map((entry) => `${entry.type}:${entry.key}`); + assert.deepEqual(await keysIn(genericPath), ["settings:app"]); + assert.equal(existsSync(settingsPath), false); +}); + +test("CultCache refuses at load a record delivered by a store that is not its home", async () => { + const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ theme: z.string() }) }); + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-load-home-")); + const genericPath = join(tempDir, "generic.cc"); + const settingsPath = join(tempDir, "settings.cc"); + const writer = CultCache.builder() + .withRegistry(defineDocumentRegistry(settingsDocument)) + .withGenericStore(new SingleFileMessagePackBackingStore(genericPath)) + .build(); + await writer.put(settingsDocument, "app", { theme: "stray" }); + const genericBytes = await readFile(genericPath); + + const cache = CultCache.builder() + .withRegistry(defineDocumentRegistry(settingsDocument)) + .withBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument) + .withGenericStore(new SingleFileMessagePackBackingStore(genericPath)) + .build(); + // The cache already holds a record, in its home store, before the refused load. + await cache.put(settingsDocument, "held", { theme: "home" }); + const before = cache.snapshot(); + const settingsBytes = await readFile(settingsPath); + await assert.rejects( + cache.pullAllBackingStores(), + /"settings" record "app" was loaded from the generic store, but its home is the store routed to settings/u, + ); + assert.equal(cache.get(settingsDocument, "app"), undefined); + assert.deepEqual(cache.snapshot(), before); + assert.deepEqual(cache.getRequired(settingsDocument, "held"), { theme: "home" }); + assert.deepEqual(await readFile(genericPath), genericBytes); + assert.deepEqual(await readFile(settingsPath), settingsBytes); +}); + +async function storeState(path: string): Promise { + if (!existsSync(path)) { + return []; + } + return (await new SingleFileMessagePackBackingStore(path).pullAll()) + .map((entry) => `${entry.type}:${entry.key}:${Buffer.from(entry.payload).toString("hex")}`) + .sort(); +} + +test("putWithThrowingAccessorChangesNeitherStoreNorCache", async () => { + const itemDocument = defineDocumentType({ + type: "item", + schema: z.object({ n: z.string() }), + name: (value: { n: string }) => { + if (value.n === "bad") { + throw new Error("accessor refuses bad"); + } + return value.n; + }, + }); + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-put-accessor-")), "generic.cc"); + const cache = CultCache.builder() + .withRegistry(defineDocumentRegistry(itemDocument)) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + await cache.put(itemDocument, "k", { n: "good" }); + const storeBefore = await storeState(storePath); + const cacheBefore = cache.snapshot(); + + await assert.rejects(cache.put(itemDocument, "k", { n: "bad" }), /accessor refuses bad/u); + assert.deepEqual(await storeState(storePath), storeBefore); + assert.deepEqual(cache.snapshot(), cacheBefore); + assert.deepEqual(cache.getRequired(itemDocument, "k"), { n: "good" }); + assert.equal(cache.getKeyByName(itemDocument, "good"), "k"); +}); + +test("overwriteUnderRegisteredAccessorThatNowThrowsChangesNeitherStoreNorCache", async () => { + const itemDocument = defineDocumentType({ type: "item", schema: z.object({ c: z.string() }) }); + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-overwrite-accessor-")), "generic.cc"); + const cache = CultCache.builder() + .withRegistry(defineDocumentRegistry(itemDocument)) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + await cache.put(itemDocument, "k", { c: "old" }); + let refusing = false; + await cache.registerIndex(itemDocument, "category", (value: { c: string }) => { + if (refusing) { + throw new Error("accessor refuses now"); + } + return value.c; + }); + refusing = true; + const storeBefore = await storeState(storePath); + const cacheBefore = cache.snapshot(); + + await assert.rejects(cache.put(itemDocument, "k", { c: "new" }), /accessor refuses now/u); + assert.deepEqual(await storeState(storePath), storeBefore); + assert.deepEqual(cache.snapshot(), cacheBefore); + assert.equal(cache.getKeyByIndex(itemDocument, "category", "old"), "k"); + // Deleting runs no accessor: it uses the lookup values stored when the record was admitted. + assert.equal(await cache.delete(itemDocument, "k"), true); + assert.equal(cache.getKeyByIndex(itemDocument, "category", "old"), undefined); +}); + +test("registeringAccessorThatThrowsOnHeldRecordInstallsNothing", async () => { + const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) }); + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-register-accessor-")), "generic.cc"); + const cache = CultCache.builder() + .withRegistry(defineDocumentRegistry(itemDocument)) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + await cache.put(itemDocument, "old", { n: "bad" }); + await assert.rejects( + async () => cache.registerIndex(itemDocument, "i", (value: { n: string }) => { + if (value.n === "bad") { + throw new Error("index refuses bad"); + } + return value.n; + }), + /index refuses bad/u, + ); + await cache.put(itemDocument, "old", { n: "fine" }); + assert.deepEqual(cache.getRequired(itemDocument, "old"), { n: "fine" }); + assert.equal(cache.getKeyByIndex(itemDocument, "i", "fine"), undefined); +}); + +test("globalReplaceWithFailingStorePushKeepsOldGlobal", async () => { + const settingsDocument = defineDocumentType({ + type: "settings", + schema: z.object({ t: z.string() }), + global: true, + }); + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-global-push-")), "generic.cc"); + const inner = new SingleFileMessagePackBackingStore(storePath); + let failPush = false; + const store: CacheBackingStore = { + pullAll: () => inner.pullAll(), + delete: (entry) => inner.delete(entry), + pushAll: (entries, options) => inner.pushAll(entries, options), + push: async (entry) => { + if (failPush) { + throw new Error("disk full"); + } + return inner.push(entry); + }, + }; + const cache = CultCache.builder() + .withRegistry(defineDocumentRegistry(settingsDocument)) + .withGenericStore(store) + .build(); + await cache.putGlobal(settingsDocument, { t: "base" }); + const storeBefore = await storeState(storePath); + const cacheBefore = cache.snapshot(); + failPush = true; + + await assert.rejects(cache.putGlobal(settingsDocument, { t: "new" }), /disk full/u); + await assert.rejects(cache.put(settingsDocument, "other", { t: "new" }), /must use key "__global__"/u); + assert.deepEqual(await storeState(storePath), storeBefore); + assert.deepEqual(cache.snapshot(), cacheBefore); + assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "base" }); +}); + +test("concurrentGlobalPutsCannotBothReachDisk", async () => { + const settingsDocument = defineDocumentType({ + type: "settings", + schema: z.object({ t: z.string() }), + global: true, + }); + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-global-race-")), "generic.cc"); + const inner = new SingleFileMessagePackBackingStore(storePath); + // The first push after arming writes, then pauses until released. A second write that reaches the + // store during that pause is recorded: serialized writes cannot. + let armed = false; + let paused = false; + let reachedStoreWhilePaused = false; + let release!: () => void; + let reportPaused!: () => void; + const pausedSignal = new Promise((resolve) => { + reportPaused = resolve; + }); + const store: CacheBackingStore = { + pullAll: () => inner.pullAll(), + delete: (entry) => inner.delete(entry), + push: async (entry) => { + reachedStoreWhilePaused ||= paused; + await inner.push(entry); + if (armed) { + armed = false; + paused = true; + await new Promise((resolve) => { + release = resolve; + reportPaused(); + }); + paused = false; + } + }, + }; + const build = (backing: CacheBackingStore) => CultCache.builder() + .withRegistry(defineDocumentRegistry(settingsDocument)) + .withGenericStore(backing) + .build(); + const cache = build(store); + await cache.putGlobal(settingsDocument, { t: "base" }); + + armed = true; + const first = cache.putGlobal(settingsDocument, { t: "first" }); + await pausedSignal; + const second = cache.putGlobal(settingsDocument, { t: "second" }); + await new Promise((resolve) => setTimeout(resolve, 50)); + release(); + await Promise.all([first, second]); + + assert.equal(reachedStoreWhilePaused, false); + const onDisk = await inner.pullAll(); + assert.deepEqual(onDisk.map((entry) => entry.key), [CultCache.GLOBAL_KEY]); + assert.deepEqual( + Buffer.from(onDisk[0].payload), + Buffer.from(cache.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY).payload), + ); + assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "second" }); + const reloaded = build(new SingleFileMessagePackBackingStore(storePath)); + await reloaded.pullAllBackingStores(); + assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), { t: "second" }); +}); + +test("unawaitedFailingPutIsAnUnhandledRejection", async () => { + const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) }); + const store: CacheBackingStore = { + pullAll: async () => [], + push: async () => { + throw new Error("disk full"); + }, + delete: async () => undefined, + }; + const cache = CultCache.builder() + .withRegistry(defineDocumentRegistry(itemDocument)) + .withGenericStore(store) + .build(); + + const runnerListeners = process.listeners("unhandledRejection"); + process.removeAllListeners("unhandledRejection"); + const unhandled: unknown[] = []; + const listener = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", listener); + try { + void cache.put(itemDocument, "k", { n: "x" }); + await new Promise((resolve) => setTimeout(resolve, 50)); + } finally { + process.off("unhandledRejection", listener); + for (const runnerListener of runnerListeners) { + process.on("unhandledRejection", runnerListener); + } + } + + assert.equal(unhandled.length, 1); + assert.match(String((unhandled[0] as Error).message), /disk full/u); + // The chain survived the rejection: the next operation still runs. + await assert.rejects(cache.put(itemDocument, "k", { n: "y" }), /disk full/u); +}); + +test("storeChangeHandlerWritingBackQueuesAfterTheOperation", async () => { + const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) }); + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-writeback-")), "generic.cc"); + const inner = new SingleFileMessagePackBackingStore(storePath); + const changes = new EventEmitter(); + const pushed: string[] = []; + const store: CacheBackingStore = { + pullAll: () => inner.pullAll(), + delete: (entry) => inner.delete(entry), + push: async (entry) => { + await inner.push(entry); + pushed.push(entry.key); + changes.emit("pushed", entry.key); + }, + }; + const cache = CultCache.builder() + .withRegistry(defineDocumentRegistry(itemDocument)) + .withGenericStore(store) + .build(); + // The handler writes back and the store does not wait for it: that write queues behind the put. + const writeBacks: Promise[] = []; + changes.on("pushed", (key: string) => { + if (key === "k") { + writeBacks.push(cache.put(itemDocument, "side", { n: "side" })); + } + }); + + await cache.put(itemDocument, "k", { n: "x" }); + await Promise.all(writeBacks); + + assert.equal(writeBacks.length, 1); + assert.deepEqual(pushed, ["k", "side"]); + assert.deepEqual(cache.get(itemDocument, "k"), { n: "x" }); + assert.deepEqual(cache.get(itemDocument, "side"), { n: "side" }); + assert.deepEqual( + (await inner.pullAll()).map((entry) => entry.key).sort(), + cache.snapshot().map((entry) => entry.key).sort(), + ); +}); + +test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async () => { + const settingsDocument = defineDocumentType({ + type: "settings", + schema: z.object({ t: z.string() }), + global: true, + }); + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-legacy-global-")), "generic.cc"); + const build = () => CultCache.builder() + .withRegistry(defineDocumentRegistry(settingsDocument)) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + const seed = CultCache.builder().withRegistry(defineDocumentRegistry(settingsDocument)).build(); + await seed.putGlobal(settingsDocument, { t: "old" }); + const legacy = { ...seed.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY), key: "legacy" }; + await new SingleFileMessagePackBackingStore(storePath).push(legacy); + const bytes = await readFile(storePath); + + const cache = build(); + await cache.pullAllBackingStores(); + assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "old" }); + assert.deepEqual(cache.get(settingsDocument, CultCache.GLOBAL_KEY), { t: "old" }); + assert.deepEqual(await readFile(storePath), bytes); + + await cache.putGlobal(settingsDocument, { t: "new" }); + assert.deepEqual( + (await new SingleFileMessagePackBackingStore(storePath).pullAll()).map((entry) => entry.key), + [CultCache.GLOBAL_KEY], + ); + const reloaded = build(); + await reloaded.pullAllBackingStores(); + assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), { t: "new" }); + + // Deleting an adopted global removes the legacy record. + await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy]); + const deleting = build(); + await deleting.pullAllBackingStores(); + assert.equal(await deleting.deleteGlobal(settingsDocument), true); + assert.deepEqual(await new SingleFileMessagePackBackingStore(storePath).pullAll(), []); + + // Two globals of one type on disk, under any keys, are refused. + await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy, { ...legacy, key: CultCache.GLOBAL_KEY }]); + await assert.rejects(build().pullAllBackingStores(), /has multiple persisted entries/u); +}); + +test("concurrentPutAndAttachCannotLandRecordInNonHomeStore", async () => { + const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ t: z.string() }) }); + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-put-attach-race-")); + const genericPath = join(tempDir, "generic.cc"); + const settingsPath = join(tempDir, "settings.cc"); + const cache = CultCache.builder() + .withRegistry(defineDocumentRegistry(settingsDocument)) + .withGenericStore(new SingleFileMessagePackBackingStore(genericPath)) + .build(); + + const pending = cache.put(settingsDocument, "app", { t: "x" }); + const attach = Promise.resolve().then(() => + cache.addBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument), + ); + await pending; + await assert.rejects(attach, /would move "settings" from the generic store/u); + + assert.deepEqual( + (await new SingleFileMessagePackBackingStore(genericPath).pullAll()).map((entry) => entry.key), + ["app"], + ); + assert.equal(existsSync(settingsPath), false); + const reloaded = CultCache.builder() + .withRegistry(defineDocumentRegistry(settingsDocument)) + .withGenericStore(new SingleFileMessagePackBackingStore(genericPath)) + .build(); + await reloaded.pullAllBackingStores(); + assert.deepEqual(reloaded.getRequired(settingsDocument, "app"), { t: "x" }); +}); + +test("pullWithDuplicateGlobalLeavesCacheUnchanged", async () => { + const itemDocument = defineDocumentType({ type: "item", schema: z.object({ name: z.string() }) }); + const settingsDocument = defineDocumentType({ + type: "settings", + schema: z.object({ theme: z.string() }), + global: true, + }); + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-duplicate-global-")); + const storePath = join(tempDir, "generic.cc"); + const cache = CultCache.builder() + .withRegistry(defineDocumentRegistry(itemDocument, settingsDocument)) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + await cache.put(itemDocument, "potion", { name: "Potion" }); + await cache.putGlobal(settingsDocument, { theme: "ash" }); + const before = cache.snapshot(); + + // A second record of the global type lands in the store file behind the cache's back. + const stray = cache.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY); + await new SingleFileMessagePackBackingStore(storePath).push({ ...stray, key: "stray" }); + + await assert.rejects(cache.pullAllBackingStores(), /has multiple persisted entries/u); + assert.deepEqual(cache.snapshot(), before); + assert.deepEqual(cache.getRequired(itemDocument, "potion"), { name: "Potion" }); + assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { theme: "ash" }); +}); + +test("pullWithThrowingIndexAccessorLeavesCacheUnchanged", async () => { + const itemDocument = defineDocumentType({ + type: "item", + schema: z.object({ name: z.string(), category: z.string() }), + name: "name", + indexes: { + category: (item: { name: string; category: string }) => { + if (item.name === "Bomb") { + throw new Error("category accessor refuses Bomb"); + } + return item.category; + }, + }, + }); + const settingsDocument = defineDocumentType({ + type: "settings", + schema: z.object({ theme: z.string() }), + global: true, + }); + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-throwing-accessor-")); + const storePath = join(tempDir, "generic.cc"); + const cache = CultCache.builder() + .withRegistry(defineDocumentRegistry(itemDocument, settingsDocument)) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + await cache.put(itemDocument, "potion", { name: "Potion", category: "consumable" }); + await cache.putGlobal(settingsDocument, { theme: "ash" }); + const before = cache.snapshot(); + + // A record whose index accessor throws lands in the store file behind the cache's back. + const potion = cache.getRequiredEnvelope(itemDocument, "potion"); + await new SingleFileMessagePackBackingStore(storePath).push({ + ...potion, + key: "bomb", + payload: encode({ name: "Bomb", category: "explosive" }), + }); + + await assert.rejects(cache.pullAllBackingStores(), /category accessor refuses Bomb/u); + assert.deepEqual(cache.snapshot(), before); + assert.deepEqual(cache.getRequired(itemDocument, "potion"), { name: "Potion", category: "consumable" }); + assert.equal(cache.get(itemDocument, "bomb"), undefined); + assert.equal(cache.getKeyByName(itemDocument, "Potion"), "potion"); + assert.equal(cache.getKeyByName(itemDocument, "Bomb"), undefined); + assert.equal(cache.getKeyByIndex(itemDocument, "category", "consumable"), "potion"); + assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { theme: "ash" }); +}); + +test("CultCache with zero stores writes and deletes in memory", async () => { + const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ theme: z.string() }) }); + const cache = CultCache.builder().withRegistry(defineDocumentRegistry(settingsDocument)).build(); + await cache.put(settingsDocument, "x", { theme: "mem" }); + assert.deepEqual(cache.getRequired(settingsDocument, "x"), { theme: "mem" }); + assert.equal(await cache.delete(settingsDocument, "x"), true); + assert.equal(cache.get(settingsDocument, "x"), undefined); +}); + test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Python", async () => { await buildInteropPeers(); const tempDir = await mkdtemp(join(tmpdir(), "cultcache-interop-")); @@ -560,6 +1145,32 @@ test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Pyth assert.ok(read.tags.includes("interop")); } } + + // C# write-routed attaches one home store per type: each file is a complete v1 snapshot holding only its own records. + const catalogFile = join(tempDir, "catalog.cc"); + const runFile = join(tempDir, "run.cc"); + const routed = await runJsonCommand("csharp-write-routed", dotnetCommand, [ + csharpInteropDll, + "write-routed", + catalogFile, + runFile, + ], cultLibRoot); + const recordsIn = async (file: string) => { + const decoded = decode(await readFile(file)) as unknown[]; + assert.equal(decoded[0], "cultcache.store.v1"); + assert.ok(Array.isArray(decoded[1]), `${file} has no schema catalog`); + return (decoded[2] as unknown[][]).map((record) => ({ schemaId: record[1], key: record[0] })); + }; + const catalogRecords = await recordsIn(catalogFile); + const runRecords = await recordsIn(runFile); + assert.deepEqual(catalogRecords.map((record) => record.key), ["note:csharp-routed"]); + assert.deepEqual(runRecords.map((record) => record.key), ["run-note:csharp-routed"]); + assert.notEqual(catalogRecords[0]?.schemaId, runRecords[0]?.schemaId); + for (const reader of readers) { + const read = await reader.read(catalogFile); + assert.equal(read.documentId, routed.documentId, `${reader.name} failed to read the routed catalog store`); + assert.equal(read.body, "One home store per document type."); + } }); test("CultCache interop reader accepts missing compatible trailing slots and rejects mismatched slots", async () => { @@ -604,6 +1215,42 @@ test("CultCache inspector decodes v1 store catalog and record payloads from byte ]); }); +test("CultCache inspector recovers schema-stamped records missing catalog entries", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-inspector-")); + const file = join(tempDir, "missing-catalog.cc"); + + await writeFile( + file, + encode([ + "cultcache.store.v1", + [], + [ + [ + "record-1", + "sha256:stale-schema-id-from-cold-record", + "2026-06-25T12:00:00Z", + encode([ + "tests.schema_stamped_entry.v1", + "schema-stamped", + "still readable", + ]), + ], + ], + ]), + ); + + const inspection = inspectCultCacheBytes(file, await readFile(file)); + assert.equal(inspection.catalog.length, 1); + assert.equal(inspection.catalog[0]?.schemaId, "sha256:stale-schema-id-from-cold-record"); + assert.equal(inspection.catalog[0]?.schemaName, "tests.schema_stamped_entry"); + assert.equal(inspection.records[0]?.schemaName, "tests.schema_stamped_entry"); + assert.deepEqual(inspection.records[0]?.payloadPreview, [ + "tests.schema_stamped_entry.v1", + "schema-stamped", + "still readable", + ]); +}); + interface InteropNote { schemaVersion: "cultcache.interop_note.v1"; documentId: string; diff --git a/packages/cultmath/.gitignore b/packages/cultmath/.gitignore new file mode 100644 index 00000000..c9b141d0 --- /dev/null +++ b/packages/cultmath/.gitignore @@ -0,0 +1,9 @@ +bin/ +obj/ +**/bin/ +**/obj/ +target/ +**/target/ +/native/cultmath-core/target.meta +.tools/ +/artifacts/ diff --git a/packages/cultmath/README.md b/packages/cultmath/README.md new file mode 100644 index 00000000..0be46738 --- /dev/null +++ b/packages/cultmath/README.md @@ -0,0 +1,136 @@ +# CultMath + +CultMath is the project name. Its clean .NET package identity is +`GameCult.Math`, owned and released from the CultLib monorepo. + +Clean-room math shaped for people who think in HLSL. + +CultMath provides lowercase vector types, shader-style intrinsics, and native +hot-path kernels so math-heavy renderer code can move between C#, Rust, and HLSL +without turning every line into a translation exercise. It is not a +Unity.Mathematics clone. It is a small, Aquarium-born library built from public +shader semantics. + +## Current Surface + +- `float2`, `float3`, `float4` +- component-wise arithmetic +- scalar-to-vector splats +- every HLSL swizzle (`xyzw` and `rgba`, repeats readable, non-repeating + masks writable) and every HLSL mixed constructor, generated by + `tools/generate-swizzles.ps1` +- conversions to and from `System.Numerics` +- `Random`, a compact deterministic XORShift stream with scalar and vector draws +- `BatchMath` SoA kernels over `Span` / `ReadOnlySpan` using + `System.Numerics.Vector` when available, including radial falloff + acceleration and semi-implicit Euler integration +- `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`, + `min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`, + `cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, exponential + `decay`/`damp`, intercept/segment helpers, Catmull-Rom, Bezier curves, and + deterministic value-noise primitives +- `shaders/CultMath.hlsl`, a canonical HLSL mirror include for shader-side + parity. HLSL already owns `float2`, `float3`, and `float4`; the include + exposes `cultmath_*` functions for shared semantics, including CultMath's + safe `normalize` contract. +- `Voronoi.SampleTones`, a C# batch surface that calls the Rust + `cultmath-core` native kernel when `cultmath_core` is available and falls back + to the managed parity path otherwise. + +## Native Core + +The canonical hot-path body lives in `native/cultmath-core`. It exports a C ABI +for batch kernels such as `cultmath_apollonian_voronoi_tones`. + +Runtime wrappers own syntax and integration. Rust owns optimized primitive +kernel behavior and parity fixtures. + +## Example + +```csharp +using CultMath; + +float3 normal = math.normalize(new float3(0.25f, 1.0f, -0.1f)); +float rim = math.saturate(1.0f - math.dot(normal, new float3(0.0f, 0.0f, -1.0f))); +float glow = math.smoothstep(0.2f, 1.0f, rim); +float grain = math.value_noise_bicubic(math.float2(12.0f, 3.5f)); +``` + +```hlsl +#include "CultMath.hlsl" + +float3 normal = cultmath_normalize(float3(0.25, 1.0, -0.1)); +float rim = cultmath_saturate(1.0 - dot(normal, float3(0.0, 0.0, -1.0))); +float glow = cultmath_smoothstep(0.2, 1.0, rim); +float grain = cultmath_value_noise_bicubic(float2(12.0, 3.5)); +``` + +## Provenance + +The small spline, damping, and pursuit helpers were promoted from Aetheria's +local `AetheriaMath` surface. The value-noise family follows public shader +literature from Inigo Quilez's noise and mini-spline articles, expressed in +CultMath names with tests instead of copied project-local helper drift. + +## Build + +```powershell +dotnet build packages/cultmath/src/CultMath/CultMath.csproj +dotnet test packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj +cargo test --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml +cargo build --release --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml +.\packages\cultmath\scripts\build-unity-package.ps1 +``` + +## Unity package + +CultMath's Unity 2021.3 surface is the tracked precompiled package at +`packages/cultmath/unity/org.gamecult.cultmath`. Unity consumes `CultMath.dll` and the numeric +`CultMath.hlsl`; it does not compile the repository's current-language C# source +tree and does not require a consumer `csc.rsp` workaround. + +```json +"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath" +``` + +The repository root is not a Unity package. Build the tracked package with +`packages/cultmath/scripts/build-unity-package.ps1`; the script fresh-builds the netstandard2.1 +assembly and rejects stale binaries, missing metadata, unexpected assemblies, +or leaked C# source. + +## Shader Tooling + +CultMath keeps portable DXC outside git under `packages/cultmath/.tools/`: + +```powershell +.\packages\cultmath\tools\get-dxc.ps1 +.\packages\cultmath\tools\get-spirv-cross.ps1 +.\packages\cultmath\tools\compile-hlsl-spirv.ps1 ` + -ShaderPath E:\Projects\Odin\crates\muninn-move-tracker\shaders\MoveSphereCandidate.comp.hlsl ` + -OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.spv ` + -IncludePath E:\Projects\CultLib\packages\cultmath\shaders +.\packages\cultmath\tools\compile-spirv-metal.ps1 ` + -SpirvPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.spv ` + -OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.metal +``` + +DXC emits Vulkan SPIR-V with `-spirv`. `-TargetEnv vulkan1.2` is exposed by the +script, but the current Windows DXC release compiled this shader successfully +with DXC's default Vulkan target while rejecting the dotted target-env value. +The SPIR-V script applies default Vulkan register shifts for cbuffers, textures, +UAVs, and samplers so cross-compiled Metal buffers do not alias by resource +class. SPIRV-Cross emits Metal Shading Language from SPIR-V on Windows; building +an iPad-ready `.metallib` still belongs to Apple's Metal compiler on an Apple +toolchain. + +## Doctrine + +The goal is mechanical sympathy between CPU-side authoring, native kernels, and +GPU-side shader math. If an intrinsic exists here, it should behave like the +shader concept unless a runtime's numeric rules make that impossible. Weird +deviations get tests and documentation, not folklore. + +CultMath is still not a shader generator. Projects should include +`shaders/CultMath.hlsl` directly or add that directory to their shader include +path. If a shared operation needs CPU/GPU parity, put the primitive here and +test it here before copying local math helpers into a renderer or daemon. diff --git a/packages/cultmath/README.md.meta b/packages/cultmath/README.md.meta new file mode 100644 index 00000000..4411da72 --- /dev/null +++ b/packages/cultmath/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 867daebafe0ce48479cf043461ee6bdf +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/THIRD-PARTY-NOTICES.md b/packages/cultmath/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..06ebb6c3 --- /dev/null +++ b/packages/cultmath/THIRD-PARTY-NOTICES.md @@ -0,0 +1,11 @@ +# Third-Party Notices + +## 2D and 3D Simplex Noise + +`math.snoise` and `cultmath_snoise` port the textureless 2D and 3D +simplex-noise functions by Ian McEwan and Ashima Arts, as distributed by Stefan +Gustavson's `webgl-noise` project and Unity Mathematics. + +- Copyright 2011 Ashima Arts. +- Upstream: +- License: MIT. diff --git a/packages/cultmath/THIRD-PARTY-NOTICES.md.meta b/packages/cultmath/THIRD-PARTY-NOTICES.md.meta new file mode 100644 index 00000000..4b43ddaa --- /dev/null +++ b/packages/cultmath/THIRD-PARTY-NOTICES.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7c28c8631c7d4054eb751d7ce952df33 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/docs.meta b/packages/cultmath/docs.meta new file mode 100644 index 00000000..ee5fab29 --- /dev/null +++ b/packages/cultmath/docs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: de535b89253410b41b5989f712b80af4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/docs/design.md b/packages/cultmath/docs/design.md new file mode 100644 index 00000000..de09ee26 --- /dev/null +++ b/packages/cultmath/docs/design.md @@ -0,0 +1,143 @@ +# Design + +CultMath exists to make renderer math readable in every place it needs to live: +Rust hot kernels, C# host code, and HLSL shader code. + +## Parity Target: HLSL + +HLSL is the semantic authority for every type and intrinsic HLSL defines. The +goal is source compatibility: HLSL-shaped math compiles as C# under +`using static CultMath.math;`. C# forces float literal suffixes (`0.5f`); the +rest of the spelling should survive the move. + +That makes these HLSL rules, not options: + +- Every swizzle exists. `float2/3/4`, `int2/3/4`, and `bool2/3/4` expose every + read swizzle of length 2 to 4, repeats included (`v.wwxy`), in both `xyzw` and + `rgba` naming (never mixed), plus single-component `r/g/b/a`. `double2/3` get + lengths 2 and 3 only, because there is no `double4`. Components and swizzles are + writable: `v.xz = f2;`, `c.rgb *= 0.5f;`, and `v[1] = s;` work. Setters exist + only for non-repeating masks, because HLSL rejects `v.xx = ...`. +- Every HLSL mixed constructor exists, as a struct constructor and as a `math` + function: `float4(float2, float2)`, `float4(float, float2, float)`, + `float3(float, float2)`, the copy form `float3(float3)`, and the rest, for the + float, int, and bool families (double gets constructors only; a `math.double3` + function would hide the `double3` type name). +- Swizzles and mixed constructors are generated. `tools/generate-swizzles.ps1` + writes the checked-in `src/CultMath/Swizzles.g.cs` (partial structs plus a + partial `math`). Edit the generator, rerun it, and commit both; never edit the + output by hand. +- Comparison operators (`<`, `>`, `<=`, `>=`, `==`, `!=`) are component-wise and + return `bool2`/`bool3`/`bool4`. Reduce them with `any` and `all`. + `Equals`/`GetHashCode` stay value-based so vectors still work as dictionary + keys. +- `select(condition, whenTrue, whenFalse)` uses HLSL's argument order. +- Matrices are row-major: constructors take rows, `m[i]` returns a reference to + row i (so `m[1][2] = s;` writes one element), `_mRC` is row R column C, and + products go through `mul` (`mul(m, v)` treats `v` as a column, `mul(v, m)` as a + row). `*` on matrices would be component-wise in HLSL, so CultMath does not + define it. +- `m[r][c] = v` writes through locals, array elements, fields of classes, and + ref locals. Through a readonly field, an `in` parameter, a property + (auto-properties included), or a static readonly value such as + `float3x3.identity`, C# takes a defensive copy: the write compiles and is + silently lost. Copy the matrix to a local, write to it, and assign it back. +- Scalar/vector arithmetic and comparisons have explicit overloads on both + sides rather than leaning on implicit scalar splat. +- Intrinsics return HLSL's types and edge cases: `sign` returns `int`/`intN` + (NaN gives 0); `step(y, x)` is `x < y ? 0 : 1` (NaN gives 1), which is how dxc + lowers it, in C# and in the HLSL mirror; float and double `min` and `max`, and + float `clamp` and `saturate`, follow the DXIL `FMin`/`FMax`/`Saturate` operations dxc emits for + them: `min(a, b)` is `a < b ? a : b`, `max(a, b)` is `a >= b ? a : b`, a NaN + operand returns the other (so `saturate(NaN)` is 0), `clamp(x, a, b)` is + `min(max(x, a), b)`, and `saturate(x)` is `min(1, max(0, x))`, which maps -0 + to +0; `any`/`all` accept numeric vectors (a component is true when it is + not 0); `min`, `max`, and `abs` on int vectors return int vectors. + +dxc marks float arithmetic and compares `fast` (no NaNs) unless a value is +`precise`, so drivers may optimize NaN handling away and NaN results on real +GPUs are not guaranteed. CultMath defines the CPU result by dxc's lowering and +the DXIL operation spec (DirectXShaderCompiler `docs/DXIL.rst`). + +NaN does not reliably propagate through CultMath, just as it does not on the +GPU. Because `min`, `max`, and `saturate` return the non-NaN operand, some +functions turn NaN or infinity into numbers: `smoothstep(a, a, a)` is 0; +infinite inputs to `smoothstep` and the intercept helpers give numbers; the +bezier, `smoothstep01`, and `smootherstep` functions return finite values for +NaN input; and `normalize((NaN, 2.41))` is `(NaN, 2.41e20)`, because the NaN +length is clamped to the epsilon and the other component is divided by it. +Callers that must detect bad data should check `isnan` before calling these +functions. + +Deferred until a consumer needs them: `float4x4`, `transpose`, `determinant`, +and HLSL's one-based `_11`..`_33` element names. + +### HLSL Target: Source Transformations + +`HlslSourceCompatibilityTests` compiles the real `shaders/CultMath.hlsl` with +Roslyn against CultMath. These transformations are the complete list; if a body +needs anything else, the test fails: + +1. Include guards (`#ifndef`/`#define`/`#endif`) are dropped. +2. Functions taking `Texture2D`/`SamplerState` are dropped; GPU resources have + no CultMath analog. +3. File-scope `static const` becomes `const`. +4. Floating literals gain `f` (`0.5` becomes `0.5f`). +5. The file body is wrapped in a class, since C# has no free functions; HLSL + functions become private instance methods. + +HLSL constructs that stay outside the target and must not appear in the mirror: +`const` locals of vector type (C# `const` is primitives only; write a plain +local) and writes through a swizzle component (`v.xy.x = s`, which C# rejects as +modifying a temporary). + +The HLSL side is checked too: `tools/get-dxc.ps1` fetches Microsoft's +DirectXShaderCompiler, and +`dxc -T lib_6_3 -HV 2021 shaders/CultMath.hlsl` must compile cleanly. + +The mirror test compares bit patterns (any NaN equals any NaN; -0 differs from +0). It proves the text of `CultMath.hlsl` computes the same float32 results as +C# `math` on the CPU, not that a GPU agrees: driver `sin` precision alone makes +`cultmath_hash` and value noise differ bit for bit on hardware. The mirror is +compiled under `using static CultMath.math;`, so its intrinsics are C# `math` +itself: the test proves the composition in the text, not the intrinsic rules, +which only `HlslSemanticsTests` pins. + +Where HLSL is silent, CultMath keeps its own decisions and does not defer to +Unity.Mathematics: `normalize` is safe (divides by `max(length, 1e-20)`), +`hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped +helpers that HLSL lacks (`float2x2.Rotate`, `float3x3.Euler`, `quaternion`, +`snoise`) enter only when a consumer needs them, with their semantics stated in +code. + +Known C# friction: under `using static CultMath.math;` the constructor +functions (`float3(...)`, `float3x3(...)`) hide the type names in member access, +so static members need a qualified type (`CultMath.float3x3.Euler(...)`). + +## Rules + +- Keep type names HLSL-shaped: `float2`, `float3`, `float4`, `float3x3`, and `math`. +- Keep Rust as the owner of native hot-path kernels and parity fixtures. +- Keep C#, shader, and scripting surfaces as runtime wrappers, not competing + math authorities. +- Keep `shaders/CultMath.hlsl` as the canonical shader mirror for shared + CultMath functions HLSL does not already provide (HLSL intrinsics such as + `mul`, `any`, and `log` need no mirror). Project-local shader helpers should + move here once more than one organ needs them or once CPU/GPU parity matters. +- Implement public shader semantics directly; do not copy Unity.Mathematics. +- Prefer plain mutable value types with public component fields, as HLSL does. +- Prefer component-wise overloads over clever generic machinery until the API + earns that complexity. +- Keep the surface boring and exact. Fill what a consumer uses, guided by HLSL + semantics; noise primitives, matrices, quaternions, and packing helpers need an + owner before they enter. +- Keep the core assembly engine-free. Unity conversions live in the Unity + package's `CultMath.UnityBridge` assembly. + +## Non-Goals + +- Compiling C# to shaders. +- Generating HLSL from Rust or Rust from HLSL. +- Replacing `System.Numerics` for general .NET work. +- Recreating every Unity.Mathematics type because a spreadsheet somewhere says + “coverage.” That way lies decorative obesity. diff --git a/packages/cultmath/docs/design.md.meta b/packages/cultmath/docs/design.md.meta new file mode 100644 index 00000000..0d31ca6a --- /dev/null +++ b/packages/cultmath/docs/design.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 07021a8be816e3448a9490c10845b0aa +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/native.meta b/packages/cultmath/native.meta new file mode 100644 index 00000000..f4135336 --- /dev/null +++ b/packages/cultmath/native.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9d1cb2ac88c530b4d9da456523115e12 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/native/cultmath-core.meta b/packages/cultmath/native/cultmath-core.meta new file mode 100644 index 00000000..48a2ef76 --- /dev/null +++ b/packages/cultmath/native/cultmath-core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a42d65478346c354c8b4f353c5581b49 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/native/cultmath-core/Cargo.lock b/packages/cultmath/native/cultmath-core/Cargo.lock new file mode 100644 index 00000000..09598b97 --- /dev/null +++ b/packages/cultmath/native/cultmath-core/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cultmath-core" +version = "0.1.0" diff --git a/packages/cultmath/native/cultmath-core/Cargo.lock.meta b/packages/cultmath/native/cultmath-core/Cargo.lock.meta new file mode 100644 index 00000000..9ca3ec60 --- /dev/null +++ b/packages/cultmath/native/cultmath-core/Cargo.lock.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f1c365f7eead2684ba621124e653bdab +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/native/cultmath-core/Cargo.toml b/packages/cultmath/native/cultmath-core/Cargo.toml new file mode 100644 index 00000000..cf7819a0 --- /dev/null +++ b/packages/cultmath/native/cultmath-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cultmath-core" +version = "0.1.0" +edition = "2021" + +[lib] +name = "cultmath_core" +crate-type = ["cdylib", "staticlib"] + +[profile.release] +lto = "thin" +codegen-units = 1 +panic = "abort" diff --git a/packages/cultmath/native/cultmath-core/Cargo.toml.meta b/packages/cultmath/native/cultmath-core/Cargo.toml.meta new file mode 100644 index 00000000..8875de09 --- /dev/null +++ b/packages/cultmath/native/cultmath-core/Cargo.toml.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: cdd2d4eab053b764b9a06d36b080e43e +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/native/cultmath-core/src.meta b/packages/cultmath/native/cultmath-core/src.meta new file mode 100644 index 00000000..8e5232db --- /dev/null +++ b/packages/cultmath/native/cultmath-core/src.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7de448b7e258d2c448bb7391051cc628 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/native/cultmath-core/src/lib.rs b/packages/cultmath/native/cultmath-core/src/lib.rs new file mode 100644 index 00000000..46e7ced6 --- /dev/null +++ b/packages/cultmath/native/cultmath-core/src/lib.rs @@ -0,0 +1,213 @@ +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CultMathColor32 { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +#[no_mangle] +pub unsafe extern "C" fn cultmath_apollonian_voronoi_tones( + xs: *const f32, + ys: *const f32, + tones: *const u8, + spans: *const f32, + count: usize, + resolution_y: f32, + frame_index: i32, + out_colors: *mut CultMathColor32, +) -> i32 { + if xs.is_null() || ys.is_null() || tones.is_null() || spans.is_null() || out_colors.is_null() { + return -1; + } + + let xs = unsafe { std::slice::from_raw_parts(xs, count) }; + let ys = unsafe { std::slice::from_raw_parts(ys, count) }; + let tones = unsafe { std::slice::from_raw_parts(tones, count) }; + let spans = unsafe { std::slice::from_raw_parts(spans, count) }; + let out_colors = unsafe { std::slice::from_raw_parts_mut(out_colors, count) }; + for index in 0..count { + out_colors[index] = tone_color( + xs[index], + ys[index], + resolution_y, + frame_index, + tones[index], + spans[index], + ); + } + + 0 +} + +fn tone_color(px: f32, py: f32, resolution_y: f32, frame_index: i32, tone: u8, span: f32) -> CultMathColor32 { + let sample = stable_sample(px, py, resolution_y, frame_index, tone, span); + let luminance = saturate(sample.0 * 0.299 + sample.1 * 0.587 + sample.2 * 0.114); + match tone { + 3 => { + let glow = 0.62 + luminance * 0.38; + color(255.0 * glow, 96.0 * glow, 14.0 * glow) + } + 4 => { + let glow = 0.68 + luminance * 0.32; + color(245.0 * glow, 248.0 * glow, 252.0 * glow) + } + _ => { + let (gain, lift) = match tone { + 1 => (0.42, 14.0), + 2 => (1.05, 24.0), + _ => (0.24, 4.0), + }; + color( + lift + sample.0 * 255.0 * gain, + lift + sample.1 * 255.0 * gain, + lift + sample.2 * 255.0 * gain, + ) + } + } +} + +fn stable_sample(px: f32, py: f32, resolution_y: f32, frame_index: i32, tone: u8, span: f32) -> (f32, f32, f32) { + let seed = px * 12.9898 + py * 78.233 + hash1((tone as f32 + 1.0) * 19.19); + let jitter_x = (hash1(seed) - 0.5) * span; + let jitter_y = (hash1(seed + 37.17) - 0.5) * span; + voronoi(px + jitter_x, py + jitter_y, resolution_y, frame_index) +} + +fn voronoi(px: f32, py: f32, resolution_y: f32, frame_index: i32) -> (f32, f32, f32) { + let time = frame_index as f32 / 120.0; + let scale = 6.0; + let x = scale * px / resolution_y.max(1.0); + let y = scale * py / resolution_y.max(1.0); + let nx = x.floor(); + let ny = y.floor(); + let fx = frac(x); + let fy = frac(y); + let mut distance = 8.0; + let mut red = 0.0; + let mut green = 0.0; + let mut blue = 0.0; + let smoothness = 0.005; + + for j in -2..=2 { + for i in -2..=2 { + let gx = i as f32; + let gy = j as f32; + let (mut ox, mut oy) = hash2(nx + gx, ny + gy); + let weight = ox * 0.5 + 0.5; + ox = 0.5 + 0.5 * (time + 6.2831 * ox).sin(); + oy = 0.5 + 0.5 * (time + 6.2831 * oy).sin(); + let dx = (gx - fx + ox).abs(); + let dy = (gy - fy + oy).abs(); + let d = dx.max(dy) * weight; + let seed = hash1((nx + gx) * 7.0 + (ny + gy) * 113.0); + let hue = frac((nx + gx) * 0.173 + (ny + gy) * 0.379 + seed * 0.431); + let (candidate_red, candidate_green, candidate_blue) = pastel_spectrum(hue); + let h = smoothstep(0.0, 1.0, 0.5 + 0.5 * (distance - d) / smoothness); + let correction = h * (1.0 - h) * smoothness / (1.0 + 3.0 * smoothness); + distance = lerp(distance, d, h) - correction; + red = lerp(red, candidate_red, h) - correction; + green = lerp(green, candidate_green, h) - correction; + blue = lerp(blue, candidate_blue, h) - correction; + } + } + + let edge_dimming = 1.0 - 0.1 * smoothstep(0.04, 0.05, distance); + ( + (red * edge_dimming).max(0.0), + (green * edge_dimming).max(0.0), + (blue * edge_dimming).max(0.0), + ) +} + +fn hash1(value: f32) -> f32 { + frac(value.sin() * 43758.5453) +} + +fn hash2(x: f32, y: f32) -> (f32, f32) { + ( + frac((x * 127.1 + y * 311.7).sin() * 43758.5453), + frac((x * 269.5 + y * 183.3).sin() * 43758.5453), + ) +} + +fn frac(value: f32) -> f32 { + value - value.floor() +} + +fn saturate(value: f32) -> f32 { + value.clamp(0.0, 1.0) +} + +fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 { + let t = saturate((value - edge0) / (edge1 - edge0)); + t * t * (3.0 - 2.0 * t) +} + +fn lerp(start: f32, end: f32, amount: f32) -> f32 { + start + (end - start) * amount +} + +fn pastel_spectrum(hue: f32) -> (f32, f32, f32) { + hsv_to_rgb(hue, 0.52, 0.98) +} + +fn hsv_to_rgb(hue: f32, saturation: f32, value: f32) -> (f32, f32, f32) { + let h = frac(hue) * 6.0; + let c = value * saturation; + let x = c * (1.0 - ((h % 2.0) - 1.0).abs()); + let m = value - c; + let (r, g, b) = if h < 1.0 { + (c, x, 0.0) + } else if h < 2.0 { + (x, c, 0.0) + } else if h < 3.0 { + (0.0, c, x) + } else if h < 4.0 { + (0.0, x, c) + } else if h < 5.0 { + (x, 0.0, c) + } else { + (c, 0.0, x) + }; + + (r + m, g + m, b + m) +} + +fn color(r: f32, g: f32, b: f32) -> CultMathColor32 { + CultMathColor32 { + r: r.clamp(0.0, 255.0) as u8, + g: g.clamp(0.0, 255.0) as u8, + b: b.clamp(0.0, 255.0) as u8, + a: 255, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn batch_sampler_writes_colors() { + let xs = [0.0, 20.0, 100.0]; + let ys = [0.0, 30.0, 140.0]; + let tones = [0, 3, 4]; + let spans = [1920.0, 8.0, 8.0]; + let mut out = [CultMathColor32::default(); 3]; + let rc = unsafe { + cultmath_apollonian_voronoi_tones( + xs.as_ptr(), + ys.as_ptr(), + tones.as_ptr(), + spans.as_ptr(), + xs.len(), + 1080.0, + 12, + out.as_mut_ptr(), + ) + }; + assert_eq!(rc, 0); + assert!(out.iter().any(|color| color.r != 0 || color.g != 0 || color.b != 0)); + } +} diff --git a/packages/cultmath/native/cultmath-core/src/lib.rs.meta b/packages/cultmath/native/cultmath-core/src/lib.rs.meta new file mode 100644 index 00000000..2fa8aee8 --- /dev/null +++ b/packages/cultmath/native/cultmath-core/src/lib.rs.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b06c69c432a633f4584830d6b28d4473 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/scripts.meta b/packages/cultmath/scripts.meta new file mode 100644 index 00000000..6f03c566 --- /dev/null +++ b/packages/cultmath/scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0ddff5b782cf14e43ae5cda048e040d8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/scripts/build-unity-package.ps1 b/packages/cultmath/scripts/build-unity-package.ps1 new file mode 100644 index 00000000..b64e9446 --- /dev/null +++ b/packages/cultmath/scripts/build-unity-package.ps1 @@ -0,0 +1,159 @@ +param( + [string] $Configuration = "Release", + [string] $OutputDirectory = "artifacts\unity\org.gamecult.cultmath" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$projectPath = Join-Path $repoRoot "src\CultMath\CultMath.csproj" +$templateRoot = Join-Path $repoRoot "unity\org.gamecult.cultmath" +$buildRoot = Join-Path $repoRoot "artifacts\cultmath-unity-build" +$outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory +} else { + Join-Path $repoRoot $OutputDirectory +} +$freshAssembly = Join-Path $buildRoot "CultMath.dll" +$freshSymbols = Join-Path $buildRoot "CultMath.pdb" +$trackedPluginRoot = Join-Path $templateRoot "Runtime\Plugins" +$trackedAssembly = Join-Path $trackedPluginRoot "CultMath.dll" +$trackedSymbols = Join-Path $trackedPluginRoot "CultMath.pdb" + +if (Test-Path -LiteralPath $buildRoot) { + Remove-Item -LiteralPath $buildRoot -Recurse -Force +} +if (Test-Path -LiteralPath $outputRoot) { + Remove-Item -LiteralPath $outputRoot -Recurse -Force +} + +# The tracked DLL and pdb are committed beside their source, so neither may name a commit: Source Link +# writes the commit SHA into the pdb, and the DLL carries that pdb's content id. +dotnet build $projectPath -c $Configuration -f netstandard2.1 -o $buildRoot --no-incremental ` + --disable-build-servers -p:UseSharedCompilation=false -p:ContinuousIntegrationBuild=true -p:EnableSourceLink=false -m:1 +if ($LASTEXITCODE -ne 0) { + throw "CultMath Unity assembly build failed with exit code $LASTEXITCODE" +} + +foreach ($path in @($freshAssembly, $freshSymbols)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "CultMath Unity build is missing $path" + } +} + +$requiredTemplateFiles = @( + "LICENSE.md", + "LICENSE.md.meta", + "README.md", + "README.md.meta", + "THIRD-PARTY-NOTICES.md", + "THIRD-PARTY-NOTICES.md.meta", + "package.json", + "package.json.meta", + "Runtime\Plugins\CultMath.dll", + "Runtime\Plugins\CultMath.dll.meta", + "Runtime\Plugins\CultMath.pdb", + "Runtime\Plugins\CultMath.pdb.meta", + "Runtime\UnityBridge\CultMath.UnityBridge.asmdef", + "Runtime\UnityBridge\CultMath.UnityBridge.asmdef.meta", + "Runtime\UnityBridge\UnityConversions.cs", + "Runtime\UnityBridge\UnityConversions.cs.meta", + "Shaders\CultMath.hlsl", + "Shaders\CultMath.hlsl.meta" +) +foreach ($relativePath in $requiredTemplateFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath) -PathType Leaf)) { + throw "Tracked CultMath Unity package is incomplete: missing $relativePath" + } +} + +$unityAssetFiles = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File | Where-Object { + $_.Extension -in @(".asmdef", ".cs", ".dll", ".pdb", ".hlsl") +}) +$missingAssetMetas = @($unityAssetFiles | Where-Object { -not (Test-Path -LiteralPath ($_.FullName + ".meta")) }) +$missingDirectoryMetas = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -Directory | Where-Object { + -not (Test-Path -LiteralPath ($_.FullName + ".meta")) +}) +if ($missingAssetMetas.Count -ne 0 -or $missingDirectoryMetas.Count -ne 0) { + $missing = @($missingAssetMetas.FullName) + @($missingDirectoryMetas.FullName) + throw "Tracked CultMath Unity package has assets without stable .meta files: $($missing -join ', ')" +} + +$manifest = Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json +$pluginMeta = Get-Content -LiteralPath ($trackedAssembly + ".meta") -Raw +if ($pluginMeta -notmatch '(?m)^\s*isExplicitlyReferenced:\s*0\s*$') { + throw "CultMath.dll must be auto-referenced; explicit-only plugins do not reach dependent Unity asmdefs" +} +# The precompiled DLL is the only authority for CultMath's numeric types. The one +# compiled assembly allowed beside it is the UnityEngine conversion bridge, which +# references that DLL and must not re-declare core sources. +$bridgeRoot = Join-Path $templateRoot "Runtime\UnityBridge" +$bridgeDefinition = Join-Path $bridgeRoot "CultMath.UnityBridge.asmdef" +$definitions = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File -Filter "*.asmdef") +if ($definitions.Count -ne 1 -or $definitions[0].FullName -ne $bridgeDefinition) { + throw "CultMath's Unity package may define only the Runtime\UnityBridge assembly; found: $($definitions.FullName -join ', ')" +} +$bridge = Get-Content -LiteralPath $bridgeDefinition -Raw | ConvertFrom-Json +if ($bridge.noEngineReferences -or -not $bridge.overrideReferences -or @($bridge.precompiledReferences) -notcontains "CultMath.dll") { + throw "CultMath.UnityBridge must reference UnityEngine and the precompiled CultMath.dll" +} +$expectedFileVersion = "$($manifest.version).0" +$trackedFileVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($trackedAssembly).FileVersion +$freshFileVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($freshAssembly).FileVersion +if ($trackedFileVersion -ne $expectedFileVersion -or $freshFileVersion -ne $expectedFileVersion) { + throw "CultMath Unity assemblies must match package version $expectedFileVersion; tracked=$trackedFileVersion fresh=$freshFileVersion" +} + +foreach ($pair in @(@($freshAssembly, $trackedAssembly), @($freshSymbols, $trackedSymbols))) { + $freshHash = (Get-FileHash -LiteralPath $pair[0] -Algorithm SHA256).Hash + $trackedHash = (Get-FileHash -LiteralPath $pair[1] -Algorithm SHA256).Hash + if ($freshHash -ne $trackedHash) { + throw "Tracked Unity artifact is stale: $($pair[1]) differs from a fresh deterministic build" + } +} + +$trackedSourceFiles = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File -Filter "*.cs") +$straySources = @($trackedSourceFiles | Where-Object { $_.DirectoryName -ne $bridgeRoot }) +if ($trackedSourceFiles.Count -eq 0 -or $straySources.Count -ne 0) { + throw "CultMath Unity package may compile only bridge sources under Runtime\UnityBridge: $($straySources.FullName -join ', ')" +} + +# Check what the bridge declares, not what its files are called: it may declare types only in +# CultMath.UnityBridge and never a type name the core CultMath sources declare. +$declarationPattern = '\b(?:struct|class|enum|interface|record|delegate\s+[\w<>.,\s]+?)\s+(\w+)' +function Get-CodeWithoutComments([string]$path) { + $text = Get-Content -LiteralPath $path -Raw + return [regex]::Replace($text, '(?s)/\*.*?\*/|//[^\r\n]*', '') +} +$coreTypeNames = @(Get-ChildItem -LiteralPath (Join-Path $repoRoot "src\CultMath") -File -Filter "*.cs" | ForEach-Object { + [regex]::Matches((Get-CodeWithoutComments $_.FullName), $declarationPattern) | ForEach-Object { $_.Groups[1].Value } +} | Sort-Object -Unique) +if ($coreTypeNames -notcontains "float3" -or $coreTypeNames -notcontains "math") { + throw "Could not read CultMath core type declarations; the bridge check would be vacuous" +} +foreach ($source in $trackedSourceFiles) { + $code = Get-CodeWithoutComments $source.FullName + $redeclared = @([regex]::Matches($code, $declarationPattern) | ForEach-Object { $_.Groups[1].Value } | Where-Object { $coreTypeNames -contains $_ }) + $foreignNamespaces = @([regex]::Matches($code, '\bnamespace\s+([\w.]+)') | ForEach-Object { $_.Groups[1].Value } | Where-Object { $_ -ne "CultMath.UnityBridge" }) + if ($redeclared.Count -ne 0 -or $foreignNamespaces.Count -ne 0) { + throw "CultMath.UnityBridge source $($source.Name) re-declares core types [$($redeclared -join ', ')] or uses namespaces [$($foreignNamespaces -join ', ')]; the precompiled DLL owns CultMath's types" + } +} + +New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null +Copy-Item -Path (Join-Path $templateRoot "*") -Destination $outputRoot -Recurse -Force + +$stagedAssemblies = @(Get-ChildItem -LiteralPath (Join-Path $outputRoot "Runtime\Plugins") -Filter "*.dll") +$stagedBridgeRoot = Join-Path $outputRoot "Runtime\UnityBridge" +$stagedSources = @(Get-ChildItem -LiteralPath $outputRoot -Recurse -File -Filter "*.cs") +$stagedDefinitions = @(Get-ChildItem -LiteralPath $outputRoot -Recurse -File -Filter "*.asmdef") +$stagedStraySources = @($stagedSources | Where-Object { $_.DirectoryName -ne $stagedBridgeRoot }) +if ($stagedAssemblies.Count -ne 1 -or $stagedAssemblies[0].Name -ne "CultMath.dll" -or $stagedStraySources.Count -ne 0 -or $stagedDefinitions.Count -ne 1) { + throw "CultMath package must contain one owned auto-referenced DLL and only the UnityBridge assembly as source" +} + +Write-Host "CultMath Unity package: $outputRoot" +Write-Host "Package: $($manifest.name)@$($manifest.version)" +Write-Host "Managed assemblies: $($stagedAssemblies.Count)" +Write-Host "Bridge C# source files: $($stagedSources.Count)" +Write-Host "Asmdef definitions: $($stagedDefinitions.Count)" diff --git a/packages/cultmath/shaders.meta b/packages/cultmath/shaders.meta new file mode 100644 index 00000000..46f9d445 --- /dev/null +++ b/packages/cultmath/shaders.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3bed74ebcba445543800218dbe185034 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/shaders/CultMath.hlsl b/packages/cultmath/shaders/CultMath.hlsl new file mode 100644 index 00000000..365705f5 --- /dev/null +++ b/packages/cultmath/shaders/CultMath.hlsl @@ -0,0 +1,356 @@ +#ifndef CULTMATH_HLSL +#define CULTMATH_HLSL + +// CultMath HLSL mirror. +// +// HLSL already owns the float2/float3/float4 type names and most primitive +// intrinsics. This include names the CultMath semantic surface explicitly so +// shader code, C# wrappers, and Rust/native parity fixtures can share the same +// small vocabulary without local helper drift. + +static const float CULTMATH_PI = 3.14159265358979323846; +static const float CULTMATH_TAU = 6.28318530717958647692; +static const float CULTMATH_HALF_PI = 1.57079632679489661923; +static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20; + +float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } +float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } + +float cultmath_frac(float value) { return value - floor(value); } +float2 cultmath_frac(float2 value) { return value - floor(value); } +float3 cultmath_frac(float3 value) { return value - floor(value); } +float4 cultmath_frac(float4 value) { return value - floor(value); } + +float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } +float2 cultmath_clamp(float2 value, float2 minimum, float2 maximum) { return min(max(value, minimum), maximum); } +float3 cultmath_clamp(float3 value, float3 minimum, float3 maximum) { return min(max(value, minimum), maximum); } +float4 cultmath_clamp(float4 value, float4 minimum, float4 maximum) { return min(max(value, minimum), maximum); } + +// DXIL Saturate is FMin(1, FMax(0, x)); dxc lowers min/max to the same FMin/FMax, NaN rules included. +float cultmath_saturate(float value) { return min(1.0, max(0.0, value)); } +float2 cultmath_saturate(float2 value) { return min(float2(1.0, 1.0), max(float2(0.0, 0.0), value)); } +float3 cultmath_saturate(float3 value) { return min(float3(1.0, 1.0, 1.0), max(float3(0.0, 0.0, 0.0), value)); } +float4 cultmath_saturate(float4 value) { return min(float4(1.0, 1.0, 1.0, 1.0), max(float4(0.0, 0.0, 0.0, 0.0), value)); } + +float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } +float2 cultmath_lerp(float2 start, float2 end, float2 amount) { return start + (end - start) * amount; } +float3 cultmath_lerp(float3 start, float3 end, float3 amount) { return start + (end - start) * amount; } +float4 cultmath_lerp(float4 start, float4 end, float4 amount) { return start + (end - start) * amount; } + +float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } +float2 cultmath_step(float2 edge, float2 value) { return float2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } +float3 cultmath_step(float3 edge, float3 value) { return float3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } +float4 cultmath_step(float4 edge, float4 value) { return float4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } + +float cultmath_smoothstep(float minimum, float maximum, float value) +{ + float t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0 - 2.0 * t); +} + +float2 cultmath_smoothstep(float2 minimum, float2 maximum, float2 value) +{ + float2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float2(3.0, 3.0) - float2(2.0, 2.0) * t); +} + +float3 cultmath_smoothstep(float3 minimum, float3 maximum, float3 value) +{ + float3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float3(3.0, 3.0, 3.0) - float3(2.0, 2.0, 2.0) * t); +} + +float4 cultmath_smoothstep(float4 minimum, float4 maximum, float4 value) +{ + float4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float4(3.0, 3.0, 3.0, 3.0) - float4(2.0, 2.0, 2.0, 2.0) * t); +} + +float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } + +float cultmath_smootherstep(float value) +{ + value = cultmath_saturate(value); + return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); +} + +float2 cultmath_snoise_mod289(float2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +float3 cultmath_snoise_mod289(float3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +float4 cultmath_snoise_mod289(float4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); } +float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); } + +// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation +// order as the C# math.snoise(float3) mirror. +float cultmath_snoise(float3 value) +{ + float2 c = float2(1.0 / 6.0, 1.0 / 3.0); + float3 i = floor(value + dot(value, c.yyy)); + float3 x0 = value - i + dot(i, c.xxx); + float3 g = step(x0.yzx, x0.xyz); + float3 l = 1.0 - g; + float3 i1 = min(g.xyz, l.zxy); + float3 i2 = max(g.xyz, l.zxy); + float3 x1 = x0 - i1 + c.xxx; + float3 x2 = x0 - i2 + c.yyy; + float3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + float4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + float4(0.0, i1.z, i2.z, 1.0)) + i.y + float4(0.0, i1.y, i2.y, 1.0)) + i.x + float4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + float3 ns = float3(2.0 * n_, 0.5 * n_ - 1.0, n_); + float4 j = p - 49.0 * floor(p * ns.z * ns.z); + float4 x_ = floor(j * ns.z); + float4 y_ = floor(j - 7.0 * x_); + float4 x = x_ * ns.x + ns.yyyy; + float4 y = y_ * ns.x + ns.yyyy; + float4 h = 1.0 - abs(x) - abs(y); + + float4 b0 = float4(x.xy, y.xy); + float4 b1 = float4(x.zw, y.zw); + float4 s0 = floor(b0) * 2.0 + 1.0; + float4 s1 = floor(b1) * 2.0 + 1.0; + float4 sh = -step(h, 0.0); + float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + float4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + float3 p0 = float3(a0.xy, h.x); + float3 p1 = float3(a0.zw, h.y); + float3 p2 = float3(a1.xy, h.z); + float3 p3 = float3(a1.zw, h.w); + float4 norm = 1.79284291400159 - 0.85373472095314 * float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + m = m * m; + return 42.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); +} + +float cultmath_snoise(float2 value) +{ + float4 c = float4( + 0.211324865405187, + 0.366025403784439, + -0.577350269189626, + 0.024390243902439); + float2 i = floor(value + dot(value, c.yy)); + float2 x0 = value - i + dot(i, c.xx); + float2 i1 = x0.x > x0.y ? float2(1.0, 0.0) : float2(0.0, 1.0); + float4 x12 = float4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z); + + i = cultmath_snoise_mod289(i); + float3 p = cultmath_snoise_permute( + cultmath_snoise_permute(i.y + float3(0.0, i1.y, 1.0)) + i.x + float3(0.0, i1.x, 1.0)); + float3 m = max(0.5 - float3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); + m *= m; + m *= m; + + float3 x = 2.0 * frac(p * c.w) - 1.0; + float3 h = abs(x) - 0.5; + float3 ox = floor(x + 0.5); + float3 a0 = x - ox; + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + + float3 g = float3( + a0.x * x0.x + h.x * x0.y, + a0.y * x12.x + h.y * x12.y, + a0.z * x12.z + h.z * x12.w); + return 130.0 * dot(m, g); +} + +float cultmath_lengthsq(float2 value) { return dot(value, value); } +float cultmath_lengthsq(float3 value) { return dot(value, value); } +float cultmath_lengthsq(float4 value) { return dot(value, value); } + +float cultmath_distance(float2 left, float2 right) { return length(left - right); } +float cultmath_distance(float3 left, float3 right) { return length(left - right); } +float cultmath_distance(float4 left, float4 right) { return length(left - right); } + +float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } + +float2 cultmath_reflect(float2 incident, float2 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +float3 cultmath_reflect(float3 incident, float3 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +float2 cultmath_rotate(float2 value, float radians_value) +{ + float s = sin(radians_value); + float c = cos(radians_value); + return float2(value.x * c - value.y * s, value.x * s + value.y * c); +} + +float2 cultmath_rotate_degrees(float2 value, float degrees_value) +{ + return cultmath_rotate(value, cultmath_radians(degrees_value)); +} + +float cultmath_csum(float2 value) { return value.x + value.y; } +float cultmath_csum(float3 value) { return value.x + value.y + value.z; } +float cultmath_csum(float4 value) { return value.x + value.y + value.z + value.w; } + +float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float2 cultmath_decay(float2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float3 cultmath_decay(float3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float4 cultmath_decay(float4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } + +float cultmath_damp(float start, float end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float2 cultmath_damp(float2 start, float2 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float3 cultmath_damp(float3 start, float3 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float4 cultmath_damp(float4 start, float4 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float2 cultmath_catmullrom(float2 p0, float2 p1, float2 p2, float2 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float3 cultmath_catmullrom(float3 p0, float3 p1, float3 p2, float3 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float4 cultmath_catmullrom(float4 p0, float4 p1, float4 p2, float4 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float3 cultmath_quadratic_bezier(float3 p0, float3 p1, float3 p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float3 cultmath_cubic_bezier(float3 p0, float3 p1, float3 p2, float3 p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); } +float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); } +float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); } + +float cultmath_value_noise(float2 position) +{ + float2 cell = floor(position); + float2 local = cultmath_frac(position); + float2 u = local * local * (float2(3.0, 3.0) - float2(2.0, 2.0) * local); + + float a = cultmath_hash(cell); + float b = cultmath_hash(cell + float2(1.0, 0.0)); + float c = cultmath_hash(cell + float2(0.0, 1.0)); + float d = cultmath_hash(cell + float2(1.0, 1.0)); + + return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); +} + +float cultmath_value_noise_bicubic(float2 position) +{ + float2 cell = floor(position); + float2 local = cultmath_frac(position); + + float y0 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, -1.0)), cultmath_hash(cell + float2(0.0, -1.0)), cultmath_hash(cell + float2(1.0, -1.0)), cultmath_hash(cell + float2(2.0, -1.0)), local.x); + float y1 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 0.0)), cultmath_hash(cell + float2(0.0, 0.0)), cultmath_hash(cell + float2(1.0, 0.0)), cultmath_hash(cell + float2(2.0, 0.0)), local.x); + float y2 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 1.0)), cultmath_hash(cell + float2(0.0, 1.0)), cultmath_hash(cell + float2(1.0, 1.0)), cultmath_hash(cell + float2(2.0, 1.0)), local.x); + float y3 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 2.0)), cultmath_hash(cell + float2(0.0, 2.0)), cultmath_hash(cell + float2(1.0, 2.0)), cultmath_hash(cell + float2(2.0, 2.0)), local.x); + + return cultmath_catmullrom(y0, y1, y2, y3, local.y); +} + +float cultmath_value_noise_texture(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale) +{ + return noise_texture.SampleLevel(noise_sampler, uv * scale, 0.0).r; +} + +float cultmath_value_noise_texture_bicubic(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale, float2 texel_size) +{ + float2 position = uv * scale; + float2 cell = floor(position); + float2 local = cultmath_frac(position); + float2 base_uv = cell / scale; + + float row0 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, -1.0), 0.0).r, + local.x); + float row1 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 0.0), 0.0).r, + local.x); + float row2 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 1.0), 0.0).r, + local.x); + float row3 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 2.0), 0.0).r, + local.x); + + return cultmath_catmullrom(row0, row1, row2, row3, local.y); +} + +#endif diff --git a/packages/cultmath/shaders/CultMath.hlsl.meta b/packages/cultmath/shaders/CultMath.hlsl.meta new file mode 100644 index 00000000..d6b8542e --- /dev/null +++ b/packages/cultmath/shaders/CultMath.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 33174674795143c4a905408718f956f8 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/src.meta b/packages/cultmath/src.meta new file mode 100644 index 00000000..3e88fcb9 --- /dev/null +++ b/packages/cultmath/src.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 64f48d0cae0810e4ba9903e9094fe4c6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/src/CultMath.meta b/packages/cultmath/src/CultMath.meta new file mode 100644 index 00000000..381b726d --- /dev/null +++ b/packages/cultmath/src/CultMath.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e857b457b93397d4fb4ad8481cb72e4c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/src/CultMath/BatchMath.cs b/packages/cultmath/src/CultMath/BatchMath.cs new file mode 100644 index 00000000..04c042d9 --- /dev/null +++ b/packages/cultmath/src/CultMath/BatchMath.cs @@ -0,0 +1,245 @@ +using System; +using System.Numerics; + +namespace CultMath; + +public static class BatchMath +{ + private const float MinimumDistanceSquared = 1.0e-8f; + + public static int LaneCount => Vector.Count; + + public static bool IsHardwareAccelerated => Vector.IsHardwareAccelerated; + + public static void Clear(Span values) => values.Clear(); + + public static void AddRadialFalloffAcceleration2D( + ReadOnlySpan positionX, + ReadOnlySpan positionY, + float centerX, + float centerY, + float strength, + float radius, + Span accelerationX, + Span accelerationY) + { + ValidateEqualLengths(positionX.Length, positionY.Length, accelerationX.Length, accelerationY.Length); + + if (radius <= 0.0f || strength == 0.0f || positionX.Length == 0) + { + return; + } + + var radiusVector = new Vector(radius); + var centerXVector = new Vector(centerX); + var centerYVector = new Vector(centerY); + var strengthVector = new Vector(strength); + var one = Vector.One; + var zero = Vector.Zero; + var minimumDistanceSquared = new Vector(MinimumDistanceSquared); + + var i = 0; + var lanes = Vector.Count; + var pxBuffer = new float[lanes]; + var pyBuffer = new float[lanes]; + var axBuffer = new float[lanes]; + var ayBuffer = new float[lanes]; + for (; i <= positionX.Length - lanes; i += lanes) + { + CopySpanToArray(positionX.Slice(i, lanes), pxBuffer); + CopySpanToArray(positionY.Slice(i, lanes), pyBuffer); + CopySpanToArray(accelerationX.Slice(i, lanes), axBuffer); + CopySpanToArray(accelerationY.Slice(i, lanes), ayBuffer); + + var px = new Vector(pxBuffer); + var py = new Vector(pyBuffer); + var dx = centerXVector - px; + var dy = centerYVector - py; + var distanceSquared = Vector.Max(dx * dx + dy * dy, minimumDistanceSquared); + var distance = Vector.SquareRoot(distanceSquared); + var inRange = Vector.LessThanOrEqual(distance, radiusVector); + var falloff = Vector.Max(zero, one - distance / radiusVector); + var magnitude = strengthVector * falloff; + var ax = Vector.ConditionalSelect(inRange, dx / distance * magnitude, zero); + var ay = Vector.ConditionalSelect(inRange, dy / distance * magnitude, zero); + + (new Vector(axBuffer) + ax).CopyTo(axBuffer); + (new Vector(ayBuffer) + ay).CopyTo(ayBuffer); + CopyArrayToSpan(axBuffer, accelerationX.Slice(i, lanes)); + CopyArrayToSpan(ayBuffer, accelerationY.Slice(i, lanes)); + } + + for (; i < positionX.Length; i++) + { + var dx = centerX - positionX[i]; + var dy = centerY - positionY[i]; + var distanceSquared = MathF.Max(dx * dx + dy * dy, MinimumDistanceSquared); + var distance = MathF.Sqrt(distanceSquared); + if (distance > radius) + { + continue; + } + + var falloff = MathF.Max(0.0f, 1.0f - distance / radius); + var magnitude = strength * falloff; + accelerationX[i] += dx / distance * magnitude; + accelerationY[i] += dy / distance * magnitude; + } + } + + public static void AddRadialFalloffAcceleration2D( + ReadOnlySpan positions, + float2 center, + float strength, + float radius, + Span acceleration) + { + ValidateEqualLengths(positions.Length, acceleration.Length); + + if (radius <= 0.0f || strength == 0.0f || positions.Length == 0) + { + return; + } + + for (var i = 0; i < positions.Length; i++) + { + var delta = center - positions[i]; + var distanceSquared = MathF.Max(delta.x * delta.x + delta.y * delta.y, MinimumDistanceSquared); + var distance = MathF.Sqrt(distanceSquared); + if (distance > radius) + { + continue; + } + + var falloff = MathF.Max(0.0f, 1.0f - distance / radius); + acceleration[i] += delta / distance * (strength * falloff); + } + } + + public static void IntegrateSemiImplicitEuler2D( + float deltaTime, + ReadOnlySpan dynamicMask, + Span positionX, + Span positionY, + Span velocityX, + Span velocityY, + ReadOnlySpan accelerationX, + ReadOnlySpan accelerationY) + { + if (deltaTime <= 0.0f) + { + throw new ArgumentOutOfRangeException(nameof(deltaTime), "Delta time must be positive."); + } + + ValidateEqualLengths( + dynamicMask.Length, + positionX.Length, + positionY.Length, + velocityX.Length, + velocityY.Length, + accelerationX.Length, + accelerationY.Length); + + var delta = new Vector(deltaTime); + var i = 0; + var lanes = Vector.Count; + var maskBuffer = new float[lanes]; + var pxBuffer = new float[lanes]; + var pyBuffer = new float[lanes]; + var vxBuffer = new float[lanes]; + var vyBuffer = new float[lanes]; + var axBuffer = new float[lanes]; + var ayBuffer = new float[lanes]; + for (; i <= positionX.Length - lanes; i += lanes) + { + CopySpanToArray(dynamicMask.Slice(i, lanes), maskBuffer); + CopySpanToArray(velocityX.Slice(i, lanes), vxBuffer); + CopySpanToArray(velocityY.Slice(i, lanes), vyBuffer); + CopySpanToArray(positionX.Slice(i, lanes), pxBuffer); + CopySpanToArray(positionY.Slice(i, lanes), pyBuffer); + CopySpanToArray(accelerationX.Slice(i, lanes), axBuffer); + CopySpanToArray(accelerationY.Slice(i, lanes), ayBuffer); + + var mask = new Vector(maskBuffer); + var vx = new Vector(vxBuffer); + var vy = new Vector(vyBuffer); + var px = new Vector(pxBuffer); + var py = new Vector(pyBuffer); + var ax = new Vector(axBuffer); + var ay = new Vector(ayBuffer); + + vx += ax * delta * mask; + vy += ay * delta * mask; + px += vx * delta * mask; + py += vy * delta * mask; + + vx.CopyTo(vxBuffer); + vy.CopyTo(vyBuffer); + px.CopyTo(pxBuffer); + py.CopyTo(pyBuffer); + CopyArrayToSpan(vxBuffer, velocityX.Slice(i, lanes)); + CopyArrayToSpan(vyBuffer, velocityY.Slice(i, lanes)); + CopyArrayToSpan(pxBuffer, positionX.Slice(i, lanes)); + CopyArrayToSpan(pyBuffer, positionY.Slice(i, lanes)); + } + + for (; i < positionX.Length; i++) + { + var mask = dynamicMask[i]; + velocityX[i] += accelerationX[i] * deltaTime * mask; + velocityY[i] += accelerationY[i] * deltaTime * mask; + positionX[i] += velocityX[i] * deltaTime * mask; + positionY[i] += velocityY[i] * deltaTime * mask; + } + } + + public static void IntegrateSemiImplicitEuler2D( + float deltaTime, + ReadOnlySpan dynamicMask, + Span position, + Span velocity, + ReadOnlySpan acceleration) + { + if (deltaTime <= 0.0f) + { + throw new ArgumentOutOfRangeException(nameof(deltaTime), "Delta time must be positive."); + } + + ValidateEqualLengths(dynamicMask.Length, position.Length, velocity.Length, acceleration.Length); + + for (var i = 0; i < position.Length; i++) + { + var mask = dynamicMask[i]; + velocity[i] += acceleration[i] * deltaTime * mask; + position[i] += velocity[i] * deltaTime * mask; + } + } + + private static void ValidateEqualLengths(params int[] lengths) + { + var length = lengths[0]; + for (var i = 1; i < lengths.Length; i++) + { + if (lengths[i] != length) + { + throw new ArgumentException("CultMath batch spans must have equal length."); + } + } + } + + private static void CopySpanToArray(ReadOnlySpan source, float[] destination) + { + for (var i = 0; i < source.Length; i++) + { + destination[i] = source[i]; + } + } + + private static void CopyArrayToSpan(float[] source, Span destination) + { + for (var i = 0; i < destination.Length; i++) + { + destination[i] = source[i]; + } + } +} diff --git a/packages/cultmath/src/CultMath/BatchMath.cs.meta b/packages/cultmath/src/CultMath/BatchMath.cs.meta new file mode 100644 index 00000000..fe7c6033 --- /dev/null +++ b/packages/cultmath/src/CultMath/BatchMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d4771320e190bdd408dd296c293a9d0e \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/Color32.cs b/packages/cultmath/src/CultMath/Color32.cs new file mode 100644 index 00000000..e9a2b481 --- /dev/null +++ b/packages/cultmath/src/CultMath/Color32.cs @@ -0,0 +1,4 @@ +namespace CultMath; + +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public readonly record struct Color32(byte r, byte g, byte b, byte a = 255); diff --git a/packages/cultmath/src/CultMath/Color32.cs.meta b/packages/cultmath/src/CultMath/Color32.cs.meta new file mode 100644 index 00000000..71e1ba55 --- /dev/null +++ b/packages/cultmath/src/CultMath/Color32.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bfa14a9aacb8ac4448761568643f2bc6 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/CultMath.asmdef b/packages/cultmath/src/CultMath/CultMath.asmdef new file mode 100644 index 00000000..449549f6 --- /dev/null +++ b/packages/cultmath/src/CultMath/CultMath.asmdef @@ -0,0 +1,14 @@ +{ + "name": "CultMath", + "rootNamespace": "CultMath", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": true, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/packages/cultmath/src/CultMath/CultMath.asmdef.meta b/packages/cultmath/src/CultMath/CultMath.asmdef.meta new file mode 100644 index 00000000..5a26d2c5 --- /dev/null +++ b/packages/cultmath/src/CultMath/CultMath.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f0b66b909d13e18468f7aa56221531e9 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/src/CultMath/CultMath.csproj b/packages/cultmath/src/CultMath/CultMath.csproj new file mode 100644 index 00000000..7c567105 --- /dev/null +++ b/packages/cultmath/src/CultMath/CultMath.csproj @@ -0,0 +1,26 @@ + + + + net8.0;netstandard2.1 + enable + enable + latest + CultMath + CultMath + GameCult.Math + 0.2.0 + Clean-room C# math shaped for HLSL-style renderer code. + GameCult + README.md + $(NoWarn);CS8981 + true + + false + + + + + + + + diff --git a/packages/cultmath/src/CultMath/CultMath.csproj.meta b/packages/cultmath/src/CultMath/CultMath.csproj.meta new file mode 100644 index 00000000..78fc304d --- /dev/null +++ b/packages/cultmath/src/CultMath/CultMath.csproj.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 77e1a0a2512910f41885650a7d0d6f60 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/src/CultMath/CultMathTone.cs b/packages/cultmath/src/CultMath/CultMathTone.cs new file mode 100644 index 00000000..25dfffeb --- /dev/null +++ b/packages/cultmath/src/CultMath/CultMathTone.cs @@ -0,0 +1,10 @@ +namespace CultMath; + +public enum CultMathTone : byte +{ + Background = 0, + Panel = 1, + Edge = 2, + Header = 3, + Body = 4, +} diff --git a/packages/cultmath/src/CultMath/CultMathTone.cs.meta b/packages/cultmath/src/CultMath/CultMathTone.cs.meta new file mode 100644 index 00000000..f41d1475 --- /dev/null +++ b/packages/cultmath/src/CultMath/CultMathTone.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d4bfc66f9f7e992448f884c2c338d50a \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/IsExternalInit.cs b/packages/cultmath/src/CultMath/IsExternalInit.cs new file mode 100644 index 00000000..b05eb4a1 --- /dev/null +++ b/packages/cultmath/src/CultMath/IsExternalInit.cs @@ -0,0 +1,5 @@ +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit +{ +} diff --git a/packages/cultmath/src/CultMath/IsExternalInit.cs.meta b/packages/cultmath/src/CultMath/IsExternalInit.cs.meta new file mode 100644 index 00000000..ca761bcb --- /dev/null +++ b/packages/cultmath/src/CultMath/IsExternalInit.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: de2b3c3e7f03c354aa2b84fbbc55b497 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/Random.cs b/packages/cultmath/src/CultMath/Random.cs new file mode 100644 index 00000000..6892e20c --- /dev/null +++ b/packages/cultmath/src/CultMath/Random.cs @@ -0,0 +1,66 @@ +namespace CultMath; + +public struct Random +{ + private const uint DefaultSeed = 0x6E624EB7u; + + public uint state; + + public Random(uint seed) + { + state = seed == 0 ? DefaultSeed : seed; + } + + public static Random CreateFromIndex(uint index) => new(index + DefaultSeed); + + public void InitState(uint seed) + { + state = seed == 0 ? DefaultSeed : seed; + } + + public uint NextUInt() + { + var x = state == 0 ? DefaultSeed : state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + state = x == 0 ? DefaultSeed : x; + return state; + } + + public int NextInt(int max) => NextInt(0, max); + + public int NextInt(int min, int max) + { + if (max <= min) + { + return min; + } + + return min + (int)(NextUInt() % (uint)(max - min)); + } + + public float NextFloat() => (NextUInt() >> 8) * (1.0f / 16777216.0f); + + public float NextFloat(float max) => NextFloat() * max; + + public float NextFloat(float min, float max) => min + NextFloat() * (max - min); + + public float2 NextFloat2() => new(NextFloat(), NextFloat()); + + public float2 NextFloat2(float min, float max) => new(NextFloat(min, max), NextFloat(min, max)); + + public float2 NextFloat2(float2 min, float2 max) => min + NextFloat2() * (max - min); + + public float2 NextFloat2Direction() + { + var angle = NextFloat(0.0f, math.TAU); + return new float2(math.cos(angle), math.sin(angle)); + } + + public float3 NextFloat3() => new(NextFloat(), NextFloat(), NextFloat()); + + public float3 NextFloat3(float3 max) => NextFloat3() * max; + + public float3 NextFloat3(float3 min, float3 max) => min + NextFloat3() * (max - min); +} diff --git a/packages/cultmath/src/CultMath/Random.cs.meta b/packages/cultmath/src/CultMath/Random.cs.meta new file mode 100644 index 00000000..55032ded --- /dev/null +++ b/packages/cultmath/src/CultMath/Random.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c1a557259026da4ca20b54264852c13 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/Swizzles.g.cs b/packages/cultmath/src/CultMath/Swizzles.g.cs new file mode 100644 index 00000000..ce84bbc4 --- /dev/null +++ b/packages/cultmath/src/CultMath/Swizzles.g.cs @@ -0,0 +1,3137 @@ +// +// Generated by tools/generate-swizzles.ps1. Do not edit by hand. +// + +namespace CultMath; + +public partial struct float2 +{ + public float2(float2 xy) : this(xy.x, xy.y) { } + public float r { readonly get => x; set => x = value; } + public float g { readonly get => y; set => y = value; } + public readonly float2 xx => new(x, x); + public float2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public float2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly float2 yy => new(y, y); + public readonly float3 xxx => new(x, x, x); + public readonly float3 xxy => new(x, x, y); + public readonly float3 xyx => new(x, y, x); + public readonly float3 xyy => new(x, y, y); + public readonly float3 yxx => new(y, x, x); + public readonly float3 yxy => new(y, x, y); + public readonly float3 yyx => new(y, y, x); + public readonly float3 yyy => new(y, y, y); + public readonly float4 xxxx => new(x, x, x, x); + public readonly float4 xxxy => new(x, x, x, y); + public readonly float4 xxyx => new(x, x, y, x); + public readonly float4 xxyy => new(x, x, y, y); + public readonly float4 xyxx => new(x, y, x, x); + public readonly float4 xyxy => new(x, y, x, y); + public readonly float4 xyyx => new(x, y, y, x); + public readonly float4 xyyy => new(x, y, y, y); + public readonly float4 yxxx => new(y, x, x, x); + public readonly float4 yxxy => new(y, x, x, y); + public readonly float4 yxyx => new(y, x, y, x); + public readonly float4 yxyy => new(y, x, y, y); + public readonly float4 yyxx => new(y, y, x, x); + public readonly float4 yyxy => new(y, y, x, y); + public readonly float4 yyyx => new(y, y, y, x); + public readonly float4 yyyy => new(y, y, y, y); + public readonly float2 rr => new(x, x); + public float2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public float2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly float2 gg => new(y, y); + public readonly float3 rrr => new(x, x, x); + public readonly float3 rrg => new(x, x, y); + public readonly float3 rgr => new(x, y, x); + public readonly float3 rgg => new(x, y, y); + public readonly float3 grr => new(y, x, x); + public readonly float3 grg => new(y, x, y); + public readonly float3 ggr => new(y, y, x); + public readonly float3 ggg => new(y, y, y); + public readonly float4 rrrr => new(x, x, x, x); + public readonly float4 rrrg => new(x, x, x, y); + public readonly float4 rrgr => new(x, x, y, x); + public readonly float4 rrgg => new(x, x, y, y); + public readonly float4 rgrr => new(x, y, x, x); + public readonly float4 rgrg => new(x, y, x, y); + public readonly float4 rggr => new(x, y, y, x); + public readonly float4 rggg => new(x, y, y, y); + public readonly float4 grrr => new(y, x, x, x); + public readonly float4 grrg => new(y, x, x, y); + public readonly float4 grgr => new(y, x, y, x); + public readonly float4 grgg => new(y, x, y, y); + public readonly float4 ggrr => new(y, y, x, x); + public readonly float4 ggrg => new(y, y, x, y); + public readonly float4 gggr => new(y, y, y, x); + public readonly float4 gggg => new(y, y, y, y); +} + +public partial struct float3 +{ + public float3(float x, float2 yz) : this(x, yz.x, yz.y) { } + public float3(float2 xy, float z) : this(xy.x, xy.y, z) { } + public float3(float3 xyz) : this(xyz.x, xyz.y, xyz.z) { } + public float r { readonly get => x; set => x = value; } + public float g { readonly get => y; set => y = value; } + public float b { readonly get => z; set => z = value; } + public readonly float2 xx => new(x, x); + public float2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public float2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public float2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly float2 yy => new(y, y); + public float2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public float2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public float2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly float2 zz => new(z, z); + public readonly float3 xxx => new(x, x, x); + public readonly float3 xxy => new(x, x, y); + public readonly float3 xxz => new(x, x, z); + public readonly float3 xyx => new(x, y, x); + public readonly float3 xyy => new(x, y, y); + public float3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public readonly float3 xzx => new(x, z, x); + public float3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly float3 xzz => new(x, z, z); + public readonly float3 yxx => new(y, x, x); + public readonly float3 yxy => new(y, x, y); + public float3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public readonly float3 yyx => new(y, y, x); + public readonly float3 yyy => new(y, y, y); + public readonly float3 yyz => new(y, y, z); + public float3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly float3 yzy => new(y, z, y); + public readonly float3 yzz => new(y, z, z); + public readonly float3 zxx => new(z, x, x); + public float3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly float3 zxz => new(z, x, z); + public float3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly float3 zyy => new(z, y, y); + public readonly float3 zyz => new(z, y, z); + public readonly float3 zzx => new(z, z, x); + public readonly float3 zzy => new(z, z, y); + public readonly float3 zzz => new(z, z, z); + public readonly float4 xxxx => new(x, x, x, x); + public readonly float4 xxxy => new(x, x, x, y); + public readonly float4 xxxz => new(x, x, x, z); + public readonly float4 xxyx => new(x, x, y, x); + public readonly float4 xxyy => new(x, x, y, y); + public readonly float4 xxyz => new(x, x, y, z); + public readonly float4 xxzx => new(x, x, z, x); + public readonly float4 xxzy => new(x, x, z, y); + public readonly float4 xxzz => new(x, x, z, z); + public readonly float4 xyxx => new(x, y, x, x); + public readonly float4 xyxy => new(x, y, x, y); + public readonly float4 xyxz => new(x, y, x, z); + public readonly float4 xyyx => new(x, y, y, x); + public readonly float4 xyyy => new(x, y, y, y); + public readonly float4 xyyz => new(x, y, y, z); + public readonly float4 xyzx => new(x, y, z, x); + public readonly float4 xyzy => new(x, y, z, y); + public readonly float4 xyzz => new(x, y, z, z); + public readonly float4 xzxx => new(x, z, x, x); + public readonly float4 xzxy => new(x, z, x, y); + public readonly float4 xzxz => new(x, z, x, z); + public readonly float4 xzyx => new(x, z, y, x); + public readonly float4 xzyy => new(x, z, y, y); + public readonly float4 xzyz => new(x, z, y, z); + public readonly float4 xzzx => new(x, z, z, x); + public readonly float4 xzzy => new(x, z, z, y); + public readonly float4 xzzz => new(x, z, z, z); + public readonly float4 yxxx => new(y, x, x, x); + public readonly float4 yxxy => new(y, x, x, y); + public readonly float4 yxxz => new(y, x, x, z); + public readonly float4 yxyx => new(y, x, y, x); + public readonly float4 yxyy => new(y, x, y, y); + public readonly float4 yxyz => new(y, x, y, z); + public readonly float4 yxzx => new(y, x, z, x); + public readonly float4 yxzy => new(y, x, z, y); + public readonly float4 yxzz => new(y, x, z, z); + public readonly float4 yyxx => new(y, y, x, x); + public readonly float4 yyxy => new(y, y, x, y); + public readonly float4 yyxz => new(y, y, x, z); + public readonly float4 yyyx => new(y, y, y, x); + public readonly float4 yyyy => new(y, y, y, y); + public readonly float4 yyyz => new(y, y, y, z); + public readonly float4 yyzx => new(y, y, z, x); + public readonly float4 yyzy => new(y, y, z, y); + public readonly float4 yyzz => new(y, y, z, z); + public readonly float4 yzxx => new(y, z, x, x); + public readonly float4 yzxy => new(y, z, x, y); + public readonly float4 yzxz => new(y, z, x, z); + public readonly float4 yzyx => new(y, z, y, x); + public readonly float4 yzyy => new(y, z, y, y); + public readonly float4 yzyz => new(y, z, y, z); + public readonly float4 yzzx => new(y, z, z, x); + public readonly float4 yzzy => new(y, z, z, y); + public readonly float4 yzzz => new(y, z, z, z); + public readonly float4 zxxx => new(z, x, x, x); + public readonly float4 zxxy => new(z, x, x, y); + public readonly float4 zxxz => new(z, x, x, z); + public readonly float4 zxyx => new(z, x, y, x); + public readonly float4 zxyy => new(z, x, y, y); + public readonly float4 zxyz => new(z, x, y, z); + public readonly float4 zxzx => new(z, x, z, x); + public readonly float4 zxzy => new(z, x, z, y); + public readonly float4 zxzz => new(z, x, z, z); + public readonly float4 zyxx => new(z, y, x, x); + public readonly float4 zyxy => new(z, y, x, y); + public readonly float4 zyxz => new(z, y, x, z); + public readonly float4 zyyx => new(z, y, y, x); + public readonly float4 zyyy => new(z, y, y, y); + public readonly float4 zyyz => new(z, y, y, z); + public readonly float4 zyzx => new(z, y, z, x); + public readonly float4 zyzy => new(z, y, z, y); + public readonly float4 zyzz => new(z, y, z, z); + public readonly float4 zzxx => new(z, z, x, x); + public readonly float4 zzxy => new(z, z, x, y); + public readonly float4 zzxz => new(z, z, x, z); + public readonly float4 zzyx => new(z, z, y, x); + public readonly float4 zzyy => new(z, z, y, y); + public readonly float4 zzyz => new(z, z, y, z); + public readonly float4 zzzx => new(z, z, z, x); + public readonly float4 zzzy => new(z, z, z, y); + public readonly float4 zzzz => new(z, z, z, z); + public readonly float2 rr => new(x, x); + public float2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public float2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public float2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly float2 gg => new(y, y); + public float2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public float2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public float2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly float2 bb => new(z, z); + public readonly float3 rrr => new(x, x, x); + public readonly float3 rrg => new(x, x, y); + public readonly float3 rrb => new(x, x, z); + public readonly float3 rgr => new(x, y, x); + public readonly float3 rgg => new(x, y, y); + public float3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public readonly float3 rbr => new(x, z, x); + public float3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly float3 rbb => new(x, z, z); + public readonly float3 grr => new(y, x, x); + public readonly float3 grg => new(y, x, y); + public float3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public readonly float3 ggr => new(y, y, x); + public readonly float3 ggg => new(y, y, y); + public readonly float3 ggb => new(y, y, z); + public float3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly float3 gbg => new(y, z, y); + public readonly float3 gbb => new(y, z, z); + public readonly float3 brr => new(z, x, x); + public float3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly float3 brb => new(z, x, z); + public float3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly float3 bgg => new(z, y, y); + public readonly float3 bgb => new(z, y, z); + public readonly float3 bbr => new(z, z, x); + public readonly float3 bbg => new(z, z, y); + public readonly float3 bbb => new(z, z, z); + public readonly float4 rrrr => new(x, x, x, x); + public readonly float4 rrrg => new(x, x, x, y); + public readonly float4 rrrb => new(x, x, x, z); + public readonly float4 rrgr => new(x, x, y, x); + public readonly float4 rrgg => new(x, x, y, y); + public readonly float4 rrgb => new(x, x, y, z); + public readonly float4 rrbr => new(x, x, z, x); + public readonly float4 rrbg => new(x, x, z, y); + public readonly float4 rrbb => new(x, x, z, z); + public readonly float4 rgrr => new(x, y, x, x); + public readonly float4 rgrg => new(x, y, x, y); + public readonly float4 rgrb => new(x, y, x, z); + public readonly float4 rggr => new(x, y, y, x); + public readonly float4 rggg => new(x, y, y, y); + public readonly float4 rggb => new(x, y, y, z); + public readonly float4 rgbr => new(x, y, z, x); + public readonly float4 rgbg => new(x, y, z, y); + public readonly float4 rgbb => new(x, y, z, z); + public readonly float4 rbrr => new(x, z, x, x); + public readonly float4 rbrg => new(x, z, x, y); + public readonly float4 rbrb => new(x, z, x, z); + public readonly float4 rbgr => new(x, z, y, x); + public readonly float4 rbgg => new(x, z, y, y); + public readonly float4 rbgb => new(x, z, y, z); + public readonly float4 rbbr => new(x, z, z, x); + public readonly float4 rbbg => new(x, z, z, y); + public readonly float4 rbbb => new(x, z, z, z); + public readonly float4 grrr => new(y, x, x, x); + public readonly float4 grrg => new(y, x, x, y); + public readonly float4 grrb => new(y, x, x, z); + public readonly float4 grgr => new(y, x, y, x); + public readonly float4 grgg => new(y, x, y, y); + public readonly float4 grgb => new(y, x, y, z); + public readonly float4 grbr => new(y, x, z, x); + public readonly float4 grbg => new(y, x, z, y); + public readonly float4 grbb => new(y, x, z, z); + public readonly float4 ggrr => new(y, y, x, x); + public readonly float4 ggrg => new(y, y, x, y); + public readonly float4 ggrb => new(y, y, x, z); + public readonly float4 gggr => new(y, y, y, x); + public readonly float4 gggg => new(y, y, y, y); + public readonly float4 gggb => new(y, y, y, z); + public readonly float4 ggbr => new(y, y, z, x); + public readonly float4 ggbg => new(y, y, z, y); + public readonly float4 ggbb => new(y, y, z, z); + public readonly float4 gbrr => new(y, z, x, x); + public readonly float4 gbrg => new(y, z, x, y); + public readonly float4 gbrb => new(y, z, x, z); + public readonly float4 gbgr => new(y, z, y, x); + public readonly float4 gbgg => new(y, z, y, y); + public readonly float4 gbgb => new(y, z, y, z); + public readonly float4 gbbr => new(y, z, z, x); + public readonly float4 gbbg => new(y, z, z, y); + public readonly float4 gbbb => new(y, z, z, z); + public readonly float4 brrr => new(z, x, x, x); + public readonly float4 brrg => new(z, x, x, y); + public readonly float4 brrb => new(z, x, x, z); + public readonly float4 brgr => new(z, x, y, x); + public readonly float4 brgg => new(z, x, y, y); + public readonly float4 brgb => new(z, x, y, z); + public readonly float4 brbr => new(z, x, z, x); + public readonly float4 brbg => new(z, x, z, y); + public readonly float4 brbb => new(z, x, z, z); + public readonly float4 bgrr => new(z, y, x, x); + public readonly float4 bgrg => new(z, y, x, y); + public readonly float4 bgrb => new(z, y, x, z); + public readonly float4 bggr => new(z, y, y, x); + public readonly float4 bggg => new(z, y, y, y); + public readonly float4 bggb => new(z, y, y, z); + public readonly float4 bgbr => new(z, y, z, x); + public readonly float4 bgbg => new(z, y, z, y); + public readonly float4 bgbb => new(z, y, z, z); + public readonly float4 bbrr => new(z, z, x, x); + public readonly float4 bbrg => new(z, z, x, y); + public readonly float4 bbrb => new(z, z, x, z); + public readonly float4 bbgr => new(z, z, y, x); + public readonly float4 bbgg => new(z, z, y, y); + public readonly float4 bbgb => new(z, z, y, z); + public readonly float4 bbbr => new(z, z, z, x); + public readonly float4 bbbg => new(z, z, z, y); + public readonly float4 bbbb => new(z, z, z, z); +} + +public partial struct float4 +{ + public float4(float x, float y, float2 zw) : this(x, y, zw.x, zw.y) { } + public float4(float x, float2 yz, float w) : this(x, yz.x, yz.y, w) { } + public float4(float x, float3 yzw) : this(x, yzw.x, yzw.y, yzw.z) { } + public float4(float2 xy, float z, float w) : this(xy.x, xy.y, z, w) { } + public float4(float2 xy, float2 zw) : this(xy.x, xy.y, zw.x, zw.y) { } + public float4(float3 xyz, float w) : this(xyz.x, xyz.y, xyz.z, w) { } + public float4(float4 xyzw) : this(xyzw.x, xyzw.y, xyzw.z, xyzw.w) { } + public float r { readonly get => x; set => x = value; } + public float g { readonly get => y; set => y = value; } + public float b { readonly get => z; set => z = value; } + public float a { readonly get => w; set => w = value; } + public readonly float2 xx => new(x, x); + public float2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public float2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public float2 xw { readonly get => new(x, w); set { x = value.x; w = value.y; } } + public float2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly float2 yy => new(y, y); + public float2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public float2 yw { readonly get => new(y, w); set { y = value.x; w = value.y; } } + public float2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public float2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly float2 zz => new(z, z); + public float2 zw { readonly get => new(z, w); set { z = value.x; w = value.y; } } + public float2 wx { readonly get => new(w, x); set { w = value.x; x = value.y; } } + public float2 wy { readonly get => new(w, y); set { w = value.x; y = value.y; } } + public float2 wz { readonly get => new(w, z); set { w = value.x; z = value.y; } } + public readonly float2 ww => new(w, w); + public readonly float3 xxx => new(x, x, x); + public readonly float3 xxy => new(x, x, y); + public readonly float3 xxz => new(x, x, z); + public readonly float3 xxw => new(x, x, w); + public readonly float3 xyx => new(x, y, x); + public readonly float3 xyy => new(x, y, y); + public float3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public float3 xyw { readonly get => new(x, y, w); set { x = value.x; y = value.y; w = value.z; } } + public readonly float3 xzx => new(x, z, x); + public float3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly float3 xzz => new(x, z, z); + public float3 xzw { readonly get => new(x, z, w); set { x = value.x; z = value.y; w = value.z; } } + public readonly float3 xwx => new(x, w, x); + public float3 xwy { readonly get => new(x, w, y); set { x = value.x; w = value.y; y = value.z; } } + public float3 xwz { readonly get => new(x, w, z); set { x = value.x; w = value.y; z = value.z; } } + public readonly float3 xww => new(x, w, w); + public readonly float3 yxx => new(y, x, x); + public readonly float3 yxy => new(y, x, y); + public float3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public float3 yxw { readonly get => new(y, x, w); set { y = value.x; x = value.y; w = value.z; } } + public readonly float3 yyx => new(y, y, x); + public readonly float3 yyy => new(y, y, y); + public readonly float3 yyz => new(y, y, z); + public readonly float3 yyw => new(y, y, w); + public float3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly float3 yzy => new(y, z, y); + public readonly float3 yzz => new(y, z, z); + public float3 yzw { readonly get => new(y, z, w); set { y = value.x; z = value.y; w = value.z; } } + public float3 ywx { readonly get => new(y, w, x); set { y = value.x; w = value.y; x = value.z; } } + public readonly float3 ywy => new(y, w, y); + public float3 ywz { readonly get => new(y, w, z); set { y = value.x; w = value.y; z = value.z; } } + public readonly float3 yww => new(y, w, w); + public readonly float3 zxx => new(z, x, x); + public float3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly float3 zxz => new(z, x, z); + public float3 zxw { readonly get => new(z, x, w); set { z = value.x; x = value.y; w = value.z; } } + public float3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly float3 zyy => new(z, y, y); + public readonly float3 zyz => new(z, y, z); + public float3 zyw { readonly get => new(z, y, w); set { z = value.x; y = value.y; w = value.z; } } + public readonly float3 zzx => new(z, z, x); + public readonly float3 zzy => new(z, z, y); + public readonly float3 zzz => new(z, z, z); + public readonly float3 zzw => new(z, z, w); + public float3 zwx { readonly get => new(z, w, x); set { z = value.x; w = value.y; x = value.z; } } + public float3 zwy { readonly get => new(z, w, y); set { z = value.x; w = value.y; y = value.z; } } + public readonly float3 zwz => new(z, w, z); + public readonly float3 zww => new(z, w, w); + public readonly float3 wxx => new(w, x, x); + public float3 wxy { readonly get => new(w, x, y); set { w = value.x; x = value.y; y = value.z; } } + public float3 wxz { readonly get => new(w, x, z); set { w = value.x; x = value.y; z = value.z; } } + public readonly float3 wxw => new(w, x, w); + public float3 wyx { readonly get => new(w, y, x); set { w = value.x; y = value.y; x = value.z; } } + public readonly float3 wyy => new(w, y, y); + public float3 wyz { readonly get => new(w, y, z); set { w = value.x; y = value.y; z = value.z; } } + public readonly float3 wyw => new(w, y, w); + public float3 wzx { readonly get => new(w, z, x); set { w = value.x; z = value.y; x = value.z; } } + public float3 wzy { readonly get => new(w, z, y); set { w = value.x; z = value.y; y = value.z; } } + public readonly float3 wzz => new(w, z, z); + public readonly float3 wzw => new(w, z, w); + public readonly float3 wwx => new(w, w, x); + public readonly float3 wwy => new(w, w, y); + public readonly float3 wwz => new(w, w, z); + public readonly float3 www => new(w, w, w); + public readonly float4 xxxx => new(x, x, x, x); + public readonly float4 xxxy => new(x, x, x, y); + public readonly float4 xxxz => new(x, x, x, z); + public readonly float4 xxxw => new(x, x, x, w); + public readonly float4 xxyx => new(x, x, y, x); + public readonly float4 xxyy => new(x, x, y, y); + public readonly float4 xxyz => new(x, x, y, z); + public readonly float4 xxyw => new(x, x, y, w); + public readonly float4 xxzx => new(x, x, z, x); + public readonly float4 xxzy => new(x, x, z, y); + public readonly float4 xxzz => new(x, x, z, z); + public readonly float4 xxzw => new(x, x, z, w); + public readonly float4 xxwx => new(x, x, w, x); + public readonly float4 xxwy => new(x, x, w, y); + public readonly float4 xxwz => new(x, x, w, z); + public readonly float4 xxww => new(x, x, w, w); + public readonly float4 xyxx => new(x, y, x, x); + public readonly float4 xyxy => new(x, y, x, y); + public readonly float4 xyxz => new(x, y, x, z); + public readonly float4 xyxw => new(x, y, x, w); + public readonly float4 xyyx => new(x, y, y, x); + public readonly float4 xyyy => new(x, y, y, y); + public readonly float4 xyyz => new(x, y, y, z); + public readonly float4 xyyw => new(x, y, y, w); + public readonly float4 xyzx => new(x, y, z, x); + public readonly float4 xyzy => new(x, y, z, y); + public readonly float4 xyzz => new(x, y, z, z); + public float4 xyzw { readonly get => new(x, y, z, w); set { x = value.x; y = value.y; z = value.z; w = value.w; } } + public readonly float4 xywx => new(x, y, w, x); + public readonly float4 xywy => new(x, y, w, y); + public float4 xywz { readonly get => new(x, y, w, z); set { x = value.x; y = value.y; w = value.z; z = value.w; } } + public readonly float4 xyww => new(x, y, w, w); + public readonly float4 xzxx => new(x, z, x, x); + public readonly float4 xzxy => new(x, z, x, y); + public readonly float4 xzxz => new(x, z, x, z); + public readonly float4 xzxw => new(x, z, x, w); + public readonly float4 xzyx => new(x, z, y, x); + public readonly float4 xzyy => new(x, z, y, y); + public readonly float4 xzyz => new(x, z, y, z); + public float4 xzyw { readonly get => new(x, z, y, w); set { x = value.x; z = value.y; y = value.z; w = value.w; } } + public readonly float4 xzzx => new(x, z, z, x); + public readonly float4 xzzy => new(x, z, z, y); + public readonly float4 xzzz => new(x, z, z, z); + public readonly float4 xzzw => new(x, z, z, w); + public readonly float4 xzwx => new(x, z, w, x); + public float4 xzwy { readonly get => new(x, z, w, y); set { x = value.x; z = value.y; w = value.z; y = value.w; } } + public readonly float4 xzwz => new(x, z, w, z); + public readonly float4 xzww => new(x, z, w, w); + public readonly float4 xwxx => new(x, w, x, x); + public readonly float4 xwxy => new(x, w, x, y); + public readonly float4 xwxz => new(x, w, x, z); + public readonly float4 xwxw => new(x, w, x, w); + public readonly float4 xwyx => new(x, w, y, x); + public readonly float4 xwyy => new(x, w, y, y); + public float4 xwyz { readonly get => new(x, w, y, z); set { x = value.x; w = value.y; y = value.z; z = value.w; } } + public readonly float4 xwyw => new(x, w, y, w); + public readonly float4 xwzx => new(x, w, z, x); + public float4 xwzy { readonly get => new(x, w, z, y); set { x = value.x; w = value.y; z = value.z; y = value.w; } } + public readonly float4 xwzz => new(x, w, z, z); + public readonly float4 xwzw => new(x, w, z, w); + public readonly float4 xwwx => new(x, w, w, x); + public readonly float4 xwwy => new(x, w, w, y); + public readonly float4 xwwz => new(x, w, w, z); + public readonly float4 xwww => new(x, w, w, w); + public readonly float4 yxxx => new(y, x, x, x); + public readonly float4 yxxy => new(y, x, x, y); + public readonly float4 yxxz => new(y, x, x, z); + public readonly float4 yxxw => new(y, x, x, w); + public readonly float4 yxyx => new(y, x, y, x); + public readonly float4 yxyy => new(y, x, y, y); + public readonly float4 yxyz => new(y, x, y, z); + public readonly float4 yxyw => new(y, x, y, w); + public readonly float4 yxzx => new(y, x, z, x); + public readonly float4 yxzy => new(y, x, z, y); + public readonly float4 yxzz => new(y, x, z, z); + public float4 yxzw { readonly get => new(y, x, z, w); set { y = value.x; x = value.y; z = value.z; w = value.w; } } + public readonly float4 yxwx => new(y, x, w, x); + public readonly float4 yxwy => new(y, x, w, y); + public float4 yxwz { readonly get => new(y, x, w, z); set { y = value.x; x = value.y; w = value.z; z = value.w; } } + public readonly float4 yxww => new(y, x, w, w); + public readonly float4 yyxx => new(y, y, x, x); + public readonly float4 yyxy => new(y, y, x, y); + public readonly float4 yyxz => new(y, y, x, z); + public readonly float4 yyxw => new(y, y, x, w); + public readonly float4 yyyx => new(y, y, y, x); + public readonly float4 yyyy => new(y, y, y, y); + public readonly float4 yyyz => new(y, y, y, z); + public readonly float4 yyyw => new(y, y, y, w); + public readonly float4 yyzx => new(y, y, z, x); + public readonly float4 yyzy => new(y, y, z, y); + public readonly float4 yyzz => new(y, y, z, z); + public readonly float4 yyzw => new(y, y, z, w); + public readonly float4 yywx => new(y, y, w, x); + public readonly float4 yywy => new(y, y, w, y); + public readonly float4 yywz => new(y, y, w, z); + public readonly float4 yyww => new(y, y, w, w); + public readonly float4 yzxx => new(y, z, x, x); + public readonly float4 yzxy => new(y, z, x, y); + public readonly float4 yzxz => new(y, z, x, z); + public float4 yzxw { readonly get => new(y, z, x, w); set { y = value.x; z = value.y; x = value.z; w = value.w; } } + public readonly float4 yzyx => new(y, z, y, x); + public readonly float4 yzyy => new(y, z, y, y); + public readonly float4 yzyz => new(y, z, y, z); + public readonly float4 yzyw => new(y, z, y, w); + public readonly float4 yzzx => new(y, z, z, x); + public readonly float4 yzzy => new(y, z, z, y); + public readonly float4 yzzz => new(y, z, z, z); + public readonly float4 yzzw => new(y, z, z, w); + public float4 yzwx { readonly get => new(y, z, w, x); set { y = value.x; z = value.y; w = value.z; x = value.w; } } + public readonly float4 yzwy => new(y, z, w, y); + public readonly float4 yzwz => new(y, z, w, z); + public readonly float4 yzww => new(y, z, w, w); + public readonly float4 ywxx => new(y, w, x, x); + public readonly float4 ywxy => new(y, w, x, y); + public float4 ywxz { readonly get => new(y, w, x, z); set { y = value.x; w = value.y; x = value.z; z = value.w; } } + public readonly float4 ywxw => new(y, w, x, w); + public readonly float4 ywyx => new(y, w, y, x); + public readonly float4 ywyy => new(y, w, y, y); + public readonly float4 ywyz => new(y, w, y, z); + public readonly float4 ywyw => new(y, w, y, w); + public float4 ywzx { readonly get => new(y, w, z, x); set { y = value.x; w = value.y; z = value.z; x = value.w; } } + public readonly float4 ywzy => new(y, w, z, y); + public readonly float4 ywzz => new(y, w, z, z); + public readonly float4 ywzw => new(y, w, z, w); + public readonly float4 ywwx => new(y, w, w, x); + public readonly float4 ywwy => new(y, w, w, y); + public readonly float4 ywwz => new(y, w, w, z); + public readonly float4 ywww => new(y, w, w, w); + public readonly float4 zxxx => new(z, x, x, x); + public readonly float4 zxxy => new(z, x, x, y); + public readonly float4 zxxz => new(z, x, x, z); + public readonly float4 zxxw => new(z, x, x, w); + public readonly float4 zxyx => new(z, x, y, x); + public readonly float4 zxyy => new(z, x, y, y); + public readonly float4 zxyz => new(z, x, y, z); + public float4 zxyw { readonly get => new(z, x, y, w); set { z = value.x; x = value.y; y = value.z; w = value.w; } } + public readonly float4 zxzx => new(z, x, z, x); + public readonly float4 zxzy => new(z, x, z, y); + public readonly float4 zxzz => new(z, x, z, z); + public readonly float4 zxzw => new(z, x, z, w); + public readonly float4 zxwx => new(z, x, w, x); + public float4 zxwy { readonly get => new(z, x, w, y); set { z = value.x; x = value.y; w = value.z; y = value.w; } } + public readonly float4 zxwz => new(z, x, w, z); + public readonly float4 zxww => new(z, x, w, w); + public readonly float4 zyxx => new(z, y, x, x); + public readonly float4 zyxy => new(z, y, x, y); + public readonly float4 zyxz => new(z, y, x, z); + public float4 zyxw { readonly get => new(z, y, x, w); set { z = value.x; y = value.y; x = value.z; w = value.w; } } + public readonly float4 zyyx => new(z, y, y, x); + public readonly float4 zyyy => new(z, y, y, y); + public readonly float4 zyyz => new(z, y, y, z); + public readonly float4 zyyw => new(z, y, y, w); + public readonly float4 zyzx => new(z, y, z, x); + public readonly float4 zyzy => new(z, y, z, y); + public readonly float4 zyzz => new(z, y, z, z); + public readonly float4 zyzw => new(z, y, z, w); + public float4 zywx { readonly get => new(z, y, w, x); set { z = value.x; y = value.y; w = value.z; x = value.w; } } + public readonly float4 zywy => new(z, y, w, y); + public readonly float4 zywz => new(z, y, w, z); + public readonly float4 zyww => new(z, y, w, w); + public readonly float4 zzxx => new(z, z, x, x); + public readonly float4 zzxy => new(z, z, x, y); + public readonly float4 zzxz => new(z, z, x, z); + public readonly float4 zzxw => new(z, z, x, w); + public readonly float4 zzyx => new(z, z, y, x); + public readonly float4 zzyy => new(z, z, y, y); + public readonly float4 zzyz => new(z, z, y, z); + public readonly float4 zzyw => new(z, z, y, w); + public readonly float4 zzzx => new(z, z, z, x); + public readonly float4 zzzy => new(z, z, z, y); + public readonly float4 zzzz => new(z, z, z, z); + public readonly float4 zzzw => new(z, z, z, w); + public readonly float4 zzwx => new(z, z, w, x); + public readonly float4 zzwy => new(z, z, w, y); + public readonly float4 zzwz => new(z, z, w, z); + public readonly float4 zzww => new(z, z, w, w); + public readonly float4 zwxx => new(z, w, x, x); + public float4 zwxy { readonly get => new(z, w, x, y); set { z = value.x; w = value.y; x = value.z; y = value.w; } } + public readonly float4 zwxz => new(z, w, x, z); + public readonly float4 zwxw => new(z, w, x, w); + public float4 zwyx { readonly get => new(z, w, y, x); set { z = value.x; w = value.y; y = value.z; x = value.w; } } + public readonly float4 zwyy => new(z, w, y, y); + public readonly float4 zwyz => new(z, w, y, z); + public readonly float4 zwyw => new(z, w, y, w); + public readonly float4 zwzx => new(z, w, z, x); + public readonly float4 zwzy => new(z, w, z, y); + public readonly float4 zwzz => new(z, w, z, z); + public readonly float4 zwzw => new(z, w, z, w); + public readonly float4 zwwx => new(z, w, w, x); + public readonly float4 zwwy => new(z, w, w, y); + public readonly float4 zwwz => new(z, w, w, z); + public readonly float4 zwww => new(z, w, w, w); + public readonly float4 wxxx => new(w, x, x, x); + public readonly float4 wxxy => new(w, x, x, y); + public readonly float4 wxxz => new(w, x, x, z); + public readonly float4 wxxw => new(w, x, x, w); + public readonly float4 wxyx => new(w, x, y, x); + public readonly float4 wxyy => new(w, x, y, y); + public float4 wxyz { readonly get => new(w, x, y, z); set { w = value.x; x = value.y; y = value.z; z = value.w; } } + public readonly float4 wxyw => new(w, x, y, w); + public readonly float4 wxzx => new(w, x, z, x); + public float4 wxzy { readonly get => new(w, x, z, y); set { w = value.x; x = value.y; z = value.z; y = value.w; } } + public readonly float4 wxzz => new(w, x, z, z); + public readonly float4 wxzw => new(w, x, z, w); + public readonly float4 wxwx => new(w, x, w, x); + public readonly float4 wxwy => new(w, x, w, y); + public readonly float4 wxwz => new(w, x, w, z); + public readonly float4 wxww => new(w, x, w, w); + public readonly float4 wyxx => new(w, y, x, x); + public readonly float4 wyxy => new(w, y, x, y); + public float4 wyxz { readonly get => new(w, y, x, z); set { w = value.x; y = value.y; x = value.z; z = value.w; } } + public readonly float4 wyxw => new(w, y, x, w); + public readonly float4 wyyx => new(w, y, y, x); + public readonly float4 wyyy => new(w, y, y, y); + public readonly float4 wyyz => new(w, y, y, z); + public readonly float4 wyyw => new(w, y, y, w); + public float4 wyzx { readonly get => new(w, y, z, x); set { w = value.x; y = value.y; z = value.z; x = value.w; } } + public readonly float4 wyzy => new(w, y, z, y); + public readonly float4 wyzz => new(w, y, z, z); + public readonly float4 wyzw => new(w, y, z, w); + public readonly float4 wywx => new(w, y, w, x); + public readonly float4 wywy => new(w, y, w, y); + public readonly float4 wywz => new(w, y, w, z); + public readonly float4 wyww => new(w, y, w, w); + public readonly float4 wzxx => new(w, z, x, x); + public float4 wzxy { readonly get => new(w, z, x, y); set { w = value.x; z = value.y; x = value.z; y = value.w; } } + public readonly float4 wzxz => new(w, z, x, z); + public readonly float4 wzxw => new(w, z, x, w); + public float4 wzyx { readonly get => new(w, z, y, x); set { w = value.x; z = value.y; y = value.z; x = value.w; } } + public readonly float4 wzyy => new(w, z, y, y); + public readonly float4 wzyz => new(w, z, y, z); + public readonly float4 wzyw => new(w, z, y, w); + public readonly float4 wzzx => new(w, z, z, x); + public readonly float4 wzzy => new(w, z, z, y); + public readonly float4 wzzz => new(w, z, z, z); + public readonly float4 wzzw => new(w, z, z, w); + public readonly float4 wzwx => new(w, z, w, x); + public readonly float4 wzwy => new(w, z, w, y); + public readonly float4 wzwz => new(w, z, w, z); + public readonly float4 wzww => new(w, z, w, w); + public readonly float4 wwxx => new(w, w, x, x); + public readonly float4 wwxy => new(w, w, x, y); + public readonly float4 wwxz => new(w, w, x, z); + public readonly float4 wwxw => new(w, w, x, w); + public readonly float4 wwyx => new(w, w, y, x); + public readonly float4 wwyy => new(w, w, y, y); + public readonly float4 wwyz => new(w, w, y, z); + public readonly float4 wwyw => new(w, w, y, w); + public readonly float4 wwzx => new(w, w, z, x); + public readonly float4 wwzy => new(w, w, z, y); + public readonly float4 wwzz => new(w, w, z, z); + public readonly float4 wwzw => new(w, w, z, w); + public readonly float4 wwwx => new(w, w, w, x); + public readonly float4 wwwy => new(w, w, w, y); + public readonly float4 wwwz => new(w, w, w, z); + public readonly float4 wwww => new(w, w, w, w); + public readonly float2 rr => new(x, x); + public float2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public float2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public float2 ra { readonly get => new(x, w); set { x = value.x; w = value.y; } } + public float2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly float2 gg => new(y, y); + public float2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public float2 ga { readonly get => new(y, w); set { y = value.x; w = value.y; } } + public float2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public float2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly float2 bb => new(z, z); + public float2 ba { readonly get => new(z, w); set { z = value.x; w = value.y; } } + public float2 ar { readonly get => new(w, x); set { w = value.x; x = value.y; } } + public float2 ag { readonly get => new(w, y); set { w = value.x; y = value.y; } } + public float2 ab { readonly get => new(w, z); set { w = value.x; z = value.y; } } + public readonly float2 aa => new(w, w); + public readonly float3 rrr => new(x, x, x); + public readonly float3 rrg => new(x, x, y); + public readonly float3 rrb => new(x, x, z); + public readonly float3 rra => new(x, x, w); + public readonly float3 rgr => new(x, y, x); + public readonly float3 rgg => new(x, y, y); + public float3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public float3 rga { readonly get => new(x, y, w); set { x = value.x; y = value.y; w = value.z; } } + public readonly float3 rbr => new(x, z, x); + public float3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly float3 rbb => new(x, z, z); + public float3 rba { readonly get => new(x, z, w); set { x = value.x; z = value.y; w = value.z; } } + public readonly float3 rar => new(x, w, x); + public float3 rag { readonly get => new(x, w, y); set { x = value.x; w = value.y; y = value.z; } } + public float3 rab { readonly get => new(x, w, z); set { x = value.x; w = value.y; z = value.z; } } + public readonly float3 raa => new(x, w, w); + public readonly float3 grr => new(y, x, x); + public readonly float3 grg => new(y, x, y); + public float3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public float3 gra { readonly get => new(y, x, w); set { y = value.x; x = value.y; w = value.z; } } + public readonly float3 ggr => new(y, y, x); + public readonly float3 ggg => new(y, y, y); + public readonly float3 ggb => new(y, y, z); + public readonly float3 gga => new(y, y, w); + public float3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly float3 gbg => new(y, z, y); + public readonly float3 gbb => new(y, z, z); + public float3 gba { readonly get => new(y, z, w); set { y = value.x; z = value.y; w = value.z; } } + public float3 gar { readonly get => new(y, w, x); set { y = value.x; w = value.y; x = value.z; } } + public readonly float3 gag => new(y, w, y); + public float3 gab { readonly get => new(y, w, z); set { y = value.x; w = value.y; z = value.z; } } + public readonly float3 gaa => new(y, w, w); + public readonly float3 brr => new(z, x, x); + public float3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly float3 brb => new(z, x, z); + public float3 bra { readonly get => new(z, x, w); set { z = value.x; x = value.y; w = value.z; } } + public float3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly float3 bgg => new(z, y, y); + public readonly float3 bgb => new(z, y, z); + public float3 bga { readonly get => new(z, y, w); set { z = value.x; y = value.y; w = value.z; } } + public readonly float3 bbr => new(z, z, x); + public readonly float3 bbg => new(z, z, y); + public readonly float3 bbb => new(z, z, z); + public readonly float3 bba => new(z, z, w); + public float3 bar { readonly get => new(z, w, x); set { z = value.x; w = value.y; x = value.z; } } + public float3 bag { readonly get => new(z, w, y); set { z = value.x; w = value.y; y = value.z; } } + public readonly float3 bab => new(z, w, z); + public readonly float3 baa => new(z, w, w); + public readonly float3 arr => new(w, x, x); + public float3 arg { readonly get => new(w, x, y); set { w = value.x; x = value.y; y = value.z; } } + public float3 arb { readonly get => new(w, x, z); set { w = value.x; x = value.y; z = value.z; } } + public readonly float3 ara => new(w, x, w); + public float3 agr { readonly get => new(w, y, x); set { w = value.x; y = value.y; x = value.z; } } + public readonly float3 agg => new(w, y, y); + public float3 agb { readonly get => new(w, y, z); set { w = value.x; y = value.y; z = value.z; } } + public readonly float3 aga => new(w, y, w); + public float3 abr { readonly get => new(w, z, x); set { w = value.x; z = value.y; x = value.z; } } + public float3 abg { readonly get => new(w, z, y); set { w = value.x; z = value.y; y = value.z; } } + public readonly float3 abb => new(w, z, z); + public readonly float3 aba => new(w, z, w); + public readonly float3 aar => new(w, w, x); + public readonly float3 aag => new(w, w, y); + public readonly float3 aab => new(w, w, z); + public readonly float3 aaa => new(w, w, w); + public readonly float4 rrrr => new(x, x, x, x); + public readonly float4 rrrg => new(x, x, x, y); + public readonly float4 rrrb => new(x, x, x, z); + public readonly float4 rrra => new(x, x, x, w); + public readonly float4 rrgr => new(x, x, y, x); + public readonly float4 rrgg => new(x, x, y, y); + public readonly float4 rrgb => new(x, x, y, z); + public readonly float4 rrga => new(x, x, y, w); + public readonly float4 rrbr => new(x, x, z, x); + public readonly float4 rrbg => new(x, x, z, y); + public readonly float4 rrbb => new(x, x, z, z); + public readonly float4 rrba => new(x, x, z, w); + public readonly float4 rrar => new(x, x, w, x); + public readonly float4 rrag => new(x, x, w, y); + public readonly float4 rrab => new(x, x, w, z); + public readonly float4 rraa => new(x, x, w, w); + public readonly float4 rgrr => new(x, y, x, x); + public readonly float4 rgrg => new(x, y, x, y); + public readonly float4 rgrb => new(x, y, x, z); + public readonly float4 rgra => new(x, y, x, w); + public readonly float4 rggr => new(x, y, y, x); + public readonly float4 rggg => new(x, y, y, y); + public readonly float4 rggb => new(x, y, y, z); + public readonly float4 rgga => new(x, y, y, w); + public readonly float4 rgbr => new(x, y, z, x); + public readonly float4 rgbg => new(x, y, z, y); + public readonly float4 rgbb => new(x, y, z, z); + public float4 rgba { readonly get => new(x, y, z, w); set { x = value.x; y = value.y; z = value.z; w = value.w; } } + public readonly float4 rgar => new(x, y, w, x); + public readonly float4 rgag => new(x, y, w, y); + public float4 rgab { readonly get => new(x, y, w, z); set { x = value.x; y = value.y; w = value.z; z = value.w; } } + public readonly float4 rgaa => new(x, y, w, w); + public readonly float4 rbrr => new(x, z, x, x); + public readonly float4 rbrg => new(x, z, x, y); + public readonly float4 rbrb => new(x, z, x, z); + public readonly float4 rbra => new(x, z, x, w); + public readonly float4 rbgr => new(x, z, y, x); + public readonly float4 rbgg => new(x, z, y, y); + public readonly float4 rbgb => new(x, z, y, z); + public float4 rbga { readonly get => new(x, z, y, w); set { x = value.x; z = value.y; y = value.z; w = value.w; } } + public readonly float4 rbbr => new(x, z, z, x); + public readonly float4 rbbg => new(x, z, z, y); + public readonly float4 rbbb => new(x, z, z, z); + public readonly float4 rbba => new(x, z, z, w); + public readonly float4 rbar => new(x, z, w, x); + public float4 rbag { readonly get => new(x, z, w, y); set { x = value.x; z = value.y; w = value.z; y = value.w; } } + public readonly float4 rbab => new(x, z, w, z); + public readonly float4 rbaa => new(x, z, w, w); + public readonly float4 rarr => new(x, w, x, x); + public readonly float4 rarg => new(x, w, x, y); + public readonly float4 rarb => new(x, w, x, z); + public readonly float4 rara => new(x, w, x, w); + public readonly float4 ragr => new(x, w, y, x); + public readonly float4 ragg => new(x, w, y, y); + public float4 ragb { readonly get => new(x, w, y, z); set { x = value.x; w = value.y; y = value.z; z = value.w; } } + public readonly float4 raga => new(x, w, y, w); + public readonly float4 rabr => new(x, w, z, x); + public float4 rabg { readonly get => new(x, w, z, y); set { x = value.x; w = value.y; z = value.z; y = value.w; } } + public readonly float4 rabb => new(x, w, z, z); + public readonly float4 raba => new(x, w, z, w); + public readonly float4 raar => new(x, w, w, x); + public readonly float4 raag => new(x, w, w, y); + public readonly float4 raab => new(x, w, w, z); + public readonly float4 raaa => new(x, w, w, w); + public readonly float4 grrr => new(y, x, x, x); + public readonly float4 grrg => new(y, x, x, y); + public readonly float4 grrb => new(y, x, x, z); + public readonly float4 grra => new(y, x, x, w); + public readonly float4 grgr => new(y, x, y, x); + public readonly float4 grgg => new(y, x, y, y); + public readonly float4 grgb => new(y, x, y, z); + public readonly float4 grga => new(y, x, y, w); + public readonly float4 grbr => new(y, x, z, x); + public readonly float4 grbg => new(y, x, z, y); + public readonly float4 grbb => new(y, x, z, z); + public float4 grba { readonly get => new(y, x, z, w); set { y = value.x; x = value.y; z = value.z; w = value.w; } } + public readonly float4 grar => new(y, x, w, x); + public readonly float4 grag => new(y, x, w, y); + public float4 grab { readonly get => new(y, x, w, z); set { y = value.x; x = value.y; w = value.z; z = value.w; } } + public readonly float4 graa => new(y, x, w, w); + public readonly float4 ggrr => new(y, y, x, x); + public readonly float4 ggrg => new(y, y, x, y); + public readonly float4 ggrb => new(y, y, x, z); + public readonly float4 ggra => new(y, y, x, w); + public readonly float4 gggr => new(y, y, y, x); + public readonly float4 gggg => new(y, y, y, y); + public readonly float4 gggb => new(y, y, y, z); + public readonly float4 ggga => new(y, y, y, w); + public readonly float4 ggbr => new(y, y, z, x); + public readonly float4 ggbg => new(y, y, z, y); + public readonly float4 ggbb => new(y, y, z, z); + public readonly float4 ggba => new(y, y, z, w); + public readonly float4 ggar => new(y, y, w, x); + public readonly float4 ggag => new(y, y, w, y); + public readonly float4 ggab => new(y, y, w, z); + public readonly float4 ggaa => new(y, y, w, w); + public readonly float4 gbrr => new(y, z, x, x); + public readonly float4 gbrg => new(y, z, x, y); + public readonly float4 gbrb => new(y, z, x, z); + public float4 gbra { readonly get => new(y, z, x, w); set { y = value.x; z = value.y; x = value.z; w = value.w; } } + public readonly float4 gbgr => new(y, z, y, x); + public readonly float4 gbgg => new(y, z, y, y); + public readonly float4 gbgb => new(y, z, y, z); + public readonly float4 gbga => new(y, z, y, w); + public readonly float4 gbbr => new(y, z, z, x); + public readonly float4 gbbg => new(y, z, z, y); + public readonly float4 gbbb => new(y, z, z, z); + public readonly float4 gbba => new(y, z, z, w); + public float4 gbar { readonly get => new(y, z, w, x); set { y = value.x; z = value.y; w = value.z; x = value.w; } } + public readonly float4 gbag => new(y, z, w, y); + public readonly float4 gbab => new(y, z, w, z); + public readonly float4 gbaa => new(y, z, w, w); + public readonly float4 garr => new(y, w, x, x); + public readonly float4 garg => new(y, w, x, y); + public float4 garb { readonly get => new(y, w, x, z); set { y = value.x; w = value.y; x = value.z; z = value.w; } } + public readonly float4 gara => new(y, w, x, w); + public readonly float4 gagr => new(y, w, y, x); + public readonly float4 gagg => new(y, w, y, y); + public readonly float4 gagb => new(y, w, y, z); + public readonly float4 gaga => new(y, w, y, w); + public float4 gabr { readonly get => new(y, w, z, x); set { y = value.x; w = value.y; z = value.z; x = value.w; } } + public readonly float4 gabg => new(y, w, z, y); + public readonly float4 gabb => new(y, w, z, z); + public readonly float4 gaba => new(y, w, z, w); + public readonly float4 gaar => new(y, w, w, x); + public readonly float4 gaag => new(y, w, w, y); + public readonly float4 gaab => new(y, w, w, z); + public readonly float4 gaaa => new(y, w, w, w); + public readonly float4 brrr => new(z, x, x, x); + public readonly float4 brrg => new(z, x, x, y); + public readonly float4 brrb => new(z, x, x, z); + public readonly float4 brra => new(z, x, x, w); + public readonly float4 brgr => new(z, x, y, x); + public readonly float4 brgg => new(z, x, y, y); + public readonly float4 brgb => new(z, x, y, z); + public float4 brga { readonly get => new(z, x, y, w); set { z = value.x; x = value.y; y = value.z; w = value.w; } } + public readonly float4 brbr => new(z, x, z, x); + public readonly float4 brbg => new(z, x, z, y); + public readonly float4 brbb => new(z, x, z, z); + public readonly float4 brba => new(z, x, z, w); + public readonly float4 brar => new(z, x, w, x); + public float4 brag { readonly get => new(z, x, w, y); set { z = value.x; x = value.y; w = value.z; y = value.w; } } + public readonly float4 brab => new(z, x, w, z); + public readonly float4 braa => new(z, x, w, w); + public readonly float4 bgrr => new(z, y, x, x); + public readonly float4 bgrg => new(z, y, x, y); + public readonly float4 bgrb => new(z, y, x, z); + public float4 bgra { readonly get => new(z, y, x, w); set { z = value.x; y = value.y; x = value.z; w = value.w; } } + public readonly float4 bggr => new(z, y, y, x); + public readonly float4 bggg => new(z, y, y, y); + public readonly float4 bggb => new(z, y, y, z); + public readonly float4 bgga => new(z, y, y, w); + public readonly float4 bgbr => new(z, y, z, x); + public readonly float4 bgbg => new(z, y, z, y); + public readonly float4 bgbb => new(z, y, z, z); + public readonly float4 bgba => new(z, y, z, w); + public float4 bgar { readonly get => new(z, y, w, x); set { z = value.x; y = value.y; w = value.z; x = value.w; } } + public readonly float4 bgag => new(z, y, w, y); + public readonly float4 bgab => new(z, y, w, z); + public readonly float4 bgaa => new(z, y, w, w); + public readonly float4 bbrr => new(z, z, x, x); + public readonly float4 bbrg => new(z, z, x, y); + public readonly float4 bbrb => new(z, z, x, z); + public readonly float4 bbra => new(z, z, x, w); + public readonly float4 bbgr => new(z, z, y, x); + public readonly float4 bbgg => new(z, z, y, y); + public readonly float4 bbgb => new(z, z, y, z); + public readonly float4 bbga => new(z, z, y, w); + public readonly float4 bbbr => new(z, z, z, x); + public readonly float4 bbbg => new(z, z, z, y); + public readonly float4 bbbb => new(z, z, z, z); + public readonly float4 bbba => new(z, z, z, w); + public readonly float4 bbar => new(z, z, w, x); + public readonly float4 bbag => new(z, z, w, y); + public readonly float4 bbab => new(z, z, w, z); + public readonly float4 bbaa => new(z, z, w, w); + public readonly float4 barr => new(z, w, x, x); + public float4 barg { readonly get => new(z, w, x, y); set { z = value.x; w = value.y; x = value.z; y = value.w; } } + public readonly float4 barb => new(z, w, x, z); + public readonly float4 bara => new(z, w, x, w); + public float4 bagr { readonly get => new(z, w, y, x); set { z = value.x; w = value.y; y = value.z; x = value.w; } } + public readonly float4 bagg => new(z, w, y, y); + public readonly float4 bagb => new(z, w, y, z); + public readonly float4 baga => new(z, w, y, w); + public readonly float4 babr => new(z, w, z, x); + public readonly float4 babg => new(z, w, z, y); + public readonly float4 babb => new(z, w, z, z); + public readonly float4 baba => new(z, w, z, w); + public readonly float4 baar => new(z, w, w, x); + public readonly float4 baag => new(z, w, w, y); + public readonly float4 baab => new(z, w, w, z); + public readonly float4 baaa => new(z, w, w, w); + public readonly float4 arrr => new(w, x, x, x); + public readonly float4 arrg => new(w, x, x, y); + public readonly float4 arrb => new(w, x, x, z); + public readonly float4 arra => new(w, x, x, w); + public readonly float4 argr => new(w, x, y, x); + public readonly float4 argg => new(w, x, y, y); + public float4 argb { readonly get => new(w, x, y, z); set { w = value.x; x = value.y; y = value.z; z = value.w; } } + public readonly float4 arga => new(w, x, y, w); + public readonly float4 arbr => new(w, x, z, x); + public float4 arbg { readonly get => new(w, x, z, y); set { w = value.x; x = value.y; z = value.z; y = value.w; } } + public readonly float4 arbb => new(w, x, z, z); + public readonly float4 arba => new(w, x, z, w); + public readonly float4 arar => new(w, x, w, x); + public readonly float4 arag => new(w, x, w, y); + public readonly float4 arab => new(w, x, w, z); + public readonly float4 araa => new(w, x, w, w); + public readonly float4 agrr => new(w, y, x, x); + public readonly float4 agrg => new(w, y, x, y); + public float4 agrb { readonly get => new(w, y, x, z); set { w = value.x; y = value.y; x = value.z; z = value.w; } } + public readonly float4 agra => new(w, y, x, w); + public readonly float4 aggr => new(w, y, y, x); + public readonly float4 aggg => new(w, y, y, y); + public readonly float4 aggb => new(w, y, y, z); + public readonly float4 agga => new(w, y, y, w); + public float4 agbr { readonly get => new(w, y, z, x); set { w = value.x; y = value.y; z = value.z; x = value.w; } } + public readonly float4 agbg => new(w, y, z, y); + public readonly float4 agbb => new(w, y, z, z); + public readonly float4 agba => new(w, y, z, w); + public readonly float4 agar => new(w, y, w, x); + public readonly float4 agag => new(w, y, w, y); + public readonly float4 agab => new(w, y, w, z); + public readonly float4 agaa => new(w, y, w, w); + public readonly float4 abrr => new(w, z, x, x); + public float4 abrg { readonly get => new(w, z, x, y); set { w = value.x; z = value.y; x = value.z; y = value.w; } } + public readonly float4 abrb => new(w, z, x, z); + public readonly float4 abra => new(w, z, x, w); + public float4 abgr { readonly get => new(w, z, y, x); set { w = value.x; z = value.y; y = value.z; x = value.w; } } + public readonly float4 abgg => new(w, z, y, y); + public readonly float4 abgb => new(w, z, y, z); + public readonly float4 abga => new(w, z, y, w); + public readonly float4 abbr => new(w, z, z, x); + public readonly float4 abbg => new(w, z, z, y); + public readonly float4 abbb => new(w, z, z, z); + public readonly float4 abba => new(w, z, z, w); + public readonly float4 abar => new(w, z, w, x); + public readonly float4 abag => new(w, z, w, y); + public readonly float4 abab => new(w, z, w, z); + public readonly float4 abaa => new(w, z, w, w); + public readonly float4 aarr => new(w, w, x, x); + public readonly float4 aarg => new(w, w, x, y); + public readonly float4 aarb => new(w, w, x, z); + public readonly float4 aara => new(w, w, x, w); + public readonly float4 aagr => new(w, w, y, x); + public readonly float4 aagg => new(w, w, y, y); + public readonly float4 aagb => new(w, w, y, z); + public readonly float4 aaga => new(w, w, y, w); + public readonly float4 aabr => new(w, w, z, x); + public readonly float4 aabg => new(w, w, z, y); + public readonly float4 aabb => new(w, w, z, z); + public readonly float4 aaba => new(w, w, z, w); + public readonly float4 aaar => new(w, w, w, x); + public readonly float4 aaag => new(w, w, w, y); + public readonly float4 aaab => new(w, w, w, z); + public readonly float4 aaaa => new(w, w, w, w); +} + +public partial struct int2 +{ + public int2(int2 xy) : this(xy.x, xy.y) { } + public int r { readonly get => x; set => x = value; } + public int g { readonly get => y; set => y = value; } + public readonly int2 xx => new(x, x); + public int2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public int2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly int2 yy => new(y, y); + public readonly int3 xxx => new(x, x, x); + public readonly int3 xxy => new(x, x, y); + public readonly int3 xyx => new(x, y, x); + public readonly int3 xyy => new(x, y, y); + public readonly int3 yxx => new(y, x, x); + public readonly int3 yxy => new(y, x, y); + public readonly int3 yyx => new(y, y, x); + public readonly int3 yyy => new(y, y, y); + public readonly int4 xxxx => new(x, x, x, x); + public readonly int4 xxxy => new(x, x, x, y); + public readonly int4 xxyx => new(x, x, y, x); + public readonly int4 xxyy => new(x, x, y, y); + public readonly int4 xyxx => new(x, y, x, x); + public readonly int4 xyxy => new(x, y, x, y); + public readonly int4 xyyx => new(x, y, y, x); + public readonly int4 xyyy => new(x, y, y, y); + public readonly int4 yxxx => new(y, x, x, x); + public readonly int4 yxxy => new(y, x, x, y); + public readonly int4 yxyx => new(y, x, y, x); + public readonly int4 yxyy => new(y, x, y, y); + public readonly int4 yyxx => new(y, y, x, x); + public readonly int4 yyxy => new(y, y, x, y); + public readonly int4 yyyx => new(y, y, y, x); + public readonly int4 yyyy => new(y, y, y, y); + public readonly int2 rr => new(x, x); + public int2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public int2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly int2 gg => new(y, y); + public readonly int3 rrr => new(x, x, x); + public readonly int3 rrg => new(x, x, y); + public readonly int3 rgr => new(x, y, x); + public readonly int3 rgg => new(x, y, y); + public readonly int3 grr => new(y, x, x); + public readonly int3 grg => new(y, x, y); + public readonly int3 ggr => new(y, y, x); + public readonly int3 ggg => new(y, y, y); + public readonly int4 rrrr => new(x, x, x, x); + public readonly int4 rrrg => new(x, x, x, y); + public readonly int4 rrgr => new(x, x, y, x); + public readonly int4 rrgg => new(x, x, y, y); + public readonly int4 rgrr => new(x, y, x, x); + public readonly int4 rgrg => new(x, y, x, y); + public readonly int4 rggr => new(x, y, y, x); + public readonly int4 rggg => new(x, y, y, y); + public readonly int4 grrr => new(y, x, x, x); + public readonly int4 grrg => new(y, x, x, y); + public readonly int4 grgr => new(y, x, y, x); + public readonly int4 grgg => new(y, x, y, y); + public readonly int4 ggrr => new(y, y, x, x); + public readonly int4 ggrg => new(y, y, x, y); + public readonly int4 gggr => new(y, y, y, x); + public readonly int4 gggg => new(y, y, y, y); +} + +public partial struct int3 +{ + public int3(int x, int2 yz) : this(x, yz.x, yz.y) { } + public int3(int2 xy, int z) : this(xy.x, xy.y, z) { } + public int3(int3 xyz) : this(xyz.x, xyz.y, xyz.z) { } + public int r { readonly get => x; set => x = value; } + public int g { readonly get => y; set => y = value; } + public int b { readonly get => z; set => z = value; } + public readonly int2 xx => new(x, x); + public int2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public int2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public int2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly int2 yy => new(y, y); + public int2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public int2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public int2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly int2 zz => new(z, z); + public readonly int3 xxx => new(x, x, x); + public readonly int3 xxy => new(x, x, y); + public readonly int3 xxz => new(x, x, z); + public readonly int3 xyx => new(x, y, x); + public readonly int3 xyy => new(x, y, y); + public int3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public readonly int3 xzx => new(x, z, x); + public int3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly int3 xzz => new(x, z, z); + public readonly int3 yxx => new(y, x, x); + public readonly int3 yxy => new(y, x, y); + public int3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public readonly int3 yyx => new(y, y, x); + public readonly int3 yyy => new(y, y, y); + public readonly int3 yyz => new(y, y, z); + public int3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly int3 yzy => new(y, z, y); + public readonly int3 yzz => new(y, z, z); + public readonly int3 zxx => new(z, x, x); + public int3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly int3 zxz => new(z, x, z); + public int3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly int3 zyy => new(z, y, y); + public readonly int3 zyz => new(z, y, z); + public readonly int3 zzx => new(z, z, x); + public readonly int3 zzy => new(z, z, y); + public readonly int3 zzz => new(z, z, z); + public readonly int4 xxxx => new(x, x, x, x); + public readonly int4 xxxy => new(x, x, x, y); + public readonly int4 xxxz => new(x, x, x, z); + public readonly int4 xxyx => new(x, x, y, x); + public readonly int4 xxyy => new(x, x, y, y); + public readonly int4 xxyz => new(x, x, y, z); + public readonly int4 xxzx => new(x, x, z, x); + public readonly int4 xxzy => new(x, x, z, y); + public readonly int4 xxzz => new(x, x, z, z); + public readonly int4 xyxx => new(x, y, x, x); + public readonly int4 xyxy => new(x, y, x, y); + public readonly int4 xyxz => new(x, y, x, z); + public readonly int4 xyyx => new(x, y, y, x); + public readonly int4 xyyy => new(x, y, y, y); + public readonly int4 xyyz => new(x, y, y, z); + public readonly int4 xyzx => new(x, y, z, x); + public readonly int4 xyzy => new(x, y, z, y); + public readonly int4 xyzz => new(x, y, z, z); + public readonly int4 xzxx => new(x, z, x, x); + public readonly int4 xzxy => new(x, z, x, y); + public readonly int4 xzxz => new(x, z, x, z); + public readonly int4 xzyx => new(x, z, y, x); + public readonly int4 xzyy => new(x, z, y, y); + public readonly int4 xzyz => new(x, z, y, z); + public readonly int4 xzzx => new(x, z, z, x); + public readonly int4 xzzy => new(x, z, z, y); + public readonly int4 xzzz => new(x, z, z, z); + public readonly int4 yxxx => new(y, x, x, x); + public readonly int4 yxxy => new(y, x, x, y); + public readonly int4 yxxz => new(y, x, x, z); + public readonly int4 yxyx => new(y, x, y, x); + public readonly int4 yxyy => new(y, x, y, y); + public readonly int4 yxyz => new(y, x, y, z); + public readonly int4 yxzx => new(y, x, z, x); + public readonly int4 yxzy => new(y, x, z, y); + public readonly int4 yxzz => new(y, x, z, z); + public readonly int4 yyxx => new(y, y, x, x); + public readonly int4 yyxy => new(y, y, x, y); + public readonly int4 yyxz => new(y, y, x, z); + public readonly int4 yyyx => new(y, y, y, x); + public readonly int4 yyyy => new(y, y, y, y); + public readonly int4 yyyz => new(y, y, y, z); + public readonly int4 yyzx => new(y, y, z, x); + public readonly int4 yyzy => new(y, y, z, y); + public readonly int4 yyzz => new(y, y, z, z); + public readonly int4 yzxx => new(y, z, x, x); + public readonly int4 yzxy => new(y, z, x, y); + public readonly int4 yzxz => new(y, z, x, z); + public readonly int4 yzyx => new(y, z, y, x); + public readonly int4 yzyy => new(y, z, y, y); + public readonly int4 yzyz => new(y, z, y, z); + public readonly int4 yzzx => new(y, z, z, x); + public readonly int4 yzzy => new(y, z, z, y); + public readonly int4 yzzz => new(y, z, z, z); + public readonly int4 zxxx => new(z, x, x, x); + public readonly int4 zxxy => new(z, x, x, y); + public readonly int4 zxxz => new(z, x, x, z); + public readonly int4 zxyx => new(z, x, y, x); + public readonly int4 zxyy => new(z, x, y, y); + public readonly int4 zxyz => new(z, x, y, z); + public readonly int4 zxzx => new(z, x, z, x); + public readonly int4 zxzy => new(z, x, z, y); + public readonly int4 zxzz => new(z, x, z, z); + public readonly int4 zyxx => new(z, y, x, x); + public readonly int4 zyxy => new(z, y, x, y); + public readonly int4 zyxz => new(z, y, x, z); + public readonly int4 zyyx => new(z, y, y, x); + public readonly int4 zyyy => new(z, y, y, y); + public readonly int4 zyyz => new(z, y, y, z); + public readonly int4 zyzx => new(z, y, z, x); + public readonly int4 zyzy => new(z, y, z, y); + public readonly int4 zyzz => new(z, y, z, z); + public readonly int4 zzxx => new(z, z, x, x); + public readonly int4 zzxy => new(z, z, x, y); + public readonly int4 zzxz => new(z, z, x, z); + public readonly int4 zzyx => new(z, z, y, x); + public readonly int4 zzyy => new(z, z, y, y); + public readonly int4 zzyz => new(z, z, y, z); + public readonly int4 zzzx => new(z, z, z, x); + public readonly int4 zzzy => new(z, z, z, y); + public readonly int4 zzzz => new(z, z, z, z); + public readonly int2 rr => new(x, x); + public int2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public int2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public int2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly int2 gg => new(y, y); + public int2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public int2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public int2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly int2 bb => new(z, z); + public readonly int3 rrr => new(x, x, x); + public readonly int3 rrg => new(x, x, y); + public readonly int3 rrb => new(x, x, z); + public readonly int3 rgr => new(x, y, x); + public readonly int3 rgg => new(x, y, y); + public int3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public readonly int3 rbr => new(x, z, x); + public int3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly int3 rbb => new(x, z, z); + public readonly int3 grr => new(y, x, x); + public readonly int3 grg => new(y, x, y); + public int3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public readonly int3 ggr => new(y, y, x); + public readonly int3 ggg => new(y, y, y); + public readonly int3 ggb => new(y, y, z); + public int3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly int3 gbg => new(y, z, y); + public readonly int3 gbb => new(y, z, z); + public readonly int3 brr => new(z, x, x); + public int3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly int3 brb => new(z, x, z); + public int3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly int3 bgg => new(z, y, y); + public readonly int3 bgb => new(z, y, z); + public readonly int3 bbr => new(z, z, x); + public readonly int3 bbg => new(z, z, y); + public readonly int3 bbb => new(z, z, z); + public readonly int4 rrrr => new(x, x, x, x); + public readonly int4 rrrg => new(x, x, x, y); + public readonly int4 rrrb => new(x, x, x, z); + public readonly int4 rrgr => new(x, x, y, x); + public readonly int4 rrgg => new(x, x, y, y); + public readonly int4 rrgb => new(x, x, y, z); + public readonly int4 rrbr => new(x, x, z, x); + public readonly int4 rrbg => new(x, x, z, y); + public readonly int4 rrbb => new(x, x, z, z); + public readonly int4 rgrr => new(x, y, x, x); + public readonly int4 rgrg => new(x, y, x, y); + public readonly int4 rgrb => new(x, y, x, z); + public readonly int4 rggr => new(x, y, y, x); + public readonly int4 rggg => new(x, y, y, y); + public readonly int4 rggb => new(x, y, y, z); + public readonly int4 rgbr => new(x, y, z, x); + public readonly int4 rgbg => new(x, y, z, y); + public readonly int4 rgbb => new(x, y, z, z); + public readonly int4 rbrr => new(x, z, x, x); + public readonly int4 rbrg => new(x, z, x, y); + public readonly int4 rbrb => new(x, z, x, z); + public readonly int4 rbgr => new(x, z, y, x); + public readonly int4 rbgg => new(x, z, y, y); + public readonly int4 rbgb => new(x, z, y, z); + public readonly int4 rbbr => new(x, z, z, x); + public readonly int4 rbbg => new(x, z, z, y); + public readonly int4 rbbb => new(x, z, z, z); + public readonly int4 grrr => new(y, x, x, x); + public readonly int4 grrg => new(y, x, x, y); + public readonly int4 grrb => new(y, x, x, z); + public readonly int4 grgr => new(y, x, y, x); + public readonly int4 grgg => new(y, x, y, y); + public readonly int4 grgb => new(y, x, y, z); + public readonly int4 grbr => new(y, x, z, x); + public readonly int4 grbg => new(y, x, z, y); + public readonly int4 grbb => new(y, x, z, z); + public readonly int4 ggrr => new(y, y, x, x); + public readonly int4 ggrg => new(y, y, x, y); + public readonly int4 ggrb => new(y, y, x, z); + public readonly int4 gggr => new(y, y, y, x); + public readonly int4 gggg => new(y, y, y, y); + public readonly int4 gggb => new(y, y, y, z); + public readonly int4 ggbr => new(y, y, z, x); + public readonly int4 ggbg => new(y, y, z, y); + public readonly int4 ggbb => new(y, y, z, z); + public readonly int4 gbrr => new(y, z, x, x); + public readonly int4 gbrg => new(y, z, x, y); + public readonly int4 gbrb => new(y, z, x, z); + public readonly int4 gbgr => new(y, z, y, x); + public readonly int4 gbgg => new(y, z, y, y); + public readonly int4 gbgb => new(y, z, y, z); + public readonly int4 gbbr => new(y, z, z, x); + public readonly int4 gbbg => new(y, z, z, y); + public readonly int4 gbbb => new(y, z, z, z); + public readonly int4 brrr => new(z, x, x, x); + public readonly int4 brrg => new(z, x, x, y); + public readonly int4 brrb => new(z, x, x, z); + public readonly int4 brgr => new(z, x, y, x); + public readonly int4 brgg => new(z, x, y, y); + public readonly int4 brgb => new(z, x, y, z); + public readonly int4 brbr => new(z, x, z, x); + public readonly int4 brbg => new(z, x, z, y); + public readonly int4 brbb => new(z, x, z, z); + public readonly int4 bgrr => new(z, y, x, x); + public readonly int4 bgrg => new(z, y, x, y); + public readonly int4 bgrb => new(z, y, x, z); + public readonly int4 bggr => new(z, y, y, x); + public readonly int4 bggg => new(z, y, y, y); + public readonly int4 bggb => new(z, y, y, z); + public readonly int4 bgbr => new(z, y, z, x); + public readonly int4 bgbg => new(z, y, z, y); + public readonly int4 bgbb => new(z, y, z, z); + public readonly int4 bbrr => new(z, z, x, x); + public readonly int4 bbrg => new(z, z, x, y); + public readonly int4 bbrb => new(z, z, x, z); + public readonly int4 bbgr => new(z, z, y, x); + public readonly int4 bbgg => new(z, z, y, y); + public readonly int4 bbgb => new(z, z, y, z); + public readonly int4 bbbr => new(z, z, z, x); + public readonly int4 bbbg => new(z, z, z, y); + public readonly int4 bbbb => new(z, z, z, z); +} + +public partial struct int4 +{ + public int4(int x, int y, int2 zw) : this(x, y, zw.x, zw.y) { } + public int4(int x, int2 yz, int w) : this(x, yz.x, yz.y, w) { } + public int4(int x, int3 yzw) : this(x, yzw.x, yzw.y, yzw.z) { } + public int4(int2 xy, int z, int w) : this(xy.x, xy.y, z, w) { } + public int4(int2 xy, int2 zw) : this(xy.x, xy.y, zw.x, zw.y) { } + public int4(int3 xyz, int w) : this(xyz.x, xyz.y, xyz.z, w) { } + public int4(int4 xyzw) : this(xyzw.x, xyzw.y, xyzw.z, xyzw.w) { } + public int r { readonly get => x; set => x = value; } + public int g { readonly get => y; set => y = value; } + public int b { readonly get => z; set => z = value; } + public int a { readonly get => w; set => w = value; } + public readonly int2 xx => new(x, x); + public int2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public int2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public int2 xw { readonly get => new(x, w); set { x = value.x; w = value.y; } } + public int2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly int2 yy => new(y, y); + public int2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public int2 yw { readonly get => new(y, w); set { y = value.x; w = value.y; } } + public int2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public int2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly int2 zz => new(z, z); + public int2 zw { readonly get => new(z, w); set { z = value.x; w = value.y; } } + public int2 wx { readonly get => new(w, x); set { w = value.x; x = value.y; } } + public int2 wy { readonly get => new(w, y); set { w = value.x; y = value.y; } } + public int2 wz { readonly get => new(w, z); set { w = value.x; z = value.y; } } + public readonly int2 ww => new(w, w); + public readonly int3 xxx => new(x, x, x); + public readonly int3 xxy => new(x, x, y); + public readonly int3 xxz => new(x, x, z); + public readonly int3 xxw => new(x, x, w); + public readonly int3 xyx => new(x, y, x); + public readonly int3 xyy => new(x, y, y); + public int3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public int3 xyw { readonly get => new(x, y, w); set { x = value.x; y = value.y; w = value.z; } } + public readonly int3 xzx => new(x, z, x); + public int3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly int3 xzz => new(x, z, z); + public int3 xzw { readonly get => new(x, z, w); set { x = value.x; z = value.y; w = value.z; } } + public readonly int3 xwx => new(x, w, x); + public int3 xwy { readonly get => new(x, w, y); set { x = value.x; w = value.y; y = value.z; } } + public int3 xwz { readonly get => new(x, w, z); set { x = value.x; w = value.y; z = value.z; } } + public readonly int3 xww => new(x, w, w); + public readonly int3 yxx => new(y, x, x); + public readonly int3 yxy => new(y, x, y); + public int3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public int3 yxw { readonly get => new(y, x, w); set { y = value.x; x = value.y; w = value.z; } } + public readonly int3 yyx => new(y, y, x); + public readonly int3 yyy => new(y, y, y); + public readonly int3 yyz => new(y, y, z); + public readonly int3 yyw => new(y, y, w); + public int3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly int3 yzy => new(y, z, y); + public readonly int3 yzz => new(y, z, z); + public int3 yzw { readonly get => new(y, z, w); set { y = value.x; z = value.y; w = value.z; } } + public int3 ywx { readonly get => new(y, w, x); set { y = value.x; w = value.y; x = value.z; } } + public readonly int3 ywy => new(y, w, y); + public int3 ywz { readonly get => new(y, w, z); set { y = value.x; w = value.y; z = value.z; } } + public readonly int3 yww => new(y, w, w); + public readonly int3 zxx => new(z, x, x); + public int3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly int3 zxz => new(z, x, z); + public int3 zxw { readonly get => new(z, x, w); set { z = value.x; x = value.y; w = value.z; } } + public int3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly int3 zyy => new(z, y, y); + public readonly int3 zyz => new(z, y, z); + public int3 zyw { readonly get => new(z, y, w); set { z = value.x; y = value.y; w = value.z; } } + public readonly int3 zzx => new(z, z, x); + public readonly int3 zzy => new(z, z, y); + public readonly int3 zzz => new(z, z, z); + public readonly int3 zzw => new(z, z, w); + public int3 zwx { readonly get => new(z, w, x); set { z = value.x; w = value.y; x = value.z; } } + public int3 zwy { readonly get => new(z, w, y); set { z = value.x; w = value.y; y = value.z; } } + public readonly int3 zwz => new(z, w, z); + public readonly int3 zww => new(z, w, w); + public readonly int3 wxx => new(w, x, x); + public int3 wxy { readonly get => new(w, x, y); set { w = value.x; x = value.y; y = value.z; } } + public int3 wxz { readonly get => new(w, x, z); set { w = value.x; x = value.y; z = value.z; } } + public readonly int3 wxw => new(w, x, w); + public int3 wyx { readonly get => new(w, y, x); set { w = value.x; y = value.y; x = value.z; } } + public readonly int3 wyy => new(w, y, y); + public int3 wyz { readonly get => new(w, y, z); set { w = value.x; y = value.y; z = value.z; } } + public readonly int3 wyw => new(w, y, w); + public int3 wzx { readonly get => new(w, z, x); set { w = value.x; z = value.y; x = value.z; } } + public int3 wzy { readonly get => new(w, z, y); set { w = value.x; z = value.y; y = value.z; } } + public readonly int3 wzz => new(w, z, z); + public readonly int3 wzw => new(w, z, w); + public readonly int3 wwx => new(w, w, x); + public readonly int3 wwy => new(w, w, y); + public readonly int3 wwz => new(w, w, z); + public readonly int3 www => new(w, w, w); + public readonly int4 xxxx => new(x, x, x, x); + public readonly int4 xxxy => new(x, x, x, y); + public readonly int4 xxxz => new(x, x, x, z); + public readonly int4 xxxw => new(x, x, x, w); + public readonly int4 xxyx => new(x, x, y, x); + public readonly int4 xxyy => new(x, x, y, y); + public readonly int4 xxyz => new(x, x, y, z); + public readonly int4 xxyw => new(x, x, y, w); + public readonly int4 xxzx => new(x, x, z, x); + public readonly int4 xxzy => new(x, x, z, y); + public readonly int4 xxzz => new(x, x, z, z); + public readonly int4 xxzw => new(x, x, z, w); + public readonly int4 xxwx => new(x, x, w, x); + public readonly int4 xxwy => new(x, x, w, y); + public readonly int4 xxwz => new(x, x, w, z); + public readonly int4 xxww => new(x, x, w, w); + public readonly int4 xyxx => new(x, y, x, x); + public readonly int4 xyxy => new(x, y, x, y); + public readonly int4 xyxz => new(x, y, x, z); + public readonly int4 xyxw => new(x, y, x, w); + public readonly int4 xyyx => new(x, y, y, x); + public readonly int4 xyyy => new(x, y, y, y); + public readonly int4 xyyz => new(x, y, y, z); + public readonly int4 xyyw => new(x, y, y, w); + public readonly int4 xyzx => new(x, y, z, x); + public readonly int4 xyzy => new(x, y, z, y); + public readonly int4 xyzz => new(x, y, z, z); + public int4 xyzw { readonly get => new(x, y, z, w); set { x = value.x; y = value.y; z = value.z; w = value.w; } } + public readonly int4 xywx => new(x, y, w, x); + public readonly int4 xywy => new(x, y, w, y); + public int4 xywz { readonly get => new(x, y, w, z); set { x = value.x; y = value.y; w = value.z; z = value.w; } } + public readonly int4 xyww => new(x, y, w, w); + public readonly int4 xzxx => new(x, z, x, x); + public readonly int4 xzxy => new(x, z, x, y); + public readonly int4 xzxz => new(x, z, x, z); + public readonly int4 xzxw => new(x, z, x, w); + public readonly int4 xzyx => new(x, z, y, x); + public readonly int4 xzyy => new(x, z, y, y); + public readonly int4 xzyz => new(x, z, y, z); + public int4 xzyw { readonly get => new(x, z, y, w); set { x = value.x; z = value.y; y = value.z; w = value.w; } } + public readonly int4 xzzx => new(x, z, z, x); + public readonly int4 xzzy => new(x, z, z, y); + public readonly int4 xzzz => new(x, z, z, z); + public readonly int4 xzzw => new(x, z, z, w); + public readonly int4 xzwx => new(x, z, w, x); + public int4 xzwy { readonly get => new(x, z, w, y); set { x = value.x; z = value.y; w = value.z; y = value.w; } } + public readonly int4 xzwz => new(x, z, w, z); + public readonly int4 xzww => new(x, z, w, w); + public readonly int4 xwxx => new(x, w, x, x); + public readonly int4 xwxy => new(x, w, x, y); + public readonly int4 xwxz => new(x, w, x, z); + public readonly int4 xwxw => new(x, w, x, w); + public readonly int4 xwyx => new(x, w, y, x); + public readonly int4 xwyy => new(x, w, y, y); + public int4 xwyz { readonly get => new(x, w, y, z); set { x = value.x; w = value.y; y = value.z; z = value.w; } } + public readonly int4 xwyw => new(x, w, y, w); + public readonly int4 xwzx => new(x, w, z, x); + public int4 xwzy { readonly get => new(x, w, z, y); set { x = value.x; w = value.y; z = value.z; y = value.w; } } + public readonly int4 xwzz => new(x, w, z, z); + public readonly int4 xwzw => new(x, w, z, w); + public readonly int4 xwwx => new(x, w, w, x); + public readonly int4 xwwy => new(x, w, w, y); + public readonly int4 xwwz => new(x, w, w, z); + public readonly int4 xwww => new(x, w, w, w); + public readonly int4 yxxx => new(y, x, x, x); + public readonly int4 yxxy => new(y, x, x, y); + public readonly int4 yxxz => new(y, x, x, z); + public readonly int4 yxxw => new(y, x, x, w); + public readonly int4 yxyx => new(y, x, y, x); + public readonly int4 yxyy => new(y, x, y, y); + public readonly int4 yxyz => new(y, x, y, z); + public readonly int4 yxyw => new(y, x, y, w); + public readonly int4 yxzx => new(y, x, z, x); + public readonly int4 yxzy => new(y, x, z, y); + public readonly int4 yxzz => new(y, x, z, z); + public int4 yxzw { readonly get => new(y, x, z, w); set { y = value.x; x = value.y; z = value.z; w = value.w; } } + public readonly int4 yxwx => new(y, x, w, x); + public readonly int4 yxwy => new(y, x, w, y); + public int4 yxwz { readonly get => new(y, x, w, z); set { y = value.x; x = value.y; w = value.z; z = value.w; } } + public readonly int4 yxww => new(y, x, w, w); + public readonly int4 yyxx => new(y, y, x, x); + public readonly int4 yyxy => new(y, y, x, y); + public readonly int4 yyxz => new(y, y, x, z); + public readonly int4 yyxw => new(y, y, x, w); + public readonly int4 yyyx => new(y, y, y, x); + public readonly int4 yyyy => new(y, y, y, y); + public readonly int4 yyyz => new(y, y, y, z); + public readonly int4 yyyw => new(y, y, y, w); + public readonly int4 yyzx => new(y, y, z, x); + public readonly int4 yyzy => new(y, y, z, y); + public readonly int4 yyzz => new(y, y, z, z); + public readonly int4 yyzw => new(y, y, z, w); + public readonly int4 yywx => new(y, y, w, x); + public readonly int4 yywy => new(y, y, w, y); + public readonly int4 yywz => new(y, y, w, z); + public readonly int4 yyww => new(y, y, w, w); + public readonly int4 yzxx => new(y, z, x, x); + public readonly int4 yzxy => new(y, z, x, y); + public readonly int4 yzxz => new(y, z, x, z); + public int4 yzxw { readonly get => new(y, z, x, w); set { y = value.x; z = value.y; x = value.z; w = value.w; } } + public readonly int4 yzyx => new(y, z, y, x); + public readonly int4 yzyy => new(y, z, y, y); + public readonly int4 yzyz => new(y, z, y, z); + public readonly int4 yzyw => new(y, z, y, w); + public readonly int4 yzzx => new(y, z, z, x); + public readonly int4 yzzy => new(y, z, z, y); + public readonly int4 yzzz => new(y, z, z, z); + public readonly int4 yzzw => new(y, z, z, w); + public int4 yzwx { readonly get => new(y, z, w, x); set { y = value.x; z = value.y; w = value.z; x = value.w; } } + public readonly int4 yzwy => new(y, z, w, y); + public readonly int4 yzwz => new(y, z, w, z); + public readonly int4 yzww => new(y, z, w, w); + public readonly int4 ywxx => new(y, w, x, x); + public readonly int4 ywxy => new(y, w, x, y); + public int4 ywxz { readonly get => new(y, w, x, z); set { y = value.x; w = value.y; x = value.z; z = value.w; } } + public readonly int4 ywxw => new(y, w, x, w); + public readonly int4 ywyx => new(y, w, y, x); + public readonly int4 ywyy => new(y, w, y, y); + public readonly int4 ywyz => new(y, w, y, z); + public readonly int4 ywyw => new(y, w, y, w); + public int4 ywzx { readonly get => new(y, w, z, x); set { y = value.x; w = value.y; z = value.z; x = value.w; } } + public readonly int4 ywzy => new(y, w, z, y); + public readonly int4 ywzz => new(y, w, z, z); + public readonly int4 ywzw => new(y, w, z, w); + public readonly int4 ywwx => new(y, w, w, x); + public readonly int4 ywwy => new(y, w, w, y); + public readonly int4 ywwz => new(y, w, w, z); + public readonly int4 ywww => new(y, w, w, w); + public readonly int4 zxxx => new(z, x, x, x); + public readonly int4 zxxy => new(z, x, x, y); + public readonly int4 zxxz => new(z, x, x, z); + public readonly int4 zxxw => new(z, x, x, w); + public readonly int4 zxyx => new(z, x, y, x); + public readonly int4 zxyy => new(z, x, y, y); + public readonly int4 zxyz => new(z, x, y, z); + public int4 zxyw { readonly get => new(z, x, y, w); set { z = value.x; x = value.y; y = value.z; w = value.w; } } + public readonly int4 zxzx => new(z, x, z, x); + public readonly int4 zxzy => new(z, x, z, y); + public readonly int4 zxzz => new(z, x, z, z); + public readonly int4 zxzw => new(z, x, z, w); + public readonly int4 zxwx => new(z, x, w, x); + public int4 zxwy { readonly get => new(z, x, w, y); set { z = value.x; x = value.y; w = value.z; y = value.w; } } + public readonly int4 zxwz => new(z, x, w, z); + public readonly int4 zxww => new(z, x, w, w); + public readonly int4 zyxx => new(z, y, x, x); + public readonly int4 zyxy => new(z, y, x, y); + public readonly int4 zyxz => new(z, y, x, z); + public int4 zyxw { readonly get => new(z, y, x, w); set { z = value.x; y = value.y; x = value.z; w = value.w; } } + public readonly int4 zyyx => new(z, y, y, x); + public readonly int4 zyyy => new(z, y, y, y); + public readonly int4 zyyz => new(z, y, y, z); + public readonly int4 zyyw => new(z, y, y, w); + public readonly int4 zyzx => new(z, y, z, x); + public readonly int4 zyzy => new(z, y, z, y); + public readonly int4 zyzz => new(z, y, z, z); + public readonly int4 zyzw => new(z, y, z, w); + public int4 zywx { readonly get => new(z, y, w, x); set { z = value.x; y = value.y; w = value.z; x = value.w; } } + public readonly int4 zywy => new(z, y, w, y); + public readonly int4 zywz => new(z, y, w, z); + public readonly int4 zyww => new(z, y, w, w); + public readonly int4 zzxx => new(z, z, x, x); + public readonly int4 zzxy => new(z, z, x, y); + public readonly int4 zzxz => new(z, z, x, z); + public readonly int4 zzxw => new(z, z, x, w); + public readonly int4 zzyx => new(z, z, y, x); + public readonly int4 zzyy => new(z, z, y, y); + public readonly int4 zzyz => new(z, z, y, z); + public readonly int4 zzyw => new(z, z, y, w); + public readonly int4 zzzx => new(z, z, z, x); + public readonly int4 zzzy => new(z, z, z, y); + public readonly int4 zzzz => new(z, z, z, z); + public readonly int4 zzzw => new(z, z, z, w); + public readonly int4 zzwx => new(z, z, w, x); + public readonly int4 zzwy => new(z, z, w, y); + public readonly int4 zzwz => new(z, z, w, z); + public readonly int4 zzww => new(z, z, w, w); + public readonly int4 zwxx => new(z, w, x, x); + public int4 zwxy { readonly get => new(z, w, x, y); set { z = value.x; w = value.y; x = value.z; y = value.w; } } + public readonly int4 zwxz => new(z, w, x, z); + public readonly int4 zwxw => new(z, w, x, w); + public int4 zwyx { readonly get => new(z, w, y, x); set { z = value.x; w = value.y; y = value.z; x = value.w; } } + public readonly int4 zwyy => new(z, w, y, y); + public readonly int4 zwyz => new(z, w, y, z); + public readonly int4 zwyw => new(z, w, y, w); + public readonly int4 zwzx => new(z, w, z, x); + public readonly int4 zwzy => new(z, w, z, y); + public readonly int4 zwzz => new(z, w, z, z); + public readonly int4 zwzw => new(z, w, z, w); + public readonly int4 zwwx => new(z, w, w, x); + public readonly int4 zwwy => new(z, w, w, y); + public readonly int4 zwwz => new(z, w, w, z); + public readonly int4 zwww => new(z, w, w, w); + public readonly int4 wxxx => new(w, x, x, x); + public readonly int4 wxxy => new(w, x, x, y); + public readonly int4 wxxz => new(w, x, x, z); + public readonly int4 wxxw => new(w, x, x, w); + public readonly int4 wxyx => new(w, x, y, x); + public readonly int4 wxyy => new(w, x, y, y); + public int4 wxyz { readonly get => new(w, x, y, z); set { w = value.x; x = value.y; y = value.z; z = value.w; } } + public readonly int4 wxyw => new(w, x, y, w); + public readonly int4 wxzx => new(w, x, z, x); + public int4 wxzy { readonly get => new(w, x, z, y); set { w = value.x; x = value.y; z = value.z; y = value.w; } } + public readonly int4 wxzz => new(w, x, z, z); + public readonly int4 wxzw => new(w, x, z, w); + public readonly int4 wxwx => new(w, x, w, x); + public readonly int4 wxwy => new(w, x, w, y); + public readonly int4 wxwz => new(w, x, w, z); + public readonly int4 wxww => new(w, x, w, w); + public readonly int4 wyxx => new(w, y, x, x); + public readonly int4 wyxy => new(w, y, x, y); + public int4 wyxz { readonly get => new(w, y, x, z); set { w = value.x; y = value.y; x = value.z; z = value.w; } } + public readonly int4 wyxw => new(w, y, x, w); + public readonly int4 wyyx => new(w, y, y, x); + public readonly int4 wyyy => new(w, y, y, y); + public readonly int4 wyyz => new(w, y, y, z); + public readonly int4 wyyw => new(w, y, y, w); + public int4 wyzx { readonly get => new(w, y, z, x); set { w = value.x; y = value.y; z = value.z; x = value.w; } } + public readonly int4 wyzy => new(w, y, z, y); + public readonly int4 wyzz => new(w, y, z, z); + public readonly int4 wyzw => new(w, y, z, w); + public readonly int4 wywx => new(w, y, w, x); + public readonly int4 wywy => new(w, y, w, y); + public readonly int4 wywz => new(w, y, w, z); + public readonly int4 wyww => new(w, y, w, w); + public readonly int4 wzxx => new(w, z, x, x); + public int4 wzxy { readonly get => new(w, z, x, y); set { w = value.x; z = value.y; x = value.z; y = value.w; } } + public readonly int4 wzxz => new(w, z, x, z); + public readonly int4 wzxw => new(w, z, x, w); + public int4 wzyx { readonly get => new(w, z, y, x); set { w = value.x; z = value.y; y = value.z; x = value.w; } } + public readonly int4 wzyy => new(w, z, y, y); + public readonly int4 wzyz => new(w, z, y, z); + public readonly int4 wzyw => new(w, z, y, w); + public readonly int4 wzzx => new(w, z, z, x); + public readonly int4 wzzy => new(w, z, z, y); + public readonly int4 wzzz => new(w, z, z, z); + public readonly int4 wzzw => new(w, z, z, w); + public readonly int4 wzwx => new(w, z, w, x); + public readonly int4 wzwy => new(w, z, w, y); + public readonly int4 wzwz => new(w, z, w, z); + public readonly int4 wzww => new(w, z, w, w); + public readonly int4 wwxx => new(w, w, x, x); + public readonly int4 wwxy => new(w, w, x, y); + public readonly int4 wwxz => new(w, w, x, z); + public readonly int4 wwxw => new(w, w, x, w); + public readonly int4 wwyx => new(w, w, y, x); + public readonly int4 wwyy => new(w, w, y, y); + public readonly int4 wwyz => new(w, w, y, z); + public readonly int4 wwyw => new(w, w, y, w); + public readonly int4 wwzx => new(w, w, z, x); + public readonly int4 wwzy => new(w, w, z, y); + public readonly int4 wwzz => new(w, w, z, z); + public readonly int4 wwzw => new(w, w, z, w); + public readonly int4 wwwx => new(w, w, w, x); + public readonly int4 wwwy => new(w, w, w, y); + public readonly int4 wwwz => new(w, w, w, z); + public readonly int4 wwww => new(w, w, w, w); + public readonly int2 rr => new(x, x); + public int2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public int2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public int2 ra { readonly get => new(x, w); set { x = value.x; w = value.y; } } + public int2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly int2 gg => new(y, y); + public int2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public int2 ga { readonly get => new(y, w); set { y = value.x; w = value.y; } } + public int2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public int2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly int2 bb => new(z, z); + public int2 ba { readonly get => new(z, w); set { z = value.x; w = value.y; } } + public int2 ar { readonly get => new(w, x); set { w = value.x; x = value.y; } } + public int2 ag { readonly get => new(w, y); set { w = value.x; y = value.y; } } + public int2 ab { readonly get => new(w, z); set { w = value.x; z = value.y; } } + public readonly int2 aa => new(w, w); + public readonly int3 rrr => new(x, x, x); + public readonly int3 rrg => new(x, x, y); + public readonly int3 rrb => new(x, x, z); + public readonly int3 rra => new(x, x, w); + public readonly int3 rgr => new(x, y, x); + public readonly int3 rgg => new(x, y, y); + public int3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public int3 rga { readonly get => new(x, y, w); set { x = value.x; y = value.y; w = value.z; } } + public readonly int3 rbr => new(x, z, x); + public int3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly int3 rbb => new(x, z, z); + public int3 rba { readonly get => new(x, z, w); set { x = value.x; z = value.y; w = value.z; } } + public readonly int3 rar => new(x, w, x); + public int3 rag { readonly get => new(x, w, y); set { x = value.x; w = value.y; y = value.z; } } + public int3 rab { readonly get => new(x, w, z); set { x = value.x; w = value.y; z = value.z; } } + public readonly int3 raa => new(x, w, w); + public readonly int3 grr => new(y, x, x); + public readonly int3 grg => new(y, x, y); + public int3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public int3 gra { readonly get => new(y, x, w); set { y = value.x; x = value.y; w = value.z; } } + public readonly int3 ggr => new(y, y, x); + public readonly int3 ggg => new(y, y, y); + public readonly int3 ggb => new(y, y, z); + public readonly int3 gga => new(y, y, w); + public int3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly int3 gbg => new(y, z, y); + public readonly int3 gbb => new(y, z, z); + public int3 gba { readonly get => new(y, z, w); set { y = value.x; z = value.y; w = value.z; } } + public int3 gar { readonly get => new(y, w, x); set { y = value.x; w = value.y; x = value.z; } } + public readonly int3 gag => new(y, w, y); + public int3 gab { readonly get => new(y, w, z); set { y = value.x; w = value.y; z = value.z; } } + public readonly int3 gaa => new(y, w, w); + public readonly int3 brr => new(z, x, x); + public int3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly int3 brb => new(z, x, z); + public int3 bra { readonly get => new(z, x, w); set { z = value.x; x = value.y; w = value.z; } } + public int3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly int3 bgg => new(z, y, y); + public readonly int3 bgb => new(z, y, z); + public int3 bga { readonly get => new(z, y, w); set { z = value.x; y = value.y; w = value.z; } } + public readonly int3 bbr => new(z, z, x); + public readonly int3 bbg => new(z, z, y); + public readonly int3 bbb => new(z, z, z); + public readonly int3 bba => new(z, z, w); + public int3 bar { readonly get => new(z, w, x); set { z = value.x; w = value.y; x = value.z; } } + public int3 bag { readonly get => new(z, w, y); set { z = value.x; w = value.y; y = value.z; } } + public readonly int3 bab => new(z, w, z); + public readonly int3 baa => new(z, w, w); + public readonly int3 arr => new(w, x, x); + public int3 arg { readonly get => new(w, x, y); set { w = value.x; x = value.y; y = value.z; } } + public int3 arb { readonly get => new(w, x, z); set { w = value.x; x = value.y; z = value.z; } } + public readonly int3 ara => new(w, x, w); + public int3 agr { readonly get => new(w, y, x); set { w = value.x; y = value.y; x = value.z; } } + public readonly int3 agg => new(w, y, y); + public int3 agb { readonly get => new(w, y, z); set { w = value.x; y = value.y; z = value.z; } } + public readonly int3 aga => new(w, y, w); + public int3 abr { readonly get => new(w, z, x); set { w = value.x; z = value.y; x = value.z; } } + public int3 abg { readonly get => new(w, z, y); set { w = value.x; z = value.y; y = value.z; } } + public readonly int3 abb => new(w, z, z); + public readonly int3 aba => new(w, z, w); + public readonly int3 aar => new(w, w, x); + public readonly int3 aag => new(w, w, y); + public readonly int3 aab => new(w, w, z); + public readonly int3 aaa => new(w, w, w); + public readonly int4 rrrr => new(x, x, x, x); + public readonly int4 rrrg => new(x, x, x, y); + public readonly int4 rrrb => new(x, x, x, z); + public readonly int4 rrra => new(x, x, x, w); + public readonly int4 rrgr => new(x, x, y, x); + public readonly int4 rrgg => new(x, x, y, y); + public readonly int4 rrgb => new(x, x, y, z); + public readonly int4 rrga => new(x, x, y, w); + public readonly int4 rrbr => new(x, x, z, x); + public readonly int4 rrbg => new(x, x, z, y); + public readonly int4 rrbb => new(x, x, z, z); + public readonly int4 rrba => new(x, x, z, w); + public readonly int4 rrar => new(x, x, w, x); + public readonly int4 rrag => new(x, x, w, y); + public readonly int4 rrab => new(x, x, w, z); + public readonly int4 rraa => new(x, x, w, w); + public readonly int4 rgrr => new(x, y, x, x); + public readonly int4 rgrg => new(x, y, x, y); + public readonly int4 rgrb => new(x, y, x, z); + public readonly int4 rgra => new(x, y, x, w); + public readonly int4 rggr => new(x, y, y, x); + public readonly int4 rggg => new(x, y, y, y); + public readonly int4 rggb => new(x, y, y, z); + public readonly int4 rgga => new(x, y, y, w); + public readonly int4 rgbr => new(x, y, z, x); + public readonly int4 rgbg => new(x, y, z, y); + public readonly int4 rgbb => new(x, y, z, z); + public int4 rgba { readonly get => new(x, y, z, w); set { x = value.x; y = value.y; z = value.z; w = value.w; } } + public readonly int4 rgar => new(x, y, w, x); + public readonly int4 rgag => new(x, y, w, y); + public int4 rgab { readonly get => new(x, y, w, z); set { x = value.x; y = value.y; w = value.z; z = value.w; } } + public readonly int4 rgaa => new(x, y, w, w); + public readonly int4 rbrr => new(x, z, x, x); + public readonly int4 rbrg => new(x, z, x, y); + public readonly int4 rbrb => new(x, z, x, z); + public readonly int4 rbra => new(x, z, x, w); + public readonly int4 rbgr => new(x, z, y, x); + public readonly int4 rbgg => new(x, z, y, y); + public readonly int4 rbgb => new(x, z, y, z); + public int4 rbga { readonly get => new(x, z, y, w); set { x = value.x; z = value.y; y = value.z; w = value.w; } } + public readonly int4 rbbr => new(x, z, z, x); + public readonly int4 rbbg => new(x, z, z, y); + public readonly int4 rbbb => new(x, z, z, z); + public readonly int4 rbba => new(x, z, z, w); + public readonly int4 rbar => new(x, z, w, x); + public int4 rbag { readonly get => new(x, z, w, y); set { x = value.x; z = value.y; w = value.z; y = value.w; } } + public readonly int4 rbab => new(x, z, w, z); + public readonly int4 rbaa => new(x, z, w, w); + public readonly int4 rarr => new(x, w, x, x); + public readonly int4 rarg => new(x, w, x, y); + public readonly int4 rarb => new(x, w, x, z); + public readonly int4 rara => new(x, w, x, w); + public readonly int4 ragr => new(x, w, y, x); + public readonly int4 ragg => new(x, w, y, y); + public int4 ragb { readonly get => new(x, w, y, z); set { x = value.x; w = value.y; y = value.z; z = value.w; } } + public readonly int4 raga => new(x, w, y, w); + public readonly int4 rabr => new(x, w, z, x); + public int4 rabg { readonly get => new(x, w, z, y); set { x = value.x; w = value.y; z = value.z; y = value.w; } } + public readonly int4 rabb => new(x, w, z, z); + public readonly int4 raba => new(x, w, z, w); + public readonly int4 raar => new(x, w, w, x); + public readonly int4 raag => new(x, w, w, y); + public readonly int4 raab => new(x, w, w, z); + public readonly int4 raaa => new(x, w, w, w); + public readonly int4 grrr => new(y, x, x, x); + public readonly int4 grrg => new(y, x, x, y); + public readonly int4 grrb => new(y, x, x, z); + public readonly int4 grra => new(y, x, x, w); + public readonly int4 grgr => new(y, x, y, x); + public readonly int4 grgg => new(y, x, y, y); + public readonly int4 grgb => new(y, x, y, z); + public readonly int4 grga => new(y, x, y, w); + public readonly int4 grbr => new(y, x, z, x); + public readonly int4 grbg => new(y, x, z, y); + public readonly int4 grbb => new(y, x, z, z); + public int4 grba { readonly get => new(y, x, z, w); set { y = value.x; x = value.y; z = value.z; w = value.w; } } + public readonly int4 grar => new(y, x, w, x); + public readonly int4 grag => new(y, x, w, y); + public int4 grab { readonly get => new(y, x, w, z); set { y = value.x; x = value.y; w = value.z; z = value.w; } } + public readonly int4 graa => new(y, x, w, w); + public readonly int4 ggrr => new(y, y, x, x); + public readonly int4 ggrg => new(y, y, x, y); + public readonly int4 ggrb => new(y, y, x, z); + public readonly int4 ggra => new(y, y, x, w); + public readonly int4 gggr => new(y, y, y, x); + public readonly int4 gggg => new(y, y, y, y); + public readonly int4 gggb => new(y, y, y, z); + public readonly int4 ggga => new(y, y, y, w); + public readonly int4 ggbr => new(y, y, z, x); + public readonly int4 ggbg => new(y, y, z, y); + public readonly int4 ggbb => new(y, y, z, z); + public readonly int4 ggba => new(y, y, z, w); + public readonly int4 ggar => new(y, y, w, x); + public readonly int4 ggag => new(y, y, w, y); + public readonly int4 ggab => new(y, y, w, z); + public readonly int4 ggaa => new(y, y, w, w); + public readonly int4 gbrr => new(y, z, x, x); + public readonly int4 gbrg => new(y, z, x, y); + public readonly int4 gbrb => new(y, z, x, z); + public int4 gbra { readonly get => new(y, z, x, w); set { y = value.x; z = value.y; x = value.z; w = value.w; } } + public readonly int4 gbgr => new(y, z, y, x); + public readonly int4 gbgg => new(y, z, y, y); + public readonly int4 gbgb => new(y, z, y, z); + public readonly int4 gbga => new(y, z, y, w); + public readonly int4 gbbr => new(y, z, z, x); + public readonly int4 gbbg => new(y, z, z, y); + public readonly int4 gbbb => new(y, z, z, z); + public readonly int4 gbba => new(y, z, z, w); + public int4 gbar { readonly get => new(y, z, w, x); set { y = value.x; z = value.y; w = value.z; x = value.w; } } + public readonly int4 gbag => new(y, z, w, y); + public readonly int4 gbab => new(y, z, w, z); + public readonly int4 gbaa => new(y, z, w, w); + public readonly int4 garr => new(y, w, x, x); + public readonly int4 garg => new(y, w, x, y); + public int4 garb { readonly get => new(y, w, x, z); set { y = value.x; w = value.y; x = value.z; z = value.w; } } + public readonly int4 gara => new(y, w, x, w); + public readonly int4 gagr => new(y, w, y, x); + public readonly int4 gagg => new(y, w, y, y); + public readonly int4 gagb => new(y, w, y, z); + public readonly int4 gaga => new(y, w, y, w); + public int4 gabr { readonly get => new(y, w, z, x); set { y = value.x; w = value.y; z = value.z; x = value.w; } } + public readonly int4 gabg => new(y, w, z, y); + public readonly int4 gabb => new(y, w, z, z); + public readonly int4 gaba => new(y, w, z, w); + public readonly int4 gaar => new(y, w, w, x); + public readonly int4 gaag => new(y, w, w, y); + public readonly int4 gaab => new(y, w, w, z); + public readonly int4 gaaa => new(y, w, w, w); + public readonly int4 brrr => new(z, x, x, x); + public readonly int4 brrg => new(z, x, x, y); + public readonly int4 brrb => new(z, x, x, z); + public readonly int4 brra => new(z, x, x, w); + public readonly int4 brgr => new(z, x, y, x); + public readonly int4 brgg => new(z, x, y, y); + public readonly int4 brgb => new(z, x, y, z); + public int4 brga { readonly get => new(z, x, y, w); set { z = value.x; x = value.y; y = value.z; w = value.w; } } + public readonly int4 brbr => new(z, x, z, x); + public readonly int4 brbg => new(z, x, z, y); + public readonly int4 brbb => new(z, x, z, z); + public readonly int4 brba => new(z, x, z, w); + public readonly int4 brar => new(z, x, w, x); + public int4 brag { readonly get => new(z, x, w, y); set { z = value.x; x = value.y; w = value.z; y = value.w; } } + public readonly int4 brab => new(z, x, w, z); + public readonly int4 braa => new(z, x, w, w); + public readonly int4 bgrr => new(z, y, x, x); + public readonly int4 bgrg => new(z, y, x, y); + public readonly int4 bgrb => new(z, y, x, z); + public int4 bgra { readonly get => new(z, y, x, w); set { z = value.x; y = value.y; x = value.z; w = value.w; } } + public readonly int4 bggr => new(z, y, y, x); + public readonly int4 bggg => new(z, y, y, y); + public readonly int4 bggb => new(z, y, y, z); + public readonly int4 bgga => new(z, y, y, w); + public readonly int4 bgbr => new(z, y, z, x); + public readonly int4 bgbg => new(z, y, z, y); + public readonly int4 bgbb => new(z, y, z, z); + public readonly int4 bgba => new(z, y, z, w); + public int4 bgar { readonly get => new(z, y, w, x); set { z = value.x; y = value.y; w = value.z; x = value.w; } } + public readonly int4 bgag => new(z, y, w, y); + public readonly int4 bgab => new(z, y, w, z); + public readonly int4 bgaa => new(z, y, w, w); + public readonly int4 bbrr => new(z, z, x, x); + public readonly int4 bbrg => new(z, z, x, y); + public readonly int4 bbrb => new(z, z, x, z); + public readonly int4 bbra => new(z, z, x, w); + public readonly int4 bbgr => new(z, z, y, x); + public readonly int4 bbgg => new(z, z, y, y); + public readonly int4 bbgb => new(z, z, y, z); + public readonly int4 bbga => new(z, z, y, w); + public readonly int4 bbbr => new(z, z, z, x); + public readonly int4 bbbg => new(z, z, z, y); + public readonly int4 bbbb => new(z, z, z, z); + public readonly int4 bbba => new(z, z, z, w); + public readonly int4 bbar => new(z, z, w, x); + public readonly int4 bbag => new(z, z, w, y); + public readonly int4 bbab => new(z, z, w, z); + public readonly int4 bbaa => new(z, z, w, w); + public readonly int4 barr => new(z, w, x, x); + public int4 barg { readonly get => new(z, w, x, y); set { z = value.x; w = value.y; x = value.z; y = value.w; } } + public readonly int4 barb => new(z, w, x, z); + public readonly int4 bara => new(z, w, x, w); + public int4 bagr { readonly get => new(z, w, y, x); set { z = value.x; w = value.y; y = value.z; x = value.w; } } + public readonly int4 bagg => new(z, w, y, y); + public readonly int4 bagb => new(z, w, y, z); + public readonly int4 baga => new(z, w, y, w); + public readonly int4 babr => new(z, w, z, x); + public readonly int4 babg => new(z, w, z, y); + public readonly int4 babb => new(z, w, z, z); + public readonly int4 baba => new(z, w, z, w); + public readonly int4 baar => new(z, w, w, x); + public readonly int4 baag => new(z, w, w, y); + public readonly int4 baab => new(z, w, w, z); + public readonly int4 baaa => new(z, w, w, w); + public readonly int4 arrr => new(w, x, x, x); + public readonly int4 arrg => new(w, x, x, y); + public readonly int4 arrb => new(w, x, x, z); + public readonly int4 arra => new(w, x, x, w); + public readonly int4 argr => new(w, x, y, x); + public readonly int4 argg => new(w, x, y, y); + public int4 argb { readonly get => new(w, x, y, z); set { w = value.x; x = value.y; y = value.z; z = value.w; } } + public readonly int4 arga => new(w, x, y, w); + public readonly int4 arbr => new(w, x, z, x); + public int4 arbg { readonly get => new(w, x, z, y); set { w = value.x; x = value.y; z = value.z; y = value.w; } } + public readonly int4 arbb => new(w, x, z, z); + public readonly int4 arba => new(w, x, z, w); + public readonly int4 arar => new(w, x, w, x); + public readonly int4 arag => new(w, x, w, y); + public readonly int4 arab => new(w, x, w, z); + public readonly int4 araa => new(w, x, w, w); + public readonly int4 agrr => new(w, y, x, x); + public readonly int4 agrg => new(w, y, x, y); + public int4 agrb { readonly get => new(w, y, x, z); set { w = value.x; y = value.y; x = value.z; z = value.w; } } + public readonly int4 agra => new(w, y, x, w); + public readonly int4 aggr => new(w, y, y, x); + public readonly int4 aggg => new(w, y, y, y); + public readonly int4 aggb => new(w, y, y, z); + public readonly int4 agga => new(w, y, y, w); + public int4 agbr { readonly get => new(w, y, z, x); set { w = value.x; y = value.y; z = value.z; x = value.w; } } + public readonly int4 agbg => new(w, y, z, y); + public readonly int4 agbb => new(w, y, z, z); + public readonly int4 agba => new(w, y, z, w); + public readonly int4 agar => new(w, y, w, x); + public readonly int4 agag => new(w, y, w, y); + public readonly int4 agab => new(w, y, w, z); + public readonly int4 agaa => new(w, y, w, w); + public readonly int4 abrr => new(w, z, x, x); + public int4 abrg { readonly get => new(w, z, x, y); set { w = value.x; z = value.y; x = value.z; y = value.w; } } + public readonly int4 abrb => new(w, z, x, z); + public readonly int4 abra => new(w, z, x, w); + public int4 abgr { readonly get => new(w, z, y, x); set { w = value.x; z = value.y; y = value.z; x = value.w; } } + public readonly int4 abgg => new(w, z, y, y); + public readonly int4 abgb => new(w, z, y, z); + public readonly int4 abga => new(w, z, y, w); + public readonly int4 abbr => new(w, z, z, x); + public readonly int4 abbg => new(w, z, z, y); + public readonly int4 abbb => new(w, z, z, z); + public readonly int4 abba => new(w, z, z, w); + public readonly int4 abar => new(w, z, w, x); + public readonly int4 abag => new(w, z, w, y); + public readonly int4 abab => new(w, z, w, z); + public readonly int4 abaa => new(w, z, w, w); + public readonly int4 aarr => new(w, w, x, x); + public readonly int4 aarg => new(w, w, x, y); + public readonly int4 aarb => new(w, w, x, z); + public readonly int4 aara => new(w, w, x, w); + public readonly int4 aagr => new(w, w, y, x); + public readonly int4 aagg => new(w, w, y, y); + public readonly int4 aagb => new(w, w, y, z); + public readonly int4 aaga => new(w, w, y, w); + public readonly int4 aabr => new(w, w, z, x); + public readonly int4 aabg => new(w, w, z, y); + public readonly int4 aabb => new(w, w, z, z); + public readonly int4 aaba => new(w, w, z, w); + public readonly int4 aaar => new(w, w, w, x); + public readonly int4 aaag => new(w, w, w, y); + public readonly int4 aaab => new(w, w, w, z); + public readonly int4 aaaa => new(w, w, w, w); +} + +public partial struct bool2 +{ + public bool2(bool2 xy) : this(xy.x, xy.y) { } + public bool r { readonly get => x; set => x = value; } + public bool g { readonly get => y; set => y = value; } + public readonly bool2 xx => new(x, x); + public bool2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public bool2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly bool2 yy => new(y, y); + public readonly bool3 xxx => new(x, x, x); + public readonly bool3 xxy => new(x, x, y); + public readonly bool3 xyx => new(x, y, x); + public readonly bool3 xyy => new(x, y, y); + public readonly bool3 yxx => new(y, x, x); + public readonly bool3 yxy => new(y, x, y); + public readonly bool3 yyx => new(y, y, x); + public readonly bool3 yyy => new(y, y, y); + public readonly bool4 xxxx => new(x, x, x, x); + public readonly bool4 xxxy => new(x, x, x, y); + public readonly bool4 xxyx => new(x, x, y, x); + public readonly bool4 xxyy => new(x, x, y, y); + public readonly bool4 xyxx => new(x, y, x, x); + public readonly bool4 xyxy => new(x, y, x, y); + public readonly bool4 xyyx => new(x, y, y, x); + public readonly bool4 xyyy => new(x, y, y, y); + public readonly bool4 yxxx => new(y, x, x, x); + public readonly bool4 yxxy => new(y, x, x, y); + public readonly bool4 yxyx => new(y, x, y, x); + public readonly bool4 yxyy => new(y, x, y, y); + public readonly bool4 yyxx => new(y, y, x, x); + public readonly bool4 yyxy => new(y, y, x, y); + public readonly bool4 yyyx => new(y, y, y, x); + public readonly bool4 yyyy => new(y, y, y, y); + public readonly bool2 rr => new(x, x); + public bool2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public bool2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly bool2 gg => new(y, y); + public readonly bool3 rrr => new(x, x, x); + public readonly bool3 rrg => new(x, x, y); + public readonly bool3 rgr => new(x, y, x); + public readonly bool3 rgg => new(x, y, y); + public readonly bool3 grr => new(y, x, x); + public readonly bool3 grg => new(y, x, y); + public readonly bool3 ggr => new(y, y, x); + public readonly bool3 ggg => new(y, y, y); + public readonly bool4 rrrr => new(x, x, x, x); + public readonly bool4 rrrg => new(x, x, x, y); + public readonly bool4 rrgr => new(x, x, y, x); + public readonly bool4 rrgg => new(x, x, y, y); + public readonly bool4 rgrr => new(x, y, x, x); + public readonly bool4 rgrg => new(x, y, x, y); + public readonly bool4 rggr => new(x, y, y, x); + public readonly bool4 rggg => new(x, y, y, y); + public readonly bool4 grrr => new(y, x, x, x); + public readonly bool4 grrg => new(y, x, x, y); + public readonly bool4 grgr => new(y, x, y, x); + public readonly bool4 grgg => new(y, x, y, y); + public readonly bool4 ggrr => new(y, y, x, x); + public readonly bool4 ggrg => new(y, y, x, y); + public readonly bool4 gggr => new(y, y, y, x); + public readonly bool4 gggg => new(y, y, y, y); +} + +public partial struct bool3 +{ + public bool3(bool x, bool2 yz) : this(x, yz.x, yz.y) { } + public bool3(bool2 xy, bool z) : this(xy.x, xy.y, z) { } + public bool3(bool3 xyz) : this(xyz.x, xyz.y, xyz.z) { } + public bool r { readonly get => x; set => x = value; } + public bool g { readonly get => y; set => y = value; } + public bool b { readonly get => z; set => z = value; } + public readonly bool2 xx => new(x, x); + public bool2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public bool2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public bool2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly bool2 yy => new(y, y); + public bool2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public bool2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public bool2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly bool2 zz => new(z, z); + public readonly bool3 xxx => new(x, x, x); + public readonly bool3 xxy => new(x, x, y); + public readonly bool3 xxz => new(x, x, z); + public readonly bool3 xyx => new(x, y, x); + public readonly bool3 xyy => new(x, y, y); + public bool3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public readonly bool3 xzx => new(x, z, x); + public bool3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly bool3 xzz => new(x, z, z); + public readonly bool3 yxx => new(y, x, x); + public readonly bool3 yxy => new(y, x, y); + public bool3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public readonly bool3 yyx => new(y, y, x); + public readonly bool3 yyy => new(y, y, y); + public readonly bool3 yyz => new(y, y, z); + public bool3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly bool3 yzy => new(y, z, y); + public readonly bool3 yzz => new(y, z, z); + public readonly bool3 zxx => new(z, x, x); + public bool3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly bool3 zxz => new(z, x, z); + public bool3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly bool3 zyy => new(z, y, y); + public readonly bool3 zyz => new(z, y, z); + public readonly bool3 zzx => new(z, z, x); + public readonly bool3 zzy => new(z, z, y); + public readonly bool3 zzz => new(z, z, z); + public readonly bool4 xxxx => new(x, x, x, x); + public readonly bool4 xxxy => new(x, x, x, y); + public readonly bool4 xxxz => new(x, x, x, z); + public readonly bool4 xxyx => new(x, x, y, x); + public readonly bool4 xxyy => new(x, x, y, y); + public readonly bool4 xxyz => new(x, x, y, z); + public readonly bool4 xxzx => new(x, x, z, x); + public readonly bool4 xxzy => new(x, x, z, y); + public readonly bool4 xxzz => new(x, x, z, z); + public readonly bool4 xyxx => new(x, y, x, x); + public readonly bool4 xyxy => new(x, y, x, y); + public readonly bool4 xyxz => new(x, y, x, z); + public readonly bool4 xyyx => new(x, y, y, x); + public readonly bool4 xyyy => new(x, y, y, y); + public readonly bool4 xyyz => new(x, y, y, z); + public readonly bool4 xyzx => new(x, y, z, x); + public readonly bool4 xyzy => new(x, y, z, y); + public readonly bool4 xyzz => new(x, y, z, z); + public readonly bool4 xzxx => new(x, z, x, x); + public readonly bool4 xzxy => new(x, z, x, y); + public readonly bool4 xzxz => new(x, z, x, z); + public readonly bool4 xzyx => new(x, z, y, x); + public readonly bool4 xzyy => new(x, z, y, y); + public readonly bool4 xzyz => new(x, z, y, z); + public readonly bool4 xzzx => new(x, z, z, x); + public readonly bool4 xzzy => new(x, z, z, y); + public readonly bool4 xzzz => new(x, z, z, z); + public readonly bool4 yxxx => new(y, x, x, x); + public readonly bool4 yxxy => new(y, x, x, y); + public readonly bool4 yxxz => new(y, x, x, z); + public readonly bool4 yxyx => new(y, x, y, x); + public readonly bool4 yxyy => new(y, x, y, y); + public readonly bool4 yxyz => new(y, x, y, z); + public readonly bool4 yxzx => new(y, x, z, x); + public readonly bool4 yxzy => new(y, x, z, y); + public readonly bool4 yxzz => new(y, x, z, z); + public readonly bool4 yyxx => new(y, y, x, x); + public readonly bool4 yyxy => new(y, y, x, y); + public readonly bool4 yyxz => new(y, y, x, z); + public readonly bool4 yyyx => new(y, y, y, x); + public readonly bool4 yyyy => new(y, y, y, y); + public readonly bool4 yyyz => new(y, y, y, z); + public readonly bool4 yyzx => new(y, y, z, x); + public readonly bool4 yyzy => new(y, y, z, y); + public readonly bool4 yyzz => new(y, y, z, z); + public readonly bool4 yzxx => new(y, z, x, x); + public readonly bool4 yzxy => new(y, z, x, y); + public readonly bool4 yzxz => new(y, z, x, z); + public readonly bool4 yzyx => new(y, z, y, x); + public readonly bool4 yzyy => new(y, z, y, y); + public readonly bool4 yzyz => new(y, z, y, z); + public readonly bool4 yzzx => new(y, z, z, x); + public readonly bool4 yzzy => new(y, z, z, y); + public readonly bool4 yzzz => new(y, z, z, z); + public readonly bool4 zxxx => new(z, x, x, x); + public readonly bool4 zxxy => new(z, x, x, y); + public readonly bool4 zxxz => new(z, x, x, z); + public readonly bool4 zxyx => new(z, x, y, x); + public readonly bool4 zxyy => new(z, x, y, y); + public readonly bool4 zxyz => new(z, x, y, z); + public readonly bool4 zxzx => new(z, x, z, x); + public readonly bool4 zxzy => new(z, x, z, y); + public readonly bool4 zxzz => new(z, x, z, z); + public readonly bool4 zyxx => new(z, y, x, x); + public readonly bool4 zyxy => new(z, y, x, y); + public readonly bool4 zyxz => new(z, y, x, z); + public readonly bool4 zyyx => new(z, y, y, x); + public readonly bool4 zyyy => new(z, y, y, y); + public readonly bool4 zyyz => new(z, y, y, z); + public readonly bool4 zyzx => new(z, y, z, x); + public readonly bool4 zyzy => new(z, y, z, y); + public readonly bool4 zyzz => new(z, y, z, z); + public readonly bool4 zzxx => new(z, z, x, x); + public readonly bool4 zzxy => new(z, z, x, y); + public readonly bool4 zzxz => new(z, z, x, z); + public readonly bool4 zzyx => new(z, z, y, x); + public readonly bool4 zzyy => new(z, z, y, y); + public readonly bool4 zzyz => new(z, z, y, z); + public readonly bool4 zzzx => new(z, z, z, x); + public readonly bool4 zzzy => new(z, z, z, y); + public readonly bool4 zzzz => new(z, z, z, z); + public readonly bool2 rr => new(x, x); + public bool2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public bool2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public bool2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly bool2 gg => new(y, y); + public bool2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public bool2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public bool2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly bool2 bb => new(z, z); + public readonly bool3 rrr => new(x, x, x); + public readonly bool3 rrg => new(x, x, y); + public readonly bool3 rrb => new(x, x, z); + public readonly bool3 rgr => new(x, y, x); + public readonly bool3 rgg => new(x, y, y); + public bool3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public readonly bool3 rbr => new(x, z, x); + public bool3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly bool3 rbb => new(x, z, z); + public readonly bool3 grr => new(y, x, x); + public readonly bool3 grg => new(y, x, y); + public bool3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public readonly bool3 ggr => new(y, y, x); + public readonly bool3 ggg => new(y, y, y); + public readonly bool3 ggb => new(y, y, z); + public bool3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly bool3 gbg => new(y, z, y); + public readonly bool3 gbb => new(y, z, z); + public readonly bool3 brr => new(z, x, x); + public bool3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly bool3 brb => new(z, x, z); + public bool3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly bool3 bgg => new(z, y, y); + public readonly bool3 bgb => new(z, y, z); + public readonly bool3 bbr => new(z, z, x); + public readonly bool3 bbg => new(z, z, y); + public readonly bool3 bbb => new(z, z, z); + public readonly bool4 rrrr => new(x, x, x, x); + public readonly bool4 rrrg => new(x, x, x, y); + public readonly bool4 rrrb => new(x, x, x, z); + public readonly bool4 rrgr => new(x, x, y, x); + public readonly bool4 rrgg => new(x, x, y, y); + public readonly bool4 rrgb => new(x, x, y, z); + public readonly bool4 rrbr => new(x, x, z, x); + public readonly bool4 rrbg => new(x, x, z, y); + public readonly bool4 rrbb => new(x, x, z, z); + public readonly bool4 rgrr => new(x, y, x, x); + public readonly bool4 rgrg => new(x, y, x, y); + public readonly bool4 rgrb => new(x, y, x, z); + public readonly bool4 rggr => new(x, y, y, x); + public readonly bool4 rggg => new(x, y, y, y); + public readonly bool4 rggb => new(x, y, y, z); + public readonly bool4 rgbr => new(x, y, z, x); + public readonly bool4 rgbg => new(x, y, z, y); + public readonly bool4 rgbb => new(x, y, z, z); + public readonly bool4 rbrr => new(x, z, x, x); + public readonly bool4 rbrg => new(x, z, x, y); + public readonly bool4 rbrb => new(x, z, x, z); + public readonly bool4 rbgr => new(x, z, y, x); + public readonly bool4 rbgg => new(x, z, y, y); + public readonly bool4 rbgb => new(x, z, y, z); + public readonly bool4 rbbr => new(x, z, z, x); + public readonly bool4 rbbg => new(x, z, z, y); + public readonly bool4 rbbb => new(x, z, z, z); + public readonly bool4 grrr => new(y, x, x, x); + public readonly bool4 grrg => new(y, x, x, y); + public readonly bool4 grrb => new(y, x, x, z); + public readonly bool4 grgr => new(y, x, y, x); + public readonly bool4 grgg => new(y, x, y, y); + public readonly bool4 grgb => new(y, x, y, z); + public readonly bool4 grbr => new(y, x, z, x); + public readonly bool4 grbg => new(y, x, z, y); + public readonly bool4 grbb => new(y, x, z, z); + public readonly bool4 ggrr => new(y, y, x, x); + public readonly bool4 ggrg => new(y, y, x, y); + public readonly bool4 ggrb => new(y, y, x, z); + public readonly bool4 gggr => new(y, y, y, x); + public readonly bool4 gggg => new(y, y, y, y); + public readonly bool4 gggb => new(y, y, y, z); + public readonly bool4 ggbr => new(y, y, z, x); + public readonly bool4 ggbg => new(y, y, z, y); + public readonly bool4 ggbb => new(y, y, z, z); + public readonly bool4 gbrr => new(y, z, x, x); + public readonly bool4 gbrg => new(y, z, x, y); + public readonly bool4 gbrb => new(y, z, x, z); + public readonly bool4 gbgr => new(y, z, y, x); + public readonly bool4 gbgg => new(y, z, y, y); + public readonly bool4 gbgb => new(y, z, y, z); + public readonly bool4 gbbr => new(y, z, z, x); + public readonly bool4 gbbg => new(y, z, z, y); + public readonly bool4 gbbb => new(y, z, z, z); + public readonly bool4 brrr => new(z, x, x, x); + public readonly bool4 brrg => new(z, x, x, y); + public readonly bool4 brrb => new(z, x, x, z); + public readonly bool4 brgr => new(z, x, y, x); + public readonly bool4 brgg => new(z, x, y, y); + public readonly bool4 brgb => new(z, x, y, z); + public readonly bool4 brbr => new(z, x, z, x); + public readonly bool4 brbg => new(z, x, z, y); + public readonly bool4 brbb => new(z, x, z, z); + public readonly bool4 bgrr => new(z, y, x, x); + public readonly bool4 bgrg => new(z, y, x, y); + public readonly bool4 bgrb => new(z, y, x, z); + public readonly bool4 bggr => new(z, y, y, x); + public readonly bool4 bggg => new(z, y, y, y); + public readonly bool4 bggb => new(z, y, y, z); + public readonly bool4 bgbr => new(z, y, z, x); + public readonly bool4 bgbg => new(z, y, z, y); + public readonly bool4 bgbb => new(z, y, z, z); + public readonly bool4 bbrr => new(z, z, x, x); + public readonly bool4 bbrg => new(z, z, x, y); + public readonly bool4 bbrb => new(z, z, x, z); + public readonly bool4 bbgr => new(z, z, y, x); + public readonly bool4 bbgg => new(z, z, y, y); + public readonly bool4 bbgb => new(z, z, y, z); + public readonly bool4 bbbr => new(z, z, z, x); + public readonly bool4 bbbg => new(z, z, z, y); + public readonly bool4 bbbb => new(z, z, z, z); +} + +public partial struct bool4 +{ + public bool4(bool x, bool y, bool2 zw) : this(x, y, zw.x, zw.y) { } + public bool4(bool x, bool2 yz, bool w) : this(x, yz.x, yz.y, w) { } + public bool4(bool x, bool3 yzw) : this(x, yzw.x, yzw.y, yzw.z) { } + public bool4(bool2 xy, bool z, bool w) : this(xy.x, xy.y, z, w) { } + public bool4(bool2 xy, bool2 zw) : this(xy.x, xy.y, zw.x, zw.y) { } + public bool4(bool3 xyz, bool w) : this(xyz.x, xyz.y, xyz.z, w) { } + public bool4(bool4 xyzw) : this(xyzw.x, xyzw.y, xyzw.z, xyzw.w) { } + public bool r { readonly get => x; set => x = value; } + public bool g { readonly get => y; set => y = value; } + public bool b { readonly get => z; set => z = value; } + public bool a { readonly get => w; set => w = value; } + public readonly bool2 xx => new(x, x); + public bool2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public bool2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public bool2 xw { readonly get => new(x, w); set { x = value.x; w = value.y; } } + public bool2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly bool2 yy => new(y, y); + public bool2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public bool2 yw { readonly get => new(y, w); set { y = value.x; w = value.y; } } + public bool2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public bool2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly bool2 zz => new(z, z); + public bool2 zw { readonly get => new(z, w); set { z = value.x; w = value.y; } } + public bool2 wx { readonly get => new(w, x); set { w = value.x; x = value.y; } } + public bool2 wy { readonly get => new(w, y); set { w = value.x; y = value.y; } } + public bool2 wz { readonly get => new(w, z); set { w = value.x; z = value.y; } } + public readonly bool2 ww => new(w, w); + public readonly bool3 xxx => new(x, x, x); + public readonly bool3 xxy => new(x, x, y); + public readonly bool3 xxz => new(x, x, z); + public readonly bool3 xxw => new(x, x, w); + public readonly bool3 xyx => new(x, y, x); + public readonly bool3 xyy => new(x, y, y); + public bool3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public bool3 xyw { readonly get => new(x, y, w); set { x = value.x; y = value.y; w = value.z; } } + public readonly bool3 xzx => new(x, z, x); + public bool3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly bool3 xzz => new(x, z, z); + public bool3 xzw { readonly get => new(x, z, w); set { x = value.x; z = value.y; w = value.z; } } + public readonly bool3 xwx => new(x, w, x); + public bool3 xwy { readonly get => new(x, w, y); set { x = value.x; w = value.y; y = value.z; } } + public bool3 xwz { readonly get => new(x, w, z); set { x = value.x; w = value.y; z = value.z; } } + public readonly bool3 xww => new(x, w, w); + public readonly bool3 yxx => new(y, x, x); + public readonly bool3 yxy => new(y, x, y); + public bool3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public bool3 yxw { readonly get => new(y, x, w); set { y = value.x; x = value.y; w = value.z; } } + public readonly bool3 yyx => new(y, y, x); + public readonly bool3 yyy => new(y, y, y); + public readonly bool3 yyz => new(y, y, z); + public readonly bool3 yyw => new(y, y, w); + public bool3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly bool3 yzy => new(y, z, y); + public readonly bool3 yzz => new(y, z, z); + public bool3 yzw { readonly get => new(y, z, w); set { y = value.x; z = value.y; w = value.z; } } + public bool3 ywx { readonly get => new(y, w, x); set { y = value.x; w = value.y; x = value.z; } } + public readonly bool3 ywy => new(y, w, y); + public bool3 ywz { readonly get => new(y, w, z); set { y = value.x; w = value.y; z = value.z; } } + public readonly bool3 yww => new(y, w, w); + public readonly bool3 zxx => new(z, x, x); + public bool3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly bool3 zxz => new(z, x, z); + public bool3 zxw { readonly get => new(z, x, w); set { z = value.x; x = value.y; w = value.z; } } + public bool3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly bool3 zyy => new(z, y, y); + public readonly bool3 zyz => new(z, y, z); + public bool3 zyw { readonly get => new(z, y, w); set { z = value.x; y = value.y; w = value.z; } } + public readonly bool3 zzx => new(z, z, x); + public readonly bool3 zzy => new(z, z, y); + public readonly bool3 zzz => new(z, z, z); + public readonly bool3 zzw => new(z, z, w); + public bool3 zwx { readonly get => new(z, w, x); set { z = value.x; w = value.y; x = value.z; } } + public bool3 zwy { readonly get => new(z, w, y); set { z = value.x; w = value.y; y = value.z; } } + public readonly bool3 zwz => new(z, w, z); + public readonly bool3 zww => new(z, w, w); + public readonly bool3 wxx => new(w, x, x); + public bool3 wxy { readonly get => new(w, x, y); set { w = value.x; x = value.y; y = value.z; } } + public bool3 wxz { readonly get => new(w, x, z); set { w = value.x; x = value.y; z = value.z; } } + public readonly bool3 wxw => new(w, x, w); + public bool3 wyx { readonly get => new(w, y, x); set { w = value.x; y = value.y; x = value.z; } } + public readonly bool3 wyy => new(w, y, y); + public bool3 wyz { readonly get => new(w, y, z); set { w = value.x; y = value.y; z = value.z; } } + public readonly bool3 wyw => new(w, y, w); + public bool3 wzx { readonly get => new(w, z, x); set { w = value.x; z = value.y; x = value.z; } } + public bool3 wzy { readonly get => new(w, z, y); set { w = value.x; z = value.y; y = value.z; } } + public readonly bool3 wzz => new(w, z, z); + public readonly bool3 wzw => new(w, z, w); + public readonly bool3 wwx => new(w, w, x); + public readonly bool3 wwy => new(w, w, y); + public readonly bool3 wwz => new(w, w, z); + public readonly bool3 www => new(w, w, w); + public readonly bool4 xxxx => new(x, x, x, x); + public readonly bool4 xxxy => new(x, x, x, y); + public readonly bool4 xxxz => new(x, x, x, z); + public readonly bool4 xxxw => new(x, x, x, w); + public readonly bool4 xxyx => new(x, x, y, x); + public readonly bool4 xxyy => new(x, x, y, y); + public readonly bool4 xxyz => new(x, x, y, z); + public readonly bool4 xxyw => new(x, x, y, w); + public readonly bool4 xxzx => new(x, x, z, x); + public readonly bool4 xxzy => new(x, x, z, y); + public readonly bool4 xxzz => new(x, x, z, z); + public readonly bool4 xxzw => new(x, x, z, w); + public readonly bool4 xxwx => new(x, x, w, x); + public readonly bool4 xxwy => new(x, x, w, y); + public readonly bool4 xxwz => new(x, x, w, z); + public readonly bool4 xxww => new(x, x, w, w); + public readonly bool4 xyxx => new(x, y, x, x); + public readonly bool4 xyxy => new(x, y, x, y); + public readonly bool4 xyxz => new(x, y, x, z); + public readonly bool4 xyxw => new(x, y, x, w); + public readonly bool4 xyyx => new(x, y, y, x); + public readonly bool4 xyyy => new(x, y, y, y); + public readonly bool4 xyyz => new(x, y, y, z); + public readonly bool4 xyyw => new(x, y, y, w); + public readonly bool4 xyzx => new(x, y, z, x); + public readonly bool4 xyzy => new(x, y, z, y); + public readonly bool4 xyzz => new(x, y, z, z); + public bool4 xyzw { readonly get => new(x, y, z, w); set { x = value.x; y = value.y; z = value.z; w = value.w; } } + public readonly bool4 xywx => new(x, y, w, x); + public readonly bool4 xywy => new(x, y, w, y); + public bool4 xywz { readonly get => new(x, y, w, z); set { x = value.x; y = value.y; w = value.z; z = value.w; } } + public readonly bool4 xyww => new(x, y, w, w); + public readonly bool4 xzxx => new(x, z, x, x); + public readonly bool4 xzxy => new(x, z, x, y); + public readonly bool4 xzxz => new(x, z, x, z); + public readonly bool4 xzxw => new(x, z, x, w); + public readonly bool4 xzyx => new(x, z, y, x); + public readonly bool4 xzyy => new(x, z, y, y); + public readonly bool4 xzyz => new(x, z, y, z); + public bool4 xzyw { readonly get => new(x, z, y, w); set { x = value.x; z = value.y; y = value.z; w = value.w; } } + public readonly bool4 xzzx => new(x, z, z, x); + public readonly bool4 xzzy => new(x, z, z, y); + public readonly bool4 xzzz => new(x, z, z, z); + public readonly bool4 xzzw => new(x, z, z, w); + public readonly bool4 xzwx => new(x, z, w, x); + public bool4 xzwy { readonly get => new(x, z, w, y); set { x = value.x; z = value.y; w = value.z; y = value.w; } } + public readonly bool4 xzwz => new(x, z, w, z); + public readonly bool4 xzww => new(x, z, w, w); + public readonly bool4 xwxx => new(x, w, x, x); + public readonly bool4 xwxy => new(x, w, x, y); + public readonly bool4 xwxz => new(x, w, x, z); + public readonly bool4 xwxw => new(x, w, x, w); + public readonly bool4 xwyx => new(x, w, y, x); + public readonly bool4 xwyy => new(x, w, y, y); + public bool4 xwyz { readonly get => new(x, w, y, z); set { x = value.x; w = value.y; y = value.z; z = value.w; } } + public readonly bool4 xwyw => new(x, w, y, w); + public readonly bool4 xwzx => new(x, w, z, x); + public bool4 xwzy { readonly get => new(x, w, z, y); set { x = value.x; w = value.y; z = value.z; y = value.w; } } + public readonly bool4 xwzz => new(x, w, z, z); + public readonly bool4 xwzw => new(x, w, z, w); + public readonly bool4 xwwx => new(x, w, w, x); + public readonly bool4 xwwy => new(x, w, w, y); + public readonly bool4 xwwz => new(x, w, w, z); + public readonly bool4 xwww => new(x, w, w, w); + public readonly bool4 yxxx => new(y, x, x, x); + public readonly bool4 yxxy => new(y, x, x, y); + public readonly bool4 yxxz => new(y, x, x, z); + public readonly bool4 yxxw => new(y, x, x, w); + public readonly bool4 yxyx => new(y, x, y, x); + public readonly bool4 yxyy => new(y, x, y, y); + public readonly bool4 yxyz => new(y, x, y, z); + public readonly bool4 yxyw => new(y, x, y, w); + public readonly bool4 yxzx => new(y, x, z, x); + public readonly bool4 yxzy => new(y, x, z, y); + public readonly bool4 yxzz => new(y, x, z, z); + public bool4 yxzw { readonly get => new(y, x, z, w); set { y = value.x; x = value.y; z = value.z; w = value.w; } } + public readonly bool4 yxwx => new(y, x, w, x); + public readonly bool4 yxwy => new(y, x, w, y); + public bool4 yxwz { readonly get => new(y, x, w, z); set { y = value.x; x = value.y; w = value.z; z = value.w; } } + public readonly bool4 yxww => new(y, x, w, w); + public readonly bool4 yyxx => new(y, y, x, x); + public readonly bool4 yyxy => new(y, y, x, y); + public readonly bool4 yyxz => new(y, y, x, z); + public readonly bool4 yyxw => new(y, y, x, w); + public readonly bool4 yyyx => new(y, y, y, x); + public readonly bool4 yyyy => new(y, y, y, y); + public readonly bool4 yyyz => new(y, y, y, z); + public readonly bool4 yyyw => new(y, y, y, w); + public readonly bool4 yyzx => new(y, y, z, x); + public readonly bool4 yyzy => new(y, y, z, y); + public readonly bool4 yyzz => new(y, y, z, z); + public readonly bool4 yyzw => new(y, y, z, w); + public readonly bool4 yywx => new(y, y, w, x); + public readonly bool4 yywy => new(y, y, w, y); + public readonly bool4 yywz => new(y, y, w, z); + public readonly bool4 yyww => new(y, y, w, w); + public readonly bool4 yzxx => new(y, z, x, x); + public readonly bool4 yzxy => new(y, z, x, y); + public readonly bool4 yzxz => new(y, z, x, z); + public bool4 yzxw { readonly get => new(y, z, x, w); set { y = value.x; z = value.y; x = value.z; w = value.w; } } + public readonly bool4 yzyx => new(y, z, y, x); + public readonly bool4 yzyy => new(y, z, y, y); + public readonly bool4 yzyz => new(y, z, y, z); + public readonly bool4 yzyw => new(y, z, y, w); + public readonly bool4 yzzx => new(y, z, z, x); + public readonly bool4 yzzy => new(y, z, z, y); + public readonly bool4 yzzz => new(y, z, z, z); + public readonly bool4 yzzw => new(y, z, z, w); + public bool4 yzwx { readonly get => new(y, z, w, x); set { y = value.x; z = value.y; w = value.z; x = value.w; } } + public readonly bool4 yzwy => new(y, z, w, y); + public readonly bool4 yzwz => new(y, z, w, z); + public readonly bool4 yzww => new(y, z, w, w); + public readonly bool4 ywxx => new(y, w, x, x); + public readonly bool4 ywxy => new(y, w, x, y); + public bool4 ywxz { readonly get => new(y, w, x, z); set { y = value.x; w = value.y; x = value.z; z = value.w; } } + public readonly bool4 ywxw => new(y, w, x, w); + public readonly bool4 ywyx => new(y, w, y, x); + public readonly bool4 ywyy => new(y, w, y, y); + public readonly bool4 ywyz => new(y, w, y, z); + public readonly bool4 ywyw => new(y, w, y, w); + public bool4 ywzx { readonly get => new(y, w, z, x); set { y = value.x; w = value.y; z = value.z; x = value.w; } } + public readonly bool4 ywzy => new(y, w, z, y); + public readonly bool4 ywzz => new(y, w, z, z); + public readonly bool4 ywzw => new(y, w, z, w); + public readonly bool4 ywwx => new(y, w, w, x); + public readonly bool4 ywwy => new(y, w, w, y); + public readonly bool4 ywwz => new(y, w, w, z); + public readonly bool4 ywww => new(y, w, w, w); + public readonly bool4 zxxx => new(z, x, x, x); + public readonly bool4 zxxy => new(z, x, x, y); + public readonly bool4 zxxz => new(z, x, x, z); + public readonly bool4 zxxw => new(z, x, x, w); + public readonly bool4 zxyx => new(z, x, y, x); + public readonly bool4 zxyy => new(z, x, y, y); + public readonly bool4 zxyz => new(z, x, y, z); + public bool4 zxyw { readonly get => new(z, x, y, w); set { z = value.x; x = value.y; y = value.z; w = value.w; } } + public readonly bool4 zxzx => new(z, x, z, x); + public readonly bool4 zxzy => new(z, x, z, y); + public readonly bool4 zxzz => new(z, x, z, z); + public readonly bool4 zxzw => new(z, x, z, w); + public readonly bool4 zxwx => new(z, x, w, x); + public bool4 zxwy { readonly get => new(z, x, w, y); set { z = value.x; x = value.y; w = value.z; y = value.w; } } + public readonly bool4 zxwz => new(z, x, w, z); + public readonly bool4 zxww => new(z, x, w, w); + public readonly bool4 zyxx => new(z, y, x, x); + public readonly bool4 zyxy => new(z, y, x, y); + public readonly bool4 zyxz => new(z, y, x, z); + public bool4 zyxw { readonly get => new(z, y, x, w); set { z = value.x; y = value.y; x = value.z; w = value.w; } } + public readonly bool4 zyyx => new(z, y, y, x); + public readonly bool4 zyyy => new(z, y, y, y); + public readonly bool4 zyyz => new(z, y, y, z); + public readonly bool4 zyyw => new(z, y, y, w); + public readonly bool4 zyzx => new(z, y, z, x); + public readonly bool4 zyzy => new(z, y, z, y); + public readonly bool4 zyzz => new(z, y, z, z); + public readonly bool4 zyzw => new(z, y, z, w); + public bool4 zywx { readonly get => new(z, y, w, x); set { z = value.x; y = value.y; w = value.z; x = value.w; } } + public readonly bool4 zywy => new(z, y, w, y); + public readonly bool4 zywz => new(z, y, w, z); + public readonly bool4 zyww => new(z, y, w, w); + public readonly bool4 zzxx => new(z, z, x, x); + public readonly bool4 zzxy => new(z, z, x, y); + public readonly bool4 zzxz => new(z, z, x, z); + public readonly bool4 zzxw => new(z, z, x, w); + public readonly bool4 zzyx => new(z, z, y, x); + public readonly bool4 zzyy => new(z, z, y, y); + public readonly bool4 zzyz => new(z, z, y, z); + public readonly bool4 zzyw => new(z, z, y, w); + public readonly bool4 zzzx => new(z, z, z, x); + public readonly bool4 zzzy => new(z, z, z, y); + public readonly bool4 zzzz => new(z, z, z, z); + public readonly bool4 zzzw => new(z, z, z, w); + public readonly bool4 zzwx => new(z, z, w, x); + public readonly bool4 zzwy => new(z, z, w, y); + public readonly bool4 zzwz => new(z, z, w, z); + public readonly bool4 zzww => new(z, z, w, w); + public readonly bool4 zwxx => new(z, w, x, x); + public bool4 zwxy { readonly get => new(z, w, x, y); set { z = value.x; w = value.y; x = value.z; y = value.w; } } + public readonly bool4 zwxz => new(z, w, x, z); + public readonly bool4 zwxw => new(z, w, x, w); + public bool4 zwyx { readonly get => new(z, w, y, x); set { z = value.x; w = value.y; y = value.z; x = value.w; } } + public readonly bool4 zwyy => new(z, w, y, y); + public readonly bool4 zwyz => new(z, w, y, z); + public readonly bool4 zwyw => new(z, w, y, w); + public readonly bool4 zwzx => new(z, w, z, x); + public readonly bool4 zwzy => new(z, w, z, y); + public readonly bool4 zwzz => new(z, w, z, z); + public readonly bool4 zwzw => new(z, w, z, w); + public readonly bool4 zwwx => new(z, w, w, x); + public readonly bool4 zwwy => new(z, w, w, y); + public readonly bool4 zwwz => new(z, w, w, z); + public readonly bool4 zwww => new(z, w, w, w); + public readonly bool4 wxxx => new(w, x, x, x); + public readonly bool4 wxxy => new(w, x, x, y); + public readonly bool4 wxxz => new(w, x, x, z); + public readonly bool4 wxxw => new(w, x, x, w); + public readonly bool4 wxyx => new(w, x, y, x); + public readonly bool4 wxyy => new(w, x, y, y); + public bool4 wxyz { readonly get => new(w, x, y, z); set { w = value.x; x = value.y; y = value.z; z = value.w; } } + public readonly bool4 wxyw => new(w, x, y, w); + public readonly bool4 wxzx => new(w, x, z, x); + public bool4 wxzy { readonly get => new(w, x, z, y); set { w = value.x; x = value.y; z = value.z; y = value.w; } } + public readonly bool4 wxzz => new(w, x, z, z); + public readonly bool4 wxzw => new(w, x, z, w); + public readonly bool4 wxwx => new(w, x, w, x); + public readonly bool4 wxwy => new(w, x, w, y); + public readonly bool4 wxwz => new(w, x, w, z); + public readonly bool4 wxww => new(w, x, w, w); + public readonly bool4 wyxx => new(w, y, x, x); + public readonly bool4 wyxy => new(w, y, x, y); + public bool4 wyxz { readonly get => new(w, y, x, z); set { w = value.x; y = value.y; x = value.z; z = value.w; } } + public readonly bool4 wyxw => new(w, y, x, w); + public readonly bool4 wyyx => new(w, y, y, x); + public readonly bool4 wyyy => new(w, y, y, y); + public readonly bool4 wyyz => new(w, y, y, z); + public readonly bool4 wyyw => new(w, y, y, w); + public bool4 wyzx { readonly get => new(w, y, z, x); set { w = value.x; y = value.y; z = value.z; x = value.w; } } + public readonly bool4 wyzy => new(w, y, z, y); + public readonly bool4 wyzz => new(w, y, z, z); + public readonly bool4 wyzw => new(w, y, z, w); + public readonly bool4 wywx => new(w, y, w, x); + public readonly bool4 wywy => new(w, y, w, y); + public readonly bool4 wywz => new(w, y, w, z); + public readonly bool4 wyww => new(w, y, w, w); + public readonly bool4 wzxx => new(w, z, x, x); + public bool4 wzxy { readonly get => new(w, z, x, y); set { w = value.x; z = value.y; x = value.z; y = value.w; } } + public readonly bool4 wzxz => new(w, z, x, z); + public readonly bool4 wzxw => new(w, z, x, w); + public bool4 wzyx { readonly get => new(w, z, y, x); set { w = value.x; z = value.y; y = value.z; x = value.w; } } + public readonly bool4 wzyy => new(w, z, y, y); + public readonly bool4 wzyz => new(w, z, y, z); + public readonly bool4 wzyw => new(w, z, y, w); + public readonly bool4 wzzx => new(w, z, z, x); + public readonly bool4 wzzy => new(w, z, z, y); + public readonly bool4 wzzz => new(w, z, z, z); + public readonly bool4 wzzw => new(w, z, z, w); + public readonly bool4 wzwx => new(w, z, w, x); + public readonly bool4 wzwy => new(w, z, w, y); + public readonly bool4 wzwz => new(w, z, w, z); + public readonly bool4 wzww => new(w, z, w, w); + public readonly bool4 wwxx => new(w, w, x, x); + public readonly bool4 wwxy => new(w, w, x, y); + public readonly bool4 wwxz => new(w, w, x, z); + public readonly bool4 wwxw => new(w, w, x, w); + public readonly bool4 wwyx => new(w, w, y, x); + public readonly bool4 wwyy => new(w, w, y, y); + public readonly bool4 wwyz => new(w, w, y, z); + public readonly bool4 wwyw => new(w, w, y, w); + public readonly bool4 wwzx => new(w, w, z, x); + public readonly bool4 wwzy => new(w, w, z, y); + public readonly bool4 wwzz => new(w, w, z, z); + public readonly bool4 wwzw => new(w, w, z, w); + public readonly bool4 wwwx => new(w, w, w, x); + public readonly bool4 wwwy => new(w, w, w, y); + public readonly bool4 wwwz => new(w, w, w, z); + public readonly bool4 wwww => new(w, w, w, w); + public readonly bool2 rr => new(x, x); + public bool2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public bool2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public bool2 ra { readonly get => new(x, w); set { x = value.x; w = value.y; } } + public bool2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly bool2 gg => new(y, y); + public bool2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public bool2 ga { readonly get => new(y, w); set { y = value.x; w = value.y; } } + public bool2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public bool2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly bool2 bb => new(z, z); + public bool2 ba { readonly get => new(z, w); set { z = value.x; w = value.y; } } + public bool2 ar { readonly get => new(w, x); set { w = value.x; x = value.y; } } + public bool2 ag { readonly get => new(w, y); set { w = value.x; y = value.y; } } + public bool2 ab { readonly get => new(w, z); set { w = value.x; z = value.y; } } + public readonly bool2 aa => new(w, w); + public readonly bool3 rrr => new(x, x, x); + public readonly bool3 rrg => new(x, x, y); + public readonly bool3 rrb => new(x, x, z); + public readonly bool3 rra => new(x, x, w); + public readonly bool3 rgr => new(x, y, x); + public readonly bool3 rgg => new(x, y, y); + public bool3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public bool3 rga { readonly get => new(x, y, w); set { x = value.x; y = value.y; w = value.z; } } + public readonly bool3 rbr => new(x, z, x); + public bool3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly bool3 rbb => new(x, z, z); + public bool3 rba { readonly get => new(x, z, w); set { x = value.x; z = value.y; w = value.z; } } + public readonly bool3 rar => new(x, w, x); + public bool3 rag { readonly get => new(x, w, y); set { x = value.x; w = value.y; y = value.z; } } + public bool3 rab { readonly get => new(x, w, z); set { x = value.x; w = value.y; z = value.z; } } + public readonly bool3 raa => new(x, w, w); + public readonly bool3 grr => new(y, x, x); + public readonly bool3 grg => new(y, x, y); + public bool3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public bool3 gra { readonly get => new(y, x, w); set { y = value.x; x = value.y; w = value.z; } } + public readonly bool3 ggr => new(y, y, x); + public readonly bool3 ggg => new(y, y, y); + public readonly bool3 ggb => new(y, y, z); + public readonly bool3 gga => new(y, y, w); + public bool3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly bool3 gbg => new(y, z, y); + public readonly bool3 gbb => new(y, z, z); + public bool3 gba { readonly get => new(y, z, w); set { y = value.x; z = value.y; w = value.z; } } + public bool3 gar { readonly get => new(y, w, x); set { y = value.x; w = value.y; x = value.z; } } + public readonly bool3 gag => new(y, w, y); + public bool3 gab { readonly get => new(y, w, z); set { y = value.x; w = value.y; z = value.z; } } + public readonly bool3 gaa => new(y, w, w); + public readonly bool3 brr => new(z, x, x); + public bool3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly bool3 brb => new(z, x, z); + public bool3 bra { readonly get => new(z, x, w); set { z = value.x; x = value.y; w = value.z; } } + public bool3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly bool3 bgg => new(z, y, y); + public readonly bool3 bgb => new(z, y, z); + public bool3 bga { readonly get => new(z, y, w); set { z = value.x; y = value.y; w = value.z; } } + public readonly bool3 bbr => new(z, z, x); + public readonly bool3 bbg => new(z, z, y); + public readonly bool3 bbb => new(z, z, z); + public readonly bool3 bba => new(z, z, w); + public bool3 bar { readonly get => new(z, w, x); set { z = value.x; w = value.y; x = value.z; } } + public bool3 bag { readonly get => new(z, w, y); set { z = value.x; w = value.y; y = value.z; } } + public readonly bool3 bab => new(z, w, z); + public readonly bool3 baa => new(z, w, w); + public readonly bool3 arr => new(w, x, x); + public bool3 arg { readonly get => new(w, x, y); set { w = value.x; x = value.y; y = value.z; } } + public bool3 arb { readonly get => new(w, x, z); set { w = value.x; x = value.y; z = value.z; } } + public readonly bool3 ara => new(w, x, w); + public bool3 agr { readonly get => new(w, y, x); set { w = value.x; y = value.y; x = value.z; } } + public readonly bool3 agg => new(w, y, y); + public bool3 agb { readonly get => new(w, y, z); set { w = value.x; y = value.y; z = value.z; } } + public readonly bool3 aga => new(w, y, w); + public bool3 abr { readonly get => new(w, z, x); set { w = value.x; z = value.y; x = value.z; } } + public bool3 abg { readonly get => new(w, z, y); set { w = value.x; z = value.y; y = value.z; } } + public readonly bool3 abb => new(w, z, z); + public readonly bool3 aba => new(w, z, w); + public readonly bool3 aar => new(w, w, x); + public readonly bool3 aag => new(w, w, y); + public readonly bool3 aab => new(w, w, z); + public readonly bool3 aaa => new(w, w, w); + public readonly bool4 rrrr => new(x, x, x, x); + public readonly bool4 rrrg => new(x, x, x, y); + public readonly bool4 rrrb => new(x, x, x, z); + public readonly bool4 rrra => new(x, x, x, w); + public readonly bool4 rrgr => new(x, x, y, x); + public readonly bool4 rrgg => new(x, x, y, y); + public readonly bool4 rrgb => new(x, x, y, z); + public readonly bool4 rrga => new(x, x, y, w); + public readonly bool4 rrbr => new(x, x, z, x); + public readonly bool4 rrbg => new(x, x, z, y); + public readonly bool4 rrbb => new(x, x, z, z); + public readonly bool4 rrba => new(x, x, z, w); + public readonly bool4 rrar => new(x, x, w, x); + public readonly bool4 rrag => new(x, x, w, y); + public readonly bool4 rrab => new(x, x, w, z); + public readonly bool4 rraa => new(x, x, w, w); + public readonly bool4 rgrr => new(x, y, x, x); + public readonly bool4 rgrg => new(x, y, x, y); + public readonly bool4 rgrb => new(x, y, x, z); + public readonly bool4 rgra => new(x, y, x, w); + public readonly bool4 rggr => new(x, y, y, x); + public readonly bool4 rggg => new(x, y, y, y); + public readonly bool4 rggb => new(x, y, y, z); + public readonly bool4 rgga => new(x, y, y, w); + public readonly bool4 rgbr => new(x, y, z, x); + public readonly bool4 rgbg => new(x, y, z, y); + public readonly bool4 rgbb => new(x, y, z, z); + public bool4 rgba { readonly get => new(x, y, z, w); set { x = value.x; y = value.y; z = value.z; w = value.w; } } + public readonly bool4 rgar => new(x, y, w, x); + public readonly bool4 rgag => new(x, y, w, y); + public bool4 rgab { readonly get => new(x, y, w, z); set { x = value.x; y = value.y; w = value.z; z = value.w; } } + public readonly bool4 rgaa => new(x, y, w, w); + public readonly bool4 rbrr => new(x, z, x, x); + public readonly bool4 rbrg => new(x, z, x, y); + public readonly bool4 rbrb => new(x, z, x, z); + public readonly bool4 rbra => new(x, z, x, w); + public readonly bool4 rbgr => new(x, z, y, x); + public readonly bool4 rbgg => new(x, z, y, y); + public readonly bool4 rbgb => new(x, z, y, z); + public bool4 rbga { readonly get => new(x, z, y, w); set { x = value.x; z = value.y; y = value.z; w = value.w; } } + public readonly bool4 rbbr => new(x, z, z, x); + public readonly bool4 rbbg => new(x, z, z, y); + public readonly bool4 rbbb => new(x, z, z, z); + public readonly bool4 rbba => new(x, z, z, w); + public readonly bool4 rbar => new(x, z, w, x); + public bool4 rbag { readonly get => new(x, z, w, y); set { x = value.x; z = value.y; w = value.z; y = value.w; } } + public readonly bool4 rbab => new(x, z, w, z); + public readonly bool4 rbaa => new(x, z, w, w); + public readonly bool4 rarr => new(x, w, x, x); + public readonly bool4 rarg => new(x, w, x, y); + public readonly bool4 rarb => new(x, w, x, z); + public readonly bool4 rara => new(x, w, x, w); + public readonly bool4 ragr => new(x, w, y, x); + public readonly bool4 ragg => new(x, w, y, y); + public bool4 ragb { readonly get => new(x, w, y, z); set { x = value.x; w = value.y; y = value.z; z = value.w; } } + public readonly bool4 raga => new(x, w, y, w); + public readonly bool4 rabr => new(x, w, z, x); + public bool4 rabg { readonly get => new(x, w, z, y); set { x = value.x; w = value.y; z = value.z; y = value.w; } } + public readonly bool4 rabb => new(x, w, z, z); + public readonly bool4 raba => new(x, w, z, w); + public readonly bool4 raar => new(x, w, w, x); + public readonly bool4 raag => new(x, w, w, y); + public readonly bool4 raab => new(x, w, w, z); + public readonly bool4 raaa => new(x, w, w, w); + public readonly bool4 grrr => new(y, x, x, x); + public readonly bool4 grrg => new(y, x, x, y); + public readonly bool4 grrb => new(y, x, x, z); + public readonly bool4 grra => new(y, x, x, w); + public readonly bool4 grgr => new(y, x, y, x); + public readonly bool4 grgg => new(y, x, y, y); + public readonly bool4 grgb => new(y, x, y, z); + public readonly bool4 grga => new(y, x, y, w); + public readonly bool4 grbr => new(y, x, z, x); + public readonly bool4 grbg => new(y, x, z, y); + public readonly bool4 grbb => new(y, x, z, z); + public bool4 grba { readonly get => new(y, x, z, w); set { y = value.x; x = value.y; z = value.z; w = value.w; } } + public readonly bool4 grar => new(y, x, w, x); + public readonly bool4 grag => new(y, x, w, y); + public bool4 grab { readonly get => new(y, x, w, z); set { y = value.x; x = value.y; w = value.z; z = value.w; } } + public readonly bool4 graa => new(y, x, w, w); + public readonly bool4 ggrr => new(y, y, x, x); + public readonly bool4 ggrg => new(y, y, x, y); + public readonly bool4 ggrb => new(y, y, x, z); + public readonly bool4 ggra => new(y, y, x, w); + public readonly bool4 gggr => new(y, y, y, x); + public readonly bool4 gggg => new(y, y, y, y); + public readonly bool4 gggb => new(y, y, y, z); + public readonly bool4 ggga => new(y, y, y, w); + public readonly bool4 ggbr => new(y, y, z, x); + public readonly bool4 ggbg => new(y, y, z, y); + public readonly bool4 ggbb => new(y, y, z, z); + public readonly bool4 ggba => new(y, y, z, w); + public readonly bool4 ggar => new(y, y, w, x); + public readonly bool4 ggag => new(y, y, w, y); + public readonly bool4 ggab => new(y, y, w, z); + public readonly bool4 ggaa => new(y, y, w, w); + public readonly bool4 gbrr => new(y, z, x, x); + public readonly bool4 gbrg => new(y, z, x, y); + public readonly bool4 gbrb => new(y, z, x, z); + public bool4 gbra { readonly get => new(y, z, x, w); set { y = value.x; z = value.y; x = value.z; w = value.w; } } + public readonly bool4 gbgr => new(y, z, y, x); + public readonly bool4 gbgg => new(y, z, y, y); + public readonly bool4 gbgb => new(y, z, y, z); + public readonly bool4 gbga => new(y, z, y, w); + public readonly bool4 gbbr => new(y, z, z, x); + public readonly bool4 gbbg => new(y, z, z, y); + public readonly bool4 gbbb => new(y, z, z, z); + public readonly bool4 gbba => new(y, z, z, w); + public bool4 gbar { readonly get => new(y, z, w, x); set { y = value.x; z = value.y; w = value.z; x = value.w; } } + public readonly bool4 gbag => new(y, z, w, y); + public readonly bool4 gbab => new(y, z, w, z); + public readonly bool4 gbaa => new(y, z, w, w); + public readonly bool4 garr => new(y, w, x, x); + public readonly bool4 garg => new(y, w, x, y); + public bool4 garb { readonly get => new(y, w, x, z); set { y = value.x; w = value.y; x = value.z; z = value.w; } } + public readonly bool4 gara => new(y, w, x, w); + public readonly bool4 gagr => new(y, w, y, x); + public readonly bool4 gagg => new(y, w, y, y); + public readonly bool4 gagb => new(y, w, y, z); + public readonly bool4 gaga => new(y, w, y, w); + public bool4 gabr { readonly get => new(y, w, z, x); set { y = value.x; w = value.y; z = value.z; x = value.w; } } + public readonly bool4 gabg => new(y, w, z, y); + public readonly bool4 gabb => new(y, w, z, z); + public readonly bool4 gaba => new(y, w, z, w); + public readonly bool4 gaar => new(y, w, w, x); + public readonly bool4 gaag => new(y, w, w, y); + public readonly bool4 gaab => new(y, w, w, z); + public readonly bool4 gaaa => new(y, w, w, w); + public readonly bool4 brrr => new(z, x, x, x); + public readonly bool4 brrg => new(z, x, x, y); + public readonly bool4 brrb => new(z, x, x, z); + public readonly bool4 brra => new(z, x, x, w); + public readonly bool4 brgr => new(z, x, y, x); + public readonly bool4 brgg => new(z, x, y, y); + public readonly bool4 brgb => new(z, x, y, z); + public bool4 brga { readonly get => new(z, x, y, w); set { z = value.x; x = value.y; y = value.z; w = value.w; } } + public readonly bool4 brbr => new(z, x, z, x); + public readonly bool4 brbg => new(z, x, z, y); + public readonly bool4 brbb => new(z, x, z, z); + public readonly bool4 brba => new(z, x, z, w); + public readonly bool4 brar => new(z, x, w, x); + public bool4 brag { readonly get => new(z, x, w, y); set { z = value.x; x = value.y; w = value.z; y = value.w; } } + public readonly bool4 brab => new(z, x, w, z); + public readonly bool4 braa => new(z, x, w, w); + public readonly bool4 bgrr => new(z, y, x, x); + public readonly bool4 bgrg => new(z, y, x, y); + public readonly bool4 bgrb => new(z, y, x, z); + public bool4 bgra { readonly get => new(z, y, x, w); set { z = value.x; y = value.y; x = value.z; w = value.w; } } + public readonly bool4 bggr => new(z, y, y, x); + public readonly bool4 bggg => new(z, y, y, y); + public readonly bool4 bggb => new(z, y, y, z); + public readonly bool4 bgga => new(z, y, y, w); + public readonly bool4 bgbr => new(z, y, z, x); + public readonly bool4 bgbg => new(z, y, z, y); + public readonly bool4 bgbb => new(z, y, z, z); + public readonly bool4 bgba => new(z, y, z, w); + public bool4 bgar { readonly get => new(z, y, w, x); set { z = value.x; y = value.y; w = value.z; x = value.w; } } + public readonly bool4 bgag => new(z, y, w, y); + public readonly bool4 bgab => new(z, y, w, z); + public readonly bool4 bgaa => new(z, y, w, w); + public readonly bool4 bbrr => new(z, z, x, x); + public readonly bool4 bbrg => new(z, z, x, y); + public readonly bool4 bbrb => new(z, z, x, z); + public readonly bool4 bbra => new(z, z, x, w); + public readonly bool4 bbgr => new(z, z, y, x); + public readonly bool4 bbgg => new(z, z, y, y); + public readonly bool4 bbgb => new(z, z, y, z); + public readonly bool4 bbga => new(z, z, y, w); + public readonly bool4 bbbr => new(z, z, z, x); + public readonly bool4 bbbg => new(z, z, z, y); + public readonly bool4 bbbb => new(z, z, z, z); + public readonly bool4 bbba => new(z, z, z, w); + public readonly bool4 bbar => new(z, z, w, x); + public readonly bool4 bbag => new(z, z, w, y); + public readonly bool4 bbab => new(z, z, w, z); + public readonly bool4 bbaa => new(z, z, w, w); + public readonly bool4 barr => new(z, w, x, x); + public bool4 barg { readonly get => new(z, w, x, y); set { z = value.x; w = value.y; x = value.z; y = value.w; } } + public readonly bool4 barb => new(z, w, x, z); + public readonly bool4 bara => new(z, w, x, w); + public bool4 bagr { readonly get => new(z, w, y, x); set { z = value.x; w = value.y; y = value.z; x = value.w; } } + public readonly bool4 bagg => new(z, w, y, y); + public readonly bool4 bagb => new(z, w, y, z); + public readonly bool4 baga => new(z, w, y, w); + public readonly bool4 babr => new(z, w, z, x); + public readonly bool4 babg => new(z, w, z, y); + public readonly bool4 babb => new(z, w, z, z); + public readonly bool4 baba => new(z, w, z, w); + public readonly bool4 baar => new(z, w, w, x); + public readonly bool4 baag => new(z, w, w, y); + public readonly bool4 baab => new(z, w, w, z); + public readonly bool4 baaa => new(z, w, w, w); + public readonly bool4 arrr => new(w, x, x, x); + public readonly bool4 arrg => new(w, x, x, y); + public readonly bool4 arrb => new(w, x, x, z); + public readonly bool4 arra => new(w, x, x, w); + public readonly bool4 argr => new(w, x, y, x); + public readonly bool4 argg => new(w, x, y, y); + public bool4 argb { readonly get => new(w, x, y, z); set { w = value.x; x = value.y; y = value.z; z = value.w; } } + public readonly bool4 arga => new(w, x, y, w); + public readonly bool4 arbr => new(w, x, z, x); + public bool4 arbg { readonly get => new(w, x, z, y); set { w = value.x; x = value.y; z = value.z; y = value.w; } } + public readonly bool4 arbb => new(w, x, z, z); + public readonly bool4 arba => new(w, x, z, w); + public readonly bool4 arar => new(w, x, w, x); + public readonly bool4 arag => new(w, x, w, y); + public readonly bool4 arab => new(w, x, w, z); + public readonly bool4 araa => new(w, x, w, w); + public readonly bool4 agrr => new(w, y, x, x); + public readonly bool4 agrg => new(w, y, x, y); + public bool4 agrb { readonly get => new(w, y, x, z); set { w = value.x; y = value.y; x = value.z; z = value.w; } } + public readonly bool4 agra => new(w, y, x, w); + public readonly bool4 aggr => new(w, y, y, x); + public readonly bool4 aggg => new(w, y, y, y); + public readonly bool4 aggb => new(w, y, y, z); + public readonly bool4 agga => new(w, y, y, w); + public bool4 agbr { readonly get => new(w, y, z, x); set { w = value.x; y = value.y; z = value.z; x = value.w; } } + public readonly bool4 agbg => new(w, y, z, y); + public readonly bool4 agbb => new(w, y, z, z); + public readonly bool4 agba => new(w, y, z, w); + public readonly bool4 agar => new(w, y, w, x); + public readonly bool4 agag => new(w, y, w, y); + public readonly bool4 agab => new(w, y, w, z); + public readonly bool4 agaa => new(w, y, w, w); + public readonly bool4 abrr => new(w, z, x, x); + public bool4 abrg { readonly get => new(w, z, x, y); set { w = value.x; z = value.y; x = value.z; y = value.w; } } + public readonly bool4 abrb => new(w, z, x, z); + public readonly bool4 abra => new(w, z, x, w); + public bool4 abgr { readonly get => new(w, z, y, x); set { w = value.x; z = value.y; y = value.z; x = value.w; } } + public readonly bool4 abgg => new(w, z, y, y); + public readonly bool4 abgb => new(w, z, y, z); + public readonly bool4 abga => new(w, z, y, w); + public readonly bool4 abbr => new(w, z, z, x); + public readonly bool4 abbg => new(w, z, z, y); + public readonly bool4 abbb => new(w, z, z, z); + public readonly bool4 abba => new(w, z, z, w); + public readonly bool4 abar => new(w, z, w, x); + public readonly bool4 abag => new(w, z, w, y); + public readonly bool4 abab => new(w, z, w, z); + public readonly bool4 abaa => new(w, z, w, w); + public readonly bool4 aarr => new(w, w, x, x); + public readonly bool4 aarg => new(w, w, x, y); + public readonly bool4 aarb => new(w, w, x, z); + public readonly bool4 aara => new(w, w, x, w); + public readonly bool4 aagr => new(w, w, y, x); + public readonly bool4 aagg => new(w, w, y, y); + public readonly bool4 aagb => new(w, w, y, z); + public readonly bool4 aaga => new(w, w, y, w); + public readonly bool4 aabr => new(w, w, z, x); + public readonly bool4 aabg => new(w, w, z, y); + public readonly bool4 aabb => new(w, w, z, z); + public readonly bool4 aaba => new(w, w, z, w); + public readonly bool4 aaar => new(w, w, w, x); + public readonly bool4 aaag => new(w, w, w, y); + public readonly bool4 aaab => new(w, w, w, z); + public readonly bool4 aaaa => new(w, w, w, w); +} + +public partial struct double2 +{ + public double2(double2 xy) : this(xy.x, xy.y) { } + public double r { readonly get => x; set => x = value; } + public double g { readonly get => y; set => y = value; } + public readonly double2 xx => new(x, x); + public double2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public double2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly double2 yy => new(y, y); + public readonly double3 xxx => new(x, x, x); + public readonly double3 xxy => new(x, x, y); + public readonly double3 xyx => new(x, y, x); + public readonly double3 xyy => new(x, y, y); + public readonly double3 yxx => new(y, x, x); + public readonly double3 yxy => new(y, x, y); + public readonly double3 yyx => new(y, y, x); + public readonly double3 yyy => new(y, y, y); + public readonly double2 rr => new(x, x); + public double2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public double2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly double2 gg => new(y, y); + public readonly double3 rrr => new(x, x, x); + public readonly double3 rrg => new(x, x, y); + public readonly double3 rgr => new(x, y, x); + public readonly double3 rgg => new(x, y, y); + public readonly double3 grr => new(y, x, x); + public readonly double3 grg => new(y, x, y); + public readonly double3 ggr => new(y, y, x); + public readonly double3 ggg => new(y, y, y); +} + +public partial struct double3 +{ + public double3(double x, double2 yz) : this(x, yz.x, yz.y) { } + public double3(double2 xy, double z) : this(xy.x, xy.y, z) { } + public double3(double3 xyz) : this(xyz.x, xyz.y, xyz.z) { } + public double r { readonly get => x; set => x = value; } + public double g { readonly get => y; set => y = value; } + public double b { readonly get => z; set => z = value; } + public readonly double2 xx => new(x, x); + public double2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public double2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public double2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly double2 yy => new(y, y); + public double2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public double2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public double2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly double2 zz => new(z, z); + public readonly double3 xxx => new(x, x, x); + public readonly double3 xxy => new(x, x, y); + public readonly double3 xxz => new(x, x, z); + public readonly double3 xyx => new(x, y, x); + public readonly double3 xyy => new(x, y, y); + public double3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public readonly double3 xzx => new(x, z, x); + public double3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly double3 xzz => new(x, z, z); + public readonly double3 yxx => new(y, x, x); + public readonly double3 yxy => new(y, x, y); + public double3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public readonly double3 yyx => new(y, y, x); + public readonly double3 yyy => new(y, y, y); + public readonly double3 yyz => new(y, y, z); + public double3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly double3 yzy => new(y, z, y); + public readonly double3 yzz => new(y, z, z); + public readonly double3 zxx => new(z, x, x); + public double3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly double3 zxz => new(z, x, z); + public double3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly double3 zyy => new(z, y, y); + public readonly double3 zyz => new(z, y, z); + public readonly double3 zzx => new(z, z, x); + public readonly double3 zzy => new(z, z, y); + public readonly double3 zzz => new(z, z, z); + public readonly double2 rr => new(x, x); + public double2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } + public double2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } + public double2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } + public readonly double2 gg => new(y, y); + public double2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } + public double2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } + public double2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } + public readonly double2 bb => new(z, z); + public readonly double3 rrr => new(x, x, x); + public readonly double3 rrg => new(x, x, y); + public readonly double3 rrb => new(x, x, z); + public readonly double3 rgr => new(x, y, x); + public readonly double3 rgg => new(x, y, y); + public double3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } + public readonly double3 rbr => new(x, z, x); + public double3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } + public readonly double3 rbb => new(x, z, z); + public readonly double3 grr => new(y, x, x); + public readonly double3 grg => new(y, x, y); + public double3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } + public readonly double3 ggr => new(y, y, x); + public readonly double3 ggg => new(y, y, y); + public readonly double3 ggb => new(y, y, z); + public double3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } + public readonly double3 gbg => new(y, z, y); + public readonly double3 gbb => new(y, z, z); + public readonly double3 brr => new(z, x, x); + public double3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } + public readonly double3 brb => new(z, x, z); + public double3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } + public readonly double3 bgg => new(z, y, y); + public readonly double3 bgb => new(z, y, z); + public readonly double3 bbr => new(z, z, x); + public readonly double3 bbg => new(z, z, y); + public readonly double3 bbb => new(z, z, z); +} + +public static partial class math +{ + public static float2 float2(float2 xy) => new(xy); + public static float3 float3(float x, float2 yz) => new(x, yz); + public static float3 float3(float2 xy, float z) => new(xy, z); + public static float3 float3(float3 xyz) => new(xyz); + public static float4 float4(float x, float y, float2 zw) => new(x, y, zw); + public static float4 float4(float x, float2 yz, float w) => new(x, yz, w); + public static float4 float4(float x, float3 yzw) => new(x, yzw); + public static float4 float4(float2 xy, float z, float w) => new(xy, z, w); + public static float4 float4(float2 xy, float2 zw) => new(xy, zw); + public static float4 float4(float3 xyz, float w) => new(xyz, w); + public static float4 float4(float4 xyzw) => new(xyzw); + public static int2 int2(int2 xy) => new(xy); + public static int3 int3(int x, int2 yz) => new(x, yz); + public static int3 int3(int2 xy, int z) => new(xy, z); + public static int3 int3(int3 xyz) => new(xyz); + public static int4 int4(int x, int y, int2 zw) => new(x, y, zw); + public static int4 int4(int x, int2 yz, int w) => new(x, yz, w); + public static int4 int4(int x, int3 yzw) => new(x, yzw); + public static int4 int4(int2 xy, int z, int w) => new(xy, z, w); + public static int4 int4(int2 xy, int2 zw) => new(xy, zw); + public static int4 int4(int3 xyz, int w) => new(xyz, w); + public static int4 int4(int4 xyzw) => new(xyzw); + public static bool2 bool2(bool2 xy) => new(xy); + public static bool3 bool3(bool x, bool2 yz) => new(x, yz); + public static bool3 bool3(bool2 xy, bool z) => new(xy, z); + public static bool3 bool3(bool3 xyz) => new(xyz); + public static bool4 bool4(bool x, bool y, bool2 zw) => new(x, y, zw); + public static bool4 bool4(bool x, bool2 yz, bool w) => new(x, yz, w); + public static bool4 bool4(bool x, bool3 yzw) => new(x, yzw); + public static bool4 bool4(bool2 xy, bool z, bool w) => new(xy, z, w); + public static bool4 bool4(bool2 xy, bool2 zw) => new(xy, zw); + public static bool4 bool4(bool3 xyz, bool w) => new(xyz, w); + public static bool4 bool4(bool4 xyzw) => new(xyzw); +} diff --git a/packages/cultmath/src/CultMath/Swizzles.g.cs.meta b/packages/cultmath/src/CultMath/Swizzles.g.cs.meta new file mode 100644 index 00000000..035e577f --- /dev/null +++ b/packages/cultmath/src/CultMath/Swizzles.g.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e89d6089b354459788eb6f0faebfd6c0 diff --git a/packages/cultmath/src/CultMath/UnscopedRefAttribute.cs b/packages/cultmath/src/CultMath/UnscopedRefAttribute.cs new file mode 100644 index 00000000..6e1e3148 --- /dev/null +++ b/packages/cultmath/src/CultMath/UnscopedRefAttribute.cs @@ -0,0 +1,10 @@ +#if !NET7_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis; + +// Compiler-recognized polyfill so netstandard2.1 builds can return refs into a struct's +// own fields (the matrix row indexer). net7.0+ supplies the real attribute. +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] +internal sealed class UnscopedRefAttribute : Attribute +{ +} +#endif diff --git a/packages/cultmath/src/CultMath/UnscopedRefAttribute.cs.meta b/packages/cultmath/src/CultMath/UnscopedRefAttribute.cs.meta new file mode 100644 index 00000000..61df7771 --- /dev/null +++ b/packages/cultmath/src/CultMath/UnscopedRefAttribute.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3f204000f9854517a8dd754e2d9dc975 diff --git a/packages/cultmath/src/CultMath/Voronoi.cs b/packages/cultmath/src/CultMath/Voronoi.cs new file mode 100644 index 00000000..38b52d81 --- /dev/null +++ b/packages/cultmath/src/CultMath/Voronoi.cs @@ -0,0 +1,229 @@ +using System; +using System.Runtime.InteropServices; + +namespace CultMath; + +public static class Voronoi +{ + public static bool NativeAvailable { get; private set; } = true; + + public static Color32 SampleTone(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span) + { + Span xs = stackalloc[] { x }; + Span ys = stackalloc[] { y }; + Span tones = stackalloc[] { tone }; + Span spans = stackalloc[] { span }; + Span colors = stackalloc Color32[1]; + SampleTones(xs, ys, tones, spans, resolutionY, frameIndex, colors); + return colors[0]; + } + + public static void SampleTones( + ReadOnlySpan xs, + ReadOnlySpan ys, + ReadOnlySpan tones, + ReadOnlySpan spans, + float resolutionY, + int frameIndex, + Span colors) + { + if (xs.Length != ys.Length || xs.Length != tones.Length || xs.Length != spans.Length || xs.Length != colors.Length) + { + throw new ArgumentException("CultMath Voronoi batch spans must have equal length."); + } + + if (xs.IsEmpty) + { + return; + } + + if (NativeAvailable && TrySampleNative(xs, ys, tones, spans, resolutionY, frameIndex, colors)) + { + return; + } + + for (var index = 0; index < xs.Length; index++) + { + colors[index] = SampleManaged(xs[index], ys[index], resolutionY, frameIndex, tones[index], spans[index]); + } + } + + private static unsafe bool TrySampleNative( + ReadOnlySpan xs, + ReadOnlySpan ys, + ReadOnlySpan tones, + ReadOnlySpan spans, + float resolutionY, + int frameIndex, + Span colors) + { + try + { + fixed (float* xPtr = xs) + fixed (float* yPtr = ys) + fixed (CultMathTone* tonePtr = tones) + fixed (float* spanPtr = spans) + fixed (Color32* colorPtr = colors) + { + var result = Native.cultmath_apollonian_voronoi_tones( + xPtr, + yPtr, + (byte*)tonePtr, + spanPtr, + (nuint)xs.Length, + resolutionY, + frameIndex, + colorPtr); + if (result == 0) + { + return true; + } + } + } + catch (DllNotFoundException) + { + NativeAvailable = false; + } + catch (EntryPointNotFoundException) + { + NativeAvailable = false; + } + + return false; + } + + private static Color32 SampleManaged(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span) + { + var sample = StableSample(x, y, resolutionY, frameIndex, tone, span); + var luminance = math.saturate(sample.x * 0.299f + sample.y * 0.587f + sample.z * 0.114f); + if (tone == CultMathTone.Header) + { + var glow = 0.62f + luminance * 0.38f; + return new Color32((byte)(255 * glow), (byte)(96 * glow), (byte)(14 * glow)); + } + + if (tone == CultMathTone.Body) + { + var glow = 0.68f + luminance * 0.32f; + return new Color32((byte)(245 * glow), (byte)(248 * glow), (byte)(252 * glow)); + } + + var gain = tone switch + { + CultMathTone.Panel => 0.42f, + CultMathTone.Edge => 1.05f, + _ => 0.24f, + }; + var lift = tone switch + { + CultMathTone.Panel => 14.0f, + CultMathTone.Edge => 24.0f, + _ => 4.0f, + }; + return new Color32( + (byte)Math.Clamp(lift + sample.x * 255.0f * gain, 0, 255), + (byte)Math.Clamp(lift + sample.y * 255.0f * gain, 0, 255), + (byte)Math.Clamp(lift + sample.z * 255.0f * gain, 0, 255)); + } + + private static float3 StableSample(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span) + { + var seed = x * 12.9898f + y * 78.233f + Hash1(((byte)tone + 1.0f) * 19.19f); + var jitter = new float2((Hash1(seed) - 0.5f) * span, (Hash1(seed + 37.17f) - 0.5f) * span); + return SampleField(x + jitter.x, y + jitter.y, resolutionY, frameIndex); + } + + private static float3 SampleField(float px, float py, float resolutionY, int frameIndex) + { + var time = frameIndex / 120.0f; + var p = new float2(6.0f * px / Math.Max(1.0f, resolutionY), 6.0f * py / Math.Max(1.0f, resolutionY)); + var n = math.floor(p); + var f = math.frac(p); + var distance = 8.0f; + var color = float3.zero; + const float smoothness = 0.005f; + for (var j = -2; j <= 2; j++) + { + for (var i = -2; i <= 2; i++) + { + var g = new float2(i, j); + var o = Hash2(n + g); + var weight = o.x * 0.5f + 0.5f; + o = 0.5f + 0.5f * new float2(math.sin(time + 6.2831f * o.x), math.sin(time + 6.2831f * o.y)); + var d2 = math.abs(g - f + o); + var d = Math.Max(d2.x, d2.y) * weight; + var seed = Hash1(math.dot(n + g, new float2(7.0f, 113.0f))); + var hue = math.frac((n.x + g.x) * 0.173f + (n.y + g.y) * 0.379f + seed * 0.431f); + var candidate = PastelSpectrum(hue); + var h = math.smoothstep(0.0f, 1.0f, 0.5f + 0.5f * (distance - d) / smoothness); + var correction = h * (1.0f - h) * smoothness / (1.0f + 3.0f * smoothness); + distance = math.lerp(distance, d, h) - correction; + color = math.lerp(color, candidate, h) - correction; + } + } + + return math.max(float3.zero, color * (1.0f - 0.1f * math.smoothstep(0.04f, 0.05f, distance))); + } + + private static float Hash1(float value) => math.frac(math.sin(value) * 43758.5453f); + + private static float3 PastelSpectrum(float hue) + { + return HsvToRgb(hue, 0.52f, 0.98f); + } + + private static float3 HsvToRgb(float hue, float saturation, float value) + { + var h = math.frac(hue) * 6.0f; + var c = value * saturation; + var x = c * (1.0f - Math.Abs(h % 2.0f - 1.0f)); + var m = value - c; + float3 rgb; + if (h < 1.0f) + { + rgb = new float3(c, x, 0.0f); + } + else if (h < 2.0f) + { + rgb = new float3(x, c, 0.0f); + } + else if (h < 3.0f) + { + rgb = new float3(0.0f, c, x); + } + else if (h < 4.0f) + { + rgb = new float3(0.0f, x, c); + } + else if (h < 5.0f) + { + rgb = new float3(x, 0.0f, c); + } + else + { + rgb = new float3(c, 0.0f, x); + } + + return rgb + m; + } + + private static float2 Hash2(float2 value) + { + var p = new float2(math.dot(value, new float2(127.1f, 311.7f)), math.dot(value, new float2(269.5f, 183.3f))); + return math.frac(new float2(math.sin(p.x) * 43758.5453f, math.sin(p.y) * 43758.5453f)); + } + + private static unsafe partial class Native + { + [DllImport("cultmath_core", CallingConvention = CallingConvention.Cdecl)] + public static extern int cultmath_apollonian_voronoi_tones( + float* xs, + float* ys, + byte* tones, + float* spans, + nuint count, + float resolutionY, + int frameIndex, + Color32* outColors); + } +} diff --git a/packages/cultmath/src/CultMath/Voronoi.cs.meta b/packages/cultmath/src/CultMath/Voronoi.cs.meta new file mode 100644 index 00000000..6c738085 --- /dev/null +++ b/packages/cultmath/src/CultMath/Voronoi.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cb530f1428ac8ff43939b986986ae959 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/bool2.cs b/packages/cultmath/src/CultMath/bool2.cs new file mode 100644 index 00000000..29710f0c --- /dev/null +++ b/packages/cultmath/src/CultMath/bool2.cs @@ -0,0 +1,51 @@ +using System; + +namespace CultMath; + +public partial struct bool2 : IEquatable +{ + public static readonly bool2 @false = new(false, false); + public static readonly bool2 @true = new(true, true); + + public bool x; + public bool y; + + public bool2(bool x, bool y) + { + this.x = x; + this.y = y; + } + + public bool this[int index] + { + readonly get => index switch + { + 0 => x, + 1 => y, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + set + { + switch (index) + { + case 0: x = value; break; + case 1: y = value; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + } + } + + public static implicit operator bool2(bool value) => new(value, value); + + public static bool2 operator !(bool2 value) => new(!value.x, !value.y); + public static bool2 operator &(bool2 left, bool2 right) => new(left.x & right.x, left.y & right.y); + public static bool2 operator |(bool2 left, bool2 right) => new(left.x | right.x, left.y | right.y); + public static bool2 operator ^(bool2 left, bool2 right) => new(left.x ^ right.x, left.y ^ right.y); + public static bool2 operator ==(bool2 left, bool2 right) => new(left.x == right.x, left.y == right.y); + public static bool2 operator !=(bool2 left, bool2 right) => new(left.x != right.x, left.y != right.y); + + public readonly bool Equals(bool2 other) => x == other.x && y == other.y; + public override readonly bool Equals(object? obj) => obj is bool2 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(x, y); + public override readonly string ToString() => $"bool2({x}, {y})"; +} diff --git a/packages/cultmath/src/CultMath/bool2.cs.meta b/packages/cultmath/src/CultMath/bool2.cs.meta new file mode 100644 index 00000000..477ff144 --- /dev/null +++ b/packages/cultmath/src/CultMath/bool2.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 725efb2725e987449a3782452f5a95e0 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/bool3.cs b/packages/cultmath/src/CultMath/bool3.cs new file mode 100644 index 00000000..9e4029c7 --- /dev/null +++ b/packages/cultmath/src/CultMath/bool3.cs @@ -0,0 +1,55 @@ +using System; + +namespace CultMath; + +public partial struct bool3 : IEquatable +{ + public static readonly bool3 @false = new(false, false, false); + public static readonly bool3 @true = new(true, true, true); + + public bool x; + public bool y; + public bool z; + + public bool3(bool x, bool y, bool z) + { + this.x = x; + this.y = y; + this.z = z; + } + + public bool this[int index] + { + readonly get => index switch + { + 0 => x, + 1 => y, + 2 => z, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + set + { + switch (index) + { + case 0: x = value; break; + case 1: y = value; break; + case 2: z = value; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + } + } + + public static implicit operator bool3(bool value) => new(value, value, value); + + public static bool3 operator !(bool3 value) => new(!value.x, !value.y, !value.z); + public static bool3 operator &(bool3 left, bool3 right) => new(left.x & right.x, left.y & right.y, left.z & right.z); + public static bool3 operator |(bool3 left, bool3 right) => new(left.x | right.x, left.y | right.y, left.z | right.z); + public static bool3 operator ^(bool3 left, bool3 right) => new(left.x ^ right.x, left.y ^ right.y, left.z ^ right.z); + public static bool3 operator ==(bool3 left, bool3 right) => new(left.x == right.x, left.y == right.y, left.z == right.z); + public static bool3 operator !=(bool3 left, bool3 right) => new(left.x != right.x, left.y != right.y, left.z != right.z); + + public readonly bool Equals(bool3 other) => x == other.x && y == other.y && z == other.z; + public override readonly bool Equals(object? obj) => obj is bool3 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(x, y, z); + public override readonly string ToString() => $"bool3({x}, {y}, {z})"; +} diff --git a/packages/cultmath/src/CultMath/bool3.cs.meta b/packages/cultmath/src/CultMath/bool3.cs.meta new file mode 100644 index 00000000..ada81b6d --- /dev/null +++ b/packages/cultmath/src/CultMath/bool3.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 323ec670fc374c8ba4d87b4bf4c91f34 diff --git a/packages/cultmath/src/CultMath/bool4.cs b/packages/cultmath/src/CultMath/bool4.cs new file mode 100644 index 00000000..b12f15b6 --- /dev/null +++ b/packages/cultmath/src/CultMath/bool4.cs @@ -0,0 +1,59 @@ +using System; + +namespace CultMath; + +public partial struct bool4 : IEquatable +{ + public static readonly bool4 @false = new(false, false, false, false); + public static readonly bool4 @true = new(true, true, true, true); + + public bool x; + public bool y; + public bool z; + public bool w; + + public bool4(bool x, bool y, bool z, bool w) + { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + public bool this[int index] + { + readonly get => index switch + { + 0 => x, + 1 => y, + 2 => z, + 3 => w, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + set + { + switch (index) + { + case 0: x = value; break; + case 1: y = value; break; + case 2: z = value; break; + case 3: w = value; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + } + } + + public static implicit operator bool4(bool value) => new(value, value, value, value); + + public static bool4 operator !(bool4 value) => new(!value.x, !value.y, !value.z, !value.w); + public static bool4 operator &(bool4 left, bool4 right) => new(left.x & right.x, left.y & right.y, left.z & right.z, left.w & right.w); + public static bool4 operator |(bool4 left, bool4 right) => new(left.x | right.x, left.y | right.y, left.z | right.z, left.w | right.w); + public static bool4 operator ^(bool4 left, bool4 right) => new(left.x ^ right.x, left.y ^ right.y, left.z ^ right.z, left.w ^ right.w); + public static bool4 operator ==(bool4 left, bool4 right) => new(left.x == right.x, left.y == right.y, left.z == right.z, left.w == right.w); + public static bool4 operator !=(bool4 left, bool4 right) => new(left.x != right.x, left.y != right.y, left.z != right.z, left.w != right.w); + + public readonly bool Equals(bool4 other) => x == other.x && y == other.y && z == other.z && w == other.w; + public override readonly bool Equals(object? obj) => obj is bool4 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(x, y, z, w); + public override readonly string ToString() => $"bool4({x}, {y}, {z}, {w})"; +} diff --git a/packages/cultmath/src/CultMath/bool4.cs.meta b/packages/cultmath/src/CultMath/bool4.cs.meta new file mode 100644 index 00000000..625bb46b --- /dev/null +++ b/packages/cultmath/src/CultMath/bool4.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d38b9f70d88c459584441b85ecbfd92a diff --git a/packages/cultmath/src/CultMath/double2.cs b/packages/cultmath/src/CultMath/double2.cs new file mode 100644 index 00000000..958b409d --- /dev/null +++ b/packages/cultmath/src/CultMath/double2.cs @@ -0,0 +1,58 @@ +using System; + +namespace CultMath; + +public partial struct double2 : IEquatable +{ + public static readonly double2 zero = new(0.0, 0.0); + public static readonly double2 one = new(1.0, 1.0); + + public double x; + public double y; + + public double2(double x, double y) + { + this.x = x; + this.y = y; + } + + public double this[int index] + { + readonly get => index switch + { + 0 => x, + 1 => y, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + set + { + switch (index) + { + case 0: x = value; break; + case 1: y = value; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + } + } + + public static implicit operator double2(double value) => new(value, value); + + public static double2 operator +(double2 value) => value; + public static double2 operator -(double2 value) => new(-value.x, -value.y); + public static double2 operator +(double2 left, double2 right) => new(left.x + right.x, left.y + right.y); + public static double2 operator -(double2 left, double2 right) => new(left.x - right.x, left.y - right.y); + public static double2 operator *(double2 left, double2 right) => new(left.x * right.x, left.y * right.y); + public static double2 operator /(double2 left, double2 right) => new(left.x / right.x, left.y / right.y); + + public static bool2 operator <(double2 left, double2 right) => new(left.x < right.x, left.y < right.y); + public static bool2 operator >(double2 left, double2 right) => new(left.x > right.x, left.y > right.y); + public static bool2 operator <=(double2 left, double2 right) => new(left.x <= right.x, left.y <= right.y); + public static bool2 operator >=(double2 left, double2 right) => new(left.x >= right.x, left.y >= right.y); + public static bool2 operator ==(double2 left, double2 right) => new(left.x == right.x, left.y == right.y); + public static bool2 operator !=(double2 left, double2 right) => new(left.x != right.x, left.y != right.y); + + public readonly bool Equals(double2 other) => x.Equals(other.x) && y.Equals(other.y); + public override readonly bool Equals(object? obj) => obj is double2 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(x, y); + public override readonly string ToString() => FormattableString.Invariant($"double2({x:R}, {y:R})"); +} diff --git a/packages/cultmath/src/CultMath/double2.cs.meta b/packages/cultmath/src/CultMath/double2.cs.meta new file mode 100644 index 00000000..61243b2a --- /dev/null +++ b/packages/cultmath/src/CultMath/double2.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bbe739a5f0216c3459762d2b5ee7401a \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/double3.cs b/packages/cultmath/src/CultMath/double3.cs new file mode 100644 index 00000000..5fbe9c65 --- /dev/null +++ b/packages/cultmath/src/CultMath/double3.cs @@ -0,0 +1,62 @@ +using System; + +namespace CultMath; + +public partial struct double3 : IEquatable +{ + public static readonly double3 zero = new(0.0, 0.0, 0.0); + public static readonly double3 one = new(1.0, 1.0, 1.0); + + public double x; + public double y; + public double z; + + public double3(double x, double y, double z) + { + this.x = x; + this.y = y; + this.z = z; + } + + public double this[int index] + { + readonly get => index switch + { + 0 => x, + 1 => y, + 2 => z, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + set + { + switch (index) + { + case 0: x = value; break; + case 1: y = value; break; + case 2: z = value; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + } + } + + public static implicit operator double3(double value) => new(value, value, value); + + public static double3 operator +(double3 value) => value; + public static double3 operator -(double3 value) => new(-value.x, -value.y, -value.z); + public static double3 operator +(double3 left, double3 right) => new(left.x + right.x, left.y + right.y, left.z + right.z); + public static double3 operator -(double3 left, double3 right) => new(left.x - right.x, left.y - right.y, left.z - right.z); + public static double3 operator *(double3 left, double3 right) => new(left.x * right.x, left.y * right.y, left.z * right.z); + public static double3 operator /(double3 left, double3 right) => new(left.x / right.x, left.y / right.y, left.z / right.z); + + public static bool3 operator <(double3 left, double3 right) => new(left.x < right.x, left.y < right.y, left.z < right.z); + public static bool3 operator >(double3 left, double3 right) => new(left.x > right.x, left.y > right.y, left.z > right.z); + public static bool3 operator <=(double3 left, double3 right) => new(left.x <= right.x, left.y <= right.y, left.z <= right.z); + public static bool3 operator >=(double3 left, double3 right) => new(left.x >= right.x, left.y >= right.y, left.z >= right.z); + public static bool3 operator ==(double3 left, double3 right) => new(left.x == right.x, left.y == right.y, left.z == right.z); + public static bool3 operator !=(double3 left, double3 right) => new(left.x != right.x, left.y != right.y, left.z != right.z); + + public readonly bool Equals(double3 other) => x.Equals(other.x) && y.Equals(other.y) && z.Equals(other.z); + public override readonly bool Equals(object? obj) => obj is double3 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(x, y, z); + public override readonly string ToString() => FormattableString.Invariant($"double3({x:R}, {y:R}, {z:R})"); +} diff --git a/packages/cultmath/src/CultMath/double3.cs.meta b/packages/cultmath/src/CultMath/double3.cs.meta new file mode 100644 index 00000000..302d13d8 --- /dev/null +++ b/packages/cultmath/src/CultMath/double3.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1cbaafd219703ee43ae279aa5cbf407f \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/float2.cs b/packages/cultmath/src/CultMath/float2.cs new file mode 100644 index 00000000..e33655a8 --- /dev/null +++ b/packages/cultmath/src/CultMath/float2.cs @@ -0,0 +1,84 @@ +using System; + +namespace CultMath; + +public partial struct float2 : IEquatable +{ + public static readonly float2 zero = new(0.0f, 0.0f); + public static readonly float2 one = new(1.0f, 1.0f); + + public float x; + public float y; + + public float2(float x, float y) + { + this.x = x; + this.y = y; + } + + public float this[int index] + { + readonly get => index switch + { + 0 => x, + 1 => y, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + set + { + switch (index) + { + case 0: x = value; break; + case 1: y = value; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + } + } + + public static implicit operator float2(float value) => new(value, value); + public static explicit operator System.Numerics.Vector2(float2 value) => new(value.x, value.y); + public static explicit operator float2(System.Numerics.Vector2 value) => new(value.X, value.Y); + + public static float2 operator +(float2 value) => value; + public static float2 operator -(float2 value) => new(-value.x, -value.y); + + public static float2 operator +(float2 left, float2 right) => new(left.x + right.x, left.y + right.y); + public static float2 operator +(float2 left, float right) => new(left.x + right, left.y + right); + public static float2 operator +(float left, float2 right) => new(left + right.x, left + right.y); + public static float2 operator -(float2 left, float2 right) => new(left.x - right.x, left.y - right.y); + public static float2 operator -(float2 left, float right) => new(left.x - right, left.y - right); + public static float2 operator -(float left, float2 right) => new(left - right.x, left - right.y); + public static float2 operator *(float2 left, float2 right) => new(left.x * right.x, left.y * right.y); + public static float2 operator *(float2 left, float right) => new(left.x * right, left.y * right); + public static float2 operator *(float left, float2 right) => new(left * right.x, left * right.y); + public static float2 operator /(float2 left, float2 right) => new(left.x / right.x, left.y / right.y); + public static float2 operator /(float2 left, float right) => new(left.x / right, left.y / right); + public static float2 operator /(float left, float2 right) => new(left / right.x, left / right.y); + public static float2 operator %(float2 left, float2 right) => new(left.x % right.x, left.y % right.y); + public static float2 operator %(float2 left, float right) => new(left.x % right, left.y % right); + public static float2 operator %(float left, float2 right) => new(left % right.x, left % right.y); + + public static bool2 operator <(float2 left, float2 right) => new(left.x < right.x, left.y < right.y); + public static bool2 operator <(float2 left, float right) => new(left.x < right, left.y < right); + public static bool2 operator <(float left, float2 right) => new(left < right.x, left < right.y); + public static bool2 operator >(float2 left, float2 right) => new(left.x > right.x, left.y > right.y); + public static bool2 operator >(float2 left, float right) => new(left.x > right, left.y > right); + public static bool2 operator >(float left, float2 right) => new(left > right.x, left > right.y); + public static bool2 operator <=(float2 left, float2 right) => new(left.x <= right.x, left.y <= right.y); + public static bool2 operator <=(float2 left, float right) => new(left.x <= right, left.y <= right); + public static bool2 operator <=(float left, float2 right) => new(left <= right.x, left <= right.y); + public static bool2 operator >=(float2 left, float2 right) => new(left.x >= right.x, left.y >= right.y); + public static bool2 operator >=(float2 left, float right) => new(left.x >= right, left.y >= right); + public static bool2 operator >=(float left, float2 right) => new(left >= right.x, left >= right.y); + public static bool2 operator ==(float2 left, float2 right) => new(left.x == right.x, left.y == right.y); + public static bool2 operator ==(float2 left, float right) => new(left.x == right, left.y == right); + public static bool2 operator ==(float left, float2 right) => new(left == right.x, left == right.y); + public static bool2 operator !=(float2 left, float2 right) => new(left.x != right.x, left.y != right.y); + public static bool2 operator !=(float2 left, float right) => new(left.x != right, left.y != right); + public static bool2 operator !=(float left, float2 right) => new(left != right.x, left != right.y); + + public readonly bool Equals(float2 other) => x.Equals(other.x) && y.Equals(other.y); + public override readonly bool Equals(object? obj) => obj is float2 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(x, y); + public override readonly string ToString() => FormattableString.Invariant($"float2({x:R}, {y:R})"); +} diff --git a/packages/cultmath/src/CultMath/float2.cs.meta b/packages/cultmath/src/CultMath/float2.cs.meta new file mode 100644 index 00000000..c82f69ae --- /dev/null +++ b/packages/cultmath/src/CultMath/float2.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fa066a0aed5494f499d462a2ebc51e50 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/float2x2.cs b/packages/cultmath/src/CultMath/float2x2.cs new file mode 100644 index 00000000..bd8673ae --- /dev/null +++ b/packages/cultmath/src/CultMath/float2x2.cs @@ -0,0 +1,67 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace CultMath; + +/// +/// HLSL float2x2: row-major. Constructors take rows, m[i] is a writable reference to +/// row i (so m[1][0] = s works), and _mRC names row R, column C exactly as HLSL +/// does. Use for products; there is no * +/// matrix product because HLSL's * is component-wise. +/// +public struct float2x2 : IEquatable +{ + public static readonly float2x2 identity = new(1.0f, 0.0f, 0.0f, 1.0f); + public static readonly float2x2 zero = new(0.0f, 0.0f, 0.0f, 0.0f); + + private float2 _row0; + private float2 _row1; + + public float2x2(float m00, float m01, float m10, float m11) + { + _row0 = new float2(m00, m01); + _row1 = new float2(m10, m11); + } + + public float2x2(float2 row0, float2 row1) + { + _row0 = row0; + _row1 = row1; + } + + public float _m00 { readonly get => _row0.x; set => _row0.x = value; } + public float _m01 { readonly get => _row0.y; set => _row0.y = value; } + public float _m10 { readonly get => _row1.x; set => _row1.x = value; } + public float _m11 { readonly get => _row1.y; set => _row1.y = value; } + + /// Writes through a readonly field, in parameter, property, or identity hit a defensive copy and are lost; write to a local and assign it back. + [UnscopedRef] + public ref float2 this[int row] + { + get + { + switch (row) + { + case 0: return ref _row0; + case 1: return ref _row1; + default: throw new ArgumentOutOfRangeException(nameof(row)); + } + } + } + + /// + /// Counter-clockwise rotation for column vectors: mul(Rotate(a), v) equals + /// math.rotate(v, a); mul(v, Rotate(a)) rotates the other way. + /// + public static float2x2 Rotate(float radians) + { + var s = MathF.Sin(radians); + var c = MathF.Cos(radians); + return new float2x2(c, -s, s, c); + } + + public readonly bool Equals(float2x2 other) => _row0.Equals(other._row0) && _row1.Equals(other._row1); + public override readonly bool Equals(object? obj) => obj is float2x2 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(_row0, _row1); + public override readonly string ToString() => FormattableString.Invariant($"float2x2({_row0.x:R}, {_row0.y:R}, {_row1.x:R}, {_row1.y:R})"); +} diff --git a/packages/cultmath/src/CultMath/float2x2.cs.meta b/packages/cultmath/src/CultMath/float2x2.cs.meta new file mode 100644 index 00000000..56840df7 --- /dev/null +++ b/packages/cultmath/src/CultMath/float2x2.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c0dcec14c0b7432dbd8ba0bebb0a7c2f diff --git a/packages/cultmath/src/CultMath/float3.cs b/packages/cultmath/src/CultMath/float3.cs new file mode 100644 index 00000000..ca83628b --- /dev/null +++ b/packages/cultmath/src/CultMath/float3.cs @@ -0,0 +1,88 @@ +using System; + +namespace CultMath; + +public partial struct float3 : IEquatable +{ + public static readonly float3 zero = new(0.0f, 0.0f, 0.0f); + public static readonly float3 one = new(1.0f, 1.0f, 1.0f); + + public float x; + public float y; + public float z; + + public float3(float x, float y, float z) + { + this.x = x; + this.y = y; + this.z = z; + } + + public float this[int index] + { + readonly get => index switch + { + 0 => x, + 1 => y, + 2 => z, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + set + { + switch (index) + { + case 0: x = value; break; + case 1: y = value; break; + case 2: z = value; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + } + } + + public static implicit operator float3(float value) => new(value, value, value); + public static explicit operator System.Numerics.Vector3(float3 value) => new(value.x, value.y, value.z); + public static explicit operator float3(System.Numerics.Vector3 value) => new(value.X, value.Y, value.Z); + + public static float3 operator +(float3 value) => value; + public static float3 operator -(float3 value) => new(-value.x, -value.y, -value.z); + + public static float3 operator +(float3 left, float3 right) => new(left.x + right.x, left.y + right.y, left.z + right.z); + public static float3 operator +(float3 left, float right) => new(left.x + right, left.y + right, left.z + right); + public static float3 operator +(float left, float3 right) => new(left + right.x, left + right.y, left + right.z); + public static float3 operator -(float3 left, float3 right) => new(left.x - right.x, left.y - right.y, left.z - right.z); + public static float3 operator -(float3 left, float right) => new(left.x - right, left.y - right, left.z - right); + public static float3 operator -(float left, float3 right) => new(left - right.x, left - right.y, left - right.z); + public static float3 operator *(float3 left, float3 right) => new(left.x * right.x, left.y * right.y, left.z * right.z); + public static float3 operator *(float3 left, float right) => new(left.x * right, left.y * right, left.z * right); + public static float3 operator *(float left, float3 right) => new(left * right.x, left * right.y, left * right.z); + public static float3 operator /(float3 left, float3 right) => new(left.x / right.x, left.y / right.y, left.z / right.z); + public static float3 operator /(float3 left, float right) => new(left.x / right, left.y / right, left.z / right); + public static float3 operator /(float left, float3 right) => new(left / right.x, left / right.y, left / right.z); + public static float3 operator %(float3 left, float3 right) => new(left.x % right.x, left.y % right.y, left.z % right.z); + public static float3 operator %(float3 left, float right) => new(left.x % right, left.y % right, left.z % right); + public static float3 operator %(float left, float3 right) => new(left % right.x, left % right.y, left % right.z); + + public static bool3 operator <(float3 left, float3 right) => new(left.x < right.x, left.y < right.y, left.z < right.z); + public static bool3 operator <(float3 left, float right) => new(left.x < right, left.y < right, left.z < right); + public static bool3 operator <(float left, float3 right) => new(left < right.x, left < right.y, left < right.z); + public static bool3 operator >(float3 left, float3 right) => new(left.x > right.x, left.y > right.y, left.z > right.z); + public static bool3 operator >(float3 left, float right) => new(left.x > right, left.y > right, left.z > right); + public static bool3 operator >(float left, float3 right) => new(left > right.x, left > right.y, left > right.z); + public static bool3 operator <=(float3 left, float3 right) => new(left.x <= right.x, left.y <= right.y, left.z <= right.z); + public static bool3 operator <=(float3 left, float right) => new(left.x <= right, left.y <= right, left.z <= right); + public static bool3 operator <=(float left, float3 right) => new(left <= right.x, left <= right.y, left <= right.z); + public static bool3 operator >=(float3 left, float3 right) => new(left.x >= right.x, left.y >= right.y, left.z >= right.z); + public static bool3 operator >=(float3 left, float right) => new(left.x >= right, left.y >= right, left.z >= right); + public static bool3 operator >=(float left, float3 right) => new(left >= right.x, left >= right.y, left >= right.z); + public static bool3 operator ==(float3 left, float3 right) => new(left.x == right.x, left.y == right.y, left.z == right.z); + public static bool3 operator ==(float3 left, float right) => new(left.x == right, left.y == right, left.z == right); + public static bool3 operator ==(float left, float3 right) => new(left == right.x, left == right.y, left == right.z); + public static bool3 operator !=(float3 left, float3 right) => new(left.x != right.x, left.y != right.y, left.z != right.z); + public static bool3 operator !=(float3 left, float right) => new(left.x != right, left.y != right, left.z != right); + public static bool3 operator !=(float left, float3 right) => new(left != right.x, left != right.y, left != right.z); + + public readonly bool Equals(float3 other) => x.Equals(other.x) && y.Equals(other.y) && z.Equals(other.z); + public override readonly bool Equals(object? obj) => obj is float3 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(x, y, z); + public override readonly string ToString() => FormattableString.Invariant($"float3({x:R}, {y:R}, {z:R})"); +} diff --git a/packages/cultmath/src/CultMath/float3.cs.meta b/packages/cultmath/src/CultMath/float3.cs.meta new file mode 100644 index 00000000..3c649c4a --- /dev/null +++ b/packages/cultmath/src/CultMath/float3.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 58a355dd1cf6b9f48a7595eea0c0e1f2 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/float3x3.cs b/packages/cultmath/src/CultMath/float3x3.cs new file mode 100644 index 00000000..d7aad21c --- /dev/null +++ b/packages/cultmath/src/CultMath/float3x3.cs @@ -0,0 +1,116 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace CultMath; + +/// +/// HLSL float3x3: row-major. Constructors take rows, m[i] is a writable reference to +/// row i (so m[1][2] = s works), and _mRC names row R, column C exactly as HLSL +/// does. Use for products; there is no * +/// matrix product because HLSL's * is component-wise. +/// +public struct float3x3 : IEquatable +{ + public static readonly float3x3 identity = new(1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f); + public static readonly float3x3 zero = new(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); + + private float3 _row0; + private float3 _row1; + private float3 _row2; + + public float3x3(float m00, float m01, float m02, float m10, float m11, float m12, float m20, float m21, float m22) + { + _row0 = new float3(m00, m01, m02); + _row1 = new float3(m10, m11, m12); + _row2 = new float3(m20, m21, m22); + } + + public float3x3(float3 row0, float3 row1, float3 row2) + { + _row0 = row0; + _row1 = row1; + _row2 = row2; + } + + public float _m00 { readonly get => _row0.x; set => _row0.x = value; } + public float _m01 { readonly get => _row0.y; set => _row0.y = value; } + public float _m02 { readonly get => _row0.z; set => _row0.z = value; } + public float _m10 { readonly get => _row1.x; set => _row1.x = value; } + public float _m11 { readonly get => _row1.y; set => _row1.y = value; } + public float _m12 { readonly get => _row1.z; set => _row1.z = value; } + public float _m20 { readonly get => _row2.x; set => _row2.x = value; } + public float _m21 { readonly get => _row2.y; set => _row2.y = value; } + public float _m22 { readonly get => _row2.z; set => _row2.z = value; } + + /// Writes through a readonly field, in parameter, property, or identity hit a defensive copy and are lost; write to a local and assign it back. + [UnscopedRef] + public ref float3 this[int row] + { + get + { + switch (row) + { + case 0: return ref _row0; + case 1: return ref _row1; + case 2: return ref _row2; + default: throw new ArgumentOutOfRangeException(nameof(row)); + } + } + } + + /// Rotation about +X for column vectors (mul(m, v)). + public static float3x3 RotateX(float radians) + { + var s = MathF.Sin(radians); + var c = MathF.Cos(radians); + return new float3x3(1.0f, 0.0f, 0.0f, 0.0f, c, -s, 0.0f, s, c); + } + + /// Rotation about +Y for column vectors (mul(m, v)). + public static float3x3 RotateY(float radians) + { + var s = MathF.Sin(radians); + var c = MathF.Cos(radians); + return new float3x3(c, 0.0f, s, 0.0f, 1.0f, 0.0f, -s, 0.0f, c); + } + + /// Rotation about +Z for column vectors (mul(m, v)). + public static float3x3 RotateZ(float radians) + { + var s = MathF.Sin(radians); + var c = MathF.Cos(radians); + return new float3x3(c, -s, 0.0f, s, c, 0.0f, 0.0f, 0.0f, 1.0f); + } + + /// + /// Extrinsic Euler rotation in radians. The order names the axis applied first: + /// rotates about Y, then X, then Z, so the + /// matrix is mul(RotateZ, mul(RotateX, RotateY)) for column vectors. + /// + public static float3x3 Euler(float3 xyz, math.RotationOrder order = math.RotationOrder.Default) + { + var x = RotateX(xyz.x); + var y = RotateY(xyz.y); + var z = RotateZ(xyz.z); + return order switch + { + math.RotationOrder.XYZ => math.mul(z, math.mul(y, x)), + math.RotationOrder.XZY => math.mul(y, math.mul(z, x)), + math.RotationOrder.YXZ => math.mul(z, math.mul(x, y)), + math.RotationOrder.YZX => math.mul(x, math.mul(z, y)), + math.RotationOrder.ZXY => math.mul(y, math.mul(x, z)), + math.RotationOrder.ZYX => math.mul(x, math.mul(y, z)), + _ => throw new ArgumentOutOfRangeException(nameof(order)), + }; + } + + public static float3x3 Euler(float x, float y, float z, math.RotationOrder order = math.RotationOrder.Default) => + Euler(new float3(x, y, z), order); + + public readonly bool Equals(float3x3 other) => _row0.Equals(other._row0) && _row1.Equals(other._row1) && _row2.Equals(other._row2); + public override readonly bool Equals(object? obj) => obj is float3x3 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(_row0, _row1, _row2); + + public override readonly string ToString() => + FormattableString.Invariant($"float3x3({_row0.x:R}, {_row0.y:R}, {_row0.z:R}, {_row1.x:R}, {_row1.y:R}, {_row1.z:R}, {_row2.x:R}, {_row2.y:R}, {_row2.z:R})"); +} diff --git a/packages/cultmath/src/CultMath/float3x3.cs.meta b/packages/cultmath/src/CultMath/float3x3.cs.meta new file mode 100644 index 00000000..99b25ed3 --- /dev/null +++ b/packages/cultmath/src/CultMath/float3x3.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ca36a9bfff144a9b51478a6d7b19c8e diff --git a/packages/cultmath/src/CultMath/float4.cs b/packages/cultmath/src/CultMath/float4.cs new file mode 100644 index 00000000..3f647971 --- /dev/null +++ b/packages/cultmath/src/CultMath/float4.cs @@ -0,0 +1,92 @@ +using System; + +namespace CultMath; + +public partial struct float4 : IEquatable +{ + public static readonly float4 zero = new(0.0f, 0.0f, 0.0f, 0.0f); + public static readonly float4 one = new(1.0f, 1.0f, 1.0f, 1.0f); + + public float x; + public float y; + public float z; + public float w; + + public float4(float x, float y, float z, float w) + { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + public float this[int index] + { + readonly get => index switch + { + 0 => x, + 1 => y, + 2 => z, + 3 => w, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + set + { + switch (index) + { + case 0: x = value; break; + case 1: y = value; break; + case 2: z = value; break; + case 3: w = value; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + } + } + + public static implicit operator float4(float value) => new(value, value, value, value); + public static explicit operator System.Numerics.Vector4(float4 value) => new(value.x, value.y, value.z, value.w); + public static explicit operator float4(System.Numerics.Vector4 value) => new(value.X, value.Y, value.Z, value.W); + + public static float4 operator +(float4 value) => value; + public static float4 operator -(float4 value) => new(-value.x, -value.y, -value.z, -value.w); + + public static float4 operator +(float4 left, float4 right) => new(left.x + right.x, left.y + right.y, left.z + right.z, left.w + right.w); + public static float4 operator +(float4 left, float right) => new(left.x + right, left.y + right, left.z + right, left.w + right); + public static float4 operator +(float left, float4 right) => new(left + right.x, left + right.y, left + right.z, left + right.w); + public static float4 operator -(float4 left, float4 right) => new(left.x - right.x, left.y - right.y, left.z - right.z, left.w - right.w); + public static float4 operator -(float4 left, float right) => new(left.x - right, left.y - right, left.z - right, left.w - right); + public static float4 operator -(float left, float4 right) => new(left - right.x, left - right.y, left - right.z, left - right.w); + public static float4 operator *(float4 left, float4 right) => new(left.x * right.x, left.y * right.y, left.z * right.z, left.w * right.w); + public static float4 operator *(float4 left, float right) => new(left.x * right, left.y * right, left.z * right, left.w * right); + public static float4 operator *(float left, float4 right) => new(left * right.x, left * right.y, left * right.z, left * right.w); + public static float4 operator /(float4 left, float4 right) => new(left.x / right.x, left.y / right.y, left.z / right.z, left.w / right.w); + public static float4 operator /(float4 left, float right) => new(left.x / right, left.y / right, left.z / right, left.w / right); + public static float4 operator /(float left, float4 right) => new(left / right.x, left / right.y, left / right.z, left / right.w); + public static float4 operator %(float4 left, float4 right) => new(left.x % right.x, left.y % right.y, left.z % right.z, left.w % right.w); + public static float4 operator %(float4 left, float right) => new(left.x % right, left.y % right, left.z % right, left.w % right); + public static float4 operator %(float left, float4 right) => new(left % right.x, left % right.y, left % right.z, left % right.w); + + public static bool4 operator <(float4 left, float4 right) => new(left.x < right.x, left.y < right.y, left.z < right.z, left.w < right.w); + public static bool4 operator <(float4 left, float right) => new(left.x < right, left.y < right, left.z < right, left.w < right); + public static bool4 operator <(float left, float4 right) => new(left < right.x, left < right.y, left < right.z, left < right.w); + public static bool4 operator >(float4 left, float4 right) => new(left.x > right.x, left.y > right.y, left.z > right.z, left.w > right.w); + public static bool4 operator >(float4 left, float right) => new(left.x > right, left.y > right, left.z > right, left.w > right); + public static bool4 operator >(float left, float4 right) => new(left > right.x, left > right.y, left > right.z, left > right.w); + public static bool4 operator <=(float4 left, float4 right) => new(left.x <= right.x, left.y <= right.y, left.z <= right.z, left.w <= right.w); + public static bool4 operator <=(float4 left, float right) => new(left.x <= right, left.y <= right, left.z <= right, left.w <= right); + public static bool4 operator <=(float left, float4 right) => new(left <= right.x, left <= right.y, left <= right.z, left <= right.w); + public static bool4 operator >=(float4 left, float4 right) => new(left.x >= right.x, left.y >= right.y, left.z >= right.z, left.w >= right.w); + public static bool4 operator >=(float4 left, float right) => new(left.x >= right, left.y >= right, left.z >= right, left.w >= right); + public static bool4 operator >=(float left, float4 right) => new(left >= right.x, left >= right.y, left >= right.z, left >= right.w); + public static bool4 operator ==(float4 left, float4 right) => new(left.x == right.x, left.y == right.y, left.z == right.z, left.w == right.w); + public static bool4 operator ==(float4 left, float right) => new(left.x == right, left.y == right, left.z == right, left.w == right); + public static bool4 operator ==(float left, float4 right) => new(left == right.x, left == right.y, left == right.z, left == right.w); + public static bool4 operator !=(float4 left, float4 right) => new(left.x != right.x, left.y != right.y, left.z != right.z, left.w != right.w); + public static bool4 operator !=(float4 left, float right) => new(left.x != right, left.y != right, left.z != right, left.w != right); + public static bool4 operator !=(float left, float4 right) => new(left != right.x, left != right.y, left != right.z, left != right.w); + + public readonly bool Equals(float4 other) => x.Equals(other.x) && y.Equals(other.y) && z.Equals(other.z) && w.Equals(other.w); + public override readonly bool Equals(object? obj) => obj is float4 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(x, y, z, w); + public override readonly string ToString() => FormattableString.Invariant($"float4({x:R}, {y:R}, {z:R}, {w:R})"); +} diff --git a/packages/cultmath/src/CultMath/float4.cs.meta b/packages/cultmath/src/CultMath/float4.cs.meta new file mode 100644 index 00000000..206444e0 --- /dev/null +++ b/packages/cultmath/src/CultMath/float4.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe24fbdab33538849bc6be0afbb0a692 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/int2.cs b/packages/cultmath/src/CultMath/int2.cs new file mode 100644 index 00000000..f690a25d --- /dev/null +++ b/packages/cultmath/src/CultMath/int2.cs @@ -0,0 +1,62 @@ +using System; + +namespace CultMath; + +public partial struct int2 : IEquatable +{ + public static readonly int2 zero = new(0, 0); + public static readonly int2 one = new(1, 1); + + public int x; + public int y; + + public int2(int x, int y) + { + this.x = x; + this.y = y; + } + + public int this[int index] + { + readonly get => index switch + { + 0 => x, + 1 => y, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + set + { + switch (index) + { + case 0: x = value; break; + case 1: y = value; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + } + } + + public static implicit operator int2(int value) => new(value, value); + public static implicit operator float2(int2 value) => new(value.x, value.y); + public static explicit operator System.Numerics.Vector2(int2 value) => new(value.x, value.y); + public static explicit operator int2(System.Numerics.Vector2 value) => new((int)value.X, (int)value.Y); + + public static int2 operator +(int2 value) => value; + public static int2 operator -(int2 value) => new(-value.x, -value.y); + public static int2 operator +(int2 left, int2 right) => new(left.x + right.x, left.y + right.y); + public static int2 operator -(int2 left, int2 right) => new(left.x - right.x, left.y - right.y); + public static int2 operator *(int2 left, int2 right) => new(left.x * right.x, left.y * right.y); + public static int2 operator /(int2 left, int2 right) => new(left.x / right.x, left.y / right.y); + public static int2 operator %(int2 left, int2 right) => new(left.x % right.x, left.y % right.y); + + public static bool2 operator <(int2 left, int2 right) => new(left.x < right.x, left.y < right.y); + public static bool2 operator >(int2 left, int2 right) => new(left.x > right.x, left.y > right.y); + public static bool2 operator <=(int2 left, int2 right) => new(left.x <= right.x, left.y <= right.y); + public static bool2 operator >=(int2 left, int2 right) => new(left.x >= right.x, left.y >= right.y); + public static bool2 operator ==(int2 left, int2 right) => new(left.x == right.x, left.y == right.y); + public static bool2 operator !=(int2 left, int2 right) => new(left.x != right.x, left.y != right.y); + + public readonly bool Equals(int2 other) => x == other.x && y == other.y; + public override readonly bool Equals(object? obj) => obj is int2 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(x, y); + public override readonly string ToString() => FormattableString.Invariant($"int2({x}, {y})"); +} diff --git a/packages/cultmath/src/CultMath/int2.cs.meta b/packages/cultmath/src/CultMath/int2.cs.meta new file mode 100644 index 00000000..5920328a --- /dev/null +++ b/packages/cultmath/src/CultMath/int2.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 577b174a998aaa4478ec26e7a6782f46 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/int3.cs b/packages/cultmath/src/CultMath/int3.cs new file mode 100644 index 00000000..454a8970 --- /dev/null +++ b/packages/cultmath/src/CultMath/int3.cs @@ -0,0 +1,64 @@ +using System; + +namespace CultMath; + +public partial struct int3 : IEquatable +{ + public static readonly int3 zero = new(0, 0, 0); + public static readonly int3 one = new(1, 1, 1); + + public int x; + public int y; + public int z; + + public int3(int x, int y, int z) + { + this.x = x; + this.y = y; + this.z = z; + } + + public int this[int index] + { + readonly get => index switch + { + 0 => x, + 1 => y, + 2 => z, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + set + { + switch (index) + { + case 0: x = value; break; + case 1: y = value; break; + case 2: z = value; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + } + } + + public static implicit operator int3(int value) => new(value, value, value); + public static implicit operator float3(int3 value) => new(value.x, value.y, value.z); + + public static int3 operator +(int3 value) => value; + public static int3 operator -(int3 value) => new(-value.x, -value.y, -value.z); + public static int3 operator +(int3 left, int3 right) => new(left.x + right.x, left.y + right.y, left.z + right.z); + public static int3 operator -(int3 left, int3 right) => new(left.x - right.x, left.y - right.y, left.z - right.z); + public static int3 operator *(int3 left, int3 right) => new(left.x * right.x, left.y * right.y, left.z * right.z); + public static int3 operator /(int3 left, int3 right) => new(left.x / right.x, left.y / right.y, left.z / right.z); + public static int3 operator %(int3 left, int3 right) => new(left.x % right.x, left.y % right.y, left.z % right.z); + + public static bool3 operator <(int3 left, int3 right) => new(left.x < right.x, left.y < right.y, left.z < right.z); + public static bool3 operator >(int3 left, int3 right) => new(left.x > right.x, left.y > right.y, left.z > right.z); + public static bool3 operator <=(int3 left, int3 right) => new(left.x <= right.x, left.y <= right.y, left.z <= right.z); + public static bool3 operator >=(int3 left, int3 right) => new(left.x >= right.x, left.y >= right.y, left.z >= right.z); + public static bool3 operator ==(int3 left, int3 right) => new(left.x == right.x, left.y == right.y, left.z == right.z); + public static bool3 operator !=(int3 left, int3 right) => new(left.x != right.x, left.y != right.y, left.z != right.z); + + public readonly bool Equals(int3 other) => x == other.x && y == other.y && z == other.z; + public override readonly bool Equals(object? obj) => obj is int3 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(x, y, z); + public override readonly string ToString() => FormattableString.Invariant($"int3({x}, {y}, {z})"); +} diff --git a/packages/cultmath/src/CultMath/int3.cs.meta b/packages/cultmath/src/CultMath/int3.cs.meta new file mode 100644 index 00000000..d0400303 --- /dev/null +++ b/packages/cultmath/src/CultMath/int3.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 42ee98aace354db19b3ae87bdcbc1df9 diff --git a/packages/cultmath/src/CultMath/int4.cs b/packages/cultmath/src/CultMath/int4.cs new file mode 100644 index 00000000..caaaeb9e --- /dev/null +++ b/packages/cultmath/src/CultMath/int4.cs @@ -0,0 +1,68 @@ +using System; + +namespace CultMath; + +public partial struct int4 : IEquatable +{ + public static readonly int4 zero = new(0, 0, 0, 0); + public static readonly int4 one = new(1, 1, 1, 1); + + public int x; + public int y; + public int z; + public int w; + + public int4(int x, int y, int z, int w) + { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + public int this[int index] + { + readonly get => index switch + { + 0 => x, + 1 => y, + 2 => z, + 3 => w, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + set + { + switch (index) + { + case 0: x = value; break; + case 1: y = value; break; + case 2: z = value; break; + case 3: w = value; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + } + } + + public static implicit operator int4(int value) => new(value, value, value, value); + public static implicit operator float4(int4 value) => new(value.x, value.y, value.z, value.w); + + public static int4 operator +(int4 value) => value; + public static int4 operator -(int4 value) => new(-value.x, -value.y, -value.z, -value.w); + public static int4 operator +(int4 left, int4 right) => new(left.x + right.x, left.y + right.y, left.z + right.z, left.w + right.w); + public static int4 operator -(int4 left, int4 right) => new(left.x - right.x, left.y - right.y, left.z - right.z, left.w - right.w); + public static int4 operator *(int4 left, int4 right) => new(left.x * right.x, left.y * right.y, left.z * right.z, left.w * right.w); + public static int4 operator /(int4 left, int4 right) => new(left.x / right.x, left.y / right.y, left.z / right.z, left.w / right.w); + public static int4 operator %(int4 left, int4 right) => new(left.x % right.x, left.y % right.y, left.z % right.z, left.w % right.w); + + public static bool4 operator <(int4 left, int4 right) => new(left.x < right.x, left.y < right.y, left.z < right.z, left.w < right.w); + public static bool4 operator >(int4 left, int4 right) => new(left.x > right.x, left.y > right.y, left.z > right.z, left.w > right.w); + public static bool4 operator <=(int4 left, int4 right) => new(left.x <= right.x, left.y <= right.y, left.z <= right.z, left.w <= right.w); + public static bool4 operator >=(int4 left, int4 right) => new(left.x >= right.x, left.y >= right.y, left.z >= right.z, left.w >= right.w); + public static bool4 operator ==(int4 left, int4 right) => new(left.x == right.x, left.y == right.y, left.z == right.z, left.w == right.w); + public static bool4 operator !=(int4 left, int4 right) => new(left.x != right.x, left.y != right.y, left.z != right.z, left.w != right.w); + + public readonly bool Equals(int4 other) => x == other.x && y == other.y && z == other.z && w == other.w; + public override readonly bool Equals(object? obj) => obj is int4 other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(x, y, z, w); + public override readonly string ToString() => FormattableString.Invariant($"int4({x}, {y}, {z}, {w})"); +} diff --git a/packages/cultmath/src/CultMath/int4.cs.meta b/packages/cultmath/src/CultMath/int4.cs.meta new file mode 100644 index 00000000..48d0eaba --- /dev/null +++ b/packages/cultmath/src/CultMath/int4.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1a313709f76a430eb83901bb2d05c74e diff --git a/packages/cultmath/src/CultMath/math.cs b/packages/cultmath/src/CultMath/math.cs new file mode 100644 index 00000000..ca74857f --- /dev/null +++ b/packages/cultmath/src/CultMath/math.cs @@ -0,0 +1,623 @@ +using System; + +namespace CultMath; + +public static partial class math +{ + public const float PI = MathF.PI; + public const float TAU = MathF.PI * 2.0f; + public const float HALF_PI = MathF.PI * 0.5f; + + /// + /// Extrinsic Euler order for : + /// the first letter is the axis applied first. Nested here so using static CultMath.math; + /// brings it into scope with the rest of the shader vocabulary. + /// + public enum RotationOrder : byte + { + XYZ, + XZY, + YXZ, + YZX, + ZXY, + ZYX, + Default = ZXY, + } + + public static float2 float2(float x, float y) => new(x, y); + public static float2 float2(float value) => new(value, value); + public static bool2 bool2(bool x, bool y) => new(x, y); + public static bool2 bool2(bool value) => new(value, value); + public static int2 int2(int x, int y) => new(x, y); + public static int2 int2(int value) => new(value, value); + // Mixed constructor functions (float4(float2, float2), ...) are generated in Swizzles.g.cs. + public static float3 float3(float x, float y, float z) => new(x, y, z); + public static float3 float3(float value) => new(value, value, value); + public static float4 float4(float x, float y, float z, float w) => new(x, y, z, w); + public static float4 float4(float value) => new(value, value, value, value); + public static bool3 bool3(bool x, bool y, bool z) => new(x, y, z); + public static bool3 bool3(bool value) => new(value, value, value); + public static bool4 bool4(bool x, bool y, bool z, bool w) => new(x, y, z, w); + public static bool4 bool4(bool value) => new(value, value, value, value); + public static int3 int3(int x, int y, int z) => new(x, y, z); + public static int3 int3(int value) => new(value, value, value); + public static int4 int4(int x, int y, int z, int w) => new(x, y, z, w); + public static int4 int4(int value) => new(value, value, value, value); + + // Matrix constructor functions take rows, as HLSL does. Under `using static CultMath.math;` + // these method groups hide the type names in member access, so static members need a + // qualified type: `CultMath.float3x3.Euler(...)`, `CultMath.float2x2.Rotate(...)`. + public static float2x2 float2x2(float m00, float m01, float m10, float m11) => new(m00, m01, m10, m11); + public static float2x2 float2x2(float2 row0, float2 row1) => new(row0, row1); + public static float3x3 float3x3(float m00, float m01, float m02, float m10, float m11, float m12, float m20, float m21, float m22) => + new(m00, m01, m02, m10, m11, m12, m20, m21, m22); + public static float3x3 float3x3(float3 row0, float3 row1, float3 row2) => new(row0, row1, row2); + public static float radians(float degrees) => degrees * (PI / 180.0f); + public static float degrees(float radians) => radians * (180.0f / PI); + public static float sin(float value) => MathF.Sin(value); + public static float cos(float value) => MathF.Cos(value); + public static float tan(float value) => MathF.Tan(value); + public static float asin(float value) => MathF.Asin(value); + public static float acos(float value) => MathF.Acos(value); + public static float atan(float value) => MathF.Atan(value); + public static float atan2(float y, float x) => MathF.Atan2(y, x); + public static float sqrt(float value) => MathF.Sqrt(value); + public static float pow(float value, float power) => MathF.Pow(value, power); + public static bool isinf(float value) => float.IsInfinity(value); + public static float exp(float value) => MathF.Exp(value); + public static double exp(double value) => Math.Exp(value); + public static float2 exp(float2 value) => new(exp(value.x), exp(value.y)); + public static float3 exp(float3 value) => new(exp(value.x), exp(value.y), exp(value.z)); + public static float4 exp(float4 value) => new(exp(value.x), exp(value.y), exp(value.z), exp(value.w)); + public static double2 exp(double2 value) => new(exp(value.x), exp(value.y)); + public static double3 exp(double3 value) => new(exp(value.x), exp(value.y), exp(value.z)); + + public static float abs(float value) => MathF.Abs(value); + public static float2 abs(float2 value) => new(abs(value.x), abs(value.y)); + public static float3 abs(float3 value) => new(abs(value.x), abs(value.y), abs(value.z)); + public static float4 abs(float4 value) => new(abs(value.x), abs(value.y), abs(value.z), abs(value.w)); + public static int abs(int value) => Math.Abs(value); + public static int2 abs(int2 value) => new(abs(value.x), abs(value.y)); + public static int3 abs(int3 value) => new(abs(value.x), abs(value.y), abs(value.z)); + public static int4 abs(int4 value) => new(abs(value.x), abs(value.y), abs(value.z), abs(value.w)); + + // HLSL sign returns int. NaN compares false both ways, so it yields 0 instead of throwing. + public static int sign(float value) => value > 0.0f ? 1 : value < 0.0f ? -1 : 0; + public static int2 sign(float2 value) => new(sign(value.x), sign(value.y)); + public static int3 sign(float3 value) => new(sign(value.x), sign(value.y), sign(value.z)); + public static int4 sign(float4 value) => new(sign(value.x), sign(value.y), sign(value.z), sign(value.w)); + public static int sign(int value) => value > 0 ? 1 : value < 0 ? -1 : 0; + public static int2 sign(int2 value) => new(sign(value.x), sign(value.y)); + public static int3 sign(int3 value) => new(sign(value.x), sign(value.y), sign(value.z)); + public static int4 sign(int4 value) => new(sign(value.x), sign(value.y), sign(value.z), sign(value.w)); + + public static float floor(float value) => MathF.Floor(value); + public static float2 floor(float2 value) => new(floor(value.x), floor(value.y)); + public static float3 floor(float3 value) => new(floor(value.x), floor(value.y), floor(value.z)); + public static float4 floor(float4 value) => new(floor(value.x), floor(value.y), floor(value.z), floor(value.w)); + + public static float ceil(float value) => MathF.Ceiling(value); + public static float2 ceil(float2 value) => new(ceil(value.x), ceil(value.y)); + public static float3 ceil(float3 value) => new(ceil(value.x), ceil(value.y), ceil(value.z)); + public static float4 ceil(float4 value) => new(ceil(value.x), ceil(value.y), ceil(value.z), ceil(value.w)); + + public static float frac(float value) => value - floor(value); + public static float2 frac(float2 value) => value - floor(value); + public static float3 frac(float3 value) => value - floor(value); + public static float4 frac(float4 value) => value - floor(value); + + // dxc lowers float min/max/clamp to DXIL FMin/FMax (saturate to Saturate, defined as FMin(1, FMax(0, x))). + // DXIL.rst: FMin(a, b) is a < b ? a : b, FMax(a, b) is a >= b ? a : b, and a NaN operand returns the other. + public static float min(float left, float right) => left < right || float.IsNaN(right) ? left : right; + public static int min(int left, int right) => Math.Min(left, right); + public static float2 min(float2 left, float2 right) => new(min(left.x, right.x), min(left.y, right.y)); + public static float3 min(float3 left, float3 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z)); + public static float4 min(float4 left, float4 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z), min(left.w, right.w)); + public static int2 min(int2 left, int2 right) => new(min(left.x, right.x), min(left.y, right.y)); + public static int3 min(int3 left, int3 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z)); + public static int4 min(int4 left, int4 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z), min(left.w, right.w)); + + public static float max(float left, float right) => left >= right || float.IsNaN(right) ? left : right; + public static int max(int left, int right) => Math.Max(left, right); + public static float2 max(float2 left, float2 right) => new(max(left.x, right.x), max(left.y, right.y)); + public static float3 max(float3 left, float3 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z)); + public static float4 max(float4 left, float4 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z), max(left.w, right.w)); + public static int2 max(int2 left, int2 right) => new(max(left.x, right.x), max(left.y, right.y)); + public static int3 max(int3 left, int3 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z)); + public static int4 max(int4 left, int4 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z), max(left.w, right.w)); + + public static float clamp(float value, float minimum, float maximum) => min(max(value, minimum), maximum); + public static float2 clamp(float2 value, float2 minimum, float2 maximum) => min(max(value, minimum), maximum); + public static float3 clamp(float3 value, float3 minimum, float3 maximum) => min(max(value, minimum), maximum); + public static float4 clamp(float4 value, float4 minimum, float4 maximum) => min(max(value, minimum), maximum); + + // DXIL Saturate is FMin(1, FMax(0, x)); that operand order, unlike clamp(x, 0, 1), maps -0 to +0. + public static float saturate(float value) => min(1.0f, max(0.0f, value)); + public static float2 saturate(float2 value) => min(1.0f, max(0.0f, value)); + public static float3 saturate(float3 value) => min(1.0f, max(0.0f, value)); + public static float4 saturate(float4 value) => min(1.0f, max(0.0f, value)); + + public static float lerp(float start, float end, float amount) => start + (end - start) * amount; + public static double lerp(double start, double end, double amount) => start + (end - start) * amount; + public static float2 lerp(float2 start, float2 end, float2 amount) => start + (end - start) * amount; + public static float3 lerp(float3 start, float3 end, float3 amount) => start + (end - start) * amount; + public static float4 lerp(float4 start, float4 end, float4 amount) => start + (end - start) * amount; + public static double2 lerp(double2 start, double2 end, double2 amount) => start + (end - start) * amount; + public static double3 lerp(double3 start, double3 end, double3 amount) => start + (end - start) * amount; + + public static float unlerp(float start, float end, float value) => (value - start) / (end - start); + public static double unlerp(double start, double end, double value) => (value - start) / (end - start); + public static float2 unlerp(float2 start, float2 end, float2 value) => (value - start) / (end - start); + public static float3 unlerp(float3 start, float3 end, float3 value) => (value - start) / (end - start); + public static float4 unlerp(float4 start, float4 end, float4 value) => (value - start) / (end - start); + public static double2 unlerp(double2 start, double2 end, double2 value) => (value - start) / (end - start); + public static double3 unlerp(double3 start, double3 end, double3 value) => (value - start) / (end - start); + + // dxc lowers step(y, x) to x < y ? 0 : 1 (fcmp olt, then select), so a NaN on either side yields 1. + public static float step(float edge, float value) => value < edge ? 0.0f : 1.0f; + public static float2 step(float2 edge, float2 value) => new(step(edge.x, value.x), step(edge.y, value.y)); + public static float3 step(float3 edge, float3 value) => new(step(edge.x, value.x), step(edge.y, value.y), step(edge.z, value.z)); + public static float4 step(float4 edge, float4 value) => new(step(edge.x, value.x), step(edge.y, value.y), step(edge.z, value.z), step(edge.w, value.w)); + + public static float smoothstep(float minimum, float maximum, float value) + { + var t = saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0f - 2.0f * t); + } + + public static float2 smoothstep(float2 minimum, float2 maximum, float2 value) + { + var t = saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0f - 2.0f * t); + } + + public static float3 smoothstep(float3 minimum, float3 maximum, float3 value) + { + var t = saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0f - 2.0f * t); + } + + public static float4 smoothstep(float4 minimum, float4 maximum, float4 value) + { + var t = saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0f - 2.0f * t); + } + + public static float dot(float2 left, float2 right) => left.x * right.x + left.y * right.y; + public static float dot(float3 left, float3 right) => left.x * right.x + left.y * right.y + left.z * right.z; + public static float dot(float4 left, float4 right) => left.x * right.x + left.y * right.y + left.z * right.z + left.w * right.w; + + public static float3 cross(float3 left, float3 right) => + new( + left.y * right.z - left.z * right.y, + left.z * right.x - left.x * right.z, + left.x * right.y - left.y * right.x); + + public static float lengthsq(float2 value) => dot(value, value); + public static float lengthsq(float3 value) => dot(value, value); + public static float lengthsq(float4 value) => dot(value, value); + + public static float length(float2 value) => MathF.Sqrt(lengthsq(value)); + public static float length(float3 value) => MathF.Sqrt(lengthsq(value)); + public static float length(float4 value) => MathF.Sqrt(lengthsq(value)); + + public static float distance(float2 left, float2 right) => length(left - right); + public static float distance(float3 left, float3 right) => length(left - right); + public static float distance(float4 left, float4 right) => length(left - right); + + public static float2 normalize(float2 value) => value / max(length(value), 1.0e-20f); + public static float3 normalize(float3 value) => value / max(length(value), 1.0e-20f); + public static float4 normalize(float4 value) => value / max(length(value), 1.0e-20f); + public static quaternion normalize(quaternion value) + { + var length = MathF.Sqrt(value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w); + if (length <= 1.0e-20f) + { + return quaternion.identity; + } + + var invLength = 1.0f / length; + return new quaternion(value.x * invLength, value.y * invLength, value.z * invLength, value.w * invLength); + } + + public static float2 reflect(float2 incident, float2 normal) => incident - 2.0f * dot(normal, incident) * normal; + public static float3 reflect(float3 incident, float3 normal) => incident - 2.0f * dot(normal, incident) * normal; + + public static float2 rotate(float2 value, float radians) + { + var s = sin(radians); + var c = cos(radians); + return new float2(value.x * c - value.y * s, value.x * s + value.y * c); + } + + public static float2 rotate_degrees(float2 value, float degrees) => rotate(value, radians(degrees)); + + public static float csum(float2 value) => value.x + value.y; + public static float csum(float3 value) => value.x + value.y + value.z; + public static float csum(float4 value) => value.x + value.y + value.z + value.w; + + public static float decay(float source, float lambda, float dt) => source * exp(-lambda * dt); + public static float2 decay(float2 source, float lambda, float dt) => source * exp(-lambda * dt); + public static float3 decay(float3 source, float lambda, float dt) => source * exp(-lambda * dt); + public static float4 decay(float4 source, float lambda, float dt) => source * exp(-lambda * dt); + public static float2 decay(float2 source, float2 lambda, float dt) => source * exp(-lambda * dt); + public static float3 decay(float3 source, float3 lambda, float dt) => source * exp(-lambda * dt); + public static float4 decay(float4 source, float4 lambda, float dt) => source * exp(-lambda * dt); + + public static float damp(float start, float end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); + public static float2 damp(float2 start, float2 end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); + public static float3 damp(float3 start, float3 end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); + public static float4 damp(float4 start, float4 end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); + + public static float first_order_intercept_time(float shotSpeed, float3 targetRelativePosition, float3 targetRelativeVelocity) + { + var velocitySquared = lengthsq(targetRelativeVelocity); + if (velocitySquared < 0.001f) + { + return 0.0f; + } + + var a = velocitySquared - shotSpeed * shotSpeed; + if (abs(a) < 0.001f) + { + var time = -lengthsq(targetRelativePosition) / (2.0f * dot(targetRelativeVelocity, targetRelativePosition)); + return max(time, 0.0f); + } + + var b = 2.0f * dot(targetRelativeVelocity, targetRelativePosition); + var c = lengthsq(targetRelativePosition); + var determinant = b * b - 4.0f * a * c; + + if (determinant > 0.0f) + { + var root = sqrt(determinant); + var t1 = (-b + root) / (2.0f * a); + var t2 = (-b - root) / (2.0f * a); + if (t1 > 0.0f) + { + return t2 > 0.0f ? min(t1, t2) : t1; + } + + return max(t2, 0.0f); + } + + if (determinant < 0.0f) + { + return 0.0f; + } + + return max(-b / (2.0f * a), 0.0f); + } + + public static float3 first_order_intercept( + float3 shooterPosition, + float3 shooterVelocity, + float shotSpeed, + float3 targetPosition, + float3 targetVelocity) + { + var targetRelativePosition = targetPosition - shooterPosition; + var targetRelativeVelocity = targetVelocity - shooterVelocity; + var time = first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity); + return targetPosition + time * targetRelativeVelocity; + } + + public static float distance_to_segment(float2 point, float2 start, float2 end, out float2 closest) + { + var segment = end - start; + var segmentLengthSq = lengthsq(segment); + if (segmentLengthSq <= 0.0f) + { + closest = start; + return distance(point, start); + } + + var t = saturate(dot(point - start, segment) / segmentLengthSq); + closest = start + segment * t; + return distance(point, closest); + } + + public static float catmullrom(float p0, float p1, float p2, float p3, float t) + { + var t2 = t * t; + var t3 = t2 * t; + return 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3); + } + + public static float2 catmullrom(float2 p0, float2 p1, float2 p2, float2 p3, float t) => + 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * (t * t) + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * (t * t * t)); + + public static float3 catmullrom(float3 p0, float3 p1, float3 p2, float3 p3, float t) => + 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * (t * t) + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * (t * t * t)); + + public static float4 catmullrom(float4 p0, float4 p1, float4 p2, float4 p3, float t) => + 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * (t * t) + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * (t * t * t)); + + public static float quadratic_bezier(float p0, float p1, float p2, float t) + { + t = saturate(t); + var inv = 1.0f - t; + return inv * inv * p0 + 2.0f * inv * t * p1 + t * t * p2; + } + + public static float3 quadratic_bezier(float3 p0, float3 p1, float3 p2, float t) + { + t = saturate(t); + var inv = 1.0f - t; + return inv * inv * p0 + 2.0f * inv * t * p1 + t * t * p2; + } + + public static float cubic_bezier(float p0, float p1, float p2, float p3, float t) + { + t = saturate(t); + var inv = 1.0f - t; + return inv * inv * inv * p0 + 3.0f * inv * inv * t * p1 + 3.0f * inv * t * t * p2 + t * t * t * p3; + } + + public static float3 cubic_bezier(float3 p0, float3 p1, float3 p2, float3 p3, float t) + { + t = saturate(t); + var inv = 1.0f - t; + return inv * inv * inv * p0 + 3.0f * inv * inv * t * p1 + 3.0f * inv * t * t * p2 + t * t * t * p3; + } + + public static float smoothstep01(float value) => smoothstep(0.0f, 1.0f, value); + + public static float smootherstep(float value) + { + value = saturate(value); + return value * value * value * (value * (value * 6.0f - 15.0f) + 10.0f); + } + + public static float hash(float value) => frac(sin(value) * 43758.5453f); + public static float hash(float2 value) => hash(dot(value, new float2(127.1f, 311.7f))); + public static float hash(float3 value) => hash(dot(value, new float3(127.1f, 311.7f, 74.7f))); + + public static float log(float value) => MathF.Log(value); + public static float2 log(float2 value) => new(log(value.x), log(value.y)); + public static float3 log(float3 value) => new(log(value.x), log(value.y), log(value.z)); + public static float4 log(float4 value) => new(log(value.x), log(value.y), log(value.z), log(value.w)); + + public static float2 pow(float2 value, float2 power) => new(pow(value.x, power.x), pow(value.y, power.y)); + public static float3 pow(float3 value, float3 power) => new(pow(value.x, power.x), pow(value.y, power.y), pow(value.z, power.z)); + public static float4 pow(float4 value, float4 power) => new(pow(value.x, power.x), pow(value.y, power.y), pow(value.z, power.z), pow(value.w, power.w)); + + public static float2 sqrt(float2 value) => new(sqrt(value.x), sqrt(value.y)); + public static float3 sqrt(float3 value) => new(sqrt(value.x), sqrt(value.y), sqrt(value.z)); + public static float4 sqrt(float4 value) => new(sqrt(value.x), sqrt(value.y), sqrt(value.z), sqrt(value.w)); + + public static float2 sin(float2 value) => new(sin(value.x), sin(value.y)); + public static float3 sin(float3 value) => new(sin(value.x), sin(value.y), sin(value.z)); + public static float4 sin(float4 value) => new(sin(value.x), sin(value.y), sin(value.z), sin(value.w)); + + public static float2 cos(float2 value) => new(cos(value.x), cos(value.y)); + public static float3 cos(float3 value) => new(cos(value.x), cos(value.y), cos(value.z)); + public static float4 cos(float4 value) => new(cos(value.x), cos(value.y), cos(value.z), cos(value.w)); + + public static float2 acos(float2 value) => new(acos(value.x), acos(value.y)); + public static float3 acos(float3 value) => new(acos(value.x), acos(value.y), acos(value.z)); + public static float4 acos(float4 value) => new(acos(value.x), acos(value.y), acos(value.z), acos(value.w)); + + public static float2 atan2(float2 y, float2 x) => new(atan2(y.x, x.x), atan2(y.y, x.y)); + public static float3 atan2(float3 y, float3 x) => new(atan2(y.x, x.x), atan2(y.y, x.y), atan2(y.z, x.z)); + public static float4 atan2(float4 y, float4 x) => new(atan2(y.x, x.x), atan2(y.y, x.y), atan2(y.z, x.z), atan2(y.w, x.w)); + + // DXIL FMin/FMax take double overloads with the same rules as float (see min/max above). + public static double min(double left, double right) => left < right || double.IsNaN(right) ? left : right; + public static double max(double left, double right) => left >= right || double.IsNaN(right) ? left : right; + + public static bool any(bool2 value) => value.x || value.y; + public static bool any(bool3 value) => value.x || value.y || value.z; + public static bool any(bool4 value) => value.x || value.y || value.z || value.w; + public static bool all(bool2 value) => value.x && value.y; + public static bool all(bool3 value) => value.x && value.y && value.z; + public static bool all(bool4 value) => value.x && value.y && value.z && value.w; + + // HLSL any/all on numeric vectors test components against zero (NaN != 0 counts as true). + public static bool any(float2 value) => value.x != 0.0f || value.y != 0.0f; + public static bool any(float3 value) => value.x != 0.0f || value.y != 0.0f || value.z != 0.0f; + public static bool any(float4 value) => value.x != 0.0f || value.y != 0.0f || value.z != 0.0f || value.w != 0.0f; + public static bool all(float2 value) => value.x != 0.0f && value.y != 0.0f; + public static bool all(float3 value) => value.x != 0.0f && value.y != 0.0f && value.z != 0.0f; + public static bool all(float4 value) => value.x != 0.0f && value.y != 0.0f && value.z != 0.0f && value.w != 0.0f; + public static bool any(int2 value) => value.x != 0 || value.y != 0; + public static bool any(int3 value) => value.x != 0 || value.y != 0 || value.z != 0; + public static bool any(int4 value) => value.x != 0 || value.y != 0 || value.z != 0 || value.w != 0; + public static bool all(int2 value) => value.x != 0 && value.y != 0; + public static bool all(int3 value) => value.x != 0 && value.y != 0 && value.z != 0; + public static bool all(int4 value) => value.x != 0 && value.y != 0 && value.z != 0 && value.w != 0; + + // HLSL 2021 argument order: select(condition, whenTrue, whenFalse). + // Unity.Mathematics' select(falseValue, trueValue, condition) is the reverse. + public static float select(bool condition, float whenTrue, float whenFalse) => condition ? whenTrue : whenFalse; + public static float2 select(bool2 condition, float2 whenTrue, float2 whenFalse) => + new(condition.x ? whenTrue.x : whenFalse.x, condition.y ? whenTrue.y : whenFalse.y); + public static float3 select(bool3 condition, float3 whenTrue, float3 whenFalse) => + new(condition.x ? whenTrue.x : whenFalse.x, condition.y ? whenTrue.y : whenFalse.y, condition.z ? whenTrue.z : whenFalse.z); + public static float4 select(bool4 condition, float4 whenTrue, float4 whenFalse) => + new(condition.x ? whenTrue.x : whenFalse.x, condition.y ? whenTrue.y : whenFalse.y, condition.z ? whenTrue.z : whenFalse.z, condition.w ? whenTrue.w : whenFalse.w); + + // HLSL mul: mul(m, v) treats v as a column vector, mul(v, m) as a row vector. + public static float mul(float left, float right) => left * right; + + public static float2 mul(float2x2 m, float2 v) => + new(m._m00 * v.x + m._m01 * v.y, m._m10 * v.x + m._m11 * v.y); + + public static float2 mul(float2 v, float2x2 m) => + new(v.x * m._m00 + v.y * m._m10, v.x * m._m01 + v.y * m._m11); + + public static float2x2 mul(float2x2 a, float2x2 b) => + new( + a._m00 * b._m00 + a._m01 * b._m10, a._m00 * b._m01 + a._m01 * b._m11, + a._m10 * b._m00 + a._m11 * b._m10, a._m10 * b._m01 + a._m11 * b._m11); + + public static float3 mul(float3x3 m, float3 v) => + new( + m._m00 * v.x + m._m01 * v.y + m._m02 * v.z, + m._m10 * v.x + m._m11 * v.y + m._m12 * v.z, + m._m20 * v.x + m._m21 * v.y + m._m22 * v.z); + + public static float3 mul(float3 v, float3x3 m) => + new( + v.x * m._m00 + v.y * m._m10 + v.z * m._m20, + v.x * m._m01 + v.y * m._m11 + v.z * m._m21, + v.x * m._m02 + v.y * m._m12 + v.z * m._m22); + + public static float3x3 mul(float3x3 a, float3x3 b) => + new( + a._m00 * b._m00 + a._m01 * b._m10 + a._m02 * b._m20, + a._m00 * b._m01 + a._m01 * b._m11 + a._m02 * b._m21, + a._m00 * b._m02 + a._m01 * b._m12 + a._m02 * b._m22, + a._m10 * b._m00 + a._m11 * b._m10 + a._m12 * b._m20, + a._m10 * b._m01 + a._m11 * b._m11 + a._m12 * b._m21, + a._m10 * b._m02 + a._m11 * b._m12 + a._m12 * b._m22, + a._m20 * b._m00 + a._m21 * b._m10 + a._m22 * b._m20, + a._m20 * b._m01 + a._m21 * b._m11 + a._m22 * b._m21, + a._m20 * b._m02 + a._m21 * b._m12 + a._m22 * b._m22); + + // Ashima Arts / Ian McEwan 3D simplex noise (MIT), mirrored by cultmath_snoise(float3) + // in shaders/CultMath.hlsl with the same float32 evaluation order. + public static float snoise(float3 value) + { + const float cx = 1.0f / 6.0f; + const float cy = 1.0f / 3.0f; + var i = floor(value + dot(value, new float3(cy, cy, cy))); + var x0 = value - i + dot(i, new float3(cx, cx, cx)); + var g = step(new float3(x0.y, x0.z, x0.x), x0); + var l = 1.0f - g; + var lzxy = new float3(l.z, l.x, l.y); + var i1 = min(g, lzxy); + var i2 = max(g, lzxy); + var x1 = x0 - i1 + cx; + var x2 = x0 - i2 + cy; + var x3 = x0 - 0.5f; + + i = snoise_mod289(i); + var p = snoise_permute(snoise_permute(snoise_permute( + i.z + new float4(0.0f, i1.z, i2.z, 1.0f)) + i.y + new float4(0.0f, i1.y, i2.y, 1.0f)) + i.x + new float4(0.0f, i1.x, i2.x, 1.0f)); + + const float n = 0.142857142857f; + var ns = new float3(2.0f * n, 0.5f * n - 1.0f, n); + var j = p - 49.0f * floor(p * ns.z * ns.z); + var xs = floor(j * ns.z); + var ys = floor(j - 7.0f * xs); + var x = xs * ns.x + ns.y; + var y = ys * ns.x + ns.y; + var h = 1.0f - abs(x) - abs(y); + + var b0 = new float4(x.x, x.y, y.x, y.y); + var b1 = new float4(x.z, x.w, y.z, y.w); + var s0 = floor(b0) * 2.0f + 1.0f; + var s1 = floor(b1) * 2.0f + 1.0f; + var sh = -step(h, 0.0f); + var a0 = new float4(b0.x, b0.z, b0.y, b0.w) + new float4(s0.x, s0.z, s0.y, s0.w) * new float4(sh.x, sh.x, sh.y, sh.y); + var a1 = new float4(b1.x, b1.z, b1.y, b1.w) + new float4(s1.x, s1.z, s1.y, s1.w) * new float4(sh.z, sh.z, sh.w, sh.w); + + var p0 = new float3(a0.x, a0.y, h.x); + var p1 = new float3(a0.z, a0.w, h.y); + var p2 = new float3(a1.x, a1.y, h.z); + var p3 = new float3(a1.z, a1.w, h.w); + var norm = 1.79284291400159f - 0.85373472095314f * new float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + var m = max(0.6f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f); + m = m * m; + return 42.0f * dot(m * m, new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); + } + + // Ashima Arts / Ian McEwan 2D simplex noise, kept component-explicit so + // the C# and HLSL mirrors preserve the same float32 evaluation order. + public static float snoise(float2 value) + { + var c = new float4( + 0.211324865405187f, + 0.366025403784439f, + -0.577350269189626f, + 0.024390243902439f); + var i = floor(value + dot(value, new float2(c.y, c.y))); + var x0 = value - i + dot(i, new float2(c.x, c.x)); + var i1 = x0.x > x0.y ? new float2(1.0f, 0.0f) : new float2(0.0f, 1.0f); + var x12 = new float4( + x0.x + c.x - i1.x, + x0.y + c.x - i1.y, + x0.x + c.z, + x0.y + c.z); + + i = snoise_mod289(i); + var p = snoise_permute(snoise_permute(i.y + new float3(0.0f, i1.y, 1.0f)) + i.x + new float3(0.0f, i1.x, 1.0f)); + var m = max(0.5f - new float3( + dot(x0, x0), + x12.x * x12.x + x12.y * x12.y, + x12.z * x12.z + x12.w * x12.w), 0.0f); + m = m * m; + m = m * m; + + var x = 2.0f * frac(p * c.w) - 1.0f; + var h = abs(x) - 0.5f; + var ox = floor(x + 0.5f); + var a0 = x - ox; + m *= 1.79284291400159f - 0.85373472095314f * (a0 * a0 + h * h); + + var g = new float3( + a0.x * x0.x + h.x * x0.y, + a0.y * x12.x + h.y * x12.y, + a0.z * x12.z + h.z * x12.w); + return 130.0f * dot(m, g); + } + + private static float2 snoise_mod289(float2 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f; + private static float3 snoise_mod289(float3 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f; + private static float4 snoise_mod289(float4 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f; + private static float3 snoise_permute(float3 value) => snoise_mod289(((value * 34.0f) + 1.0f) * value); + private static float4 snoise_permute(float4 value) => snoise_mod289(((value * 34.0f) + 1.0f) * value); + + public static float value_noise(float2 position) + { + var cell = floor(position); + var local = frac(position); + var u = local * local * (3.0f - 2.0f * local); + + var a = hash(cell); + var b = hash(cell + new float2(1.0f, 0.0f)); + var c = hash(cell + new float2(0.0f, 1.0f)); + var d = hash(cell + new float2(1.0f, 1.0f)); + + return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y); + } + + public static float value_noise_bicubic(float2 position) + { + var cell = floor(position); + var local = frac(position); + + var y0 = catmullrom( + hash(cell + new float2(-1.0f, -1.0f)), + hash(cell + new float2(0.0f, -1.0f)), + hash(cell + new float2(1.0f, -1.0f)), + hash(cell + new float2(2.0f, -1.0f)), + local.x); + var y1 = catmullrom( + hash(cell + new float2(-1.0f, 0.0f)), + hash(cell + new float2(0.0f, 0.0f)), + hash(cell + new float2(1.0f, 0.0f)), + hash(cell + new float2(2.0f, 0.0f)), + local.x); + var y2 = catmullrom( + hash(cell + new float2(-1.0f, 1.0f)), + hash(cell + new float2(0.0f, 1.0f)), + hash(cell + new float2(1.0f, 1.0f)), + hash(cell + new float2(2.0f, 1.0f)), + local.x); + var y3 = catmullrom( + hash(cell + new float2(-1.0f, 2.0f)), + hash(cell + new float2(0.0f, 2.0f)), + hash(cell + new float2(1.0f, 2.0f)), + hash(cell + new float2(2.0f, 2.0f)), + local.x); + + return catmullrom(y0, y1, y2, y3, local.y); + } +} diff --git a/packages/cultmath/src/CultMath/math.cs.meta b/packages/cultmath/src/CultMath/math.cs.meta new file mode 100644 index 00000000..9222af7d --- /dev/null +++ b/packages/cultmath/src/CultMath/math.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0d9b4cacc40d169429ee743d19577cb7 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/quaternion.cs b/packages/cultmath/src/CultMath/quaternion.cs new file mode 100644 index 00000000..e8c4a48c --- /dev/null +++ b/packages/cultmath/src/CultMath/quaternion.cs @@ -0,0 +1,95 @@ +using System; + +namespace CultMath; + +/// +/// Not an HLSL type: shaders carry rotations as float4. Equality therefore stays +/// scalar (== returns bool) instead of following the component-wise vector rule. +/// +public struct quaternion : IEquatable +{ + public static readonly quaternion identity = new(0.0f, 0.0f, 0.0f, 1.0f); + + public float x; + public float y; + public float z; + public float w; + + public quaternion(float x, float y, float z, float w) + { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + public static quaternion LookRotation(float3 forward, float3 up) + { + forward = math.normalize(forward); + up = math.normalize(up); + + var right = math.normalize(math.cross(up, forward)); + up = math.cross(forward, right); + + var m00 = right.x; + var m01 = up.x; + var m02 = forward.x; + var m10 = right.y; + var m11 = up.y; + var m12 = forward.y; + var m20 = right.z; + var m21 = up.z; + var m22 = forward.z; + + var trace = m00 + m11 + m22; + if (trace > 0.0f) + { + var s = math.sqrt(trace + 1.0f) * 2.0f; + return math.normalize(new quaternion( + (m21 - m12) / s, + (m02 - m20) / s, + (m10 - m01) / s, + 0.25f * s)); + } + + if (m00 > m11 && m00 > m22) + { + var s = math.sqrt(1.0f + m00 - m11 - m22) * 2.0f; + return math.normalize(new quaternion( + 0.25f * s, + (m01 + m10) / s, + (m02 + m20) / s, + (m21 - m12) / s)); + } + + if (m11 > m22) + { + var s = math.sqrt(1.0f + m11 - m00 - m22) * 2.0f; + return math.normalize(new quaternion( + (m01 + m10) / s, + 0.25f * s, + (m12 + m21) / s, + (m02 - m20) / s)); + } + + { + var s = math.sqrt(1.0f + m22 - m00 - m11) * 2.0f; + return math.normalize(new quaternion( + (m02 + m20) / s, + (m12 + m21) / s, + 0.25f * s, + (m10 - m01) / s)); + } + } + + public static implicit operator quaternion(float4 value) => new(value.x, value.y, value.z, value.w); + public static implicit operator float4(quaternion value) => new(value.x, value.y, value.z, value.w); + + public static bool operator ==(quaternion left, quaternion right) => left.Equals(right); + public static bool operator !=(quaternion left, quaternion right) => !left.Equals(right); + + public readonly bool Equals(quaternion other) => x.Equals(other.x) && y.Equals(other.y) && z.Equals(other.z) && w.Equals(other.w); + public override readonly bool Equals(object? obj) => obj is quaternion other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(x, y, z, w); + public override readonly string ToString() => FormattableString.Invariant($"quaternion({x:R}, {y:R}, {z:R}, {w:R})"); +} diff --git a/packages/cultmath/src/CultMath/quaternion.cs.meta b/packages/cultmath/src/CultMath/quaternion.cs.meta new file mode 100644 index 00000000..ae6e8a11 --- /dev/null +++ b/packages/cultmath/src/CultMath/quaternion.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 12f3627d9250dc348be9c1a1056ec7d0 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/rect.cs b/packages/cultmath/src/CultMath/rect.cs new file mode 100644 index 00000000..51e05c19 --- /dev/null +++ b/packages/cultmath/src/CultMath/rect.cs @@ -0,0 +1,59 @@ +namespace CultMath; + +/// +/// Axis-aligned 2D bounds stored canonically as normalized minimum and maximum +/// corners. Point containment and intersection include the boundary. +/// +public readonly record struct rect +{ + public rect(float minX, float minY, float maxX, float maxY) + : this(new float2(minX, minY), new float2(maxX, maxY)) + { + } + + public rect(float2 min, float2 max) + { + this.min = new float2(MathF.Min(min.x, max.x), MathF.Min(min.y, max.y)); + this.max = new float2(MathF.Max(min.x, max.x), MathF.Max(min.y, max.y)); + } + + public float2 min { get; } + public float2 max { get; } + + public float width => max.x - min.x; + public float height => max.y - min.y; + public float2 size => max - min; + public float2 center => (min + max) * 0.5f; + public float area => width * height; + public bool isEmpty => width == 0.0f || height == 0.0f; + + public bool Contains(float2 point) => + point.x >= min.x && point.x <= max.x && + point.y >= min.y && point.y <= max.y; + + public bool Contains(rect other) => + other.min.x >= min.x && other.max.x <= max.x && + other.min.y >= min.y && other.max.y <= max.y; + + public bool Intersects(rect other) => + min.x <= other.max.x && max.x >= other.min.x && + min.y <= other.max.y && max.y >= other.min.y; + + public rect Intersection(rect other) + { + if (!Intersects(other)) + return new rect(0.0f, 0.0f, 0.0f, 0.0f); + + return new rect( + MathF.Max(min.x, other.min.x), + MathF.Max(min.y, other.min.y), + MathF.Min(max.x, other.max.x), + MathF.Min(max.y, other.max.y)); + } + + public rect Encapsulate(float2 point) => new( + MathF.Min(min.x, point.x), + MathF.Min(min.y, point.y), + MathF.Max(max.x, point.x), + MathF.Max(max.y, point.y)); +} diff --git a/packages/cultmath/src/CultMath/rect.cs.meta b/packages/cultmath/src/CultMath/rect.cs.meta new file mode 100644 index 00000000..0b04a5ce --- /dev/null +++ b/packages/cultmath/src/CultMath/rect.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5aab0d8e1ca94d42b85bba780a7ee449 diff --git a/packages/cultmath/tests.meta b/packages/cultmath/tests.meta new file mode 100644 index 00000000..a35356c4 --- /dev/null +++ b/packages/cultmath/tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4ca61dedbfb3c5d46b0acaf23202b5c2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/tests/CultMath.Tests.meta b/packages/cultmath/tests/CultMath.Tests.meta new file mode 100644 index 00000000..3f1451c6 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bcee65f13f85daa45b0cf3fdaa901779 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj b/packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj new file mode 100644 index 00000000..d77fd05f --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + diff --git a/packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj.meta b/packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj.meta new file mode 100644 index 00000000..709184bb --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 636051db121e19146a0baf92b790415c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs new file mode 100644 index 00000000..445ad255 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs @@ -0,0 +1,77 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class HlslMirrorTests +{ + [Fact] + public void HlslMirrorDoesNotPublishGeometryKernels() + { + var source = File.ReadAllText(Path.Combine(GetCultMathRoot(), "shaders", "CultMath.hlsl")); + Assert.DoesNotContain("AdvancedErosionFilter.hlsl", source); + Assert.DoesNotContain("Planetary.hlsl", source); + Assert.DoesNotContain("spherical_erosion", source); + Assert.DoesNotContain("planetary_radial_refinement", source); + } + + [Fact] + public void HlslMirrorPublishesCultMathPrimitives() + { + var include = File.ReadAllText(Path.Combine(GetCultMathRoot(), "shaders", "CultMath.hlsl")); + var requiredSymbols = new[] + { + "cultmath_radians", + "cultmath_degrees", + "cultmath_frac", + "cultmath_clamp", + "cultmath_saturate", + "cultmath_lerp", + "cultmath_step", + "cultmath_smoothstep", + "cultmath_smootherstep", + "cultmath_lengthsq", + "cultmath_distance", + "cultmath_normalize", + "cultmath_reflect", + "cultmath_rotate", + "cultmath_csum", + "cultmath_decay", + "cultmath_damp", + "cultmath_catmullrom", + "cultmath_quadratic_bezier", + "cultmath_cubic_bezier", + "cultmath_hash", + "cultmath_value_noise", + "cultmath_snoise(float2", + "cultmath_snoise(float3", + "cultmath_value_noise_bicubic", + "cultmath_value_noise_texture", + "cultmath_value_noise_texture_bicubic", + }; + + foreach (var symbol in requiredSymbols) + { + Assert.Contains(symbol, include); + } + + Assert.Contains("CULTMATH_NORMALIZE_EPSILON", include); + Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include); + } + + private static string GetCultMathRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var monorepoPackage = Path.Combine(directory.FullName, "packages", "cultmath"); + if (File.Exists(Path.Combine(monorepoPackage, "shaders", "CultMath.hlsl"))) + { + return monorepoPackage; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate the CultMath package root."); + } +} diff --git a/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs.meta b/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs.meta new file mode 100644 index 00000000..1fcf44b3 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3d7eb9df849ea0148a71419bbfe2352c \ No newline at end of file diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs new file mode 100644 index 00000000..7b845ea7 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs @@ -0,0 +1,441 @@ +using System.Text.Json; +using Xunit; + +namespace CultMath.Tests; + +public sealed class HlslSemanticsTests +{ + [Fact] + public void ComponentsAndIndexersAreWritable() + { + var value = new float4(1.0f, 2.0f, 3.0f, 4.0f); + value.w = 9.0f; + value[0] = -1.0f; + + Assert.Equal(new float4(-1.0f, 2.0f, 3.0f, 9.0f), value); + + var cell = new int3(1, 2, 3); + cell[2] = 7; + Assert.Equal(new int3(1, 2, 7), cell); + + ref var component = ref value.y; + component = 20.0f; + Assert.Equal(20.0f, value.y); + } + + [Fact] + public void Float2SwizzlesReadAndWrite() + { + var value = new float2(1.0f, 2.0f); + Assert.Equal(new float2(2.0f, 1.0f), value.yx); + + value.yx = new float2(5.0f, 6.0f); + Assert.Equal(new float2(6.0f, 5.0f), value); + } + + [Fact] + public void Float3SwizzlesReadAndWrite() + { + var source = new float3(1.0f, 2.0f, 3.0f); + Assert.Equal(new float2(1.0f, 2.0f), source.xy); + Assert.Equal(new float2(1.0f, 3.0f), source.xz); + Assert.Equal(new float2(2.0f, 1.0f), source.yx); + Assert.Equal(new float2(2.0f, 3.0f), source.yz); + Assert.Equal(new float2(3.0f, 2.0f), source.zy); + Assert.Equal(new float3(1.0f, 2.0f, 3.0f), source.xyz); + Assert.Equal(new float3(1.0f, 3.0f, 2.0f), source.xzy); + Assert.Equal(new float3(2.0f, 3.0f, 1.0f), source.yzx); + Assert.Equal(new float3(3.0f, 2.0f, 1.0f), source.zyx); + + Assert.Equal(new float3(8.0f, 9.0f, 3.0f), Write(source, (ref float3 v) => v.xy = new float2(8.0f, 9.0f))); + Assert.Equal(new float3(8.0f, 2.0f, 9.0f), Write(source, (ref float3 v) => v.xz = new float2(8.0f, 9.0f))); + Assert.Equal(new float3(9.0f, 8.0f, 3.0f), Write(source, (ref float3 v) => v.yx = new float2(8.0f, 9.0f))); + Assert.Equal(new float3(1.0f, 8.0f, 9.0f), Write(source, (ref float3 v) => v.yz = new float2(8.0f, 9.0f))); + Assert.Equal(new float3(1.0f, 9.0f, 8.0f), Write(source, (ref float3 v) => v.zy = new float2(8.0f, 9.0f))); + Assert.Equal(new float3(7.0f, 8.0f, 9.0f), Write(source, (ref float3 v) => v.xyz = new float3(7.0f, 8.0f, 9.0f))); + Assert.Equal(new float3(7.0f, 9.0f, 8.0f), Write(source, (ref float3 v) => v.xzy = new float3(7.0f, 8.0f, 9.0f))); + Assert.Equal(new float3(9.0f, 7.0f, 8.0f), Write(source, (ref float3 v) => v.yzx = new float3(7.0f, 8.0f, 9.0f))); + Assert.Equal(new float3(9.0f, 8.0f, 7.0f), Write(source, (ref float3 v) => v.zyx = new float3(7.0f, 8.0f, 9.0f))); + } + + [Fact] + public void Float4SwizzlesReadAndWrite() + { + var source = new float4(1.0f, 2.0f, 3.0f, 4.0f); + Assert.Equal(new float2(1.0f, 2.0f), source.xy); + Assert.Equal(new float3(1.0f, 2.0f, 3.0f), source.xyz); + Assert.Equal(new float3(1.0f, 2.0f, 4.0f), source.xyw); + Assert.Equal(new float3(2.0f, 3.0f, 1.0f), source.yzx); + + var a = source; + a.xy = new float2(8.0f, 9.0f); + Assert.Equal(new float4(8.0f, 9.0f, 3.0f, 4.0f), a); + + var b = source; + b.xyz = new float3(7.0f, 8.0f, 9.0f); + Assert.Equal(new float4(7.0f, 8.0f, 9.0f, 4.0f), b); + + var c = source; + c.xyw = new float3(7.0f, 8.0f, 9.0f); + Assert.Equal(new float4(7.0f, 8.0f, 3.0f, 9.0f), c); + + var d = source; + d.yzx = new float3(7.0f, 8.0f, 9.0f); + Assert.Equal(new float4(9.0f, 7.0f, 8.0f, 4.0f), d); + } + + [Fact] + public void GeneratedSwizzlesCoverRepeatsColorNamesAndAllVectorFamilies() + { + var v = new float4(1.0f, 2.0f, 3.0f, 4.0f); + Assert.Equal(new float4(4.0f, 4.0f, 1.0f, 2.0f), v.wwxy); + Assert.Equal(new float3(3.0f, 2.0f, 1.0f), v.bgr); + Assert.Equal(4.0f, v.a); + Assert.Equal(new float4(2.0f, 2.0f, 2.0f, 2.0f), new float2(1.0f, 2.0f).yyyy); + + v.wx = new float2(9.0f, 8.0f); + Assert.Equal(new float4(8.0f, 2.0f, 3.0f, 9.0f), v); + v.r = 0.5f; + v.gb += new float2(1.0f, 1.0f); + Assert.Equal(new float4(0.5f, 3.0f, 4.0f, 9.0f), v); + + Assert.Equal(new int3(3, 1, 1), new int3(1, 2, 3).zxx); + Assert.Equal(new bool2(false, true), new bool4(true, false, false, true).zw); + Assert.Equal(new double2(2.0, 1.0), new double3(1.0, 2.0, 3.0).yx); + + var cell = new int4(1, 2, 3, 4); + cell.xyz = new int3(7, 8, 9); + Assert.Equal(new int4(7, 8, 9, 4), cell); + } + + [Fact] + public void MixedConstructorsMatchHlsl() + { + var xy = new float2(1.0f, 2.0f); + var zw = new float2(3.0f, 4.0f); + var expected = new float4(1.0f, 2.0f, 3.0f, 4.0f); + + Assert.Equal(expected, math.float4(xy, zw)); + Assert.Equal(expected, math.float4(1.0f, new float3(2.0f, 3.0f, 4.0f))); + Assert.Equal(expected, math.float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); + Assert.Equal(expected, math.float4(xy, 3.0f, 4.0f)); + Assert.Equal(expected, math.float4(1.0f, 2.0f, zw)); + Assert.Equal(expected, math.float4(1.0f, new float2(2.0f, 3.0f), 4.0f)); + Assert.Equal(expected, new float4(xy, zw)); + Assert.Equal(new float3(1.0f, 2.0f, 3.0f), math.float3(1.0f, zw - 1.0f)); + Assert.Equal(new int4(1, 2, 3, 4), math.int4(new int2(1, 2), new int2(3, 4))); + Assert.Equal(new bool3(true, false, true), math.bool3(true, new bool2(false, true))); + Assert.Equal(new double3(1.0, 2.0, 3.0), new double3(new double2(1.0, 2.0), 3.0)); + } + + [Fact] + public void MatrixRowElementsAreWritable() + { + var m = float3x3.identity; + m[1][2] = 5.0f; + m[2].x = 7.0f; + m._m00 = 2.0f; + + Assert.Equal(5.0f, m._m12); + Assert.Equal(7.0f, m._m20); + Assert.Equal(math.float3x3(2.0f, 0.0f, 0.0f, 0.0f, 1.0f, 5.0f, 7.0f, 0.0f, 1.0f), m); + Assert.Equal(float3x3.identity, math.float3x3(1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f)); + + var r = float2x2.identity; + r[0][1] = 3.0f; + Assert.Equal(math.float2x2(1.0f, 3.0f, 0.0f, 1.0f), r); + Assert.Equal(new float2(0.0f, 1.0f), float2x2.identity[1]); + } + + [Fact] + public void IntrinsicEdgeCasesFollowHlsl() + { + Assert.True(math.any(new float3(0.0f, 0.0f, -2.0f))); + Assert.False(math.any(float4.zero)); + Assert.True(math.all(new int2(1, -1))); + Assert.False(math.all(new int3(1, 0, 1))); + Assert.True(math.any(new float2(float.NaN, 0.0f))); + + Assert.Equal(new int3(1, 2, 3), math.min(new int3(1, 5, 3), new int3(4, 2, 6))); + Assert.Equal(new int2(4, 5), math.max(new int2(1, 5), new int2(4, 2))); + Assert.Equal(new int4(1, 2, 0, 3), math.abs(new int4(-1, 2, 0, -3))); + Assert.Equal(7, math.abs(-7)); + + Assert.Equal(1.0f, math.step(1.0f, float.NaN)); + Assert.Equal(1.0f, math.step(float.NaN, 1.0f)); + Assert.Equal(1.0f, math.step(1.0f, 1.0f)); + + // DXIL FMin/FMax/Saturate: a NaN operand returns the other; max(a, b) is a >= b ? a : b. + Assert.Equal(1.0f, math.min(float.NaN, 1.0f)); + Assert.Equal(1.0f, math.min(1.0f, float.NaN)); + Assert.Equal(1.0f, math.max(float.NaN, 1.0f)); + Assert.Equal(1.0f, math.max(1.0f, float.NaN)); + Assert.True(float.IsNaN(math.max(float.NaN, float.NaN))); + Assert.Equal(2.0f, math.clamp(float.NaN, 2.0f, 3.0f)); + Assert.Equal(new float3(0.0f, 1.0f, 0.0f), math.saturate(new float3(float.NaN, 2.0f, -1.0f))); + Assert.Equal(new float2(-1.0f, 4.0f), math.max(new float2(float.NaN, 4.0f), new float2(-1.0f, float.NaN))); + static int Bits(float value) => BitConverter.SingleToInt32Bits(value); + Assert.Equal(Bits(-0.0f), Bits(math.max(-0.0f, 0.0f))); + Assert.Equal(Bits(0.0f), Bits(math.min(-0.0f, 0.0f))); + Assert.Equal(Bits(0.0f), Bits(math.saturate(-0.0f))); + Assert.Equal(1.0, math.min(double.NaN, 1.0)); + Assert.Equal(1.0, math.min(1.0, double.NaN)); + Assert.Equal(1.0, math.max(double.NaN, 1.0)); + Assert.Equal(1.0, math.max(1.0, double.NaN)); + Assert.True(double.IsNaN(math.max(double.NaN, double.NaN))); + static long DoubleBits(double value) => BitConverter.DoubleToInt64Bits(value); + Assert.Equal(DoubleBits(-0.0), DoubleBits(math.max(-0.0, 0.0))); + Assert.Equal(DoubleBits(0.0), DoubleBits(math.min(-0.0, 0.0))); + + Assert.Equal(0, math.sign(float.NaN)); + Assert.Equal(-1, math.sign(-0.5f)); + Assert.Equal(new int3(-1, 0, 1), math.sign(new float3(-2.0f, 0.0f, 4.0f))); + Assert.Equal(new int2(1, -1), math.sign(new int2(9, -9))); + } + + [Fact] + public void ComparisonOperatorsReturnComponentWiseBoolVectors() + { + var a = new float3(1.0f, 2.0f, 3.0f); + var b = new float3(3.0f, 2.0f, 1.0f); + + Assert.Equal(new bool3(true, false, false), a < b); + Assert.Equal(new bool3(false, false, true), a > b); + Assert.Equal(new bool3(true, true, false), a <= b); + Assert.Equal(new bool3(false, true, true), a >= b); + Assert.Equal(new bool3(false, true, false), a == b); + Assert.Equal(new bool3(true, false, true), a != b); + + Assert.Equal(new bool3(true, false, false), a < 2.0f); + Assert.Equal(new bool3(false, false, true), 2.0f < a); + Assert.Equal(new bool2(true, false), new float2(0.0f, 1.0f) == 0.0f); + Assert.Equal(new bool4(false, true, true, true), new float4(0.0f, 1.0f, 2.0f, 3.0f) >= 1.0f); + Assert.Equal(new bool2(false, true), new int2(1, 2) > new int2(1, 1)); + Assert.Equal(new bool3(true, false, true), new int3(1, 2, 3) != new int3(0, 2, 4)); + Assert.Equal(new bool4(true, true, false, false), new int4(1, 2, 3, 4) <= new int4(1, 2, 2, 2)); + Assert.Equal(new bool2(true, false), new double2(0.5, 1.0) < new double2(1.0, 1.0)); + Assert.Equal(new bool3(false, false, true), new double3(1.0, 1.0, 2.0) > new double3(1.0, 1.0, 1.0)); + } + + [Fact] + public void ScalarOperatorsApplyPerComponentOnBothSides() + { + var value = new float3(1.0f, 2.0f, 4.0f); + + Assert.Equal(new float3(3.0f, 2.0f, 0.0f), 4.0f - value); + Assert.Equal(new float3(0.0f, 1.0f, 3.0f), value - 1.0f); + Assert.Equal(new float3(2.0f, 3.0f, 5.0f), 1.0f + value); + Assert.Equal(new float3(4.0f, 2.0f, 1.0f), 4.0f / value); + Assert.Equal(new float3(0.5f, 1.0f, 2.0f), value / 2.0f); + Assert.Equal(new float3(2.0f, 4.0f, 8.0f), 2.0f * value); + Assert.Equal(new float3(1.0f, 0.0f, 0.0f), value % 2.0f); + Assert.Equal(new float2(1.5f, 2.5f), new float2(1.0f, 2.0f) + 0.5f); + Assert.Equal(new float4(0.0f, 1.0f, 2.0f, 3.0f), new float4(1.0f, 2.0f, 3.0f, 4.0f) - 1.0f); + } + + [Fact] + public void EqualsAndHashCodeStayValueBasedForDictionaryKeys() + { + var map = new Dictionary { [new float3(1.0f, 2.0f, 3.0f)] = "hit" }; + Assert.Equal("hit", map[new float3(1.0f, 2.0f, 3.0f)]); + Assert.False(map.ContainsKey(new float3(1.0f, 2.0f, 3.5f))); + + var cells = new HashSet { new int2(3, 4) }; + Assert.Contains(new int2(3, 4), cells); + Assert.Contains(new int3(1, 2, 3), new HashSet { new int3(1, 2, 3) }); + + var nan = new float2(float.NaN, 0.0f); + var sameNan = nan; + Assert.True(nan.Equals(sameNan)); + Assert.False((nan == sameNan).x); + + Assert.True(new quaternion(0.0f, 0.0f, 0.0f, 1.0f) == quaternion.identity); + Assert.Equal(float2x2.identity, math.float2x2(1.0f, 0.0f, 0.0f, 1.0f)); + Assert.Equal("float3(1, 2.5, -3)", new float3(1.0f, 2.5f, -3.0f).ToString()); + } + + [Fact] + public void AnyAllAndSelectFollowHlsl() + { + Assert.True(math.any(new bool2(false, true))); + Assert.False(math.any(bool3.@false)); + Assert.True(math.all(bool4.@true)); + Assert.False(math.all(new bool3(true, false, true))); + + var mask = new bool3(true, false, true); + Assert.Equal(new float3(1.0f, 20.0f, 3.0f), math.select(mask, new float3(1.0f, 2.0f, 3.0f), new float3(10.0f, 20.0f, 30.0f))); + Assert.Equal(7.0f, math.select(true, 7.0f, 8.0f)); + Assert.Equal(new float2(0.0f, 5.0f), math.select(new bool2(false, true), math.float2(5.0f), math.float2(0.0f))); + Assert.Equal(new float4(1.0f, 0.0f, 1.0f, 0.0f), math.select(new bool4(true, false, true, false), 1.0f, 0.0f)); + + Assert.Equal(new bool2(false, true), !new bool2(true, false)); + Assert.Equal(new bool3(true, false, false), new bool3(true, true, false) & new bool3(true, false, true)); + Assert.Equal(new bool3(true, true, true), new bool3(true, true, false) | new bool3(true, false, true)); + } + + [Fact] + public void AddedIntrinsicsAreComponentWise() + { + Assert.Equal(MathF.Log(3.0f), math.log(3.0f)); + Assert.Equal(new float3(0.0f, MathF.Log(2.0f), MathF.Log(4.0f)), math.log(new float3(1.0f, 2.0f, 4.0f))); + Assert.Equal(new float2(8.0f, 9.0f), math.pow(new float2(2.0f, 3.0f), new float2(3.0f, 2.0f))); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), math.sqrt(new float4(1.0f, 4.0f, 9.0f, 16.0f))); + var sines = math.sin(new float3(0.0f, math.HALF_PI, math.PI)); + Assert.Equal(0.0f, sines.x, precision: 5); + Assert.Equal(1.0f, sines.y, precision: 5); + Assert.Equal(0.0f, sines.z, precision: 5); + var cosines = math.cos(new float2(0.0f, math.PI)); + Assert.Equal(1.0f, cosines.x, precision: 5); + Assert.Equal(-1.0f, cosines.y, precision: 5); + var arccosines = math.acos(new float3(1.0f, 0.0f, -1.0f)); + Assert.Equal(0.0f, arccosines.x, precision: 5); + Assert.Equal(math.HALF_PI, arccosines.y, precision: 5); + Assert.Equal(math.PI, arccosines.z, precision: 5); + Assert.Equal(math.atan2(1.0f, -1.0f), math.atan2(new float2(1.0f, 0.0f), new float2(-1.0f, 1.0f)).x); + Assert.Equal(0.0f, math.atan2(new float4(1.0f, 0.0f, 1.0f, 0.0f), new float4(1.0f, 1.0f, 1.0f, 1.0f)).y); + Assert.Equal(2.5, math.min(2.5, 3.5)); + Assert.Equal(3.5, math.max(2.5, 3.5)); + } + + [Fact] + public void IntVectorsPromoteToFloatVectors() + { + float3 promoted3 = new int3(1, 2, 3); + float4 promoted4 = new int4(1, 2, 3, 4); + + Assert.Equal(new float3(1.0f, 2.0f, 3.0f), promoted3); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), promoted4); + Assert.Equal(new int3(2, 4, 6), new int3(1, 2, 3) * 2); + Assert.Equal(new int4(1, 0, 1, 0), new int4(3, 4, 5, 6) % 2); + Assert.Equal(new int3(1, 2, 3), math.int3(1, 2, 3)); + Assert.Equal(new int4(5, 5, 5, 5), math.int4(5)); + } + + [Fact] + public void MatrixMulFollowsHlslRowMajorConvention() + { + var m = math.float2x2(1.0f, 2.0f, 3.0f, 4.0f); + var v = new float2(5.0f, 6.0f); + + Assert.Equal(new float2(1.0f, 2.0f), m[0]); + Assert.Equal(new float2(17.0f, 39.0f), math.mul(m, v)); + Assert.Equal(new float2(23.0f, 34.0f), math.mul(v, m)); + Assert.Equal(math.float2x2(7.0f, 10.0f, 15.0f, 22.0f), math.mul(m, m)); + + var m3 = math.float3x3(new float3(1.0f, 2.0f, 3.0f), new float3(4.0f, 5.0f, 6.0f), new float3(7.0f, 8.0f, 9.0f)); + var v3 = new float3(1.0f, 0.0f, -1.0f); + Assert.Equal(new float3(-2.0f, -2.0f, -2.0f), math.mul(m3, v3)); + Assert.Equal(new float3(-6.0f, -6.0f, -6.0f), math.mul(v3, m3)); + Assert.Equal(math.float3x3(30.0f, 36.0f, 42.0f, 66.0f, 81.0f, 96.0f, 102.0f, 126.0f, 150.0f), math.mul(m3, m3)); + Assert.Equal(m3, math.mul(m3, float3x3.identity)); + + m3[1] = new float3(0.0f, 1.0f, 0.0f); + Assert.Equal(1.0f, m3._m11); + Assert.Equal(12.0f, math.mul(3.0f, 4.0f)); + } + + [Fact] + public void Float2x2RotateMatchesRotateAndUnityMathematics() + { + var rotation = float2x2.Rotate(0.5f); + var column = math.mul(rotation, new float2(1.0f, 0.0f)); + var row = math.mul(new float2(1.0f, 0.0f), rotation); + var reference = math.rotate(new float2(1.0f, 0.0f), 0.5f); + + Assert.Equal(reference.x, column.x, precision: 6); + Assert.Equal(reference.y, column.y, precision: 6); + + // Unity.Mathematics 1.x reference values: mul(float2(1, 0), float2x2.Rotate(0.5f)). + Assert.Equal(0.87758255f, row.x, precision: 6); + Assert.Equal(-0.47942555f, row.y, precision: 6); + } + + [Fact] + public void Float3x3EulerMatchesUnityMathematicsReference() + { + // Unity.Mathematics 1.x reference values captured from float3x3.Euler. + AssertRows( + float3x3.Euler(new float3(0.4f, -0.7f, 1.3f), math.RotationOrder.YXZ), + 0.44632244f, -0.8874959f, 0.114662126f, + 0.6698625f, 0.24638279f, -0.700414f, + 0.59336376f, 0.38941833f, 0.70446634f); + AssertRows( + float3x3.Euler(new float3(0.4f, -0.7f, 1.3f)), + -0.037133574f, -0.8040775f, -0.59336376f, + 0.8874959f, 0.24638279f, -0.38941833f, + 0.45931715f, -0.5410684f, 0.70446634f); + + // Aetheria ActionGameManager view direction: mul(float3(0, 0, 1), Euler(float3(yawPitch.yx, 0), YXZ)). + var yawPitch = new float2(0.3f, -1.1f); + var view = math.mul(new float3(0.0f, 0.0f, 1.0f), float3x3.Euler(new float3(yawPitch.yx, 0.0f), math.RotationOrder.YXZ)); + Assert.Equal(-0.13404681f, view.x, precision: 5); + Assert.Equal(-0.8912074f, view.y, precision: 5); + Assert.Equal(0.4333369f, view.z, precision: 5); + + var composed = math.mul(float3x3.RotateZ(0.2f), math.mul(float3x3.RotateY(0.3f), float3x3.RotateX(0.1f))); + AssertRows(float3x3.Euler(0.1f, 0.3f, 0.2f, math.RotationOrder.XYZ), + composed._m00, composed._m01, composed._m02, + composed._m10, composed._m11, composed._m12, + composed._m20, composed._m21, composed._m22); + } + + [Theory] + [InlineData(0.0f, 0.0f, 0.0f, -0.41219875f)] + [InlineData(0.25f, -0.5f, 1.75f, 0.5250269f)] + [InlineData(12.25f, -4.5f, 3.125f, 0.008141451f)] + [InlineData(-7.3f, 2.9f, 101.4f, 0.578015f)] + public void Snoise3MatchesUnityMathematicsReference(float x, float y, float z, float expected) + { + Assert.Equal(expected, math.snoise(new float3(x, y, z)), precision: 5); + } + + [Fact] + public void RandomFloat3OverloadsShareOneDrawOrder() + { + var a = new Random(99); + var b = new Random(99); + var c = new Random(99); + + var unit = a.NextFloat3(); + Assert.Equal(new float3(b.NextFloat(), b.NextFloat(), b.NextFloat()), unit); + Assert.Equal(unit * new float3(2.0f, 4.0f, 8.0f), c.NextFloat3(new float3(2.0f, 4.0f, 8.0f))); + + var d = new Random(99); + Assert.Equal(math.float3(-1.0f) + unit * 2.0f, d.NextFloat3(math.float3(-1.0f), math.float3(1.0f))); + } + + [Fact] + public void CoreAssemblyStaysEngineFree() + { + var references = typeof(math).Assembly.GetReferencedAssemblies(); + Assert.DoesNotContain(references, reference => reference.Name!.StartsWith("UnityEngine", StringComparison.Ordinal)); + + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "packages", "cultmath", "src", "CultMath", "CultMath.asmdef"))) + { + root = root.Parent; + } + + Assert.NotNull(root); + var asmdefPath = Path.Combine(root!.FullName, "packages", "cultmath", "src", "CultMath", "CultMath.asmdef"); + using var asmdef = JsonDocument.Parse(File.ReadAllText(asmdefPath)); + Assert.True(asmdef.RootElement.GetProperty("noEngineReferences").GetBoolean()); + } + + private static float3 Write(float3 value, WriteAction action) + { + action(ref value); + return value; + } + + private delegate void WriteAction(ref float3 value); + + private static void AssertRows(float3x3 m, params float[] expected) + { + var actual = new[] { m._m00, m._m01, m._m02, m._m10, m._m11, m._m12, m._m20, m._m21, m._m22 }; + for (var index = 0; index < expected.Length; index++) + { + Assert.Equal(expected[index], actual[index], precision: 5); + } + } +} diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs.meta b/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs.meta new file mode 100644 index 00000000..8158a66d --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7164d00a023e43578a31897347d8f106 diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs new file mode 100644 index 00000000..ca4584c7 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs @@ -0,0 +1,203 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; +using static CultMath.math; + +namespace CultMath.Tests; + +/// +/// The real shader mirror, shaders/CultMath.hlsl, compiles as C# against CultMath under +/// using static CultMath.math; after exactly the source transformations listed in +/// docs/design.md (HLSL Target: Source Transformations). is +/// that list in code; if a body needs anything else, this test fails. +/// +public sealed class HlslSourceCompatibilityTests +{ + [Fact] + public void ShaderMirrorBodiesCompileAsCSharp() + { + var (assembly, errors) = CompileShaderMirror(); + Assert.True(assembly is not null, "HLSL mirror did not compile as C#:" + Environment.NewLine + string.Join(Environment.NewLine, errors)); + + var shader = assembly!.GetType("CultMathHlsl.HlslShader")!; + Assert.NotNull(ShaderFunction(shader, "cultmath_snoise", typeof(float3))); + Assert.NotNull(ShaderFunction(shader, "cultmath_snoise", typeof(float2))); + Assert.NotNull(ShaderFunction(shader, "cultmath_value_noise_bicubic", typeof(float2))); + } + + // HLSL functions carry no access modifier, so they compile as private instance methods. + private static MethodInfo? ShaderFunction(Type shader, string name, params Type[] parameters) => + shader.GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic, null, parameters, null); + + // Mirror internals with no public C# math counterpart. Any other cultmath_* function must match one. + private static readonly string[] MirrorOnly = { "cultmath_snoise_mod289", "cultmath_snoise_permute" }; + + private static readonly float[] Specials = { 0.0f, -0.0f, 1.0f, -1.0f, 0.5f, -2.75f, 3.0f, 1.0e-7f, 1.0e4f, -1.0e4f, 1.0e7f, float.NaN }; + + /// + /// Every mirror function with a C# math counterpart returns the same bits (any NaN equals any NaN; + /// -0 differs from 0) over random inputs, every special value in every argument (ties, zeros, + /// negatives, NaN, large values, integer lattice points), special pairs, and vectors whose first two + /// components tie (x0.x == x0.y in snoise). This proves the text of CultMath.hlsl computes the same + /// float32 results as C# math on the CPU. It does not prove GPU agreement: driver sin precision alone + /// makes cultmath_hash and value noise differ bit for bit on hardware. The mirror's intrinsics are C# + /// math itself (it compiles under using static CultMath.math), so this proves the composition in the + /// text, not the intrinsic rules; HlslSemanticsTests alone pins those. + /// + [Fact] + public void EveryMirrorFunctionMatchesCSharpMath() + { + var (assembly, errors) = CompileShaderMirror(); + Assert.True(assembly is not null, string.Join(Environment.NewLine, errors)); + var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!; + var shader = Activator.CreateInstance(shaderType); + + var random = new System.Random(0x5EED); + float Next() => random.NextSingle() * 200.0f - 100.0f; + var cases = new List>(); + foreach (var s in Specials) cases.Add(_ => s); + for (var k = 0; k < Specials.Length; k++) { var o = k; cases.Add(i => Specials[(i + o + 1) % Specials.Length]); } + for (var k = 0; k < 64; k++) { var values = Enumerable.Range(0, 32).Select(_ => Next()).ToArray(); cases.Add(i => values[i]); } + for (var k = 0; k < 16; k++) { var values = Enumerable.Range(0, 32).Select(_ => Next()).ToArray(); cases.Add(i => values[i & ~1]); } + + var mismatches = new List(); + var compared = new HashSet(); + foreach (var mirror in shaderType.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic).Where(m => m.Name.StartsWith("cultmath_"))) + { + var types = mirror.GetParameters().Select(p => p.ParameterType).ToArray(); + var counterpart = typeof(math).GetMethod(mirror.Name["cultmath_".Length..], BindingFlags.Public | BindingFlags.Static, null, types, null); + if (counterpart is null) + { + Assert.Contains(mirror.Name, MirrorOnly); + continue; + } + + compared.Add(mirror.Name); + foreach (var input in cases) + { + var slot = 0; + var args = types.Select(t => t == typeof(float) ? (object)input(slot++) + : Activator.CreateInstance(t, Enumerable.Range(0, Components(t)).Select(_ => (object)input(slot++)).ToArray())!).ToArray(); + var expected = Floats(counterpart.Invoke(null, args)!); + var actual = Floats(mirror.Invoke(shader, args)!); + if (expected.Length != actual.Length || expected.Zip(actual).Any(p => !(float.IsNaN(p.First) && float.IsNaN(p.Second)) + && BitConverter.SingleToInt32Bits(p.First) != BitConverter.SingleToInt32Bits(p.Second))) + { + mismatches.Add($"{mirror.Name}({string.Join(", ", args.Select(a => string.Join(" ", Floats(a))))}): C# {string.Join(" ", expected)}, mirror {string.Join(" ", actual)}"); + } + } + } + + Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}"); + Assert.Equal(26, compared.Count); + } + + private static int Components(Type t) => t.GetFields().Count(f => f.FieldType == typeof(float) && !f.IsStatic); + + private static float[] Floats(object value) => value is float f ? new[] { f } + : value.GetType().GetFields().Where(field => field.FieldType == typeof(float) && !field.IsStatic).Select(field => (float)field.GetValue(value)!).ToArray(); + + /// The documented HLSL-to-C# transformations, and nothing else. + internal static string TransformHlslToCSharp(string hlsl) + { + // 1. Include guards (#ifndef/#define/#endif) are dropped; C# has no textual include. + var source = Regex.Replace(hlsl, @"(?m)^\s*#(ifndef|define|endif)\b.*$", string.Empty); + + // 2. Functions taking Texture2D/SamplerState resources are dropped; GPU resources have + // no CultMath analog. + source = Regex.Replace(source, @"(?ms)^\w+ \w+\([^)]*\b(Texture2D|SamplerState)\b[^)]*\)\s*\{.*?^\}[ \t]*\r?$", string.Empty); + + // 3. File-scope `static const` becomes `const` (C# consts are implicitly static). + source = Regex.Replace(source, @"(?m)^static const ", "const "); + + // 4. Floating literals gain the `f` suffix; an unsuffixed C# literal is a double. + source = Regex.Replace(source, @"(? Errors) CompileShaderMirror() + { + var hlsl = File.ReadAllText(Path.Combine(FindCultMathRoot(), "shaders", "CultMath.hlsl")); + var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl)); + var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Append(typeof(math).Assembly.Location) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(path => MetadataReference.CreateFromFile(path)); + var compilation = CSharpCompilation.Create( + "CultMathHlslMirror", + new[] { tree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + using var stream = new MemoryStream(); + var result = compilation.Emit(stream); + var errors = result.Diagnostics + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.ToString()) + .ToList(); + return (result.Success ? Assembly.Load(stream.ToArray()) : null, errors); + } + + private static string FindCultMathRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "packages", "cultmath", "shaders", "CultMath.hlsl"))) + { + directory = directory.Parent; + } + + return Path.Combine(directory?.FullName ?? throw new InvalidOperationException("CultMath package root not found."), "packages", "cultmath"); + } + + // ---- Hand-written HLSL-shaped host code: swizzles, mixed constructors, matrix rows. ---- + static float3 shade(float3 position, float3 normal, float2 wind, float time) + { + float3 p = position; + p.xz = p.xz + wind * time; + p.y = saturate(p.y); + + float3x3 basis = float3x3(float3(1, 0, 0), float3(0, 1, 0), float3(0, 0, 1)); + basis[1][1] = 1.0f; + float3 n = normalize(mul(basis, normal)); + float ndotl = saturate(dot(n, normalize(float3(0.3f, 1, 0.2f)))); + float4 color = float4(lerp(float3(0.1f, 0.1f, 0.2f), float3(1, 0.9f, 0.7f), ndotl), 1); + + if (any(p < 0.0f)) + color.rgb *= 0.5f; + if (all(abs(n) <= 1.0f)) + color.bgr = color.bgr * float3(1, 1, 1); + + color.rgb = select(p > 10.0f, float3(0), color.rgb); + float2 uv = frac(p.xz * 0.25f); + color.rg = lerp(color.rg, uv, 0.1f); + return float4(color.xy, color.zw).xyz; + } + + static float2 steer(float2 direction, float torque) + { + float2x2 turn = float2x2(cos(torque), -sin(torque), sin(torque), cos(torque)); + float2 turned = mul(direction, turn); + return normalize(turned) * pow(length(turned), 1.0f); + } + + [Fact] + public void HandWrittenShaderStyleMathEvaluates() + { + var color = shade(float3(1.0f, 0.5f, 2.0f), float3(0.0f, 1.0f, 0.0f), float2(1.0f, 0.0f), 0.0f); + Assert.True(color.x > 0.1f && color.x <= 1.0f); + Assert.True(float.IsFinite(color.y) && float.IsFinite(color.z)); + + var far = shade(float3(20.0f, 20.0f, 20.0f), float3(0.0f, 1.0f, 0.0f), float2(0.0f), 0.0f); + Assert.Equal(0.0f, far.z); + + var steered = steer(float2(1.0f, 0.0f), 0.5f); + var viaRotate = mul(float2(1.0f, 0.0f), CultMath.float2x2.Rotate(0.5f)); + Assert.Equal(viaRotate.x, steered.x, precision: 5); + Assert.Equal(viaRotate.y, steered.y, precision: 5); + } +} diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs.meta b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs.meta new file mode 100644 index 00000000..0d673e1e --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 942fbcb9c28a4e559e5baf435dc24cf1 diff --git a/packages/cultmath/tests/CultMath.Tests/MathTests.cs b/packages/cultmath/tests/CultMath.Tests/MathTests.cs new file mode 100644 index 00000000..de1bf996 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/MathTests.cs @@ -0,0 +1,335 @@ +using CultMath; +using Xunit; + +namespace CultMath.Tests; + +public sealed class MathTests +{ + [Fact] + public void FracMatchesShaderStylePositiveAndNegativeValues() + { + Assert.Equal(0.25f, math.frac(1.25f)); + Assert.Equal(0.75f, math.frac(-1.25f)); + } + + [Fact] + public void VectorOperationsAreComponentWise() + { + var value = new float3(1.0f, 2.0f, 3.0f) * new float3(4.0f, 5.0f, 6.0f); + + Assert.Equal(new float3(4.0f, 10.0f, 18.0f), value); + } + + [Fact] + public void VectorConstructorsAcceptLowerDimensionVectors() + { + Assert.Equal(new int2(1, 2), math.int2(1, 2)); + Assert.Equal(new float2(1.0f, 2.0f), math.float2(1.0f, 2.0f)); + Assert.Equal(new float3(1.0f, 1.0f, 1.0f), math.float3(1.0f)); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), math.float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); + Assert.Equal(new float3(1.0f, 2.0f, 3.0f), new float3(new float2(1.0f, 2.0f), 3.0f)); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float2(1.0f, 2.0f), 3.0f, 4.0f)); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); + } + + [Fact] + public void VectorsExposeCommonSwizzles() + { + Assert.Equal(new float3(2.0f, 3.0f, 1.0f), new float3(1.0f, 2.0f, 3.0f).yzx); + Assert.Equal(new float3(3.0f, 2.0f, 1.0f), new float3(1.0f, 2.0f, 3.0f).zyx); + Assert.Equal(new float2(3.0f, 2.0f), new float3(1.0f, 2.0f, 3.0f).zy); + Assert.Equal(new float3(1.0f, 2.0f, 4.0f), new float4(1.0f, 2.0f, 3.0f, 4.0f).xyw); + Assert.Equal(new float3(2.0f, 3.0f, 1.0f), new float4(1.0f, 2.0f, 3.0f, 4.0f).yzx); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(1.0f, new float3(2.0f, 3.0f, 4.0f))); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), math.float4(1.0f, new float3(2.0f, 3.0f, 4.0f))); + } + + [Fact] + public void VectorsSupportUnityStyleFieldAndSwizzleMutation() + { + var position = new float3(1.0f, 2.0f, 3.0f); + position.y = 5.0f; + position.xz = new float2(8.0f, 13.0f); + + Assert.Equal(new float3(8.0f, 5.0f, 13.0f), position); + + var bounds = new float4(1.0f, 2.0f, 3.0f, 4.0f); + bounds.xy = new float2(-1.0f, -2.0f); + + Assert.Equal(new float4(-1.0f, -2.0f, 3.0f, 4.0f), bounds); + } + + [Fact] + public void DoubleVectorsSupportDampedMath() + { + var value = new double3(1.0, 2.0, 3.0); + value.xy = new double2(5.0, 8.0); + + Assert.Equal(new double3(5.0, 8.0, 3.0), value); + Assert.Equal(new double2(Math.Exp(-2.0), Math.Exp(-4.0)), math.exp(new double2(-2.0, -4.0))); + Assert.Equal(new double3(2.0, 4.0, 6.0), math.lerp(new double3(0.0, 0.0, 0.0), new double3(4.0, 8.0, 12.0), new double3(0.5, 0.5, 0.5))); + } + + [Fact] + public void Int2SupportsGridArithmeticAndFloatPromotion() + { + var cell = new int2(2, 3); + + Assert.Equal(new int2(3, 5), cell + new int2(1, 2)); + Assert.Equal(new int2(4, 6), cell * new int2(2, 2)); + Assert.Equal(3, cell[1]); + + float2 promoted = cell; + Assert.Equal(new float2(2.0f, 3.0f), promoted); + } + + [Fact] + public void Bool2SupportsHullGridStyleStorage() + { + bool2 conductivity = true; + + conductivity.y = false; + + Assert.True(conductivity.x); + Assert.False(conductivity[1]); + Assert.Equal(new bool2(false, true), math.bool2(false, true)); + } + + [Fact] + public void QuaternionLookRotationProducesUnitOrientation() + { + var rotation = quaternion.LookRotation(new float3(0.0f, 0.0f, 1.0f), new float3(0.0f, 1.0f, 0.0f)); + + Assert.Equal(quaternion.identity, rotation); + Assert.Equal(1.0f, math.length((float4)quaternion.LookRotation(new float3(1.0f, 0.0f, 0.0f), new float3(0.0f, 1.0f, 0.0f))), precision: 5); + } + + [Fact] + public void RandomUsesStableXorShift32Sequence() + { + var a = new Random(1234); + var b = new Random(1234); + + Assert.Equal(332584831u, a.NextUInt()); + Assert.Equal(1855942593u, a.NextUInt()); + Assert.Equal(4018585650u, a.NextUInt()); + Assert.Equal(3348358578u, a.NextUInt()); + Assert.Equal(332584831u, b.NextUInt()); + } + + [Fact] + public void RandomProvidesDeterministicPortableSampling() + { + var a = new Random(1234); + var b = new Random(1234); + + Assert.Equal(a.NextUInt(), b.NextUInt()); + Assert.Equal(a.NextFloat(), b.NextFloat()); + + var bounded = a.NextFloat(-2.0f, 4.0f); + Assert.True(bounded >= -2.0f && bounded < 4.0f); + + var direction = a.NextFloat2Direction(); + Assert.Equal(1.0f, math.length(direction), precision: 5); + + var point = a.NextFloat3(new float3(-1.0f, -2.0f, -3.0f), new float3(1.0f, 2.0f, 3.0f)); + Assert.True(point.x >= -1.0f && point.x < 1.0f); + Assert.True(point.y >= -2.0f && point.y < 2.0f); + Assert.True(point.z >= -3.0f && point.z < 3.0f); + } + + [Fact] + public void NormalizeProducesUnitLength() + { + var value = math.normalize(new float3(3.0f, 4.0f, 0.0f)); + + Assert.Equal(1.0f, math.length(value), precision: 5); + } + + [Fact] + public void SmoothstepUsesHermiteRamp() + { + Assert.Equal(0.0f, math.smoothstep(0.0f, 1.0f, -1.0f)); + Assert.Equal(0.5f, math.smoothstep(0.0f, 1.0f, 0.5f), precision: 5); + Assert.Equal(1.0f, math.smoothstep(0.0f, 1.0f, 2.0f)); + Assert.Equal(0.5f, math.smootherstep(0.5f), precision: 5); + } + + [Fact] + public void TrigIntrinsicsUseRadians() + { + Assert.Equal(1.0f, math.sin(math.HALF_PI), precision: 5); + Assert.Equal(-1.0f, math.cos(math.PI), precision: 5); + Assert.Equal(1.0f, math.tan(math.PI * 0.25f), precision: 5); + Assert.Equal(math.HALF_PI, math.atan2(1.0f, 0.0f), precision: 5); + Assert.Equal(2, math.min(2, 5)); + Assert.Equal(5, math.max(2, 5)); + Assert.True(math.isinf(float.PositiveInfinity)); + } + + [Fact] + public void SignIsComponentWiseForSimulationVectors() + { + Assert.Equal(-1, math.sign(-12.0f)); + Assert.Equal(0, math.sign(0.0f)); + Assert.Equal(1, math.sign(12.0f)); + Assert.Equal(new int3(-1, 0, 1), math.sign(new float3(-2.0f, 0.0f, 4.0f))); + } + + [Fact] + public void RotateTurnsFloat2CounterClockwiseInRadians() + { + var rotated = math.rotate(new float2(1.0f, 0.0f), math.HALF_PI); + + Assert.Equal(0.0f, rotated.x, precision: 5); + Assert.Equal(1.0f, rotated.y, precision: 5); + + var rotatedDegrees = math.rotate_degrees(new float2(0.0f, 1.0f), 90.0f); + Assert.Equal(-1.0f, rotatedDegrees.x, precision: 5); + Assert.Equal(0.0f, rotatedDegrees.y, precision: 5); + } + + [Fact] + public void AetheriaPrimitivesRemainSmallAndDeterministic() + { + Assert.Equal(6.0f, math.csum(new float3(1.0f, 2.0f, 3.0f))); + Assert.Equal(0.25f, math.unlerp(10.0f, 30.0f, 15.0f)); + Assert.Equal(new float2(0.25f, 0.75f), math.unlerp(new float2(10.0f, 10.0f), new float2(30.0f, 30.0f), new float2(15.0f, 25.0f))); + Assert.Equal(MathF.Exp(-2.0f), math.decay(1.0f, 2.0f, 1.0f), precision: 5); + Assert.Equal(1.0f - MathF.Exp(-2.0f), math.damp(0.0f, 1.0f, 2.0f, 1.0f), precision: 5); + + Assert.Equal(15.0f, math.catmullrom(0.0f, 10.0f, 20.0f, 30.0f, 0.5f), precision: 5); + Assert.Equal(0.25f, math.quadratic_bezier(0.0f, 0.0f, 1.0f, 0.5f), precision: 5); + Assert.Equal(0.5f, math.cubic_bezier(0.0f, 0.0f, 1.0f, 1.0f, 0.5f), precision: 5); + } + + [Fact] + public void SegmentDistanceReportsClosestPoint() + { + var distance = math.distance_to_segment(new float2(2.0f, 3.0f), new float2(0.0f, 0.0f), new float2(4.0f, 0.0f), out var closest); + + Assert.Equal(3.0f, distance, precision: 5); + Assert.Equal(new float2(2.0f, 0.0f), closest); + } + + [Fact] + public void RectNormalizesAndDerivesFromMinMax() + { + var bounds = new rect(8.0f, 6.0f, 2.0f, -2.0f); + + Assert.Equal(new float2(2.0f, -2.0f), bounds.min); + Assert.Equal(new float2(8.0f, 6.0f), bounds.max); + Assert.Equal(new float2(6.0f, 8.0f), bounds.size); + Assert.Equal(new float2(5.0f, 2.0f), bounds.center); + Assert.Equal(48.0f, bounds.area); + } + + [Fact] + public void RectContainmentIncludesBoundary() + { + var bounds = new rect(-2.0f, -1.0f, 4.0f, 3.0f); + + Assert.True(bounds.Contains(new float2(-2.0f, -1.0f))); + Assert.True(bounds.Contains(new float2(4.0f, 3.0f))); + Assert.True(bounds.Contains(new rect(-1.0f, 0.0f, 1.0f, 2.0f))); + Assert.False(bounds.Contains(new float2(4.001f, 3.0f))); + } + + [Fact] + public void RectIntersectionTreatsTouchingEdgesAsContact() + { + var left = new rect(0.0f, 0.0f, 4.0f, 4.0f); + var touching = new rect(4.0f, 1.0f, 8.0f, 3.0f); + var separate = new rect(4.001f, 1.0f, 8.0f, 3.0f); + + Assert.True(left.Intersects(touching)); + Assert.Equal(0.0f, left.Intersection(touching).area); + Assert.False(left.Intersects(separate)); + } + + [Fact] + public void FirstOrderInterceptSolvesSimpleClosingTarget() + { + var time = math.first_order_intercept_time( + 2.0f, + new float3(10.0f, 0.0f, 0.0f), + new float3(-1.0f, 0.0f, 0.0f)); + + Assert.Equal(10.0f / 3.0f, time, precision: 5); + } + + [Fact] + public void ValueNoiseIsDeterministicAndSmoothAtCellCorners() + { + var position = new float2(12.25f, -4.5f); + + Assert.Equal(math.value_noise(position), math.value_noise(position)); + Assert.Equal(math.hash(new float2(3.0f, 5.0f)), math.value_noise(new float2(3.0f, 5.0f)), precision: 5); + + var bicubic = math.value_noise_bicubic(position); + Assert.True(bicubic > -0.25f && bicubic < 1.25f); + } + + [Theory] + [InlineData(0.0f, 0.0f, 0.0f)] + [InlineData(0.25f, -0.5f, -0.425866783f)] + [InlineData(12.25f, -4.5f, 0.258312f)] + [InlineData(536.5106f, 536.5106f, -0.6901103f)] + public void SimplexNoiseMatchesUnityMathematics(float x, float y, float expected) + { + Assert.Equal(expected, math.snoise(new float2(x, y)), precision: 6); + } + + [Fact] + public void VoronoiBatchProducesToneMappedColors() + { + var xs = new[] { 0.0f, 24.0f, 96.0f }; + var ys = new[] { 0.0f, 36.0f, 144.0f }; + var tones = new[] { CultMathTone.Background, CultMathTone.Header, CultMathTone.Body }; + var spans = new[] { 1920.0f, 8.0f, 8.0f }; + var colors = new Color32[xs.Length]; + + Voronoi.SampleTones(xs, ys, tones, spans, 1080.0f, 12, colors); + + Assert.NotEqual(new Color32(), colors[0]); + Assert.True(colors[1].r > colors[1].g); + Assert.True(colors[2].r > 120 && colors[2].g > 120 && colors[2].b > 120); + } + + [Fact] + public void BatchRadialFalloffAccelerationMatchesScalarContract() + { + var xs = new[] { 0.0f, 5.0f, 10.0f }; + var ys = new[] { 0.0f, 0.0f, 0.0f }; + var ax = new float[xs.Length]; + var ay = new float[xs.Length]; + + BatchMath.AddRadialFalloffAcceleration2D(xs, ys, 10.0f, 0.0f, 10.0f, 20.0f, ax, ay); + + Assert.True(BatchMath.LaneCount >= 4); + Assert.Equal(5.0f, ax[0], precision: 5); + Assert.Equal(7.5f, ax[1], precision: 5); + Assert.Equal(0.0f, ax[2], precision: 5); + Assert.Equal(0.0f, ay[0], precision: 5); + } + + [Fact] + public void BatchEulerIntegrationHonorsDynamicMask() + { + var dynamicMask = new[] { 1.0f, 0.0f }; + var px = new[] { 0.0f, 0.0f }; + var py = new[] { 0.0f, 0.0f }; + var vx = new[] { 1.0f, 1.0f }; + var vy = new[] { 0.0f, 0.0f }; + var ax = new[] { 2.0f, 2.0f }; + var ay = new[] { 0.0f, 4.0f }; + + BatchMath.IntegrateSemiImplicitEuler2D(0.5f, dynamicMask, px, py, vx, vy, ax, ay); + + Assert.Equal(2.0f, vx[0], precision: 5); + Assert.Equal(1.0f, px[0], precision: 5); + Assert.Equal(1.0f, vx[1], precision: 5); + Assert.Equal(0.0f, px[1], precision: 5); + Assert.Equal(0.0f, py[1], precision: 5); + } +} diff --git a/packages/cultmath/tests/CultMath.Tests/MathTests.cs.meta b/packages/cultmath/tests/CultMath.Tests/MathTests.cs.meta new file mode 100644 index 00000000..277df5a3 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/MathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c7a709e5345da214aa650e8c796fcaff \ No newline at end of file diff --git a/packages/cultmath/tools.meta b/packages/cultmath/tools.meta new file mode 100644 index 00000000..3594fa6a --- /dev/null +++ b/packages/cultmath/tools.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 697162910ffd9614ba9655d31af9afa4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/tools/compile-hlsl-spirv.ps1 b/packages/cultmath/tools/compile-hlsl-spirv.ps1 new file mode 100644 index 00000000..280e08d5 --- /dev/null +++ b/packages/cultmath/tools/compile-hlsl-spirv.ps1 @@ -0,0 +1,69 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ShaderPath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [string]$EntryPoint = "main", + [string]$Profile = "cs_6_0", + [string[]]$IncludePath = @(), + [string]$TargetEnv = "", + [int]$VulkanCBufferShift = 0, + [int]$VulkanTextureShift = 10, + [int]$VulkanUavShift = 20, + [int]$VulkanSamplerShift = 30, + [string]$DxcPath = "" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +if (-not $DxcPath) { + $DxcPath = Join-Path $repoRoot ".tools\dxc\current\bin\x64\dxc.exe" +} + +if (-not (Test-Path $DxcPath)) { + throw "DXC not found at '$DxcPath'. Run tools\get-dxc.ps1 first or pass -DxcPath." +} + +$resolvedShader = Resolve-Path $ShaderPath +$resolvedIncludes = @((Join-Path $repoRoot "shaders")) +foreach ($path in $IncludePath) { + $resolvedIncludes += (Resolve-Path $path) +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +} + +$arguments = @( + "-T", $Profile, + "-E", $EntryPoint, + "-spirv" +) + +if ($TargetEnv) { + $arguments += "-fspv-target-env=$TargetEnv" +} + +$arguments += @( + "-fvk-b-shift", $VulkanCBufferShift.ToString(), "0", + "-fvk-t-shift", $VulkanTextureShift.ToString(), "0", + "-fvk-u-shift", $VulkanUavShift.ToString(), "0", + "-fvk-s-shift", $VulkanSamplerShift.ToString(), "0" +) + +foreach ($include in $resolvedIncludes) { + $arguments += @("-I", $include) +} + +$arguments += @($resolvedShader, "-Fo", $OutputPath) + +& $DxcPath @arguments +if ($LASTEXITCODE -ne 0) { + throw "dxc.exe failed with exit code $LASTEXITCODE." +} + +Get-Item -LiteralPath $OutputPath diff --git a/packages/cultmath/tools/compile-hlsl-spirv.ps1.meta b/packages/cultmath/tools/compile-hlsl-spirv.ps1.meta new file mode 100644 index 00000000..31e7948a --- /dev/null +++ b/packages/cultmath/tools/compile-hlsl-spirv.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b47e7016a99a5d148bc44c399c13c4d3 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/tools/compile-spirv-metal.ps1 b/packages/cultmath/tools/compile-spirv-metal.ps1 new file mode 100644 index 00000000..490e6331 --- /dev/null +++ b/packages/cultmath/tools/compile-spirv-metal.ps1 @@ -0,0 +1,34 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SpirvPath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [int]$MslVersion = 23000, + [string]$SpirvCrossPath = "" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +if (-not $SpirvCrossPath) { + $SpirvCrossPath = Join-Path $repoRoot ".tools\spirv-cross\build\spirv-cross.exe" +} + +if (-not (Test-Path $SpirvCrossPath)) { + throw "SPIRV-Cross not found at '$SpirvCrossPath'. Run tools\get-spirv-cross.ps1 first or pass -SpirvCrossPath." +} + +$resolvedSpirv = Resolve-Path $SpirvPath +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +} + +& $SpirvCrossPath $resolvedSpirv --msl --msl-version $MslVersion --output $OutputPath +if ($LASTEXITCODE -ne 0) { + throw "spirv-cross failed with exit code $LASTEXITCODE." +} + +Get-Item -LiteralPath $OutputPath diff --git a/packages/cultmath/tools/compile-spirv-metal.ps1.meta b/packages/cultmath/tools/compile-spirv-metal.ps1.meta new file mode 100644 index 00000000..2e7e74c2 --- /dev/null +++ b/packages/cultmath/tools/compile-spirv-metal.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6150c8f461e471b4c8641fd32d1984ef +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/tools/generate-swizzles.ps1 b/packages/cultmath/tools/generate-swizzles.ps1 new file mode 100644 index 00000000..9030f2be --- /dev/null +++ b/packages/cultmath/tools/generate-swizzles.ps1 @@ -0,0 +1,132 @@ +param( + [string]$OutputPath = "" +) + +# Writes src\CultMath\Swizzles.g.cs: HLSL swizzles and mixed constructors for +# CultMath's vector types. The output is checked in; rerun after changing the +# family table below and commit both files together. + +$ErrorActionPreference = "Stop" + +if (-not $OutputPath) { + $OutputPath = Join-Path $PSScriptRoot "..\src\CultMath\Swizzles.g.cs" +} + +# Functions: emit math.(...) constructor functions. double has none because +# a math.double3 method group would hide the double3 type name under +# `using static CultMath.math;` and HLSL shader code does not construct doubles. +$families = @( + @{ Scalar = "float"; Sizes = @(2, 3, 4); Functions = $true }, + @{ Scalar = "int"; Sizes = @(2, 3, 4); Functions = $true }, + @{ Scalar = "bool"; Sizes = @(2, 3, 4); Functions = $true }, + @{ Scalar = "double"; Sizes = @(2, 3); Functions = $false } +) +$componentFields = @("x", "y", "z", "w") +$namings = @(@("x", "y", "z", "w"), @("r", "g", "b", "a")) + +function Get-Compositions([int]$total) { + # Ordered part sizes summing to $total, excluding the all-scalar form that the + # hand-written base constructor already owns. + $results = [System.Collections.Generic.List[object]]::new() + function Expand([int]$remaining, [int[]]$prefix) { + if ($remaining -eq 0) { + if (@($prefix | Where-Object { $_ -ne 1 }).Count -gt 0) { + $results.Add($prefix) + } + return + } + for ($part = 1; $part -le $remaining; $part++) { + Expand ($remaining - $part) ($prefix + $part) + } + } + Expand $total @() + return , $results +} + +$builder = [System.Text.StringBuilder]::new() +function Line([string]$text = "") { [void]$builder.Append($text).Append("`n") } + +Line "// " +Line "// Generated by tools/generate-swizzles.ps1. Do not edit by hand." +Line "// " +Line "" +Line "namespace CultMath;" + +$functionLines = [System.Collections.Generic.List[string]]::new() + +foreach ($family in $families) { + $scalar = $family.Scalar + $maxSize = ($family.Sizes | Measure-Object -Maximum).Maximum + foreach ($size in $family.Sizes) { + $type = "$scalar$size" + Line "" + Line "public partial struct $type" + Line "{" + + foreach ($composition in (Get-Compositions $size)) { + $parameters = @() + $arguments = @() + $offset = 0 + foreach ($part in $composition) { + $name = -join $componentFields[$offset..($offset + $part - 1)] + if ($part -eq 1) { + $parameters += "$scalar $name" + $arguments += $name + } else { + $parameters += "$scalar$part $name" + for ($i = 0; $i -lt $part; $i++) { $arguments += "$name.$($componentFields[$i])" } + } + $offset += $part + } + $parameterList = $parameters -join ", " + Line " public $type($parameterList) : this($($arguments -join ', ')) { }" + if ($family.Functions) { + $names = @($parameters | ForEach-Object { ($_ -split " ")[1] }) + $functionLines.Add(" public static $type $type($parameterList) => new($($names -join ', '));") + } + } + + # Single-component color aliases; x/y/z/w are the fields themselves. + $letters = $namings[1] + for ($i = 0; $i -lt $size; $i++) { + Line " public $scalar $($letters[$i]) { readonly get => $($componentFields[$i]); set => $($componentFields[$i]) = value; }" + } + + foreach ($letters in $namings) { + for ($length = 2; $length -le $maxSize; $length++) { + $count = [math]::Pow($size, $length) + for ($index = 0; $index -lt $count; $index++) { + [int[]]$digits = @() + [int]$remainder = $index + for ($d = 0; $d -lt $length; $d++) { + $digits = @([int]($remainder % $size)) + $digits + $remainder = [int][math]::Floor($remainder / $size) + } + $name = -join ($digits | ForEach-Object { $letters[$_] }) + $getter = ($digits | ForEach-Object { $componentFields[$_] }) -join ", " + $resultType = "$scalar$length" + $unique = @($digits | Select-Object -Unique).Count -eq $length + if ($unique) { + $assignments = for ($d = 0; $d -lt $length; $d++) { "$($componentFields[$digits[$d]]) = value.$($componentFields[$d]);" } + Line " public $resultType $name { readonly get => new($getter); set { $($assignments -join ' ') } }" + } else { + Line " public readonly $resultType $name => new($getter);" + } + } + } + } + + Line "}" + } +} + +Line "" +Line "public static partial class math" +Line "{" +foreach ($functionLine in $functionLines) { Line $functionLine } +Line "}" + +$fullPath = [System.IO.Path]::GetFullPath($OutputPath) +$content = $builder.ToString() -replace "`n", "`r`n" +[System.IO.File]::WriteAllText($fullPath, $content, [System.Text.UTF8Encoding]::new($false)) +Write-Host "Wrote $fullPath ($((Get-Item -LiteralPath $fullPath).Length) bytes)" diff --git a/packages/cultmath/tools/generate-swizzles.ps1.meta b/packages/cultmath/tools/generate-swizzles.ps1.meta new file mode 100644 index 00000000..74842405 --- /dev/null +++ b/packages/cultmath/tools/generate-swizzles.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d7a796fd2d87489c9f055fd6e73513b5 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/tools/get-dxc.ps1 b/packages/cultmath/tools/get-dxc.ps1 new file mode 100644 index 00000000..d9b12593 --- /dev/null +++ b/packages/cultmath/tools/get-dxc.ps1 @@ -0,0 +1,43 @@ +param( + [string]$InstallRoot = ".tools\dxc" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$installPath = Join-Path $repoRoot $InstallRoot +New-Item -ItemType Directory -Force -Path $installPath | Out-Null + +# Pinned: parity means this compiler's lowering (dxcompiler 1.9.0.5402). The hash is the SHA256 +# of dxc_2026_07_29.zip downloaded from Microsoft's GitHub release v1.9.2607, and matches the +# digest GitHub publishes for that asset. +$zipName = "dxc_2026_07_29.zip" +$zipUrl = "https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.9.2607/$zipName" +$zipSha256 = "A1DFB116BA3EEAE6A1582291B53A8E7BF65AD760676BD3194685C8F7367CD241" + +$zipPath = Join-Path $installPath $zipName +Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue + +& curl.exe -L --retry 5 --retry-delay 2 --fail -o $zipPath $zipUrl +if ($LASTEXITCODE -ne 0) { + throw "curl.exe failed with exit code $LASTEXITCODE." +} + +$actualSha256 = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash +if ($actualSha256 -ne $zipSha256) { + Remove-Item -LiteralPath $zipPath -Force + throw "$zipName SHA256 is $actualSha256, expected $zipSha256." +} + +$currentPath = Join-Path $installPath "current" +Remove-Item -LiteralPath $currentPath -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $currentPath | Out-Null +Expand-Archive -LiteralPath $zipPath -DestinationPath $currentPath -Force + +$dxcPath = Join-Path $currentPath "bin\x64\dxc.exe" +if (-not (Test-Path $dxcPath)) { + throw "DXC x64 executable was not found after extraction." +} + +& $dxcPath --version +Write-Host "DXC installed at $dxcPath" diff --git a/packages/cultmath/tools/get-dxc.ps1.meta b/packages/cultmath/tools/get-dxc.ps1.meta new file mode 100644 index 00000000..c9ab0d82 --- /dev/null +++ b/packages/cultmath/tools/get-dxc.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 86d0b9a1006042745b7fff523831049c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/tools/get-spirv-cross.ps1 b/packages/cultmath/tools/get-spirv-cross.ps1 new file mode 100644 index 00000000..f5105b9a --- /dev/null +++ b/packages/cultmath/tools/get-spirv-cross.ps1 @@ -0,0 +1,34 @@ +param( + [string]$InstallRoot = ".tools\spirv-cross" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$installPath = Join-Path $repoRoot $InstallRoot +$sourcePath = Join-Path $installPath "src" +$buildPath = Join-Path $installPath "build" + +New-Item -ItemType Directory -Force -Path $installPath | Out-Null + +if (-not (Test-Path $sourcePath)) { + git clone --depth 1 https://github.com/KhronosGroup/SPIRV-Cross.git $sourcePath +} + +cmake ` + -S $sourcePath ` + -B $buildPath ` + -G Ninja ` + -DSPIRV_CROSS_CLI=ON ` + -DSPIRV_CROSS_ENABLE_TESTS=OFF ` + -DSPIRV_CROSS_ENABLE_C_API=OFF + +cmake --build $buildPath --target spirv-cross --config Release + +$spirvCrossPath = Join-Path $buildPath "spirv-cross.exe" +if (-not (Test-Path $spirvCrossPath)) { + throw "SPIRV-Cross executable was not found after build." +} + +& $spirvCrossPath --version +Write-Host "SPIRV-Cross installed at $spirvCrossPath" diff --git a/packages/cultmath/tools/get-spirv-cross.ps1.meta b/packages/cultmath/tools/get-spirv-cross.ps1.meta new file mode 100644 index 00000000..12ccdd8e --- /dev/null +++ b/packages/cultmath/tools/get-spirv-cross.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dcf41da07502f4b439f8fc5ad25d16c9 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/unity.meta b/packages/cultmath/unity.meta new file mode 100644 index 00000000..de47a618 --- /dev/null +++ b/packages/cultmath/unity.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 366efeaef9f68224b80229332c7ecfc6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md b/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md new file mode 100644 index 00000000..5e50f2d6 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md @@ -0,0 +1,10 @@ +# CultMath License + +CultMath is licensed under the Mozilla Public License 2.0 (`MPL-2.0`). + +The complete license text is available from the Mozilla Foundation at +. Source and modification history for the +precompiled assembly in this package are available in the GameCult/CultMath +repository at the corresponding package tag. + +Third-party code retains its original license. See `THIRD-PARTY-NOTICES.md`. diff --git a/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md.meta b/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md.meta new file mode 100644 index 00000000..c234cd28 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7172bce28532878409e57f5fbbb12719 \ No newline at end of file diff --git a/packages/cultmath/unity/org.gamecult.cultmath/README.md b/packages/cultmath/unity/org.gamecult.cultmath/README.md new file mode 100644 index 00000000..20e1840c --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/README.md @@ -0,0 +1,36 @@ +# CultMath for Unity + +This is CultMath's Unity 2021.3-compatible Git-UPM surface. It contains the +precompiled `CultMath.dll`, portable symbols, the numeric +`Shaders/CultMath.hlsl` mirror, and the `CultMath.UnityBridge` assembly. + +`CultMath.UnityBridge` is the only C# source Unity compiles here. It holds the +UnityEngine conversions the engine-free core cannot declare: `ToCultMath()` on +`Vector2/3/4`, `Vector2Int/3Int`, `Quaternion`, and `Color`; `ToUnity()` on +`float2/3/4`, `int2/3`, and `quaternion`; `ToColor()` on `float4`. C# only +admits conversion operators on the source or target type, so these are +extension methods rather than implicit casts. Import `CultMath.UnityBridge` to +use them. + +Unity does not compile CultMath's repository source tree. That source uses the +current C# language and remains owned by the normal .NET project. The package +imports only the tracked auto-referenced precompiled assembly, so consumers do +not need `csc.rsp` language overrides or an asmdef propagation shim. + +Build and inspect the package from the repository root: + +```powershell +.\scripts\build-unity-package.ps1 +``` + +The builder fresh-builds CultMath for `netstandard2.1`, verifies the tracked +DLL/PDB and stable Unity metadata, and stages an inspectable package under +`artifacts/unity/org.gamecult.cultmath`. + +Consume the repository package with: + +```json +"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath" +``` + +Production consumers should pin that URL to a commit or release tag. diff --git a/packages/cultmath/unity/org.gamecult.cultmath/README.md.meta b/packages/cultmath/unity/org.gamecult.cultmath/README.md.meta new file mode 100644 index 00000000..0ea270c2 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/README.md.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b7429934026d747489a3876fd15576e5 \ No newline at end of file diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime.meta new file mode 100644 index 00000000..b0e76599 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a31bf2bb35e54dd1ad1c765337682769 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins.meta new file mode 100644 index 00000000..fe8acafa --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f84156013e814c14b73f668b6610b9e0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll new file mode 100644 index 00000000..bf712012 Binary files /dev/null and b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll differ diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll.meta new file mode 100644 index 00000000..917e3849 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll.meta @@ -0,0 +1,21 @@ +fileFormatVersion: 2 +guid: 063b8a850f0f474aa7e89c4167f97a19 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb new file mode 100644 index 00000000..1eaadd23 Binary files /dev/null and b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb differ diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb.meta new file mode 100644 index 00000000..e18fedbe --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a8cc555f643a4d7eb4dd340025b208f6 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge.meta new file mode 100644 index 00000000..d34f2ef0 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 01f9f472b0a645b1ab392567aa38911a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/CultMath.UnityBridge.asmdef b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/CultMath.UnityBridge.asmdef new file mode 100644 index 00000000..c54da7da --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/CultMath.UnityBridge.asmdef @@ -0,0 +1,16 @@ +{ + "name": "CultMath.UnityBridge", + "rootNamespace": "CultMath.UnityBridge", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "CultMath.dll" + ], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/CultMath.UnityBridge.asmdef.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/CultMath.UnityBridge.asmdef.meta new file mode 100644 index 00000000..6875b788 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/CultMath.UnityBridge.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 77e50a05974842bc99d285ef6d5d04f7 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/UnityConversions.cs b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/UnityConversions.cs new file mode 100644 index 00000000..c8289a77 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/UnityConversions.cs @@ -0,0 +1,28 @@ +using UnityEngine; + +namespace CultMath.UnityBridge +{ + /// + /// Explicit conversions between UnityEngine and CultMath value types. + /// C# only admits user-defined conversion operators declared on the source or target + /// type, and CultMath's core types stay engine-free, so the bridge is extension methods. + /// + public static class UnityConversions + { + public static float2 ToCultMath(this Vector2 value) => new float2(value.x, value.y); + public static float3 ToCultMath(this Vector3 value) => new float3(value.x, value.y, value.z); + public static float4 ToCultMath(this Vector4 value) => new float4(value.x, value.y, value.z, value.w); + public static float4 ToCultMath(this Color value) => new float4(value.r, value.g, value.b, value.a); + public static quaternion ToCultMath(this Quaternion value) => new quaternion(value.x, value.y, value.z, value.w); + public static int2 ToCultMath(this Vector2Int value) => new int2(value.x, value.y); + public static int3 ToCultMath(this Vector3Int value) => new int3(value.x, value.y, value.z); + + public static Vector2 ToUnity(this float2 value) => new Vector2(value.x, value.y); + public static Vector3 ToUnity(this float3 value) => new Vector3(value.x, value.y, value.z); + public static Vector4 ToUnity(this float4 value) => new Vector4(value.x, value.y, value.z, value.w); + public static Quaternion ToUnity(this quaternion value) => new Quaternion(value.x, value.y, value.z, value.w); + public static Vector2Int ToUnity(this int2 value) => new Vector2Int(value.x, value.y); + public static Vector3Int ToUnity(this int3 value) => new Vector3Int(value.x, value.y, value.z); + public static Color ToColor(this float4 value) => new Color(value.x, value.y, value.z, value.w); + } +} diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/UnityConversions.cs.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/UnityConversions.cs.meta new file mode 100644 index 00000000..ccbd3f10 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/UnityConversions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8fc742177b3f4715b204afe0e69a400c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Shaders.meta b/packages/cultmath/unity/org.gamecult.cultmath/Shaders.meta new file mode 100644 index 00000000..1e7e7110 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/Shaders.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ca5d773061ed49b3a173526048d40cb3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl new file mode 100644 index 00000000..365705f5 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl @@ -0,0 +1,356 @@ +#ifndef CULTMATH_HLSL +#define CULTMATH_HLSL + +// CultMath HLSL mirror. +// +// HLSL already owns the float2/float3/float4 type names and most primitive +// intrinsics. This include names the CultMath semantic surface explicitly so +// shader code, C# wrappers, and Rust/native parity fixtures can share the same +// small vocabulary without local helper drift. + +static const float CULTMATH_PI = 3.14159265358979323846; +static const float CULTMATH_TAU = 6.28318530717958647692; +static const float CULTMATH_HALF_PI = 1.57079632679489661923; +static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20; + +float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } +float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } + +float cultmath_frac(float value) { return value - floor(value); } +float2 cultmath_frac(float2 value) { return value - floor(value); } +float3 cultmath_frac(float3 value) { return value - floor(value); } +float4 cultmath_frac(float4 value) { return value - floor(value); } + +float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } +float2 cultmath_clamp(float2 value, float2 minimum, float2 maximum) { return min(max(value, minimum), maximum); } +float3 cultmath_clamp(float3 value, float3 minimum, float3 maximum) { return min(max(value, minimum), maximum); } +float4 cultmath_clamp(float4 value, float4 minimum, float4 maximum) { return min(max(value, minimum), maximum); } + +// DXIL Saturate is FMin(1, FMax(0, x)); dxc lowers min/max to the same FMin/FMax, NaN rules included. +float cultmath_saturate(float value) { return min(1.0, max(0.0, value)); } +float2 cultmath_saturate(float2 value) { return min(float2(1.0, 1.0), max(float2(0.0, 0.0), value)); } +float3 cultmath_saturate(float3 value) { return min(float3(1.0, 1.0, 1.0), max(float3(0.0, 0.0, 0.0), value)); } +float4 cultmath_saturate(float4 value) { return min(float4(1.0, 1.0, 1.0, 1.0), max(float4(0.0, 0.0, 0.0, 0.0), value)); } + +float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } +float2 cultmath_lerp(float2 start, float2 end, float2 amount) { return start + (end - start) * amount; } +float3 cultmath_lerp(float3 start, float3 end, float3 amount) { return start + (end - start) * amount; } +float4 cultmath_lerp(float4 start, float4 end, float4 amount) { return start + (end - start) * amount; } + +float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } +float2 cultmath_step(float2 edge, float2 value) { return float2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } +float3 cultmath_step(float3 edge, float3 value) { return float3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } +float4 cultmath_step(float4 edge, float4 value) { return float4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } + +float cultmath_smoothstep(float minimum, float maximum, float value) +{ + float t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0 - 2.0 * t); +} + +float2 cultmath_smoothstep(float2 minimum, float2 maximum, float2 value) +{ + float2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float2(3.0, 3.0) - float2(2.0, 2.0) * t); +} + +float3 cultmath_smoothstep(float3 minimum, float3 maximum, float3 value) +{ + float3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float3(3.0, 3.0, 3.0) - float3(2.0, 2.0, 2.0) * t); +} + +float4 cultmath_smoothstep(float4 minimum, float4 maximum, float4 value) +{ + float4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float4(3.0, 3.0, 3.0, 3.0) - float4(2.0, 2.0, 2.0, 2.0) * t); +} + +float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } + +float cultmath_smootherstep(float value) +{ + value = cultmath_saturate(value); + return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); +} + +float2 cultmath_snoise_mod289(float2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +float3 cultmath_snoise_mod289(float3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +float4 cultmath_snoise_mod289(float4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); } +float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); } + +// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation +// order as the C# math.snoise(float3) mirror. +float cultmath_snoise(float3 value) +{ + float2 c = float2(1.0 / 6.0, 1.0 / 3.0); + float3 i = floor(value + dot(value, c.yyy)); + float3 x0 = value - i + dot(i, c.xxx); + float3 g = step(x0.yzx, x0.xyz); + float3 l = 1.0 - g; + float3 i1 = min(g.xyz, l.zxy); + float3 i2 = max(g.xyz, l.zxy); + float3 x1 = x0 - i1 + c.xxx; + float3 x2 = x0 - i2 + c.yyy; + float3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + float4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + float4(0.0, i1.z, i2.z, 1.0)) + i.y + float4(0.0, i1.y, i2.y, 1.0)) + i.x + float4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + float3 ns = float3(2.0 * n_, 0.5 * n_ - 1.0, n_); + float4 j = p - 49.0 * floor(p * ns.z * ns.z); + float4 x_ = floor(j * ns.z); + float4 y_ = floor(j - 7.0 * x_); + float4 x = x_ * ns.x + ns.yyyy; + float4 y = y_ * ns.x + ns.yyyy; + float4 h = 1.0 - abs(x) - abs(y); + + float4 b0 = float4(x.xy, y.xy); + float4 b1 = float4(x.zw, y.zw); + float4 s0 = floor(b0) * 2.0 + 1.0; + float4 s1 = floor(b1) * 2.0 + 1.0; + float4 sh = -step(h, 0.0); + float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + float4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + float3 p0 = float3(a0.xy, h.x); + float3 p1 = float3(a0.zw, h.y); + float3 p2 = float3(a1.xy, h.z); + float3 p3 = float3(a1.zw, h.w); + float4 norm = 1.79284291400159 - 0.85373472095314 * float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + m = m * m; + return 42.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); +} + +float cultmath_snoise(float2 value) +{ + float4 c = float4( + 0.211324865405187, + 0.366025403784439, + -0.577350269189626, + 0.024390243902439); + float2 i = floor(value + dot(value, c.yy)); + float2 x0 = value - i + dot(i, c.xx); + float2 i1 = x0.x > x0.y ? float2(1.0, 0.0) : float2(0.0, 1.0); + float4 x12 = float4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z); + + i = cultmath_snoise_mod289(i); + float3 p = cultmath_snoise_permute( + cultmath_snoise_permute(i.y + float3(0.0, i1.y, 1.0)) + i.x + float3(0.0, i1.x, 1.0)); + float3 m = max(0.5 - float3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); + m *= m; + m *= m; + + float3 x = 2.0 * frac(p * c.w) - 1.0; + float3 h = abs(x) - 0.5; + float3 ox = floor(x + 0.5); + float3 a0 = x - ox; + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + + float3 g = float3( + a0.x * x0.x + h.x * x0.y, + a0.y * x12.x + h.y * x12.y, + a0.z * x12.z + h.z * x12.w); + return 130.0 * dot(m, g); +} + +float cultmath_lengthsq(float2 value) { return dot(value, value); } +float cultmath_lengthsq(float3 value) { return dot(value, value); } +float cultmath_lengthsq(float4 value) { return dot(value, value); } + +float cultmath_distance(float2 left, float2 right) { return length(left - right); } +float cultmath_distance(float3 left, float3 right) { return length(left - right); } +float cultmath_distance(float4 left, float4 right) { return length(left - right); } + +float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } + +float2 cultmath_reflect(float2 incident, float2 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +float3 cultmath_reflect(float3 incident, float3 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +float2 cultmath_rotate(float2 value, float radians_value) +{ + float s = sin(radians_value); + float c = cos(radians_value); + return float2(value.x * c - value.y * s, value.x * s + value.y * c); +} + +float2 cultmath_rotate_degrees(float2 value, float degrees_value) +{ + return cultmath_rotate(value, cultmath_radians(degrees_value)); +} + +float cultmath_csum(float2 value) { return value.x + value.y; } +float cultmath_csum(float3 value) { return value.x + value.y + value.z; } +float cultmath_csum(float4 value) { return value.x + value.y + value.z + value.w; } + +float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float2 cultmath_decay(float2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float3 cultmath_decay(float3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float4 cultmath_decay(float4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } + +float cultmath_damp(float start, float end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float2 cultmath_damp(float2 start, float2 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float3 cultmath_damp(float3 start, float3 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float4 cultmath_damp(float4 start, float4 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float2 cultmath_catmullrom(float2 p0, float2 p1, float2 p2, float2 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float3 cultmath_catmullrom(float3 p0, float3 p1, float3 p2, float3 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float4 cultmath_catmullrom(float4 p0, float4 p1, float4 p2, float4 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float3 cultmath_quadratic_bezier(float3 p0, float3 p1, float3 p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float3 cultmath_cubic_bezier(float3 p0, float3 p1, float3 p2, float3 p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); } +float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); } +float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); } + +float cultmath_value_noise(float2 position) +{ + float2 cell = floor(position); + float2 local = cultmath_frac(position); + float2 u = local * local * (float2(3.0, 3.0) - float2(2.0, 2.0) * local); + + float a = cultmath_hash(cell); + float b = cultmath_hash(cell + float2(1.0, 0.0)); + float c = cultmath_hash(cell + float2(0.0, 1.0)); + float d = cultmath_hash(cell + float2(1.0, 1.0)); + + return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); +} + +float cultmath_value_noise_bicubic(float2 position) +{ + float2 cell = floor(position); + float2 local = cultmath_frac(position); + + float y0 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, -1.0)), cultmath_hash(cell + float2(0.0, -1.0)), cultmath_hash(cell + float2(1.0, -1.0)), cultmath_hash(cell + float2(2.0, -1.0)), local.x); + float y1 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 0.0)), cultmath_hash(cell + float2(0.0, 0.0)), cultmath_hash(cell + float2(1.0, 0.0)), cultmath_hash(cell + float2(2.0, 0.0)), local.x); + float y2 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 1.0)), cultmath_hash(cell + float2(0.0, 1.0)), cultmath_hash(cell + float2(1.0, 1.0)), cultmath_hash(cell + float2(2.0, 1.0)), local.x); + float y3 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 2.0)), cultmath_hash(cell + float2(0.0, 2.0)), cultmath_hash(cell + float2(1.0, 2.0)), cultmath_hash(cell + float2(2.0, 2.0)), local.x); + + return cultmath_catmullrom(y0, y1, y2, y3, local.y); +} + +float cultmath_value_noise_texture(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale) +{ + return noise_texture.SampleLevel(noise_sampler, uv * scale, 0.0).r; +} + +float cultmath_value_noise_texture_bicubic(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale, float2 texel_size) +{ + float2 position = uv * scale; + float2 cell = floor(position); + float2 local = cultmath_frac(position); + float2 base_uv = cell / scale; + + float row0 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, -1.0), 0.0).r, + local.x); + float row1 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 0.0), 0.0).r, + local.x); + float row2 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 1.0), 0.0).r, + local.x); + float row3 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 2.0), 0.0).r, + local.x); + + return cultmath_catmullrom(row0, row1, row2, row3, local.y); +} + +#endif diff --git a/src/GameCult.Unity/Assets/Caching/Runtime/GameCult.Unity.Caching.asmdef.meta b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl.meta similarity index 58% rename from src/GameCult.Unity/Assets/Caching/Runtime/GameCult.Unity.Caching.asmdef.meta rename to packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl.meta index 00ae3475..d3b6d2b3 100644 --- a/src/GameCult.Unity/Assets/Caching/Runtime/GameCult.Unity.Caching.asmdef.meta +++ b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl.meta @@ -1,6 +1,6 @@ fileFormatVersion: 2 -guid: ab6636c1402c4c579df9c07101542f13 -AssemblyDefinitionImporter: +guid: a46136cb4acb4ff2bc47b6a4f786e4b3 +ShaderIncludeImporter: externalObjects: {} userData: assetBundleName: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md b/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..06ebb6c3 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md @@ -0,0 +1,11 @@ +# Third-Party Notices + +## 2D and 3D Simplex Noise + +`math.snoise` and `cultmath_snoise` port the textureless 2D and 3D +simplex-noise functions by Ian McEwan and Ashima Arts, as distributed by Stefan +Gustavson's `webgl-noise` project and Unity Mathematics. + +- Copyright 2011 Ashima Arts. +- Upstream: +- License: MIT. diff --git a/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md.meta b/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md.meta new file mode 100644 index 00000000..a53f08d8 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0c88669e4d1db604588be2a726b7d231 \ No newline at end of file diff --git a/packages/cultmath/unity/org.gamecult.cultmath/package.json b/packages/cultmath/unity/org.gamecult.cultmath/package.json new file mode 100644 index 00000000..c09bf6bb --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/package.json @@ -0,0 +1,17 @@ +{ + "name": "org.gamecult.cultmath", + "displayName": "CultMath", + "version": "0.2.0", + "unity": "2021.3", + "description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.", + "license": "MPL-2.0", + "author": { + "name": "GameCult" + }, + "keywords": [ + "GameCult", + "Math", + "HLSL", + "Simulation" + ] +} diff --git a/packages/cultmath/unity/org.gamecult.cultmath/package.json.meta b/packages/cultmath/unity/org.gamecult.cultmath/package.json.meta new file mode 100644 index 00000000..337ba716 --- /dev/null +++ b/packages/cultmath/unity/org.gamecult.cultmath/package.json.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 33d8d4af49bf0f946ad00a375f26b1d1 \ No newline at end of file diff --git a/packages/cultmath/web.meta b/packages/cultmath/web.meta new file mode 100644 index 00000000..d65ff3e6 --- /dev/null +++ b/packages/cultmath/web.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: daf45471f8b5f4f44a2cb86e77e010db +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmesh-browser/README.md b/packages/cultmesh-browser/README.md new file mode 100644 index 00000000..59a44016 --- /dev/null +++ b/packages/cultmesh-browser/README.md @@ -0,0 +1,61 @@ +# CultMesh Browser + +`cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one +WebSocket session for a stable Verse/authority-runtime target, retains explicit raw +document leases across route replacement, and correlates typed CultNet +operations with provider receipts. + +It does not own application state and it does not expose direct document +writes. A renderer watches provider-owned records and invokes provider-owned +operations. + +```ts +const odin = new CultMeshBrowserOdinRendezvous({ + endpoints: ["wss://odin.example/verse"], + runtimeId: "counter-browser.discovery", +}); + +const mesh = await CultMeshBrowserClient.connect({ + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + runtimeId: "counter-browser", + rendezvous: odin, +}); + +const counter = await mesh.leaseRawDocument({ + schemaId: "sample.counter_state.v1", + recordKey: "counter:main", +}); + +counter.watch(record => renderCounter(decodeCultNetPayload(record))); +``` + +`invoke(...)` returns the correlated provider response. Routing, schema, and +envelope failures reject immediately with `CultMeshBrowserOperationError`; +callers can branch on its stable `status` and `code` without parsing prose or +waiting for the request timeout. Domain rejections remain typed provider +responses because the domain still owns their receipt schema. + +`CultMeshBrowserOdinRendezvous` speaks the canonical CultNet Verse-catalog +messages to one or more configured Odin WebSocket endpoints. Application code +supplies the Verse and authority-runtime identity; it does not own endpoint +selection, reconnect, or resubscription. This is deliberately distinct from an +Eve surface's `providerId`, which identifies the UI provider inside the Verse. +The physical socket is a replaceable route, not either stable identity. + +Odin returns exact authority-route bindings. The browser selects only routes +bound to the requested authority and `cultmesh.documents.v1`, then performs a +session-open handshake. It does not report `connected` until the peer proves +the exact Verse, authority runtime, protocol, and route generation. Operation +responses must also name that authority. Legacy catalogs are accepted only +when one authority runtime makes the endpoint binding unambiguous. + +The browser handshake carries authentication through the host application's +ordinary secure cookie or equivalent upgrade credential. The C# host adapter +rejects endpoints without an authorization predicate unless anonymous local +development is explicitly enabled. + +Run `scripts/verify-eve-browser-network.mjs --eve-root ` for the +real Chromium + C# headless chronology. The witness publishes a lower-priority +decoy endpoint owned by another runtime and proves that neither client connects +to it before and after route rotation. diff --git a/packages/cultmesh-browser/package.json b/packages/cultmesh-browser/package.json new file mode 100644 index 00000000..c49062b0 --- /dev/null +++ b/packages/cultmesh-browser/package.json @@ -0,0 +1,50 @@ +{ + "name": "cultmesh-browser", + "version": "0.3.0", + "description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", + "prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", + "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false", + "test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js" + }, + "keywords": [ + "cultmesh", + "cultnet", + "browser", + "websocket", + "gamecult" + ], + "author": "metacratic", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/GameCult/CultLib.git", + "directory": "packages/cultmesh-browser" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@msgpack/msgpack": "^3.1.3", + "cultnet-ts": "^0.2.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + } +} diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts new file mode 100644 index 00000000..4dddfa7d --- /dev/null +++ b/packages/cultmesh-browser/src/index.ts @@ -0,0 +1,1076 @@ +import { decode, encode } from "@msgpack/msgpack"; +import { + encodeCultNetMessageForWire, + parseCultNetMessage, + type CultNetDatabaseChangeRawMessage, + type CultNetDatabaseSubscribeMessage, + type CultNetDatabaseUnsubscribeMessage, + type CultNetMessage, + type CultNetOperationRequestMessage, + type CultNetOperationResponseMessage, + type CultNetRawDocumentRecord, + type CultNetSnapshotResponseRawMessage, + type CultMeshVerseCatalogRequestMessage, + type CultMeshVerseCatalogResponseMessage, + type CultMeshSessionAcceptedMessage, + type CultMeshSessionOpenMessage, +} from "cultnet-ts/contracts"; + +export interface CultMeshBrowserIdentity { + verseId: string; + authorityRuntimeId: string; +} + +export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity { + endpoint: string; + protocolId?: string; + protocolIds?: readonly string[]; + priority?: number; + generation: string; + certificate?: CultMeshBrowserRouteCertificate; +} + +export interface CultMeshBrowserP256PublicKey { + keyId: string; + x: string; + y: string; +} + +export interface CultMeshBrowserRouteCertificate { + providerKey: CultMeshBrowserP256PublicKey; + odinKeyId: string; + issuedAtUnixMilliseconds: number; + expiresAtUnixMilliseconds: number; + signature: string; +} + +export type CultMeshBrowserAuthorityTrustMode = "authenticated-remote" | "local-development"; + +export interface CultMeshBrowserAuthorityTrustPolicy { + mode: CultMeshBrowserAuthorityTrustMode; + odinRoots?: readonly CultMeshBrowserP256PublicKey[]; + now?: () => number; +} + +export interface CultMeshBrowserRendezvous { + resolve( + identity: CultMeshBrowserIdentity, + trust?: CultMeshBrowserAuthorityTrustPolicy, + ): Promise; +} + +export interface CultMeshBrowserOdinRendezvousOptions { + endpoints: readonly string[]; + runtimeId: string; + transportVersion?: string; + timeoutMs?: number; + maxFrameBytes?: number; + createId?: () => string; + socketFactory?: (endpoint: string) => CultMeshBrowserSocket; +} + +export interface CultMeshBrowserSocket { + binaryType: BinaryType; + readonly readyState: number; + onopen: ((event: Event) => void) | null; + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: Event) => void) | null; + onclose: ((event: CloseEvent) => void) | null; + send(data: ArrayBuffer | ArrayBufferView): void; + close(code?: number, reason?: string): void; +} + +/** Resolves a stable Verse/authority-runtime target through Odin's canonical CultNet Verse catalog. */ +export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous { + #options: Required> & { + transportVersion?: string; + socketFactory: (endpoint: string) => CultMeshBrowserSocket; + }; + + constructor(options: CultMeshBrowserOdinRendezvousOptions) { + requireText(options.runtimeId, "runtimeId"); + const endpoints = [...new Set(options.endpoints.map(value => value.trim()).filter(Boolean))]; + if (endpoints.length === 0) throw new Error("At least one Odin WebSocket endpoint is required."); + for (const endpoint of endpoints) requireWebSocketEndpoint(endpoint, "Odin rendezvous endpoint"); + this.#options = { + ...options, + endpoints, + timeoutMs: options.timeoutMs ?? 10_000, + maxFrameBytes: options.maxFrameBytes ?? 4 * 1024 * 1024, + createId: options.createId ?? (() => crypto.randomUUID()), + socketFactory: options.socketFactory ?? (endpoint => new WebSocket(endpoint)), + }; + if (this.#options.timeoutMs <= 0) throw new Error("Odin rendezvous timeoutMs must be positive."); + if (this.#options.maxFrameBytes <= 0) throw new Error("Odin rendezvous maxFrameBytes must be positive."); + } + + async resolve( + identity: CultMeshBrowserIdentity, + trust: CultMeshBrowserAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [] }, + ): Promise { + requireText(identity.verseId, "verseId"); + requireText(identity.authorityRuntimeId, "authorityRuntimeId"); + const failures: Error[] = []; + for (const endpoint of this.#options.endpoints) { + try { + return await this.resolveFrom(endpoint, identity, trust); + } catch (error) { + failures.push(error instanceof Error ? error : new Error(String(error))); + } + } + throw new AggregateError( + failures, + `Odin could not resolve Verse '${identity.verseId}' authority runtime '${identity.authorityRuntimeId}'.`, + ); + } + + private resolveFrom( + endpoint: string, + identity: CultMeshBrowserIdentity, + trust: CultMeshBrowserAuthorityTrustPolicy, + ): Promise { + const socket = this.#options.socketFactory(endpoint); + socket.binaryType = "arraybuffer"; + const messageId = this.#options.createId(); + const request: CultMeshVerseCatalogRequestMessage = { + schemaVersion: "cultmesh.verse_catalog_request.v0", + messageId, + verseIds: [identity.verseId], + ...(this.#options.transportVersion ? { transportVersion: this.#options.transportVersion } : {}), + }; + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error, route?: CultMeshBrowserRoute) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(1000, error ? "Odin resolution failed" : "Odin resolution complete"); + if (error) reject(error); + else resolve(route!); + }; + const timer = setTimeout( + () => finish(new Error(`Odin resolution timed out at '${endpoint}'.`)), + this.#options.timeoutMs, + ); + socket.onopen = () => { + try { + sendSocketMessage(socket, { + schemaVersion: "cultnet.hello.v0", + runtimeId: this.#options.runtimeId, + runtimeKind: "browser", + supportedMessageVersions: [ + "cultmesh.verse_catalog_request.v0", + "cultmesh.verse_catalog_response.v0", + ], + }, this.#options.maxFrameBytes); + sendSocketMessage(socket, request, this.#options.maxFrameBytes); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }; + socket.onmessage = event => { + void decodeSocketMessage(event, this.#options.maxFrameBytes).then(message => { + if (message.schemaVersion === "cultnet.error.v0") { + finish(new Error(message.error)); + return; + } + if (message.schemaVersion !== "cultmesh.verse_catalog_response.v0" || message.messageId !== messageId) return; + void selectOdinRoute(message, identity, trust) + .then(route => finish(undefined, route)) + .catch(error => finish(error instanceof Error ? error : new Error(String(error)))); + }).catch(error => finish(error instanceof Error ? error : new Error(String(error)))); + }; + socket.onerror = () => finish(new Error(`Odin WebSocket could not open '${endpoint}'.`)); + socket.onclose = () => finish(new Error(`Odin WebSocket closed before resolving '${identity.verseId}'.`)); + }); + } +} + +export interface CultMeshBrowserClientOptions extends CultMeshBrowserIdentity { + runtimeId: string; + rendezvous: CultMeshBrowserRendezvous; + socketFactory?: (endpoint: string) => CultMeshBrowserSocket; + reconnectDelayMs?: number; + connectTimeoutMs?: number; + requestTimeoutMs?: number; + maxFrameBytes?: number; + createId?: () => string; + /** Consumer-owned authority roots. Remote sessions fail closed when omitted. */ + trust?: CultMeshBrowserAuthorityTrustPolicy; +} + +export interface CultMeshRawDocumentLeaseOptions { + schemaId: string; + recordKey: string; + subscriptionId?: string; +} + +export interface CultMeshOperationOptions { + serviceId: string; + operation: string; + payloadSchema: string; + payload: unknown; + idempotencyKey?: string; + /** @deprecated The connected authority is the operation target. A different value is rejected. */ + targetRuntimeId?: string; +} + +/** Portable schema returned when routing or envelope validation fails before a domain reply exists. */ +export const cultNetOperationFailureSchema = "gamecult.cultnet.operation_failure.v1"; + +export interface CultNetOperationFailure { + code: string; + message: string; +} + +/** Correlated framework-level failure returned by a CultMesh operation provider. */ +export class CultMeshBrowserOperationError extends Error { + readonly status: string; + readonly code: string; + readonly diagnostics: readonly string[]; + + constructor(response: CultNetOperationResponseMessage, failure: CultNetOperationFailure) { + super(failure.message || "CultMesh operation failed."); + this.name = "CultMeshBrowserOperationError"; + this.status = response.status || "error"; + this.code = failure.code || "operation-error"; + this.diagnostics = response.diagnostics ?? []; + } +} + +export type CultMeshConnectionState = "connecting" | "connected" | "reconnecting" | "disposed"; + +type DocumentWatcher = (record: CultNetRawDocumentRecord | undefined) => void; + +interface PendingSnapshot { + lease: CultMeshRawDocumentLease; + resolve: () => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +interface PendingOperation { + request: CultNetOperationRequestMessage; + resolve: (response: CultNetOperationResponseMessage) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +export class CultMeshRawDocumentLease implements Disposable { + readonly schemaId: string; + readonly recordKey: string; + readonly subscriptionId: string; + #client: CultMeshBrowserClient | undefined; + #current: CultNetRawDocumentRecord | undefined; + #watchers = new Set(); + + constructor( + client: CultMeshBrowserClient, + options: Required, + ) { + this.#client = client; + this.schemaId = options.schemaId; + this.recordKey = options.recordKey; + this.subscriptionId = options.subscriptionId; + } + + get current(): CultNetRawDocumentRecord | undefined { + return this.#current; + } + + watch(watcher: DocumentWatcher): () => void { + if (!this.#client) throw new Error("CultMesh document lease is disposed."); + this.#watchers.add(watcher); + watcher(this.#current); + return () => this.#watchers.delete(watcher); + } + + dispose(): void { + this.#client?.releaseLease(this); + this.#client = undefined; + this.#watchers.clear(); + } + + [Symbol.dispose](): void { + this.dispose(); + } + + apply(record: CultNetRawDocumentRecord | undefined): void { + this.#current = record; + for (const watcher of this.#watchers) watcher(record); + } +} + +export class CultMeshBrowserClient implements AsyncDisposable { + readonly identity: CultMeshBrowserIdentity; + readonly runtimeId: string; + #options: Required> & { + socketFactory: (endpoint: string) => CultMeshBrowserSocket; + }; + #socket: CultMeshBrowserSocket | undefined; + #socketGeneration = 0; + #state: CultMeshConnectionState = "connecting"; + #disposed = false; + #reconnectTimer: ReturnType | undefined; + #connectPromise: Promise | undefined; + #leases = new Map(); + #openingLeases = new Map(); + #pendingSnapshots = new Map(); + #pendingOperations = new Map(); + #stateWatchers = new Set<(state: CultMeshConnectionState) => void>(); + + private constructor(options: CultMeshBrowserClientOptions) { + requireText(options.verseId, "verseId"); + requireText(options.authorityRuntimeId, "authorityRuntimeId"); + requireText(options.runtimeId, "runtimeId"); + this.identity = { verseId: options.verseId, authorityRuntimeId: options.authorityRuntimeId }; + this.runtimeId = options.runtimeId; + this.#options = { + ...options, + reconnectDelayMs: options.reconnectDelayMs ?? 250, + connectTimeoutMs: options.connectTimeoutMs ?? 10_000, + requestTimeoutMs: options.requestTimeoutMs ?? 10_000, + maxFrameBytes: options.maxFrameBytes ?? 4 * 1024 * 1024, + createId: options.createId ?? (() => crypto.randomUUID()), + socketFactory: options.socketFactory ?? (endpoint => new WebSocket(endpoint)), + trust: options.trust ?? { mode: "authenticated-remote", odinRoots: [] }, + }; + } + + static async connect(options: CultMeshBrowserClientOptions): Promise { + const client = new CultMeshBrowserClient(options); + await client.connectSocket(); + return client; + } + + get state(): CultMeshConnectionState { + return this.#state; + } + + watchState(watcher: (state: CultMeshConnectionState) => void): () => void { + this.#stateWatchers.add(watcher); + watcher(this.#state); + return () => this.#stateWatchers.delete(watcher); + } + + async leaseRawDocument(options: CultMeshRawDocumentLeaseOptions): Promise { + this.throwIfDisposed(); + requireText(options.schemaId, "schemaId"); + requireText(options.recordKey, "recordKey"); + const subscriptionId = options.subscriptionId ?? `lease:${this.#options.createId()}`; + if (this.#leases.has(subscriptionId) || this.#openingLeases.has(subscriptionId)) { + throw new Error(`CultMesh subscription '${subscriptionId}' is already leased.`); + } + const lease = new CultMeshRawDocumentLease(this, { ...options, subscriptionId }); + this.#openingLeases.set(subscriptionId, lease); + try { + await this.subscribe(lease); + this.#openingLeases.delete(subscriptionId); + this.#leases.set(subscriptionId, lease); + return lease; + } catch (error) { + this.#openingLeases.delete(subscriptionId); + throw error; + } + } + + async invoke(options: CultMeshOperationOptions): Promise { + this.throwIfDisposed(); + requireText(options.serviceId, "serviceId"); + requireText(options.operation, "operation"); + requireText(options.payloadSchema, "payloadSchema"); + if (options.targetRuntimeId && options.targetRuntimeId !== this.identity.authorityRuntimeId) { + throw new Error( + `CultMesh operation target '${options.targetRuntimeId}' does not match connected authority '${this.identity.authorityRuntimeId}'.`, + ); + } + await this.ensureConnected(); + const messageId = options.idempotencyKey ?? this.#options.createId(); + requireText(messageId, "operation idempotencyKey"); + if (this.#pendingOperations.has(messageId)) { + throw new Error(`CultMesh operation '${messageId}' is already in flight.`); + } + const request: CultNetOperationRequestMessage = { + schemaVersion: "cultnet.operation_request.v0", + messageId, + serviceId: options.serviceId, + operation: options.operation, + payloadSchema: options.payloadSchema, + payloadEncoding: "messagepack-base64", + payload: bytesToBase64(encode(options.payload)), + sourceRuntimeId: this.runtimeId, + targetRuntimeId: this.identity.authorityRuntimeId, + }; + const response = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (!this.#pendingOperations.delete(messageId)) return; + reject(new Error(`CultMesh operation '${messageId}' timed out.`)); + }, this.#options.requestTimeoutMs); + this.#pendingOperations.set(messageId, { request, resolve, reject, timer }); + }); + try { + this.send(request); + } catch (error) { + this.scheduleReconnect(); + } + return response; + } + + async refreshRoute(): Promise { + this.throwIfDisposed(); + this.#socketGeneration++; + const socket = this.#socket; + this.#socket = undefined; + socket?.close(1012, "CultMesh route refresh"); + await this.connectSocket(); + } + + releaseLease(lease: CultMeshRawDocumentLease): void { + if (this.#leases.get(lease.subscriptionId) !== lease) return; + this.#leases.delete(lease.subscriptionId); + for (const [messageId, pending] of this.#pendingSnapshots) { + if (pending.lease !== lease) continue; + this.#pendingSnapshots.delete(messageId); + clearTimeout(pending.timer); + pending.reject(new Error("CultMesh document lease was disposed before its snapshot arrived.")); + } + if (this.#state === "connected") { + const message: CultNetDatabaseUnsubscribeMessage = { + schemaVersion: "cultnet.database_unsubscribe.v0", + messageId: this.#options.createId(), + subscriptionId: lease.subscriptionId, + }; + this.send(message); + } + } + + async dispose(): Promise { + if (this.#disposed) return; + this.#disposed = true; + this.setState("disposed"); + if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = undefined; + const error = new Error("CultMesh browser client was disposed."); + for (const pending of this.#pendingSnapshots.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + for (const pending of this.#pendingOperations.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.#pendingSnapshots.clear(); + this.#pendingOperations.clear(); + this.#leases.clear(); + this.#openingLeases.clear(); + const socket = this.#socket; + this.#socket = undefined; + socket?.close(1000, "CultMesh client disposed"); + } + + async [Symbol.asyncDispose](): Promise { + await this.dispose(); + } + + private async subscribe(lease: CultMeshRawDocumentLease): Promise { + await this.ensureConnected(); + const messageId = this.#options.createId(); + const ready = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (!this.#pendingSnapshots.delete(messageId)) return; + reject(new Error(`CultMesh subscription '${lease.subscriptionId}' timed out.`)); + }, this.#options.requestTimeoutMs); + this.#pendingSnapshots.set(messageId, { lease, resolve, reject, timer }); + }); + const request: CultNetDatabaseSubscribeMessage = { + schemaVersion: "cultnet.database_subscribe.v0", + messageId, + subscriptionId: lease.subscriptionId, + schemaIds: [lease.schemaId], + recordKeys: [lease.recordKey], + includeSnapshot: true, + consumerRuntimeId: this.runtimeId, + }; + try { + this.send(request); + } catch (error) { + const pending = this.#pendingSnapshots.get(messageId); + if (pending) clearTimeout(pending.timer); + this.#pendingSnapshots.delete(messageId); + throw error; + } + return ready; + } + + private ensureConnected(): Promise { + if (this.#state === "connected" && this.#socket?.readyState === 1) return Promise.resolve(); + return this.connectSocket(); + } + + private connectSocket(): Promise { + this.throwIfDisposed(); + if (this.#state === "connected" && this.#socket?.readyState === 1) return Promise.resolve(); + if (this.#reconnectTimer) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = undefined; + } + if (this.#connectPromise) return this.#connectPromise; + const generation = ++this.#socketGeneration; + this.setState(this.#socket ? "reconnecting" : "connecting"); + this.#connectPromise = this.openSocket(generation).finally(() => { + this.#connectPromise = undefined; + }); + return this.#connectPromise; + } + + private async openSocket(generation: number): Promise { + const route = await this.#options.rendezvous.resolve(this.identity, this.#options.trust); + if (route.verseId !== this.identity.verseId || + route.authorityRuntimeId !== this.identity.authorityRuntimeId) { + throw new Error("CultMesh rendezvous returned a route for the wrong stable identity."); + } + await verifyAuthorityRoute(route, this.#options.trust); + const endpoint = new URL(route.endpoint); + if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") { + throw new Error(`CultMesh browser route must use ws:// or wss://, got '${route.endpoint}'.`); + } + const socket = this.#options.socketFactory(route.endpoint); + socket.binaryType = "arraybuffer"; + try { + await new Promise((resolve, reject) => { + let opened = false; + let settled = false; + const handshakeId = this.#options.createId(); + const clientNonce = randomNonce(); + const sessionRequest: CultMeshSessionOpenMessage = { + schemaVersion: "cultmesh.session_open.v2", + messageId: handshakeId, + sourceRuntimeId: this.runtimeId, + verseId: this.identity.verseId, + authorityRuntimeId: this.identity.authorityRuntimeId, + protocolId: route.protocolId ?? "cultmesh.documents.v1", + routeGeneration: route.generation, + clientNonce, + }; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) reject(error); + else resolve(); + }; + const timer = setTimeout( + () => finish(new Error( + opened + ? `CultMesh authority handshake timed out for '${route.endpoint}'.` + : `CultMesh WebSocket open timed out for '${route.endpoint}'.`, + )), + this.#options.connectTimeoutMs, + ); + socket.onopen = () => { + opened = true; + try { + sendSocketMessage(socket, { + schemaVersion: "cultnet.hello.v0", + runtimeId: this.runtimeId, + runtimeKind: "browser", + supportedMessageVersions: [ + "cultmesh.session_open.v2", + "cultmesh.session_accepted.v2", + "cultnet.database_subscribe.v0", + "cultnet.database_unsubscribe.v0", + "cultnet.database_change_raw.v0", + "cultnet.operation_request.v0", + "cultnet.operation_response.v0", + ], + }, this.#options.maxFrameBytes); + sendSocketMessage(socket, sessionRequest, this.#options.maxFrameBytes); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }; + socket.onmessage = event => { + void decodeSocketMessage(event, this.#options.maxFrameBytes).then(message => { + if (message.schemaVersion === "cultnet.error.v0") { + finish(new Error(message.error)); + return; + } + if (message.schemaVersion !== "cultmesh.session_accepted.v2" || message.messageId !== handshakeId) return; + void validateSessionAcceptance( + message, + sessionRequest, + route, + this.#options.trust, + ).then(finish).catch(error => finish(error instanceof Error ? error : new Error(String(error)))); + }).catch(error => finish(error instanceof Error ? error : new Error(String(error)))); + }; + socket.onerror = () => finish(new Error(`CultMesh WebSocket could not open '${route.endpoint}'.`)); + socket.onclose = () => finish(new Error(`CultMesh WebSocket closed before proving authority at '${route.endpoint}'.`)); + }); + } catch (error) { + socket.close(1000, "CultMesh route open failed"); + throw error; + } + if (this.#disposed || generation !== this.#socketGeneration) { + socket.close(1000, "Stale CultMesh route"); + return; + } + this.#socket = socket; + socket.onmessage = event => { void this.receive(event, generation); }; + socket.onerror = () => undefined; + socket.onclose = () => this.handleClosed(generation); + this.setState("connected"); + try { + await Promise.all([...this.#leases.values()].map(lease => this.subscribe(lease))); + for (const pending of this.#pendingOperations.values()) this.send(pending.request); + } catch (error) { + if (this.#socket === socket) this.#socket = undefined; + socket.onclose = null; + socket.close(1000, "CultMesh session initialization failed"); + throw error; + } + } + + private handleClosed(generation: number): void { + if (this.#disposed || generation !== this.#socketGeneration) return; + this.#socket = undefined; + const error = new Error("CultMesh route closed before the provider response arrived."); + for (const pending of this.#pendingSnapshots.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.#pendingSnapshots.clear(); + this.setState("reconnecting"); + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = undefined; + void this.connectSocket().catch(() => this.handleClosed(this.#socketGeneration)); + }, this.#options.reconnectDelayMs); + } + + private async receive(event: MessageEvent, generation: number): Promise { + if (this.#disposed || generation !== this.#socketGeneration) return; + let message: CultNetMessage; + try { + message = await decodeSocketMessage(event, this.#options.maxFrameBytes); + } catch (error) { + const oversized = error instanceof Error && error.message.includes("exceeds"); + this.#socket?.close( + oversized ? 1009 : 1003, + oversized ? "CultNet frame exceeds configured maximum" : "CultNet requires binary WebSocket frames", + ); + return; + } + switch (message.schemaVersion) { + case "cultnet.snapshot_response_raw.v0": + this.applySnapshot(message); + return; + case "cultnet.database_change_raw.v0": + this.applyChange(message); + return; + case "cultnet.operation_response.v0": { + const pending = this.#pendingOperations.get(message.messageId); + if (!pending) return; + if (message.sourceRuntimeId !== this.identity.authorityRuntimeId) { + this.#pendingOperations.delete(message.messageId); + clearTimeout(pending.timer); + pending.reject(new Error( + `CultMesh operation response came from '${message.sourceRuntimeId ?? "unknown"}', expected '${this.identity.authorityRuntimeId}'.`, + )); + this.#socket?.close(1008, "CultMesh authority identity changed"); + return; + } + this.#pendingOperations.delete(message.messageId); + clearTimeout(pending.timer); + if (message.payloadSchema === cultNetOperationFailureSchema) { + try { + pending.reject(new CultMeshBrowserOperationError( + message, + decodeCultNetOperationPayload(message), + )); + } catch (error) { + pending.reject(error instanceof Error + ? error + : new Error("CultMesh operation failure payload was malformed.")); + } + return; + } + pending.resolve(message); + return; + } + case "cultnet.error.v0": + this.rejectCorrelated(message.error); + return; + default: + return; + } + } + + private applySnapshot(message: CultNetSnapshotResponseRawMessage): void { + const pending = this.#pendingSnapshots.get(message.messageId); + if (!pending) return; + this.#pendingSnapshots.delete(message.messageId); + clearTimeout(pending.timer); + const matches = message.documents.filter(record => + record.recordKey === pending.lease.recordKey && recordMatchesSchema(record, pending.lease.schemaId)); + if (matches.length > 1) { + pending.reject(new Error(`CultMesh snapshot returned duplicate record '${pending.lease.recordKey}'.`)); + return; + } + pending.lease.apply(matches[0]); + pending.resolve(); + } + + private applyChange(message: CultNetDatabaseChangeRawMessage): void { + const lease = this.#leases.get(message.subscriptionId) ?? this.#openingLeases.get(message.subscriptionId); + if (!lease) return; + if (message.changeKind === "removed") { + if (message.recordKey === lease.recordKey) lease.apply(undefined); + return; + } + const record = message.document; + if (record?.recordKey === lease.recordKey && recordMatchesSchema(record, lease.schemaId)) lease.apply(record); + } + + private rejectCorrelated(reason: string): void { + if (this.#pendingOperations.size === 1) { + const [messageId, pending] = this.#pendingOperations.entries().next().value!; + this.#pendingOperations.delete(messageId); + clearTimeout(pending.timer); + pending.reject(new Error(reason)); + } + } + + private send(message: CultNetMessage): void { + const socket = this.#socket; + if (!socket || socket.readyState !== 1) throw new Error("CultMesh browser route is not connected."); + const payload = encode(encodeCultNetMessageForWire(message)); + if (payload.byteLength > this.#options.maxFrameBytes) { + throw new Error(`CultMesh message exceeds the ${this.#options.maxFrameBytes}-byte limit.`); + } + socket.send(payload); + } + + private setState(state: CultMeshConnectionState): void { + if (this.#state === state) return; + this.#state = state; + for (const watcher of this.#stateWatchers) watcher(state); + } + + private scheduleReconnect(): void { + if (this.#disposed || this.#reconnectTimer) return; + this.setState("reconnecting"); + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = undefined; + void this.connectSocket().catch(() => this.scheduleReconnect()); + }, this.#options.reconnectDelayMs); + } + + private throwIfDisposed(): void { + if (this.#disposed) throw new Error("CultMesh browser client is disposed."); + } +} + +export function decodeCultNetPayload(record: CultNetRawDocumentRecord): T { + if (record.payloadEncoding !== "messagepack") { + throw new Error(`Unsupported CultNet document payload '${record.payloadEncoding}'.`); + } + return decode(record.payload) as T; +} + +function recordMatchesSchema(record: CultNetRawDocumentRecord, requestedSchema: string): boolean { + return record.schemaId === requestedSchema || + record.schemaName === requestedSchema || + record.schemaVersion === requestedSchema; +} + +export function decodeCultNetOperationPayload(response: CultNetOperationResponseMessage): T { + if (response.payloadEncoding !== "messagepack-base64") { + throw new Error(`Unsupported CultNet operation payload '${response.payloadEncoding ?? "unspecified"}'.`); + } + return decode(base64ToBytes(response.payload)) as T; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + return Uint8Array.from(binary, character => character.charCodeAt(0)); +} + +function requireWebSocketEndpoint(value: string, field: string): URL { + let endpoint: URL; + try { + endpoint = new URL(value); + } catch { + throw new Error(`${field} '${value}' is not a valid URL.`); + } + if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") { + throw new Error(`${field} must use ws:// or wss://, got '${value}'.`); + } + return endpoint; +} + +function sendSocketMessage(socket: CultMeshBrowserSocket, message: CultNetMessage, maxFrameBytes: number): void { + const payload = encode(encodeCultNetMessageForWire(message)); + if (payload.byteLength > maxFrameBytes) { + throw new Error(`CultNet message exceeds the ${maxFrameBytes}-byte limit.`); + } + socket.send(payload); +} + +async function decodeSocketMessage(event: MessageEvent, maxFrameBytes: number): Promise { + let payload: Uint8Array; + if (event.data instanceof ArrayBuffer) { + payload = new Uint8Array(event.data); + } else if (ArrayBuffer.isView(event.data)) { + payload = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); + } else if (event.data instanceof Blob) { + payload = new Uint8Array(await event.data.arrayBuffer()); + } else { + throw new Error("CultNet requires binary WebSocket frames."); + } + if (payload.byteLength > maxFrameBytes) { + throw new Error(`CultNet frame exceeds the ${maxFrameBytes}-byte limit.`); + } + return parseCultNetMessage(decode(payload)); +} + +async function selectOdinRoute( + response: CultMeshVerseCatalogResponseMessage, + identity: CultMeshBrowserIdentity, + trust: CultMeshBrowserAuthorityTrustPolicy, +): Promise { + const matchingVerses = response.verses.filter(verse => verse.verseId === identity.verseId); + if (matchingVerses.length !== 1) { + throw new Error( + `Odin returned ${matchingVerses.length} routes for Verse '${identity.verseId}' authority runtime '${identity.authorityRuntimeId}'.`, + ); + } + const verse = matchingVerses[0]; + if (verse.authorityRoutes == null && verse.authorityRuntimeIds.length !== 1) { + throw new Error( + `Odin returned ambiguous legacy routes for Verse '${identity.verseId}'. Explicit authority route bindings are required.`, + ); + } + if (verse.authorityRoutes == null && verse.authorityRuntimeIds[0] !== identity.authorityRuntimeId) { + throw new Error( + `Odin advertised no route for authority runtime '${identity.authorityRuntimeId}'.`, + ); + } + const boundRoutes = verse.authorityRoutes ?? verse.discoveryEndpoints.map(endpoint => ({ + authorityRuntimeId: verse.authorityRuntimeIds[0], + endpoint, + protocolIds: ["cultmesh.documents.v1"], + priority: 0, + generation: [verse.compatibility.transportVersion, verse.compatibility.rulesHash, endpoint].join(":"), + certificate: undefined, + })); + const routes = boundRoutes.filter(route => + route.authorityRuntimeId === identity.authorityRuntimeId && + route.protocolIds.includes("cultmesh.documents.v1") && + (() => { + try { + requireWebSocketEndpoint(route.endpoint, "Odin provider route"); + return true; + } catch { + return false; + } + })()).sort((left, right) => left.priority - right.priority || left.endpoint.localeCompare(right.endpoint)); + if (routes.length === 0) { + throw new Error(`Odin advertised no browser-compatible route for authority runtime '${identity.authorityRuntimeId}'.`); + } + const failures: Error[] = []; + for (const route of routes) { + const candidate: CultMeshBrowserRoute = { + ...identity, + endpoint: route.endpoint, + protocolId: "cultmesh.documents.v1", + protocolIds: route.protocolIds, + priority: route.priority, + generation: route.generation, + ...(route.certificate ? { certificate: { + providerKey: { + keyId: route.certificate.providerKeyId, + x: route.certificate.providerPublicKeyX, + y: route.certificate.providerPublicKeyY, + }, + odinKeyId: route.certificate.odinKeyId, + issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds, + expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds, + signature: route.certificate.signature, + } } : {}), + }; + try { + await verifyAuthorityRoute(candidate, trust); + return candidate; + } catch (error) { + failures.push(error instanceof Error ? error : new Error(String(error))); + } + } + throw new AggregateError( + failures, + `Odin advertised no trusted browser route for authority runtime '${identity.authorityRuntimeId}'.`, + ); +} + +async function validateSessionAcceptance( + message: CultMeshSessionAcceptedMessage, + request: CultMeshSessionOpenMessage, + route: CultMeshBrowserRoute, + trust: CultMeshBrowserAuthorityTrustPolicy, +): Promise { + if (!message.accepted) return new Error(message.error ?? "CultMesh authority rejected the session."); + const protocolId = route.protocolId ?? "cultmesh.documents.v1"; + if (message.verseId !== request.verseId || + message.authorityRuntimeId !== request.authorityRuntimeId || + message.protocolId !== protocolId || + message.routeGeneration !== route.generation || + message.clientNonce !== request.clientNonce) { + return new Error( + `CultMesh route proved '${message.verseId}/${message.authorityRuntimeId}/${message.protocolId}/${message.routeGeneration}', ` + + `expected '${request.verseId}/${request.authorityRuntimeId}/${protocolId}/${route.generation}'.`, + ); + } + if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && !route.certificate) return undefined; + const providerKey = route.certificate?.providerKey; + if (!providerKey || message.providerKeyId !== providerKey.keyId || !message.providerSignature) { + return new Error("CultMesh authority did not prove possession of the Odin-certified provider key."); + } + if (!await verifyP256(providerKey, canonicalSession(request, route.endpoint), message.providerSignature)) { + return new Error("CultMesh provider session proof is invalid."); + } + return undefined; +} + +async function verifyAuthorityRoute( + route: CultMeshBrowserRoute, + trust: CultMeshBrowserAuthorityTrustPolicy, +): Promise { + const certificate = route.certificate; + if (!certificate) { + if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return; + throw new Error("Remote CultMesh routes require an Odin-signed authority certificate."); + } + const endpoint = new URL(route.endpoint); + if (endpoint.protocol !== "wss:" && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) { + throw new Error("Authenticated remote CultMesh browser routes require wss:// channel protection."); + } + const now = trust.now?.() ?? Date.now(); + if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) { + throw new Error("The Odin route certificate is not currently valid."); + } + const root = trust.odinRoots?.find(candidate => candidate.keyId === certificate.odinKeyId); + if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`); + if (!await verifyP256(root, canonicalRoute(route), certificate.signature)) { + throw new Error("The Odin route certificate signature is invalid."); + } +} + +function canonicalRoute(route: CultMeshBrowserRoute): Uint8Array { + const certificate = route.certificate!; + return canonicalFields( + "gamecult.cultmesh.route-certificate.v1", + route.verseId, + route.authorityRuntimeId, + route.endpoint, + [...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])].sort().join("\u001f"), + String(route.priority ?? 0), + route.generation, + certificate.providerKey.keyId, + certificate.providerKey.x, + certificate.providerKey.y, + certificate.odinKeyId, + String(certificate.issuedAtUnixMilliseconds), + String(certificate.expiresAtUnixMilliseconds), + ); +} + +function canonicalSession(request: CultMeshSessionOpenMessage, endpoint: string): Uint8Array { + return canonicalFields( + "gamecult.cultmesh.session-proof.v1", + request.clientNonce, + request.messageId, + request.sourceRuntimeId, + request.verseId, + request.authorityRuntimeId, + request.protocolId, + endpoint, + request.routeGeneration, + ); +} + +function canonicalFields(...values: string[]): Uint8Array { + const encoder = new TextEncoder(); + const encoded = values.map(value => encoder.encode(value)); + const total = encoded.reduce((sum, value) => sum + 4 + value.byteLength, 0); + const result = new Uint8Array(total); + const view = new DataView(result.buffer); + let offset = 0; + for (const value of encoded) { + view.setUint32(offset, value.byteLength, false); + offset += 4; + result.set(value, offset); + offset += value.byteLength; + } + return result; +} + +async function verifyP256( + key: CultMeshBrowserP256PublicKey, + payload: Uint8Array, + signatureBase64: string, +): Promise { + try { + const x = base64ToBytes(key.x); + const y = base64ToBytes(key.y); + const signature = base64ToBytes(signatureBase64); + if (x.byteLength !== 32 || y.byteLength !== 32 || signature.byteLength !== 64) return false; + const raw = new Uint8Array(65); + raw[0] = 4; + raw.set(x, 1); + raw.set(y, 33); + const publicKey = await crypto.subtle.importKey( + "raw", + raw.slice().buffer as ArrayBuffer, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + return await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + signature.slice().buffer as ArrayBuffer, + payload.slice().buffer as ArrayBuffer, + ); + } catch { + return false; + } +} + +function randomNonce(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return bytesToBase64(bytes); +} + +function isLoopbackEndpoint(value: string): boolean { + try { + const host = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return host === "localhost" || host === "127.0.0.1" || host === "::1"; + } catch { + return false; + } +} + +function requireText(value: string, field: string): void { + if (!value || value.trim().length === 0) throw new Error(`CultMesh browser ${field} is required.`); +} diff --git a/packages/cultmesh-browser/test/cultmesh-browser.test.ts b/packages/cultmesh-browser/test/cultmesh-browser.test.ts new file mode 100644 index 00000000..6c169d93 --- /dev/null +++ b/packages/cultmesh-browser/test/cultmesh-browser.test.ts @@ -0,0 +1,929 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { decode, encode } from "@msgpack/msgpack"; +import { + encodeCultNetMessageForWire, + parseCultNetMessage, + type CultNetDatabaseSubscribeMessage, + type CultNetMessage, + type CultNetOperationRequestMessage, + type CultNetRawDocumentRecord, + type CultMeshVerseCatalogResponseMessage, + type CultMeshSessionOpenMessage, +} from "cultnet-ts/contracts"; + +import { + CultMeshBrowserClient, + CultMeshBrowserOperationError, + CultMeshBrowserOdinRendezvous, + decodeCultNetOperationPayload, + decodeCultNetPayload, + type CultMeshBrowserRoute, + type CultMeshBrowserP256PublicKey, + type CultMeshBrowserSocket, +} from "../src/index.js"; + +const localTrust = { mode: "local-development" as const }; + +test("Odin rendezvous resolves stable authority-runtime identity and observes route rotation", async () => { + const odin = new FakeOdin("ws://127.0.0.1:4050/mesh"); + let id = 0; + const rendezvous = new CultMeshBrowserOdinRendezvous({ + endpoints: ["ws://127.0.0.1:4040/odin"], + runtimeId: "browser-odin-test", + createId: () => `odin-${++id}`, + socketFactory: () => odin.open(), + }); + + assert.equal((await rendezvous.resolve({ + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + }, localTrust)).endpoint, "ws://127.0.0.1:4050/mesh"); + + odin.providerEndpoint = "ws://127.0.0.1:4060/mesh"; + assert.equal((await rendezvous.resolve({ + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + }, localTrust)).endpoint, "ws://127.0.0.1:4060/mesh"); + + await assert.rejects( + rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "other-runtime" }, localTrust), + /could not resolve/, + ); +}); + +test("Odin rendezvous never selects another authority's faster endpoint", async () => { + const odin = new FakeOdin("ws://127.0.0.1:4050/mesh"); + odin.decoyEndpoint = "ws://127.0.0.1:4049/decoy"; + const rendezvous = new CultMeshBrowserOdinRendezvous({ + endpoints: ["ws://127.0.0.1:4040/odin"], + runtimeId: "browser-route-binding-test", + socketFactory: () => odin.open(), + }); + + const route = await rendezvous.resolve({ + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + }, localTrust); + assert.equal(route.endpoint, "ws://127.0.0.1:4050/mesh"); +}); + +test("Odin rendezvous accepts one-authority legacy routes and rejects ambiguous legacy catalogs", async () => { + const odin = new FakeOdin("ws://127.0.0.1:4070/mesh"); + odin.omitAuthorityRoutes = true; + const rendezvous = new CultMeshBrowserOdinRendezvous({ + endpoints: ["ws://127.0.0.1:4040/odin"], + runtimeId: "browser-legacy-route-test", + socketFactory: () => odin.open(), + }); + + const route = await rendezvous.resolve({ + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + }, localTrust); + assert.equal(route.endpoint, "ws://127.0.0.1:4070/mesh"); + + odin.decoyEndpoint = "ws://127.0.0.1:4069/decoy"; + await assert.rejects( + rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon" }, localTrust), + (error: unknown) => + error instanceof AggregateError && + error.errors.some(inner => inner instanceof Error && /ambiguous legacy routes/.test(inner.message)), + ); +}); + +test("Odin rendezvous rejects a poisoned route without vetoing a certified fallback", async () => { + const signed = await createSignedRoute("wss://provider.example/mesh"); + const odin = new FakeOdin(signed.route.endpoint); + odin.authorityRoutesOverride = [ + { + authorityRuntimeId: signed.route.authorityRuntimeId, + endpoint: "wss://poison.example/mesh", + protocolIds: ["cultmesh.documents.v1"], + priority: 0, + generation: "poisoned", + }, + routeForCatalog(signed.route), + ]; + const rendezvous = new CultMeshBrowserOdinRendezvous({ + endpoints: ["ws://127.0.0.1:4040/odin"], + runtimeId: "browser-certified-fallback-test", + socketFactory: () => odin.open(), + }); + + const selected = await rendezvous.resolve( + { verseId: signed.route.verseId, authorityRuntimeId: signed.route.authorityRuntimeId }, + { mode: "authenticated-remote", odinRoots: [signed.odinPublic] }, + ); + + assert.equal(selected.endpoint, signed.route.endpoint); +}); + +test("browser client leases provider state, invokes an operation, and follows route replacement", async () => { + const first = new FakeProvider("ws://127.0.0.1:4101/mesh", 4); + const second = new FakeProvider("ws://127.0.0.1:4102/mesh", 9); + let route = first.route; + let id = 0; + const client = await CultMeshBrowserClient.connect({ + verseId: route.verseId, + authorityRuntimeId: route.authorityRuntimeId, + runtimeId: "browser-test", + trust: localTrust, + rendezvous: { resolve: async () => route }, + createId: () => `id-${++id}`, + socketFactory: endpoint => endpoint === first.route.endpoint ? first.open() : second.open(), + }); + + const lease = await client.leaseRawDocument({ + schemaId: "sample.counter_state.v1", + recordKey: "counter:main", + }); + assert.deepEqual(decodeCultNetPayload<{ count: number }>(lease.current!).count, 4); + + const observed: number[] = []; + const unwatch = lease.watch(record => { + if (record) observed.push(decodeCultNetPayload<{ count: number }>(record).count); + }); + const response = await client.invoke({ + serviceId: "sample.counter", + operation: "increment", + payloadSchema: "sample.increment.v1", + payload: { amount: 2 }, + }); + assert.equal(response.status, "accepted"); + assert.deepEqual(decodeCultNetOperationPayload<{ count: number }>(response), { count: 6 }); + assert.deepEqual(observed, [4, 6]); + + route = second.route; + await client.refreshRoute(); + assert.equal(client.state, "connected"); + assert.deepEqual(decodeCultNetPayload<{ count: number }>(lease.current!).count, 9); + assert.deepEqual(observed, [4, 6, 9]); + assert.equal(first.activeSubscriptionCount, 0); + assert.equal(second.activeSubscriptionCount, 1); + + unwatch(); + lease.dispose(); + assert.equal(second.activeSubscriptionCount, 0); + await client.dispose(); +}); + +test("browser client rejects wrong rendezvous identity and has no document write API", async () => { + const provider = new FakeProvider("ws://127.0.0.1:4201/mesh", 1); + await assert.rejects( + CultMeshBrowserClient.connect({ + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + runtimeId: "browser-test", + trust: localTrust, + rendezvous: { + resolve: async () => ({ + ...provider.route, + authorityRuntimeId: "intruder-runtime", + }), + }, + socketFactory: () => provider.open(), + }), + /wrong stable identity/, + ); + assert.equal("putDocument" in CultMeshBrowserClient.prototype, false); + assert.equal("setDocument" in CultMeshBrowserClient.prototype, false); +}); + +test("browser client rejects a route whose peer proves the wrong authority", async () => { + const provider = new FakeProvider("ws://127.0.0.1:4251/mesh", 1, false, "intruder-runtime"); + await assert.rejects( + CultMeshBrowserClient.connect({ + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + runtimeId: "browser-peer-proof-test", + trust: localTrust, + rendezvous: { resolve: async () => ({ ...provider.route, authorityRuntimeId: "sample.counter-daemon" }) }, + socketFactory: () => provider.open(), + }), + /proved .*intruder-runtime.*expected .*sample.counter-daemon/, + ); +}); + +test("browser client accepts an Odin-certified route only after provider nonce proof", async () => { + const fixture = await createSignedRoute("wss://provider.example/mesh"); + const client = await CultMeshBrowserClient.connect({ + ...fixture.route, + runtimeId: "browser-authenticated-test", + rendezvous: { resolve: async () => fixture.route }, + trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic] }, + socketFactory: () => signedHandshakeSocket(fixture.route, fixture.providerPrivate), + }); + + assert.equal(client.state, "connected"); + await client.dispose(); +}); + +test("browser client rejects a credential-free authority impersonator", async () => { + const fixture = await createSignedRoute("wss://provider.example/mesh"); + const impersonator = new FakeProvider("wss://provider.example/mesh", 1); + + await assert.rejects( + CultMeshBrowserClient.connect({ + ...fixture.route, + runtimeId: "browser-impersonation-test", + rendezvous: { resolve: async () => fixture.route }, + trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic] }, + socketFactory: () => impersonator.open(), + }), + /did not prove possession/, + ); +}); + +test("browser client rejects a mutated or expired signed route before opening a provider socket", async () => { + const fixture = await createSignedRoute("wss://provider.example/mesh"); + let opened = false; + const connect = (route: CultMeshBrowserRoute, now?: () => number) => CultMeshBrowserClient.connect({ + ...route, + runtimeId: "browser-route-certificate-test", + rendezvous: { resolve: async () => route }, + trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic], now }, + socketFactory: () => { + opened = true; + return signedHandshakeSocket(route, fixture.providerPrivate); + }, + }); + + await assert.rejects(connect({ ...fixture.route, endpoint: "wss://evil.example/mesh" }), /signature is invalid/); + assert.equal(opened, false); + await assert.rejects(connect(fixture.route, () => fixture.expiresAt), /not currently valid/); + assert.equal(opened, false); +}); + +test("browser client bounds unanswered provider operations", async () => { + const route = { + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + endpoint: "ws://127.0.0.1:4301/mesh", + generation: "route-4301", + }; + const client = await CultMeshBrowserClient.connect({ + ...route, + runtimeId: "browser-timeout-test", + trust: localTrust, + rendezvous: { resolve: async () => route }, + requestTimeoutMs: 5, + socketFactory: () => handshakeOnlySocket(route), + }); + await assert.rejects( + client.invoke({ + serviceId: "sample.counter", + operation: "increment", + payloadSchema: "sample.increment.v1", + payload: { amount: 1 }, + idempotencyKey: "timeout-1", + }), + /timed out/, + ); + await client.dispose(); +}); + +test("browser client rejects a correlated framework failure without waiting for timeout", async () => { + const provider = new FakeProvider("ws://127.0.0.1:4351/mesh", 1, true); + const client = await CultMeshBrowserClient.connect({ + ...provider.route, + runtimeId: "browser-failure-test", + trust: localTrust, + rendezvous: { resolve: async () => provider.route }, + requestTimeoutMs: 60_000, + socketFactory: () => provider.open(), + }); + + await assert.rejects( + client.invoke({ + serviceId: "sample.counter", + operation: "increment", + payloadSchema: "wrong.request.v1", + payload: { amount: 1 }, + idempotencyKey: "failure-1", + }), + (error: unknown) => { + assert.ok(error instanceof CultMeshBrowserOperationError); + assert.equal(error.status, "invalid"); + assert.equal(error.code, "request-schema-mismatch"); + assert.match(error.message, /expected payload schema/i); + return true; + }, + ); + await client.dispose(); +}); + +test("browser client replays one idempotent operation after route loss without duplicating its effect", async () => { + const firstRoute = localRoute("ws://127.0.0.1:4311/mesh", "generation-1"); + const secondRoute = localRoute("ws://127.0.0.1:4312/mesh", "generation-2"); + let route = firstRoute; + let effectCount = 0; + let dropFirstResponse = true; + const results = new Map(); + const open = () => { + let socket: FakeSocket; + socket = new FakeSocket(wire => { + const message = parseCultNetMessage(decode(wire)); + if (message.schemaVersion === "cultmesh.session_open.v2") { + socket.deliver({ + schemaVersion: "cultmesh.session_accepted.v2", + messageId: message.messageId, + accepted: true, + verseId: message.verseId, + authorityRuntimeId: message.authorityRuntimeId, + protocolId: message.protocolId, + routeGeneration: message.routeGeneration, + clientNonce: message.clientNonce, + providerKeyId: "", + providerSignature: "", + }); + return; + } + if (message.schemaVersion !== "cultnet.operation_request.v0") return; + let response = results.get(message.messageId); + if (!response) { + effectCount++; + response = { + schemaVersion: "cultnet.operation_response.v0", + messageId: message.messageId, + serviceId: message.serviceId, + operation: message.operation, + status: "accepted", + payloadSchema: "sample.increment_receipt.v1", + payloadEncoding: "messagepack-base64", + payload: Buffer.from(encode({ count: effectCount })).toString("base64"), + sourceRuntimeId: "sample.counter-daemon", + }; + results.set(message.messageId, response!); + } + if (dropFirstResponse) { + dropFirstResponse = false; + route = secondRoute; + socket.close(1012, "route lost after commit"); + return; + } + socket.deliver(response!); + }, () => undefined); + return socket; + }; + const client = await CultMeshBrowserClient.connect({ + ...firstRoute, + runtimeId: "browser-operation-replay-test", + rendezvous: { resolve: async () => route }, + trust: localTrust, + reconnectDelayMs: 1, + requestTimeoutMs: 2_000, + socketFactory: () => open(), + }); + + const response = await client.invoke({ + serviceId: "sample.counter", + operation: "increment", + payloadSchema: "sample.increment.v1", + payload: { amount: 1 }, + idempotencyKey: "replay-once", + }); + + assert.deepEqual(decodeCultNetOperationPayload<{ count: number }>(response), { count: 1 }); + assert.equal(effectCount, 1); + await client.dispose(); +}); + +test("an early caller reconnect cancels the scheduled reconnect and keeps one live socket", async () => { + const provider = new FakeProvider("ws://127.0.0.1:4361/mesh", 2); + const client = await CultMeshBrowserClient.connect({ + ...provider.route, + runtimeId: "browser-single-reconnect-test", + rendezvous: { resolve: async () => provider.route }, + trust: localTrust, + reconnectDelayMs: 40, + socketFactory: () => provider.open(), + }); + const lease = await client.leaseRawDocument({ + schemaId: "sample.counter_state.v1", + recordKey: "counter:main", + }); + + provider.dropConnections(); + await client.invoke({ + serviceId: "sample.counter", + operation: "increment", + payloadSchema: "sample.increment.v1", + payload: { amount: 1 }, + }); + await new Promise(resolve => setTimeout(resolve, 80)); + + assert.equal(provider.openCount, 2); + assert.equal(provider.liveSocketCount, 1); + assert.equal(provider.activeSubscriptionCount, 1); + lease.dispose(); + await client.dispose(); +}); + +test("browser client rejects an operation response from another runtime", async () => { + const provider = new FakeProvider( + "ws://127.0.0.1:4371/mesh", + 1, + false, + "sample.counter-daemon", + "intruder-runtime", + ); + const client = await CultMeshBrowserClient.connect({ + ...provider.route, + runtimeId: "browser-response-authority-test", + trust: localTrust, + rendezvous: { resolve: async () => provider.route }, + socketFactory: () => provider.open(), + }); + + await assert.rejects( + client.invoke({ + serviceId: "sample.counter", + operation: "increment", + payloadSchema: "sample.increment.v1", + payload: { amount: 1 }, + }), + /response came from 'intruder-runtime'/, + ); + await client.dispose(); +}); + +test("browser client rejects oversized outbound schema messages", async () => { + const route = { + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + endpoint: "ws://127.0.0.1:4401/mesh", + generation: "route-4401", + }; + await assert.rejects( + CultMeshBrowserClient.connect({ + ...route, + runtimeId: "browser-size-test", + trust: localTrust, + rendezvous: { resolve: async () => route }, + maxFrameBytes: 8, + socketFactory: () => new FakeSocket(() => undefined, () => undefined), + }), + /exceeds the 8-byte limit/, + ); +}); + +test("browser client reserves subscription identity while the initial lease opens", async () => { + const provider = new FakeProvider("ws://127.0.0.1:4501/mesh", 3); + const client = await CultMeshBrowserClient.connect({ + ...provider.route, + runtimeId: "browser-lease-race-test", + trust: localTrust, + rendezvous: { resolve: async () => provider.route }, + socketFactory: () => provider.open(), + }); + const first = client.leaseRawDocument({ + schemaId: "sample.counter_state.v1", + recordKey: "counter:main", + subscriptionId: "one-owner", + }); + await assert.rejects( + client.leaseRawDocument({ + schemaId: "sample.counter_state.v1", + recordKey: "counter:main", + subscriptionId: "one-owner", + }), + /already leased/, + ); + (await first).dispose(); + await client.dispose(); +}); + +class FakeProvider { + readonly route: CultMeshBrowserRoute; + #count: number; + #subscriptions = new Map>(); + #rejectOperations: boolean; + #runtimeId: string; + #responseRuntimeId: string; + #openCount = 0; + + constructor( + endpoint: string, + count: number, + rejectOperations = false, + runtimeId = "sample.counter-daemon", + responseRuntimeId = runtimeId, + ) { + this.route = { + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + endpoint, + generation: endpoint, + }; + this.#count = count; + this.#rejectOperations = rejectOperations; + this.#runtimeId = runtimeId; + this.#responseRuntimeId = responseRuntimeId; + } + + get activeSubscriptionCount(): number { + let count = 0; + for (const subscriptions of this.#subscriptions.values()) count += subscriptions.size; + return count; + } + + get openCount(): number { + return this.#openCount; + } + + get liveSocketCount(): number { + return this.#subscriptions.size; + } + + dropConnections(): void { + for (const socket of [...this.#subscriptions.keys()]) socket.close(1012, "route dropped"); + } + + open(): FakeSocket { + this.#openCount++; + const socket = new FakeSocket( + wire => this.receive(socket, wire), + () => this.#subscriptions.delete(socket), + ); + this.#subscriptions.set(socket, new Map()); + return socket; + } + + private receive(socket: FakeSocket, wire: Uint8Array): void { + const message = parseCultNetMessage(decode(wire)); + switch (message.schemaVersion) { + case "cultmesh.session_open.v2": + this.acceptSession(socket, message); + return; + case "cultnet.database_subscribe.v0": + this.subscribe(socket, message); + return; + case "cultnet.database_unsubscribe.v0": + this.#subscriptions.get(socket)?.delete(message.subscriptionId); + return; + case "cultnet.operation_request.v0": + this.invoke(socket, message); + return; + default: + return; + } + } + + private acceptSession(socket: FakeSocket, message: CultMeshSessionOpenMessage): void { + socket.deliver({ + schemaVersion: "cultmesh.session_accepted.v2", + messageId: message.messageId, + accepted: true, + verseId: message.verseId, + authorityRuntimeId: this.#runtimeId, + protocolId: message.protocolId, + routeGeneration: message.routeGeneration, + clientNonce: message.clientNonce, + providerKeyId: "", + providerSignature: "", + }); + } + + private subscribe(socket: FakeSocket, message: CultNetDatabaseSubscribeMessage): void { + this.#subscriptions.get(socket)!.set(message.subscriptionId, message); + socket.deliver({ + schemaVersion: "cultnet.snapshot_response_raw.v0", + messageId: message.messageId, + documents: [this.record()], + }); + } + + private invoke(socket: FakeSocket, message: CultNetOperationRequestMessage): void { + assert.equal(message.targetRuntimeId, this.#runtimeId); + if (this.#rejectOperations) { + socket.deliver({ + schemaVersion: "cultnet.operation_response.v0", + messageId: message.messageId, + serviceId: message.serviceId, + operation: message.operation, + status: "invalid", + payloadSchema: "gamecult.cultnet.operation_failure.v1", + payloadEncoding: "messagepack-base64", + payload: bytesToBase64(encode({ + code: "request-schema-mismatch", + message: "Expected payload schema 'sample.increment.v1'.", + })), + diagnostics: ["Request schema did not match."], + sourceRuntimeId: this.#responseRuntimeId, + }); + return; + } + const payload = decode(base64ToBytes(message.payload)) as { amount: number }; + this.#count += payload.amount; + for (const [peer, subscriptions] of this.#subscriptions) { + for (const subscriptionId of subscriptions.keys()) { + peer.deliver({ + schemaVersion: "cultnet.database_change_raw.v0", + messageId: `change-${message.messageId}`, + subscriptionId, + changeKind: "updated", + document: this.record(), + }); + } + } + socket.deliver({ + schemaVersion: "cultnet.operation_response.v0", + messageId: message.messageId, + serviceId: message.serviceId, + operation: message.operation, + status: "accepted", + payloadSchema: "sample.increment_receipt.v1", + payloadEncoding: "messagepack-base64", + payload: bytesToBase64(encode({ count: this.#count })), + sourceRuntimeId: this.#responseRuntimeId, + }); + } + + private record(): CultNetRawDocumentRecord { + return { + schemaId: "sha256:sample-counter-state", + schemaName: "sample.counter_state", + schemaVersion: "sample.counter_state.v1", + schemaContentHash: "sha256:sample-counter-state-content", + recordKey: "counter:main", + storedAt: "2026-08-17T00:00:00Z", + payloadEncoding: "messagepack", + payload: encode({ count: this.#count }), + sourceRuntimeId: this.#runtimeId, + }; + } +} + +class FakeOdin { + providerEndpoint: string; + decoyEndpoint: string | undefined; + omitAuthorityRoutes = false; + authorityRoutesOverride: NonNullable | undefined; + + constructor(providerEndpoint: string) { + this.providerEndpoint = providerEndpoint; + } + + open(): FakeSocket { + const socket = new FakeSocket(wire => { + const message = parseCultNetMessage(decode(wire)); + if (message.schemaVersion !== "cultmesh.verse_catalog_request.v0") return; + socket.deliver({ + schemaVersion: "cultmesh.verse_catalog_response.v0", + messageId: message.messageId, + verses: [{ + verseId: "sample.counter", + displayName: "Counter", + authorityModel: "ServerAuthoritative", + compatibility: { + transportVersion: "cultmesh.v1", + rulesHash: "counter-v1", + compatibleVerseIds: [], + requiredPluginIds: [], + optionalPluginIds: [], + }, + discoveryEndpoints: [this.providerEndpoint], + authorityRuntimeIds: this.decoyEndpoint + ? ["sample.counter-daemon", "sample.decoy-daemon"] + : ["sample.counter-daemon"], + ...(!this.omitAuthorityRoutes ? { authorityRoutes: this.authorityRoutesOverride ?? [ + ...(this.decoyEndpoint ? [{ + authorityRuntimeId: "sample.decoy-daemon", + endpoint: this.decoyEndpoint, + protocolIds: ["cultmesh.documents.v1"], + priority: 0, + generation: "decoy-generation", + }] : []), + { + authorityRuntimeId: "sample.counter-daemon", + endpoint: this.providerEndpoint, + protocolIds: ["cultmesh.documents.v1"], + priority: 10, + generation: this.providerEndpoint, + }, + ] } : {}), + }], + }); + }, () => undefined); + return socket; + } +} + +function routeForCatalog(route: CultMeshBrowserRoute): NonNullable< + CultMeshVerseCatalogResponseMessage["verses"][number]["authorityRoutes"] +>[number] { + return { + authorityRuntimeId: route.authorityRuntimeId, + endpoint: route.endpoint, + protocolIds: [...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])], + priority: route.priority ?? 0, + generation: route.generation, + ...(route.certificate ? { + certificate: { + providerKeyId: route.certificate.providerKey.keyId, + providerPublicKeyX: route.certificate.providerKey.x, + providerPublicKeyY: route.certificate.providerKey.y, + odinKeyId: route.certificate.odinKeyId, + issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds, + expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds, + signature: route.certificate.signature, + }, + } : {}), + }; +} + +function handshakeOnlySocket(route: CultMeshBrowserRoute): FakeSocket { + let socket: FakeSocket; + socket = new FakeSocket(wire => { + const message = parseCultNetMessage(decode(wire)); + if (message.schemaVersion !== "cultmesh.session_open.v2") return; + socket.deliver({ + schemaVersion: "cultmesh.session_accepted.v2", + messageId: message.messageId, + accepted: true, + verseId: route.verseId, + authorityRuntimeId: route.authorityRuntimeId, + protocolId: message.protocolId, + routeGeneration: message.routeGeneration, + clientNonce: message.clientNonce, + providerKeyId: "", + providerSignature: "", + }); + }, () => undefined); + return socket; +} + +function localRoute(endpoint: string, generation: string): CultMeshBrowserRoute { + return { + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + endpoint, + protocolId: "cultmesh.documents.v1", + protocolIds: ["cultmesh.documents.v1"], + priority: 0, + generation, + }; +} + +class FakeSocket implements CultMeshBrowserSocket { + binaryType: BinaryType = "arraybuffer"; + readyState = 0; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + #receive: (wire: Uint8Array) => void; + #closed: () => void; + + constructor(receive: (wire: Uint8Array) => void, closed: () => void) { + this.#receive = receive; + this.#closed = closed; + queueMicrotask(() => { + this.readyState = 1; + this.onopen?.({} as Event); + }); + } + + send(data: ArrayBuffer | ArrayBufferView): void { + if (this.readyState !== 1) throw new Error("Fake socket is not open."); + const wire = data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + this.#receive(wire); + } + + close(code = 1000, reason = ""): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.#closed(); + this.onclose?.({ code, reason } as CloseEvent); + } + + deliver(message: CultNetMessage): void { + const wire = encode(encodeCultNetMessageForWire(message)); + queueMicrotask(() => this.onmessage?.({ data: wire.slice().buffer } as MessageEvent)); + } +} + +async function createSignedRoute(endpoint: string): Promise<{ + route: CultMeshBrowserRoute; + odinPublic: CultMeshBrowserP256PublicKey; + providerPrivate: CryptoKey; + expiresAt: number; +}> { + const odin = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + const provider = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + const odinPublic = await exportPublic("odin-root-1", odin.publicKey); + const providerPublic = await exportPublic("provider-1", provider.publicKey); + const issuedAt = Date.now() - 1_000; + const expiresAt = Date.now() + 60_000; + const unsigned: CultMeshBrowserRoute = { + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + endpoint, + protocolId: "cultmesh.documents.v1", + protocolIds: ["cultmesh.documents.v1"], + priority: 0, + generation: "signed-generation-1", + certificate: { + providerKey: providerPublic, + odinKeyId: odinPublic.keyId, + issuedAtUnixMilliseconds: issuedAt, + expiresAtUnixMilliseconds: expiresAt, + signature: "", + }, + }; + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + odin.privateKey, + testCanonicalRoute(unsigned).slice().buffer as ArrayBuffer, + ); + return { + route: { ...unsigned, certificate: { ...unsigned.certificate!, signature: toBase64(new Uint8Array(signature)) } }, + odinPublic, + providerPrivate: provider.privateKey, + expiresAt, + }; +} + +function signedHandshakeSocket(route: CultMeshBrowserRoute, providerPrivate: CryptoKey): FakeSocket { + let socket: FakeSocket; + socket = new FakeSocket(wire => { + const message = parseCultNetMessage(decode(wire)); + if (message.schemaVersion !== "cultmesh.session_open.v2") return; + void crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + providerPrivate, + testCanonicalSession(message, route.endpoint).slice().buffer as ArrayBuffer, + ).then(signature => socket.deliver({ + schemaVersion: "cultmesh.session_accepted.v2", + messageId: message.messageId, + accepted: true, + verseId: message.verseId, + authorityRuntimeId: message.authorityRuntimeId, + protocolId: message.protocolId, + routeGeneration: message.routeGeneration, + clientNonce: message.clientNonce, + providerKeyId: route.certificate!.providerKey.keyId, + providerSignature: toBase64(new Uint8Array(signature)), + })); + }, () => undefined); + return socket; +} + +async function exportPublic(keyId: string, key: CryptoKey): Promise { + const jwk = await crypto.subtle.exportKey("jwk", key); + return { keyId, x: urlBase64ToBase64(jwk.x!), y: urlBase64ToBase64(jwk.y!) }; +} + +function testCanonicalRoute(route: CultMeshBrowserRoute): Uint8Array { + const certificate = route.certificate!; + return testCanonical( + "gamecult.cultmesh.route-certificate.v1", route.verseId, route.authorityRuntimeId, route.endpoint, + [...route.protocolIds!].sort().join("\u001f"), String(route.priority), route.generation, + certificate.providerKey.keyId, certificate.providerKey.x, certificate.providerKey.y, + certificate.odinKeyId, String(certificate.issuedAtUnixMilliseconds), String(certificate.expiresAtUnixMilliseconds), + ); +} + +function testCanonicalSession(message: CultMeshSessionOpenMessage, endpoint: string): Uint8Array { + return testCanonical( + "gamecult.cultmesh.session-proof.v1", message.clientNonce, message.messageId, message.sourceRuntimeId, + message.verseId, message.authorityRuntimeId, message.protocolId, endpoint, message.routeGeneration, + ); +} + +function testCanonical(...values: string[]): Uint8Array { + const chunks = values.map(value => new TextEncoder().encode(value)); + const bytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + 4 + chunk.length, 0)); + const view = new DataView(bytes.buffer); + let offset = 0; + for (const chunk of chunks) { + view.setUint32(offset, chunk.length, false); + offset += 4; + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64"); +} + +function urlBase64ToBase64(value: string): string { + return Buffer.from(value, "base64url").toString("base64"); +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + return Uint8Array.from(binary, character => character.charCodeAt(0)); +} diff --git a/packages/cultmesh-browser/tsconfig.json b/packages/cultmesh-browser/tsconfig.json new file mode 100644 index 00000000..b9b7caba --- /dev/null +++ b/packages/cultmesh-browser/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/cultmesh-browser/tsconfig.test.json b/packages/cultmesh-browser/tsconfig.test.json new file mode 100644 index 00000000..4d148683 --- /dev/null +++ b/packages/cultmesh-browser/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"], + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "outDir": "dist-test", + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/cultmesh-kotlin/README.md b/packages/cultmesh-kotlin/README.md index b25050bb..3da68679 100644 --- a/packages/cultmesh-kotlin/README.md +++ b/packages/cultmesh-kotlin/README.md @@ -66,11 +66,54 @@ target.applyRawSnapshotResponse(response) val note = target.require(notes, "note:1") ``` +When the caller wants one record as a local typed view, `syncDocument(...)` +applies just that snapshot record and returns the requested document type. This +is the comfortable alias path: the source can publish one Kotlin type, while +the target registers a different Kotlin type with the same schema version. + +```kotlin +val sourceNote = cultDocument(SourceNoteCodec) +val uiNote = cultDocument(UiNoteCodec) // same schemaVersion as sourceNote + +source.remember(sourceNote, "note:bridge", SourceNote("kotlin.note.v1", "hello")) + +val response = source.createRawSnapshotResponse( + cultNetSnapshotRequest( + messageId = "sync-ui-note", + schemaIds = listOf(uiNote.schemaVersion), + recordKeys = listOf("note:bridge"), + ), +) + +val note: UiNote = target.syncDocument(response, uiNote, "note:bridge") +``` + `applyRawDocumentPut(...)`, `applyRawSnapshotResponse(...)`, and `applyDocumentDelete(...)` require matching codecs to be registered first, so raw bytes re-enter the typed cache through the same document definitions that local Kotlin callers use. +Reactive document handles let Kotlin callers keep a typed local view and have +CultMesh flush changed state on the debounce cadence: + +```kotlin +val note = CultMesh.reactiveDocument( + target, + uiNote, + "note:bridge", + CultReactiveDocumentOptions(flushDelayMs = 16), +) + +note.current.body = "local prediction" +``` + +For immutable document types, use `update { it.copy(...) }` or +`setCurrent(...)`. When an authoritative raw document put arrives while local +state is dirty, `applyRawDocumentPut(...)` records a +`CultReactiveDocumentReconciliation` with the canonical value, predicted value, +and a delta. Tuple-backed MessagePack codecs report delta keys by field slot, +such as `"0"` and `"1"`. + ## WebSocket Transport For browser/mobile-friendly schema lanes, `CultNetWebSocketTransportConnection` diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt index 4b7bd68a..104bd1b9 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt @@ -195,6 +195,202 @@ fun CultCache.document(document: CultDocumentDefinition, key: Strin fun CultCache.global(document: CultDocumentDefinition): CultCacheDocumentHandle = CultCacheDocumentHandle(this, document, CultCache.GLOBAL_KEY) +data class CultReactiveDocumentOptions( + val flushDelayMs: Long = 16, + val detectLocalChanges: Boolean = true, + val replaceDirtyCurrentOnCanonicalSnapshot: Boolean = false, +) + +data class CultReactiveDocumentReconciliation( + val canonical: T, + val predicted: T, + val delta: Map, + val version: Long, + val receivedAt: Instant, +) + +class CultReactiveDocument( + private val cache: CultCache, + private val document: CultDocumentDefinition, + private val key: String, + private val options: CultReactiveDocumentOptions = CultReactiveDocumentOptions(), +) : AutoCloseable { + private val lock = Any() + private val disposed = AtomicBoolean(false) + private var lastCleanPayload = ByteArray(0) + private var flushing = false + private var flushQueued = false + private var reconciliationVersion = 0L + private val detectWorker: Thread? + + @Volatile + var current: T = cache.getRequired(document, key) + private set + + @Volatile + var isDirty: Boolean = false + private set + + @Volatile + var reconciliation: CultReactiveDocumentReconciliation? = null + private set + + @Volatile + var lastError: Throwable? = null + private set + + init { + require(key.isNotBlank()) { "key must be non-empty" } + lastCleanPayload = document.codec.encode(current) + detectWorker = if (options.detectLocalChanges) { + Thread { + val delay = options.flushDelayMs.coerceAtLeast(1) + while (!disposed.get()) { + try { + Thread.sleep(delay) + if (disposed.get()) return@Thread + if (detectLocalChanges()) flush() + } catch (_: InterruptedException) { + if (disposed.get()) return@Thread + } catch (error: Throwable) { + lastError = error + } + } + }.apply { + isDaemon = true + name = "cultmesh-reactive-document-${document.documentType}-$key" + start() + } + } else { + null + } + } + + fun update(update: (T) -> T): T { + val predicted = synchronized(lock) { + current = update(current) + isDirty = true + current + } + if (options.flushDelayMs <= 0) flush() + return predicted + } + + fun setCurrent(value: T): T { + synchronized(lock) { + current = value + isDirty = true + } + if (options.flushDelayMs <= 0) flush() + return value + } + + fun markDirty() { + synchronized(lock) { + isDirty = true + } + } + + fun refresh(): T { + val canonical = cache.getRequired(document, key) + synchronized(lock) { + current = canonical + lastCleanPayload = document.codec.encode(canonical) + isDirty = false + flushQueued = false + reconciliation = null + } + return canonical + } + + fun flush() { + val predicted = synchronized(lock) { + if (!isDirty && !detectLocalChangesLocked()) return + if (flushing) { + flushQueued = true + return + } + flushing = true + isDirty = false + current + } + val payload = document.codec.encode(predicted) + cache.put(document, key, predicted) + val shouldFlushAgain = synchronized(lock) { + flushing = false + lastCleanPayload = payload + reconciliation = null + lastError = null + if (!document.codec.encode(current).contentEquals(lastCleanPayload)) { + isDirty = true + } + val queued = flushQueued || isDirty + flushQueued = false + queued + } + if (shouldFlushAgain) flush() + } + + fun applyRawDocumentPut(message: CultNetMessage): T { + cache.applyRawDocumentPut(message) + return applyCanonical(cache.getRequired(document, key)) + } + + fun clearReconciliation() { + synchronized(lock) { + reconciliation = null + } + } + + override fun close() { + disposed.set(true) + detectWorker?.interrupt() + } + + private fun detectLocalChanges(): Boolean = synchronized(lock) { + detectLocalChangesLocked() && !flushing + } + + private fun detectLocalChangesLocked(): Boolean { + if (!isDirty && !flushing && !document.codec.encode(current).contentEquals(lastCleanPayload)) { + isDirty = true + } + return isDirty + } + + private fun applyCanonical(canonical: T): T { + synchronized(lock) { + if (isDirty || flushing) { + val predicted = current + val delta = reconciliationDelta(document.codec.encode(predicted), document.codec.encode(canonical)) + reconciliation = if (delta.isEmpty()) { + null + } else { + reconciliationVersion += 1 + CultReactiveDocumentReconciliation( + canonical = canonical, + predicted = predicted, + delta = delta, + version = reconciliationVersion, + receivedAt = Instant.now(), + ) + } + if (!options.replaceDirtyCurrentOnCanonicalSnapshot) return canonical + } + current = canonical + lastCleanPayload = document.codec.encode(canonical) + reconciliation = null + return canonical + } + } +} + +fun CultCache.reactiveDocument( + document: CultDocumentDefinition, + key: String, + options: CultReactiveDocumentOptions = CultReactiveDocumentOptions(), +): CultReactiveDocument = CultReactiveDocument(this, document, key, options) + class StringDocumentCodec( override val documentType: String, override val schemaVersion: String, @@ -225,6 +421,54 @@ fun bytesDocument( global: Boolean = false, ): CultDocumentDefinition = cultDocument(ByteArrayDocumentCodec(documentType, schemaVersion), global) +private data class KotlinCanonicalNote( + val schemaVersion: String, + val body: String, +) + +private data class KotlinUiNote( + val schemaVersion: String, + val body: String, +) + +private data class KotlinReactiveNote( + var body: String, + var revision: Long, +) + +private class KotlinAliasNoteCodec( + override val documentType: String, + override val schemaVersion: String, + private val create: (String, String) -> T, + private val readSchemaVersion: (T) -> String, + private val readBody: (T) -> String, +) : CultDocumentCodec { + override fun encode(value: T): ByteArray = + MessagePackWriter() + .value(listOf(readSchemaVersion(value), readBody(value))) + .toByteArray() + + override fun decode(payload: ByteArray): T { + val slots = anyList(MessagePackReader(payload).readAny()) + return create(slots[0] as String, slots[1] as String) + } +} + +private class KotlinReactiveNoteCodec( + override val documentType: String, + override val schemaVersion: String, +) : CultDocumentCodec { + override fun encode(value: KotlinReactiveNote): ByteArray = + MessagePackWriter() + .value(listOf(value.body, value.revision)) + .toByteArray() + + override fun decode(payload: ByteArray): KotlinReactiveNote { + val slots = anyList(MessagePackReader(payload).readAny()) + return KotlinReactiveNote(slots[0] as String, (slots[1] as Number).toLong()) + } +} + class CultMeshNode( val cache: CultCache = CultCache(), private val random: SecureRandom = SecureRandom(), @@ -270,6 +514,15 @@ class CultMeshNode( fun applyRawSnapshotResponse(response: CultNetMessage): List = cache.applyRawSnapshotResponse(response) + fun syncDocument(response: CultNetMessage, document: CultDocumentDefinition, key: String): T = + cache.syncDocument(response, document, key) + + fun reactiveDocument( + document: CultDocumentDefinition, + key: String, + options: CultReactiveDocumentOptions = CultReactiveDocumentOptions(), + ): CultReactiveDocument = cache.reactiveDocument(document, key, options) + fun applyDocumentDelete(message: CultNetMessage): Boolean = cache.applyDocumentDelete(message) fun applyShardLogResponse(response: CultNetMessage): List = cache.applyShardLogResponse(response) @@ -280,6 +533,13 @@ object CultMesh { fun startNode(cache: CultCache = CultCache()): CultMeshNode = createNode(cache) + fun reactiveDocument( + node: CultMeshNode, + document: CultDocumentDefinition, + key: String, + options: CultReactiveDocumentOptions = CultReactiveDocumentOptions(), + ): CultReactiveDocument = node.reactiveDocument(document, key, options) + fun createVerseCatalog(): CultMeshVerseCatalog = CultMeshVerseCatalog() fun createPeerCatalog(): CultMeshPeerCatalog = CultMeshPeerCatalog() @@ -1973,6 +2233,22 @@ fun CultCache.applyRawSnapshotResponse(response: CultNetMessage): List { .map { applyRawDocumentRecord(it) } } +fun CultCache.syncDocument( + response: CultNetMessage, + document: CultDocumentDefinition, + key: String, +): T { + require(response.schemaVersion == "cultnet.snapshot_response_raw.v0") { + "Expected cultnet.snapshot_response_raw.v0, received ${response.schemaVersion}" + } + val rawDocument = mapList(response.body["documents"]) + .map { rawDocumentRecordFromWire(it) } + .firstOrNull { it.schemaId == document.schemaVersion && it.recordKey == key } + ?: throw NoSuchElementException("No ${document.schemaVersion} document for key $key") + applyRawDocumentRecord(rawDocument) + return getRequired(document, key) +} + fun CultCache.applyDocumentDelete(message: CultNetMessage): Boolean { require(message.schemaVersion == "cultnet.document_delete.v0") { "Expected cultnet.document_delete.v0, received ${message.schemaVersion}" @@ -2208,6 +2484,39 @@ private fun mapValue(value: Any?): Map { return map } +private fun reconciliationDelta(predictedPayload: ByteArray, canonicalPayload: ByteArray): Map { + val predicted = MessagePackReader(predictedPayload).readAny() + val canonical = MessagePackReader(canonicalPayload).readAny() + val delta = linkedMapOf() + when { + predicted is Map<*, *> && canonical is Map<*, *> -> { + val canonicalMap = mapValue(canonical) + for ((key, predictedValue) in mapValue(predicted)) { + val canonicalValue = canonicalMap[key] + if (predictedValue == canonicalValue) continue + delta[key] = numericDelta(predictedValue, canonicalValue) ?: predictedValue + } + } + predicted is List<*> && canonical is List<*> -> { + predicted.forEachIndexed { index, predictedValue -> + val canonicalValue = canonical.getOrNull(index) + if (predictedValue == canonicalValue) return@forEachIndexed + delta[index.toString()] = numericDelta(predictedValue, canonicalValue) ?: predictedValue + } + } + predicted != canonical -> { + delta["value"] = numericDelta(predicted, canonical) ?: predicted + } + } + return delta +} + +private fun numericDelta(predicted: Any?, canonical: Any?): Double? { + val predictedNumber = predicted as? Number ?: return null + val canonicalNumber = canonical as? Number ?: return null + return predictedNumber.toDouble() - canonicalNumber.toDouble() +} + private fun sha256Hex(value: String): String { val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(StandardCharsets.UTF_8)) return digest.joinToString("") { "%02x".format(it.toInt() and 0xff) } @@ -2278,6 +2587,7 @@ data class CultNetRudpPacket( data class CultNetRudpDeliveredFrame(val channelId: String, val payload: ByteArray, val sequence: Long) data class CultNetRudpReceiveResult( val delivered: List = emptyList(), + val readyToSend: List = emptyList(), val reply: CultNetRudpPacket? = null, val pong: Boolean = false, val pongPayload: ByteArray = ByteArray(0), @@ -2310,10 +2620,16 @@ private data class FragmentBuffer( ) class CultNetRudpSession(options: CultNetRudpSessionOptions) { + companion object { + const val ReliableSendWindowPackets = 32 + private const val ReceivedSequenceWindow = 4_096 + } + val connectionId: Long = uint32(options.connectionId, "connectionId") val resendDelayMs: Long = options.resendDelayMs private val maxPendingReliablePackets: Int? = options.maxPendingReliablePackets private var nextSequence = uint32(options.initialSequence, "initialSequence") + private val nextSequencedByChannel = linkedMapOf() private var nextFragmentId = 1 var connected: Boolean = false private set @@ -2321,13 +2637,19 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { private set private var highestReceivedSequence: Long? = null private val receivedSequences = linkedSetOf() + private val latestSequencedByChannel = linkedMapOf() private val pendingReliable = linkedMapOf() + private val queuedReliable = ArrayDeque() private val orderedNextSequenceByChannel = linkedMapOf() private val orderedBuffers = linkedMapOf>() private val fragmentBuffers = linkedMapOf, FragmentBuffer>() val pendingReliableSequences: List get() = pendingReliable.keys.sorted() + val queuedReliablePacketCount: Int + get() = queuedReliable.size + val outstandingReliablePacketCount: Int + get() = pendingReliable.size + queuedReliable.size init { if (maxPendingReliablePackets != null && maxPendingReliablePackets <= 0) { @@ -2354,6 +2676,9 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { } fun send(channelId: String, payload: ByteArray, options: CultNetRudpSendOptions = CultNetRudpSendOptions()): CultNetRudpPacket { + if (options.reliable && pendingReliable.size >= ReliableSendWindowPackets) { + throw IOException("RUDP reliable send window is full; receive acknowledgements before sending") + } return sendMany(channelId, payload, options).first() } @@ -2364,13 +2689,14 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { maxFragmentBytes: Int? = null, ): List { if (!connected) throw IOException("Cannot send RUDP data before the session is connected") + if (options.ordered && !options.reliable) throw IOException("RUDP ordered delivery requires reliability") if (maxFragmentBytes != null && maxFragmentBytes <= 0) throw IOException("RUDP maxFragmentBytes must be greater than zero") if (maxFragmentBytes != null && payload.size > maxFragmentBytes) { val fragmentCount = (payload.size + maxFragmentBytes - 1) / maxFragmentBytes if (fragmentCount > 0xffff) throw IOException("RUDP payload requires more than 65535 fragments") ensureReliableCapacity(if (options.reliable) fragmentCount else 0) val fragmentId = allocateFragmentId() - return (0 until fragmentCount).map { index -> + val packets = (0 until fragmentCount).map { index -> val start = index * maxFragmentBytes val packet = createPacket( channelId = channelId, @@ -2383,14 +2709,13 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { fragmentIndex = index, fragmentCount = fragmentCount, ) - if (packet.reliable) trackReliable(packet, options.nowMs) packet } + return if (options.reliable) admitReliablePackets(packets, options.nowMs) else packets } ensureReliableCapacity(if (options.reliable) 1 else 0) val packet = createPacket(channelId = channelId, packetType = CultNetRudpPacketType.Data, payload = payload, reliable = options.reliable, ordered = options.ordered, sequenced = options.sequenced) - if (packet.reliable) trackReliable(packet, options.nowMs) - return listOf(packet) + return if (packet.reliable) admitReliablePackets(listOf(packet), options.nowMs) else listOf(packet) } fun receive(packet: CultNetRudpPacket, nowMs: Long = 0): CultNetRudpReceiveResult { @@ -2398,42 +2723,85 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { val ignoredNow = nowMs requireConnection(packet) applyAcknowledgements(packet) + val readyToSend = promoteQueuedReliable(nowMs) lastReceivedAtMs = nowMs val expectedSequenceIfUninitialized = (highestReceivedSequence ?: (packet.sequence - 1)) + 1 when (packet.packetType) { CultNetRudpPacketType.Accept -> { rememberReceived(packet.sequence) connected = true - return CultNetRudpReceiveResult() + return CultNetRudpReceiveResult(readyToSend = readyToSend) } CultNetRudpPacketType.Ping -> { - rememberReceived(packet.sequence) - return CultNetRudpReceiveResult(reply = createPacket(CultNetRudpPacketType.Pong, "control", packet.payload)) + return CultNetRudpReceiveResult(readyToSend = readyToSend, reply = createPacket(CultNetRudpPacketType.Pong, "control", packet.payload)) } CultNetRudpPacketType.Ack, CultNetRudpPacketType.Pong -> { - rememberReceived(packet.sequence) return CultNetRudpReceiveResult( + readyToSend = readyToSend, pong = packet.packetType == CultNetRudpPacketType.Pong, pongPayload = if (packet.packetType == CultNetRudpPacketType.Pong) packet.payload.copyOf() else ByteArray(0), ) } CultNetRudpPacketType.Disconnect -> { - rememberReceived(packet.sequence) connected = false - return CultNetRudpReceiveResult(disconnected = true, disconnectReason = packet.payload.copyOf()) + return CultNetRudpReceiveResult(readyToSend = readyToSend, disconnected = true, disconnectReason = packet.payload.copyOf()) } CultNetRudpPacketType.Data -> Unit - else -> return CultNetRudpReceiveResult() + else -> return CultNetRudpReceiveResult(readyToSend = readyToSend) } - val duplicate = receivedSequences.contains(packet.sequence) - rememberReceived(packet.sequence) - if (duplicate) return CultNetRudpReceiveResult() - val reassembled = reassemble(packet) ?: return CultNetRudpReceiveResult() - return CultNetRudpReceiveResult(delivered = if (reassembled.ordered) deliverOrdered(reassembled.frame, reassembled.nextSequence, expectedSequenceIfUninitialized) else listOf(reassembled.frame)) + val duplicate = packet.reliable && (receivedSequences.contains(packet.sequence) || ( + highestReceivedSequence != null && + packet.sequence < highestReceivedSequence!! && + highestReceivedSequence!! - packet.sequence >= ReceivedSequenceWindow.toLong() + )) + if (packet.reliable) rememberReceived(packet.sequence) + if (duplicate) return CultNetRudpReceiveResult(readyToSend = readyToSend) + val reassembled = reassemble(packet) ?: return CultNetRudpReceiveResult(readyToSend = readyToSend) + if (!reassembled.ordered && packet.sequenced) { + val newestSequence = reassembled.nextSequence - 1 + val latestSequence = latestSequencedByChannel[reassembled.frame.channelId] + if (latestSequence != null && newestSequence <= latestSequence) { + return CultNetRudpReceiveResult(readyToSend = readyToSend) + } + latestSequencedByChannel[reassembled.frame.channelId] = newestSequence + } + return CultNetRudpReceiveResult( + delivered = if (reassembled.ordered) deliverOrdered(reassembled.frame, reassembled.nextSequence, expectedSequenceIfUninitialized) else listOf(reassembled.frame), + readyToSend = readyToSend, + ) } - fun createAck(): CultNetRudpPacket = createPacket(CultNetRudpPacketType.Ack, "control", ByteArray(0)) + fun createAck(): CultNetRudpPacket { + val (ack, ackMask) = ackState() + return CultNetRudpPacket( + packetType = CultNetRudpPacketType.Ack, + connectionId = connectionId, + sequence = 0, + ack = ack, + ackMask = ackMask, + channelId = "control", + ) + } + + fun createAckFor(sequence: Long): CultNetRudpPacket = CultNetRudpPacket( + packetType = CultNetRudpPacketType.Ack, + connectionId = connectionId, + sequence = 0, + ack = uint32(sequence, "ack sequence"), + ackMask = 0, + channelId = "control", + ) + + fun createAckForReceived(sequence: Long): CultNetRudpPacket { + val receivedSequence = uint32(sequence, "received sequence") + val (ack, _) = ackState() + return if (ack >= receivedSequence && ack - receivedSequence <= 32L) { + createAck() + } else { + createAckFor(receivedSequence) + } + } fun createPing(payload: ByteArray = ByteArray(0)): CultNetRudpPacket = createPacket(CultNetRudpPacketType.Ping, "control", payload) @@ -2468,8 +2836,15 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { fragmentIndex: Int = 0, fragmentCount: Int = 0, ): CultNetRudpPacket { - val sequence = nextSequence - nextSequence = uint32(nextSequence + 1, "sequence") + val sequence = when { + reliable -> nextSequence.also { + nextSequence = uint32(nextSequence + 1, "reliable sequence") + } + sequenced -> (nextSequencedByChannel[channelId] ?: 1).also { + nextSequencedByChannel[channelId] = uint32(it + 1, "sequenced channel sequence") + } + else -> 0 + } val (ack, ackMask) = ackState() return CultNetRudpPacket(packetType, connectionId, sequence, ack, ackMask, channelId, reliable, ordered, sequenced, fragmentId, fragmentIndex, fragmentCount, payload.copyOf()) } @@ -2478,9 +2853,28 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { pendingReliable[packet.sequence] = PendingReliablePacket(packet.copy(payload = packet.payload.copyOf()), nowMs) } + private fun admitReliablePackets(packets: List, nowMs: Long): List { + val available = maxOf(0, ReliableSendWindowPackets - pendingReliable.size) + val ready = packets.take(available) + ready.forEach { trackReliable(it, nowMs) } + packets.drop(available).forEach { queuedReliable.addLast(it.copy(payload = it.payload.copyOf())) } + return ready + } + + private fun promoteQueuedReliable(nowMs: Long): List { + val available = maxOf(0, ReliableSendWindowPackets - pendingReliable.size) + val ready = mutableListOf() + while (ready.size < available && !queuedReliable.isEmpty()) { + val packet = queuedReliable.removeFirst() + trackReliable(packet, nowMs) + ready.add(packet) + } + return ready + } + private fun ensureReliableCapacity(packetCount: Int) { if (packetCount == 0 || maxPendingReliablePackets == null) return - if (pendingReliable.size + packetCount > maxPendingReliablePackets) { + if (outstandingReliablePacketCount + packetCount > maxPendingReliablePackets) { throw IOException("RUDP reliable send queue is full") } } @@ -2497,6 +2891,10 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { private fun rememberReceived(sequence: Long) { receivedSequences.add(sequence) if (highestReceivedSequence == null || sequence > highestReceivedSequence!!) highestReceivedSequence = sequence + if (receivedSequences.size > ReceivedSequenceWindow) { + val keepFrom = maxOf(0L, (highestReceivedSequence ?: sequence) - ReceivedSequenceWindow.toLong() + 1) + receivedSequences.removeIf { it < keepFrom } + } } private fun ackState(): Pair { @@ -2701,6 +3099,7 @@ class CultNetRudpSocketTransportConnection( maxPendingReliablePackets = maxPendingReliablePackets, ) val connected: Boolean get() = session.connected + val outstandingReliablePacketCount: Int get() = session.outstandingReliablePacketCount val localPort: Int get() = socket.localPort override val stats: CultNetTransportStats get() = CultNetTransportStats(bytesReceived, bytesSent, framesReceived, framesSent) @@ -2784,6 +3183,27 @@ class CultNetRudpSocketTransportConnection( override fun receiveSchemaMessage(timeoutMs: Long, pollIntervalMs: Long): CultNetMessage? = receiveSchema(timeoutMs, pollIntervalMs)?.let { parseCultNetMessage(it) } + fun flushReliable(timeoutMs: Long = 30_000, pollIntervalMs: Long = 5) { + val deadline = System.nanoTime() + timeoutMs.coerceAtLeast(0) * 1_000_000L + val preserved = ArrayDeque() + while (!delivered.isEmpty()) preserved.addLast(delivered.removeFirst()) + try { + while (session.outstandingReliablePacketCount > 0) { + if (System.nanoTime() >= deadline) { + throw SocketTimeoutException( + "RUDP reliable flush timed out with ${session.outstandingReliablePacketCount} packets outstanding" + ) + } + receiveOnce()?.let { preserved.addLast(it) } + pollResends() + Thread.sleep(pollIntervalMs) + } + } finally { + while (!delivered.isEmpty()) preserved.addLast(delivered.removeFirst()) + while (!preserved.isEmpty()) delivered.addLast(preserved.removeFirst()) + } + } + fun receiveOnce(): CultNetTransportFrame? { if (!delivered.isEmpty()) return delivered.removeFirst() val buffer = ByteArray(65535) @@ -2807,6 +3227,7 @@ class CultNetRudpSocketTransportConnection( } val result = session.receive(packet, nowMs()) result.reply?.let { sendPacket(it) } + result.readyToSend.forEach { sendPacket(it) } if (result.pong) { pongPayloads.add(result.pongPayload.copyOf()) } @@ -2819,7 +3240,9 @@ class CultNetRudpSocketTransportConnection( framesReceived += 1 } val frame = if (delivered.isEmpty()) null else delivered.removeFirst() - if (packet.packetType == CultNetRudpPacketType.Accept || frame != null) sendPacket(session.createAck()) + if (packet.reliable || packet.packetType == CultNetRudpPacketType.Accept || frame != null) { + sendPacket(session.createAckForReceived(packet.sequence)) + } return frame } @@ -2943,6 +3366,7 @@ fun main(args: Array) { rudpSessionPingsAndDetectsReceiveTimeout() rudpSessionBoundsPendingReliablePacketsBeforeEnqueue() rudpSessionFragmentsAndReassemblesReliableOrderedPayloads() + rudpSessionAdvancesLargeFragmentSetsThroughBoundedReliableWindow() rudpSocketTransportErgonomicFactoriesCarrySchemaFrames() rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames() rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames() @@ -3085,6 +3509,98 @@ private fun cultCacheRawSnapshotsRoundTripThroughCultNetMessages() { check(target.require(notes, "note:2") == "second") check(target.recallGlobal(settings) == null) + val canonicalNote = cultDocument(KotlinAliasNoteCodec( + documentType = "kotlin.alias_note", + schemaVersion = "kotlin.alias_note.v1", + create = ::KotlinCanonicalNote, + readSchemaVersion = KotlinCanonicalNote::schemaVersion, + readBody = KotlinCanonicalNote::body, + )) + val uiNote = cultDocument(KotlinAliasNoteCodec( + documentType = "kotlin.alias_note.ui", + schemaVersion = "kotlin.alias_note.v1", + create = ::KotlinUiNote, + readSchemaVersion = KotlinUiNote::schemaVersion, + readBody = KotlinUiNote::body, + )) + source.cache.register(canonicalNote) + target.cache.register(uiNote) + source.remember(canonicalNote, "note:alias", KotlinCanonicalNote("kotlin.alias_note.v1", "canonical-to-ui")) + val aliasSnapshotResponse = source.createRawSnapshotResponse( + cultNetSnapshotRequest( + messageId = "snapshot-alias-note", + schemaIds = listOf(uiNote.schemaVersion), + recordKeys = listOf("note:alias"), + ), + storedAt = "2026-06-15T00:00:02Z", + sourceRuntimeId = "kotlin-source", + ) + val syncedAlias = target.syncDocument(aliasSnapshotResponse, uiNote, "note:alias") + check(syncedAlias == KotlinUiNote("kotlin.alias_note.v1", "canonical-to-ui")) + check(target.require(uiNote, "note:alias") == syncedAlias) + + val reactiveUiNote = cultDocument(KotlinReactiveNoteCodec( + documentType = "kotlin.reactive_note.ui", + schemaVersion = "kotlin.reactive_note.v1", + )) + target.cache.register(reactiveUiNote) + target.remember(reactiveUiNote, "note:reactive", KotlinReactiveNote("initial", 1)) + CultMesh.reactiveDocument( + target, + reactiveUiNote, + "note:reactive", + CultReactiveDocumentOptions(flushDelayMs = 5), + ).use { reactive -> + reactive.current.body = "first-local-edit" + reactive.current.body = "second-local-edit" + reactive.current.revision = 2 + val deadline = System.nanoTime() + 500_000_000L + while (System.nanoTime() < deadline && target.require(reactiveUiNote, "note:reactive").body != "second-local-edit") { + Thread.sleep(5) + } + check(target.require(reactiveUiNote, "note:reactive") == KotlinReactiveNote("second-local-edit", 2)) + check(!reactive.isDirty) + check(reactive.lastError == null) + } + + val reactiveCanonicalNote = cultDocument(KotlinReactiveNoteCodec( + documentType = "kotlin.reactive_note", + schemaVersion = "kotlin.reactive_note.v1", + )) + target.cache.register(reactiveCanonicalNote) + target.remember(reactiveCanonicalNote, "note:reconcile", KotlinReactiveNote("initial", 1)) + target.reactiveDocument( + reactiveCanonicalNote, + "note:reconcile", + CultReactiveDocumentOptions(flushDelayMs = 60_000, detectLocalChanges = false), + ).use { reactive -> + reactive.update { + it.body = "local-prediction" + it.revision = 2 + it + } + val authoritative = cultNetDocumentPutRaw( + messageId = "put-authoritative-note", + document = CultNetRawDocumentRecord( + schemaId = reactiveCanonicalNote.schemaVersion, + recordKey = "note:reconcile", + storedAt = "2026-06-15T00:00:03Z", + payload = reactiveCanonicalNote.codec.encode(KotlinReactiveNote("authoritative", 7)), + sourceRuntimeId = "kotlin-authority", + ), + ) + reactive.applyRawDocumentPut(authoritative) + check(reactive.current == KotlinReactiveNote("local-prediction", 2)) + val reconciliation = reactive.reconciliation ?: error("Expected Kotlin reactive reconciliation") + check(reconciliation.canonical == KotlinReactiveNote("authoritative", 7)) + check(reconciliation.predicted == KotlinReactiveNote("local-prediction", 2)) + check(reconciliation.delta["0"] == "local-prediction") + check(reconciliation.delta["1"] == -5.0) + reactive.flush() + check(target.require(reactiveCanonicalNote, "note:reconcile") == KotlinReactiveNote("local-prediction", 2)) + check(reactive.reconciliation == null) + } + val rawPut = parseCultNetMessage( cultNetDocumentPutRaw( messageId = "put-note", @@ -3492,7 +4008,10 @@ private fun startInteropRudpServer( } val result = session.receive(packet, nowMs()) result.reply?.let { sendInteropRudpPacket(socket, remote, it) } - if (packet.packetType == CultNetRudpPacketType.Data) sendInteropRudpPacket(socket, remote, session.createAck()) + result.readyToSend.forEach { sendInteropRudpPacket(socket, remote, it) } + if (packet.reliable || packet.packetType == CultNetRudpPacketType.Data) { + sendInteropRudpPacket(socket, remote, session.createAckForReceived(packet.sequence)) + } for (frame in result.delivered) { if (frame.channelId != "schema") continue val sender = InteropRudpSessionSender(socket, remote, session) @@ -4822,6 +5341,43 @@ private fun rudpSessionFragmentsAndReassemblesReliableOrderedPayloads() { check(delivered.first().sequence == packets.first().sequence) } +private fun rudpSessionAdvancesLargeFragmentSetsThroughBoundedReliableWindow() { + val connectionId = 457L + val sender = CultNetRudpSession(CultNetRudpSessionOptions(connectionId = connectionId, initialSequence = 1)) + val receiver = CultNetRudpSession(CultNetRudpSessionOptions(connectionId = connectionId, initialSequence = 100)) + sender.receive(CultNetRudpPacket(CultNetRudpPacketType.Accept, connectionId, 0, 0, 0, "control")) + receiver.receive(CultNetRudpPacket(CultNetRudpPacketType.Accept, connectionId, 0, 0, 0, "control")) + + val fragmentCount = CultNetRudpSession.ReliableSendWindowPackets + 17 + val payload = ByteArray(fragmentCount * 8) { index -> (index % 251).toByte() } + val wire = ArrayDeque(sender.sendMany( + "schema", + payload, + CultNetRudpSendOptions(reliable = true, ordered = true, nowMs = 1), + maxFragmentBytes = 8, + )) + check(wire.size == CultNetRudpSession.ReliableSendWindowPackets) + val oldestSequence = wire.first().sequence + check(sender.pendingReliableSequences.size == wire.size) + check(sender.queuedReliablePacketCount == 17) + + val delivered = mutableListOf() + while (!wire.isEmpty()) { + val admitted = wire.size + repeat(admitted) { + delivered.addAll(receiver.receive(wire.removeFirst(), 2).delivered) + } + val acknowledged = sender.receive(receiver.createAck(), 3) + acknowledged.readyToSend.forEach { wire.addLast(it) } + } + check(sender.outstandingReliablePacketCount == 0) + check(delivered.size == 1) + check(delivered.first().payload.contentEquals(payload)) + val oldAck = receiver.createAckForReceived(oldestSequence) + check(oldAck.ack == oldestSequence) + check(oldAck.ackMask == 0L) +} + private fun rudpSocketTransportErgonomicFactoriesCarrySchemaFrames() { val connectionId = 0x10203042L CultMesh.createRudpServer( diff --git a/packages/cultmesh-py/README.md b/packages/cultmesh-py/README.md new file mode 100644 index 00000000..63799e52 --- /dev/null +++ b/packages/cultmesh-py/README.md @@ -0,0 +1,252 @@ +# cultmesh-py + +`cultmesh-py` is the Python CultMesh runtime: a cache-backed local node, +reactive and observed documents, the branded `CultMesh` facade, Verse and peer +discovery clients, snapshot and observation fan-out, peer health monitoring, +game sessions with prediction reconciliation, committed simulation facts, the +local TCP/RUDP server, and a package daemon. + +It depends on `cultcache-py` and `cultnet-py`, mirroring `cultmesh-rs` and +`cultmesh-ts`. The Verse catalog, peer exchange, authority lease, stream +negotiation, and simulation fact contracts are defined in +`cultnet_py.cultmesh_contracts` and re-exported here. + +This is the package a Python process embeds to serve a Verse — the Brokkr +Blender add-on serves its editor mirror through `CultMesh.serve_node(...)` +from inside Blender's own interpreter. + +## Example + +```python +from datetime import UTC, datetime, timedelta + +from cultcache_py import define_database_entry_type +from cultnet_py import ( + CultNetClientAuthorityScope, + CultNetFileShardReplicaCursorStore, + CultNetRawClient, + CultNetSchemaShardLogFetcher, + CultNetSchemaShardSnapshotFetcher, + CultNetShardDescriptor, + CultNetShardReplicator, + CultNetShardReplicatorOptions, +) +from cultmesh_py import ( + CultMesh, + CultMeshAuthorityLease, + CultMeshGameSessionOptions, + CultMeshHmacAuthorityLeaseVerifier, + CultMeshNodeOptions, + CultMeshSnapshotFanout, + peer_exchange_request, +) + +note_doc = define_database_entry_type( + "mesh.note", + [("body", 0), ("name", 1), ("kind", 2)], + name="name", + indexes={"kind": "kind"}, +) +node = CultMesh.start_node( + "mesh.cc", + runtime_id="python-runtime", + options=CultMeshNodeOptions(enable_durable_shard_logs=True), +) +node.database.register_document(note_doc) +unsubscribe = node.database.watch_record(note_doc, "note:1", lambda change: print(change.change_kind)) +unsubscribe_named = node.database.watch_by_name(note_doc, "intro", lambda change: print(change.record_key)) +unsubscribe_kind = node.database.watch_by_index(note_doc, "kind", "guide", lambda change: print(change.record_key)) +node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide"}) +live_note = node.database.get_required(note_doc, "note:1") +put_message = node.database.put_raw_message( + note_doc, + "note:2", + {"body": "wire me", "name": "wire", "kind": "interop"}, + shard_id="interop", + shard_epoch=1, +) +snapshot_response = node.database.build_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id]) +snapshot_wire = node.database.create_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id]) +delete_message = node.database.delete_raw_message(note_doc, "note:2", shard_id="interop", shard_epoch=1) +shard_log_response = node.database.build_shard_log_response(shard_id="interop", shard_epoch=1) +shard_log_wire = node.database.create_shard_log_response(shard_id="interop", shard_epoch=1) + +station_stock_doc = define_database_entry_type( + "aetheria.station_stock", + [("missiles", 0), ("coolant", 1)], + schema_id="gamecult.aetheria.station_stock.v1", +) +station_stock_ui_doc = define_database_entry_type( + "aetheria.station_stock.ui", + [("missiles", 0), ("coolant", 1)], + schema_id="gamecult.aetheria.station_stock.v1", +) +node.database.register_document(station_stock_doc) +node.database.put(station_stock_ui_doc, "station:starbridge:stock", {"missiles": 24, "coolant": 80}) +stock = node.database.get_required(station_stock_ui_doc, "station:starbridge:stock") + +peers = CultMesh.create_peer_catalog() +response = peers.create_response(peer_exchange_request("pex-1", verse_id="local")) +lease_verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"shared-lease-key"}) +lease_catalog = CultMesh.create_authority_lease_catalog( + signature_verifier=lease_verifier.verify, + require_verified_signatures=True, +) +lease_valid_from = datetime.now(UTC) +lease = lease_verifier.issue(CultMeshAuthorityLease( + lease_id="lease:python-runtime", + verse_id="local", + peer_id="python-runtime", + roles=("shard-primary",), + valid_from=lease_valid_from, + expires_at=lease_valid_from + timedelta(minutes=5), + shard_ids=("interop",), + issuer_runtime_id="odin", +)) +lease_catalog.upsert(lease) +session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope("python-runtime", schema_ids=(note_doc.catalog_entry().schema_id,), key_prefix="input:python"), + ), + ), +) +server = CultMesh.serve_node( + node, + peer_catalog=peers, + observation_hub=session.observation_hub, + port=3075, + max_snapshot_documents=1000, + max_snapshot_bytes=4 * 1024 * 1024, +) +``` + +The same local server can be launched as a package daemon when another runtime +or operator process needs a CultNet endpoint without embedding Python glue: + +```powershell +python -m cultmesh_py.daemon --runtime-id python-runtime --display-name "Python Runtime" --host 127.0.0.1 --port 3075 --cache-file mesh.cc --enable-durable-shard-logs --seed-interop-note --seed-shard-id interop --verse-id local --role shard-primary --ready-file mesh.ready.json +``` + +The ready file and stdout line are process readiness hints. The served state and +capability truth still live on the framed CultNet/CultMesh endpoint. Use +`--register-interop-note` to expose the package interop-note schema without +seeding a note, or `--seed-interop-note` to register it and publish one local +record for wire probes. Seeded records are committed through the raw mutation +path, so peers can read them through both snapshot and shard-log catch-up. Add +`--verse-id` and one or more `--role` values to publish the launched endpoint in +its own Verse and peer catalogs for discovery clients. When the daemon is +launched with `--cache-file`, `--enable-durable-shard-logs`, and +`--shard-log-file`, a later daemon process can rehydrate and serve the persisted +snapshot and shard log without reseeding. + +`CultMeshPeerHealthMonitor` probes peer-card endpoints with `cultnet.hello.v0` +and preserves the runtime id, display name, document types, message versions, +and mutation contracts reported by the peer. + +```python +client = CultMesh.create_verse_discovery_client("127.0.0.1", 3075) +verses = client.fetch_verses(transport_version="cultmesh.v0") +mesh_peers = client.fetch_peers(verse_id="python-interop", roles=["read-replica"]) +client.sync_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"]) +client.fanout_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"]) +replica_notes = client.sync_documents( + node.database, + peers, + verse_id="python-interop", + roles=["read-replica"], + documents=[(note_doc, "note:remote")], +) +snapshot_fanout = CultMeshSnapshotFanout( + client, + node.database, + peers, + verse_id="python-interop", + roles=["read-replica"], + documents=[(note_doc, "note:remote")], + on_document=lambda note: print(note), +) +snapshot_fanout.sync_once() + +raw_client = CultNetRawClient("127.0.0.1", 3075) +synced_note = node.database.sync_document(raw_client, note_doc, "note:remote") +synced_stock = CultMesh.sync_document( + node, + raw_client, + station_stock_ui_doc, + "station:starbridge:stock", +) +with CultMesh.subscribe_document( + node, + raw_client, + station_stock_ui_doc, + "station:starbridge:stock", +) as stock_subscription: + stock = stock_subscription.sync_initial() + next_change = stock_subscription.read_next_change() +node.database.sync_shard_log(raw_client, shard_id="interop", shard_epoch=1) +replicator = CultNetShardReplicator( + node.database, + CultNetShardReplicatorOptions( + fetcher=CultNetSchemaShardLogFetcher(), + snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(), + cursor_store=CultNetFileShardReplicaCursorStore("mesh.replica-cursors.msgpack"), + poll_interval_seconds=1.0, + on_error=lambda error: print(error), + ), +) +replica_shard = CultNetShardDescriptor( + shard_id="interop", + owner_runtime_id="primary-runtime", + epoch=1, + schema_ids=(note_doc.catalog_entry().schema_id,), + primary_endpoints=("cultnet://127.0.0.1:3075",), +) +replicator.pull_once(replica_shard) +replicator.start([replica_shard]) +replicator.stop() +server.stop() + +streams = CultMesh.create_stream_catalog() + +facts = CultMesh.create_simulation_fact_committer(node) +committed = facts.commit({ + "shardId": "arena", + "shardEpoch": 4, + "frame": 100, + "subjectId": "bob", + "claimKind": "hit", + "claimHash": "accepted-claim-hash", + "witnessCount": 2, + "supportWeight": 2.0, + "totalWeight": 2.0, + "confidence": 1.0, + "hasQuorum": True, +}) +prediction = session.predict(note_doc, "input:python:move", {"body": "predicted input"}) +unsubscribe() +``` + +## Verify + +`cultmesh_py.verify` checks the public exports and `py.typed` markers of all +three Python packages, smokes a local node over the wire, checks the served +capability truth, and runs the benchmark for sanity: + +```powershell +python -m cultmesh_py.verify --json +``` + +## Tests + +```powershell +$env:PYTHONPATH="$PWD\packages\cultcache-py\src;$PWD\packages\cultnet-py\src;$PWD\packages\cultmesh-py\src" +python -m unittest discover -s packages\cultmesh-py\tests +``` + +`packages\cultnet-ts\test\interop\cultnet-interop.test.ts` verifies the public +`CultMeshDiscoveryClient` can fetch typed Verse and peer descriptors from the +live Python peer. See +[docs/python-runtime-parity.md](../../docs/python-runtime-parity.md) for the +evidence map across the three Python packages. diff --git a/packages/cultmesh-py/pyproject.toml b/packages/cultmesh-py/pyproject.toml new file mode 100644 index 00000000..a1df5ee3 --- /dev/null +++ b/packages/cultmesh-py/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "cultmesh-py" +version = "0.1.0" +description = "Python CultMesh node, discovery, sessions, and local server for GameCult typed state." +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [{ name = "GameCult" }] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0"] +[project.scripts] +cultmesh-py-daemon = "cultmesh_py.daemon:main" +cultmesh-py-verify = "cultmesh_py.verify:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +cultmesh_py = ["py.typed"] diff --git a/packages/cultcache-py/src/cultmesh_py/__init__.py b/packages/cultmesh-py/src/cultmesh_py/__init__.py similarity index 79% rename from packages/cultcache-py/src/cultmesh_py/__init__.py rename to packages/cultmesh-py/src/cultmesh_py/__init__.py index 72f649ba..af4da272 100644 --- a/packages/cultcache-py/src/cultmesh_py/__init__.py +++ b/packages/cultmesh-py/src/cultmesh_py/__init__.py @@ -1,5 +1,6 @@ from .client import ( CultMeshDiscoveryClient, + CultMeshDocumentSubscription, CultMeshPeerExchangeClient, CultMeshPeerHealth, CultMeshPeerHealthMonitor, @@ -9,7 +10,20 @@ from .client import ( ) from .daemon import READY_SCHEMA_VERSION, daemon_ready_document from .facade import CultMesh, CultMeshRudpEndpoint -from .node import CultMeshDatabase, CultMeshDatabaseChange, CultMeshNode, CultMeshNodeOptions, create_node +from .node import ( + CultMeshDatabase, + CultMeshDatabaseChange, + CultMeshDocumentPublicationSource, + CultMeshDocumentWriter, + CultMeshNode, + CultMeshNodeOptions, + CultMeshObservedDocument, + CultMeshPublicationDocumentBinding, + CultMeshReactiveDocument, + CultMeshReactiveDocumentOptions, + CultMeshReactiveDocumentReconciliation, + create_node, +) from .server import CultMeshLocalServer from .session import ( CultMeshGameSession, @@ -25,7 +39,7 @@ from .simulation import ( CultMeshSimulationFactCommitter, simulation_fact_document, ) -from .wire import ( +from cultnet_py.cultmesh_contracts import ( PEER_EXCHANGE_REQUEST, PEER_EXCHANGE_RESPONSE, VERSE_CATALOG_REQUEST, @@ -55,7 +69,14 @@ __all__ = [ "CultMeshNodeOptions", "CultMeshDatabase", "CultMeshDatabaseChange", + "CultMeshDocumentPublicationSource", + "CultMeshDocumentWriter", "CultMesh", + "CultMeshPublicationDocumentBinding", + "CultMeshObservedDocument", + "CultMeshReactiveDocument", + "CultMeshReactiveDocumentOptions", + "CultMeshReactiveDocumentReconciliation", "CultMeshGameSession", "CultMeshGameSessionOptions", "CultMeshLocalServer", @@ -72,6 +93,7 @@ __all__ = [ "CultMeshHmacAuthorityLeaseVerifier", "AuthorityLeaseVerifier", "CultMeshDiscoveryClient", + "CultMeshDocumentSubscription", "CultMeshPeerExchangeClient", "CultMeshPeerHealth", "CultMeshPeerHealthMonitor", diff --git a/packages/cultcache-py/src/cultmesh_py/client.py b/packages/cultmesh-py/src/cultmesh_py/client.py similarity index 78% rename from packages/cultcache-py/src/cultmesh_py/client.py rename to packages/cultmesh-py/src/cultmesh_py/client.py index 89a9b442..2e1c3ab1 100644 --- a/packages/cultcache-py/src/cultmesh_py/client.py +++ b/packages/cultmesh-py/src/cultmesh_py/client.py @@ -6,9 +6,10 @@ from datetime import UTC, datetime from typing import Any, Callable from urllib.parse import urlparse -from cultnet_py import CultNetRawClient, CultNetRawSnapshotResponse, hello +from cultcache_py.documents import DocumentDefinition +from cultnet_py import CultNetDatabaseSubscription, CultNetRawClient, CultNetRawSnapshotResponse, hello -from .wire import ( +from cultnet_py.cultmesh_contracts import ( PEER_EXCHANGE_RESPONSE, VERSE_CATALOG_RESPONSE, CultMeshPeerCard, @@ -279,6 +280,61 @@ class CultMeshDiscoveryClient: applied.extend(database.apply_snapshot_response(response)) return applied + def sync_documents( + self, + database: Any, + catalog: CultMeshPeerCatalog, + *, + verse_id: str, + documents: list[tuple[DocumentDefinition[Any], str]], + roles: list[str] | None = None, + shard_id: str | None = None, + shard_epoch: int | None = None, + on_error: Callable[[str, Exception], None] | None = None, + ) -> list[Any]: + if not documents: + return [] + schema_ids = _distinct_non_empty([ + document.catalog_entry().schema_id + for document, _key in documents + ]) + record_keys = _distinct_non_empty([key for _document, key in documents]) + self.sync_snapshots( + database, + catalog, + verse_id=verse_id, + roles=roles, + schema_ids=schema_ids, + record_keys=record_keys, + shard_id=shard_id, + shard_epoch=shard_epoch, + on_error=on_error, + ) + return [ + database.get_required(document, key) + for document, key in documents + ] + + def subscribe_document( + self, + database: Any, + document: DocumentDefinition[Any], + key: str, + *, + subscription_id: str | None = None, + message_id: str = "cultmesh-python-document-subscribe", + include_snapshot: bool = True, + ) -> "CultMeshDocumentSubscription": + return CultMeshDocumentSubscription( + client=CultNetRawClient(self.host, self.port, self.timeout_seconds), + database=database, + document=document, + key=key, + subscription_id=subscription_id, + message_id=message_id, + include_snapshot=include_snapshot, + ) + def _fanout_endpoints( self, catalog: CultMeshPeerCatalog, @@ -353,6 +409,88 @@ class CultMeshPeerExchangeClient(CultMeshDiscoveryClient): return self.fanout_peer_catalog(catalog, verse_id=verse_id, roles=roles, limit=limit) +@dataclass +class CultMeshDocumentSubscription: + client: CultNetRawClient + database: Any + document: DocumentDefinition[Any] + key: str + subscription_id: str | None = None + message_id: str = "cultmesh-python-document-subscribe" + include_snapshot: bool = True + _subscription: CultNetDatabaseSubscription | None = field(default=None, init=False, repr=False) + + def __enter__(self) -> "CultMeshDocumentSubscription": + if not self.key: + raise ValueError("key must be non-empty") + registered = self.database._resolve_document_alias(self.document) + self._subscription = self.client.subscribe_database( + subscription_id=self.subscription_id or f"{registered.type}:{self.key}", + message_id=self.message_id, + schema_ids=[registered.catalog_entry().schema_id], + record_keys=[self.key], + include_snapshot=self.include_snapshot, + ) + self._subscription.__enter__() + return self + + def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + if self._subscription is not None: + self._subscription.__exit__(exc_type, exc, traceback) + self._subscription = None + + def sync_initial(self) -> Any: + self._require_subscription() + changes = self._capture_changes(lambda: self.database.apply_snapshot_response( + self._subscription.read_next_snapshot_response() # type: ignore[union-attr] + )) + if changes: + return changes[-1].value + return self.database.get_required(self.document, self.key) + + def send(self, message: Any) -> None: + self._require_subscription() + self._subscription.send(message) # type: ignore[union-attr] + + def read_next_change(self) -> Any: + self._require_subscription() + remote_change = self._subscription.read_next_change() # type: ignore[union-attr] + + def apply_remote_change() -> None: + if remote_change.raw_document is not None: + self.database.apply_raw_put_message({ + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": remote_change.message_id, + "document": remote_change.raw_document.to_wire(), + }) + return + if remote_change.change_kind == "removed": + self.database.apply_raw_delete_message({ + "schemaVersion": "cultnet.document_delete.v0", + "messageId": remote_change.message_id, + "schemaId": remote_change.schema_id, + "recordKey": remote_change.record_key, + }) + + changes = self._capture_changes(apply_remote_change) + if not changes: + raise RuntimeError("CultMesh document subscription change did not apply to the local database") + return changes[-1] + + def _capture_changes(self, action: Callable[[], Any]) -> list[Any]: + changes: list[Any] = [] + unsubscribe = self.database.watch_record(self.document, self.key, changes.append) + try: + action() + finally: + unsubscribe() + return changes + + def _require_subscription(self) -> None: + if self._subscription is None: + raise RuntimeError("CultMesh document subscription is not open") + + @dataclass class CultMeshSimulationObservationFanout: client: CultMeshDiscoveryClient @@ -437,17 +575,34 @@ class CultMeshSnapshotFanout: peer_catalog: CultMeshPeerCatalog verse_id: str roles: list[str] | None = None + documents: list[tuple[DocumentDefinition[Any], str]] | None = None schema_ids: list[str] | None = None record_keys: list[str] | None = None shard_id: str | None = None shard_epoch: int | None = None interval_seconds: float = 1.0 on_applied: Callable[[Any], None] | None = None + on_document: Callable[[Any], None] | None = None on_error: Callable[[str, Exception], None] | None = None _stop: threading.Event = field(default_factory=threading.Event, init=False, repr=False) _thread: threading.Thread | None = field(default=None, init=False, repr=False) def sync_once(self) -> list[Any]: + if self.documents is not None: + values = self.client.sync_documents( + self.database, + self.peer_catalog, + verse_id=self.verse_id, + roles=self.roles, + documents=self.documents, + shard_id=self.shard_id, + shard_epoch=self.shard_epoch, + on_error=self.on_error, + ) + for value in values: + if self.on_document is not None: + self.on_document(value) + return values applied = self.client.sync_snapshots( self.database, self.peer_catalog, diff --git a/packages/cultcache-py/src/cultmesh_py/daemon.py b/packages/cultmesh-py/src/cultmesh_py/daemon.py similarity index 99% rename from packages/cultcache-py/src/cultmesh_py/daemon.py rename to packages/cultmesh-py/src/cultmesh_py/daemon.py index 087dc218..d21d42d8 100644 --- a/packages/cultcache-py/src/cultmesh_py/daemon.py +++ b/packages/cultmesh-py/src/cultmesh_py/daemon.py @@ -13,7 +13,7 @@ from cultnet_py import CultNetSimulationConsensusOptions, CultNetSimulationObser from .facade import CultMesh from .server import CultMeshLocalServer -from .wire import ( +from cultnet_py.cultmesh_contracts import ( CultMeshPeerCard, CultMeshPeerCatalog, CultMeshVerseCatalog, diff --git a/packages/cultcache-py/src/cultmesh_py/facade.py b/packages/cultmesh-py/src/cultmesh_py/facade.py similarity index 78% rename from packages/cultcache-py/src/cultmesh_py/facade.py rename to packages/cultmesh-py/src/cultmesh_py/facade.py index a7900a44..53328aab 100644 --- a/packages/cultcache-py/src/cultmesh_py/facade.py +++ b/packages/cultmesh-py/src/cultmesh_py/facade.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any from urllib.parse import urlparse +from cultcache_py.documents import DocumentDefinition from cultnet_py import ( CultNetRawClient, CultNetReconnectPolicy, @@ -20,12 +21,20 @@ from cultnet_py import ( wire_message_schema_catalog, ) -from .client import CultMeshPeerExchangeClient, CultMeshVerseDiscoveryClient -from .node import CultMeshNode, CultMeshNodeOptions, create_node +from .client import CultMeshDocumentSubscription, CultMeshPeerExchangeClient, CultMeshVerseDiscoveryClient +from .node import ( + CultMeshDocumentPublicationSource, + CultMeshDocumentWriter, + CultMeshNode, + CultMeshNodeOptions, + CultMeshObservedDocument, + CultMeshPublicationDocumentBinding, + create_node, +) from .server import CultMeshLocalServer from .session import CultMeshGameSession, CultMeshGameSessionOptions from .simulation import CultMeshSimulationFactCommitter -from .wire import ( +from cultnet_py.cultmesh_contracts import ( AuthorityLeaseVerifier, CultMeshAuthorityLeaseCatalog, CultMeshPeerCard, @@ -273,6 +282,106 @@ class CultMesh: def create_simulation_fact_committer(node: CultMeshNode) -> CultMeshSimulationFactCommitter: return CultMeshSimulationFactCommitter(node) + @staticmethod + def sync_document( + node: CultMeshNode, + client: CultNetRawClient, + document: DocumentDefinition[Any], + key: str, + *, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> Any: + return node.sync_document( + client, + document, + key, + shard_id=shard_id, + shard_epoch=shard_epoch, + ) + + @staticmethod + def authoritative_writer( + node: CultMeshNode, + document: DocumentDefinition[Any], + key: str, + ) -> CultMeshDocumentWriter: + return node.authoritative_writer(document, key) + + @staticmethod + def observe_document( + node: CultMeshNode, + document: DocumentDefinition[Any], + key: str, + ) -> CultMeshObservedDocument: + return node.observe_document(document, key) + + @staticmethod + def publication_source_from_peer_snapshot( + client: CultNetRawClient, + *, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> CultMeshDocumentPublicationSource: + return CultMeshDocumentPublicationSource.peer_snapshot( + client, + shard_id=shard_id, + shard_epoch=shard_epoch, + ) + + @staticmethod + def publication_source_from_single_file(path: str | Path) -> CultMeshDocumentPublicationSource: + return CultMeshDocumentPublicationSource.single_file(path) + + @staticmethod + def publication_document( + document: DocumentDefinition[Any], + key: str, + *, + source: CultMeshDocumentPublicationSource | None = None, + ) -> CultMeshPublicationDocumentBinding: + if not key: + raise ValueError("key must be non-empty") + return CultMeshPublicationDocumentBinding(document=document, key=key, source=source) + + @staticmethod + def sync_document_from_publication( + node: CultMeshNode, + source: CultMeshDocumentPublicationSource, + document: DocumentDefinition[Any], + key: str, + ) -> Any: + return node.sync_document_from_publication(source, document, key) + + @staticmethod + def sync_documents_from_publication( + node: CultMeshNode, + source: CultMeshDocumentPublicationSource, + bindings: list[CultMeshPublicationDocumentBinding] | tuple[CultMeshPublicationDocumentBinding, ...], + ) -> list[Any]: + return node.sync_documents_from_publication(source, bindings) + + @staticmethod + def subscribe_document( + node: CultMeshNode, + client: CultNetRawClient, + document: DocumentDefinition[Any], + key: str, + *, + subscription_id: str | None = None, + message_id: str = "cultmesh-python-document-subscribe", + include_snapshot: bool = True, + ) -> CultMeshDocumentSubscription: + return CultMeshDocumentSubscription( + client=client, + database=node.database, + document=document, + key=key, + subscription_id=subscription_id, + message_id=message_id, + include_snapshot=include_snapshot, + ) + @staticmethod def create_game_session( node: CultMeshNode, diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultmesh-py/src/cultmesh_py/node.py similarity index 53% rename from packages/cultcache-py/src/cultmesh_py/node.py rename to packages/cultmesh-py/src/cultmesh_py/node.py index b567ca82..09ac2012 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultmesh-py/src/cultmesh_py/node.py @@ -1,11 +1,16 @@ from __future__ import annotations +import threading +import heapq +import time +import traceback from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path from typing import Any, Callable from cultcache_py import CultCache, CultCacheEnvelope, SingleFileMessagePackBackingStore +from cultcache_py.cache import CultCacheError from cultcache_py.documents import DocumentDefinition, extract_value from cultnet_py import ( CultNetAppliedRecord, @@ -21,6 +26,7 @@ from cultnet_py import ( apply_shard_log_response as apply_cultnet_shard_log_response, document_delete, document_put_raw, + resolve_document_and_schema_id_for_raw_record, schema_document_map, ) @@ -35,6 +41,136 @@ class CultMeshDatabaseChange: previous_value: Any | None = None +@dataclass(frozen=True) +class CultMeshDocumentPublicationSource: + kind: str + client: CultNetRawClient | None = None + path: Path | None = None + shard_id: str | None = None + shard_epoch: int | None = None + + @staticmethod + def peer_snapshot( + client: CultNetRawClient, + *, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> "CultMeshDocumentPublicationSource": + return CultMeshDocumentPublicationSource( + kind="peer_snapshot", + client=client, + shard_id=shard_id, + shard_epoch=shard_epoch, + ) + + @staticmethod + def single_file(path: str | Path) -> "CultMeshDocumentPublicationSource": + return CultMeshDocumentPublicationSource(kind="single_file", path=Path(path)) + + +@dataclass(frozen=True) +class CultMeshPublicationDocumentBinding: + document: DocumentDefinition[Any] + key: str + source: CultMeshDocumentPublicationSource | None = None + + +@dataclass(frozen=True) +class CultMeshReactiveDocumentReconciliation: + canonical: Any + predicted: Any + delta: dict[str, Any] + version: int + received_at: datetime + + +@dataclass(frozen=True) +class CultMeshReactiveDocumentOptions: + flush_delay_seconds: float = 0.016 + replace_dirty_current_on_canonical_snapshot: bool = False + + +class _CultMeshScheduledCall: + def __init__(self, callback: Callable[[], None], wake: Callable[[], None]) -> None: + self._callback: Callable[[], None] | None = callback + self._wake = wake + self._lock = threading.Lock() + + def cancel(self) -> None: + with self._lock: + self._callback = None + self._wake() + + def take(self) -> Callable[[], None] | None: + with self._lock: + callback = self._callback + self._callback = None + return callback + + @property + def cancelled(self) -> bool: + with self._lock: + return self._callback is None + + +class _CultMeshReactiveScheduler: + """One lazy daemon worker owns delayed reactive flushes for the process.""" + + def __init__(self) -> None: + self._condition = threading.Condition() + self._queue: list[tuple[float, int, _CultMeshScheduledCall]] = [] + self._sequence = 0 + self._worker: threading.Thread | None = None + + def schedule(self, delay_seconds: float, callback: Callable[[], None]) -> _CultMeshScheduledCall: + call = _CultMeshScheduledCall(callback, self._wake) + with self._condition: + self._sequence += 1 + heapq.heappush( + self._queue, + (time.monotonic() + max(0.0, delay_seconds), self._sequence, call), + ) + if self._worker is None: + self._worker = threading.Thread( + target=self._run, + name="cultmesh-reactive-scheduler", + daemon=True, + ) + self._worker.start() + self._condition.notify() + return call + + def _wake(self) -> None: + with self._condition: + self._condition.notify() + + def _run(self) -> None: + while True: + callback: Callable[[], None] | None = None + with self._condition: + while callback is None: + while self._queue and self._queue[0][2].cancelled: + heapq.heappop(self._queue) + if not self._queue: + self._condition.wait() + continue + deadline, _, call = self._queue[0] + remaining = deadline - time.monotonic() + if remaining > 0: + self._condition.wait(remaining) + continue + heapq.heappop(self._queue) + callback = call.take() + if callback is not None: + try: + callback() + except Exception: + traceback.print_exc() + + +_REACTIVE_SCHEDULER = _CultMeshReactiveScheduler() + + @dataclass class CultMeshNode: cache: CultCache = field(default_factory=CultCache) @@ -79,6 +215,15 @@ class CultMeshNode: def get_required(self, document: DocumentDefinition[Any], key: str) -> Any: return self.database.get_required(document, key) + def get_all(self, document: DocumentDefinition[Any]) -> list[Any]: + return self.database.get_all(document) + + def get_by_name(self, document: DocumentDefinition[Any], name: str) -> Any: + return self.database.get_by_name(document, name) + + def get_by_index(self, document: DocumentDefinition[Any], index: str, value: str) -> Any: + return self.database.get_by_index(document, index, value) + def get_global(self, document: DocumentDefinition[Any]) -> Any: return self.database.get_global(document) @@ -169,6 +314,52 @@ class CultMeshNode: shard_epoch=shard_epoch, ) + def sync_document( + self, + client: CultNetRawClient, + document: DocumentDefinition[Any], + key: str, + *, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> Any: + return self.database.sync_document( + client, + document, + key, + shard_id=shard_id, + shard_epoch=shard_epoch, + ) + + def authoritative_writer( + self, + document: DocumentDefinition[Any], + key: str, + ) -> "CultMeshDocumentWriter": + return self.database.authoritative_writer(document, key) + + def observe_document( + self, + document: DocumentDefinition[Any], + key: str, + ) -> "CultMeshObservedDocument": + return self.database.observe_document(document, key) + + def sync_document_from_publication( + self, + source: CultMeshDocumentPublicationSource, + document: DocumentDefinition[Any], + key: str, + ) -> Any: + return self.database.sync_document_from_publication(source, document, key) + + def sync_documents_from_publication( + self, + source: CultMeshDocumentPublicationSource, + bindings: list[CultMeshPublicationDocumentBinding] | tuple[CultMeshPublicationDocumentBinding, ...], + ) -> list[Any]: + return self.database.sync_documents_from_publication(source, bindings) + def sync_shard_log( self, client: CultNetRawClient, @@ -253,7 +444,16 @@ class CultMeshDatabase: document: DocumentDefinition[Any] | None = None, key: str | None = None, ) -> Callable[[], None]: - subscriber = (document, key, callback) + requested = document + registered = None if document is None else self._resolve_document_alias(document) + + def converted(change: CultMeshDatabaseChange) -> None: + if requested is None: + callback(change) + else: + callback(self._change_as_document(change, requested)) + + subscriber = (registered, key, converted) self._subscribers.append(subscriber) def unsubscribe() -> None: @@ -268,7 +468,13 @@ class CultMeshDatabase: key: str, callback: Callable[[CultMeshDatabaseChange], None], ) -> Callable[[], None]: - return self.watch(callback, document=document, key=key) + requested = document + registered = self._resolve_document_alias(document) + + def converted(change: CultMeshDatabaseChange) -> None: + callback(self._change_as_document(change, requested)) + + return self.watch(converted, document=registered, key=key) def watch_global( self, @@ -283,6 +489,8 @@ class CultMeshDatabase: name: str, callback: Callable[[CultMeshDatabaseChange], None], ) -> Callable[[], None]: + requested = document + document = self._resolve_document_alias(document) if not name: raise ValueError("Name watch values must be non-empty") if document.name is None: @@ -290,7 +498,7 @@ class CultMeshDatabase: def filtered(change: CultMeshDatabaseChange) -> None: if self._change_matches_extractor(change, document.name, name): - callback(change) + callback(self._change_as_document(change, requested)) return self.watch(filtered, document=document) @@ -301,6 +509,8 @@ class CultMeshDatabase: value: str, callback: Callable[[CultMeshDatabaseChange], None], ) -> Callable[[], None]: + requested = document + document = self._resolve_document_alias(document) if not index: raise ValueError("Index watch aliases must be non-empty") if not value: @@ -311,7 +521,7 @@ class CultMeshDatabase: def filtered(change: CultMeshDatabaseChange) -> None: if self._change_matches_extractor(change, extractor, value): - callback(change) + callback(self._change_as_document(change, requested)) return self.watch(filtered, document=document) @@ -329,9 +539,11 @@ class CultMeshDatabase: self._shard_log_store = store def put(self, document: DocumentDefinition[Any], key: str, value: Any) -> None: - previous = self.cache.get(document, key) - self.cache.put(document, key, value) - self._publish_local_change(document, key, "added" if previous is None else "updated", value, previous) + registered = self._resolve_document_alias(document) + parsed = self._convert_document_value(value, document, registered) + previous = self.cache.get(registered, key) + self.cache.put(registered, key, parsed) + self._publish_local_change(registered, key, "added" if previous is None else "updated", parsed, previous) def put_global(self, document: DocumentDefinition[Any], value: Any) -> None: self.put(document, self.cache.GLOBAL_KEY, value) @@ -346,17 +558,19 @@ class CultMeshDatabase: shard_id: str | None = None, shard_epoch: int | None = None, ) -> CultNetMessage: - catalog_entry = document.catalog_entry() - previous = self.cache.get(document, key) + registered = self._resolve_document_alias(document) + parsed = self._convert_document_value(value, document, registered) + catalog_entry = registered.catalog_entry() + previous = self.cache.get(registered, key) envelope = CultCacheEnvelope.create( key=key, - type=document.type, - payload=document.encode_payload(value), + type=registered.type, + payload=registered.encode_payload(parsed), schema_id=catalog_entry.schema_id, catalog_entry=catalog_entry, ) - value = self.cache.put_envelope(document, envelope) - self._publish_local_change(document, key, "added" if previous is None else "updated", value, previous) + value = self.cache.put_envelope(registered, envelope) + self._publish_local_change(registered, key, "added" if previous is None else "updated", value, previous) message = document_put_raw( message_id=message_id, key=key, @@ -371,22 +585,45 @@ class CultMeshDatabase: return message def get(self, document: DocumentDefinition[Any], key: str) -> Any: - return self.cache.get(document, key) + registered = self._resolve_document_alias(document) + value = self.cache.get(registered, key) + return None if value is None else self._convert_document_value(value, registered, document) def get_required(self, document: DocumentDefinition[Any], key: str) -> Any: - return self.cache.get_required(document, key) + value = self.get(document, key) + if value is None: + raise CultCacheError(f"Missing {document.type}:{key}") + return value + + def get_all(self, document: DocumentDefinition[Any]) -> list[Any]: + registered = self._resolve_document_alias(document) + return [ + self._convert_document_value(value, registered, document) + for value in self.cache.get_all(registered) + ] + + def get_by_name(self, document: DocumentDefinition[Any], name: str) -> Any: + registered = self._resolve_document_alias(document) + value = self.cache.get_by_name(registered, name) + return None if value is None else self._convert_document_value(value, registered, document) + + def get_by_index(self, document: DocumentDefinition[Any], index: str, value: str) -> Any: + registered = self._resolve_document_alias(document) + result = self.cache.get_by_index(registered, index, value) + return None if result is None else self._convert_document_value(result, registered, document) def get_global(self, document: DocumentDefinition[Any]) -> Any: - return self.cache.get_global(document) + return self.get(document, self.cache.GLOBAL_KEY) def get_required_global(self, document: DocumentDefinition[Any]) -> Any: - return self.cache.get_required_global(document) + return self.get_required(document, self.cache.GLOBAL_KEY) def delete(self, document: DocumentDefinition[Any], key: str) -> None: - previous = self.cache.get(document, key) - self.cache.delete(document, key) + registered = self._resolve_document_alias(document) + previous = self.cache.get(registered, key) + self.cache.delete(registered, key) if previous is not None: - self._publish_local_change(document, key, "removed", None, previous) + self._publish_local_change(registered, key, "removed", None, previous) def delete_global(self, document: DocumentDefinition[Any]) -> None: self.delete(document, self.cache.GLOBAL_KEY) @@ -400,7 +637,8 @@ class CultMeshDatabase: shard_id: str | None = None, shard_epoch: int | None = None, ) -> CultNetMessage: - schema_id = document.catalog_entry().schema_id + registered = self._resolve_document_alias(document) + schema_id = registered.catalog_entry().schema_id self.delete(document, key) message = document_delete( message_id=message_id, @@ -417,15 +655,21 @@ class CultMeshDatabase: if not isinstance(document_record, dict): return None schema_id = str(document_record.get("schemaId") or "") - document = self._document_for_schema(schema_id) - if document is None: + documents_by_schema_id = schema_document_map(self.documents) + try: + document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( + documents_by_schema_id, + schema_id, + document_record, + ) + except KeyError: return None record_key = str(document_record["recordKey"]) previous = self.cache.get(document, record_key) envelope = CultCacheEnvelope( key=record_key, type=document.type, - schema_id=schema_id, + schema_id=resolved_schema_id, payload=bytes(document_record["payload"]), stored_at=str(document_record.get("storedAt") or ""), catalog_entry=document.catalog_entry(), @@ -513,6 +757,8 @@ class CultMeshDatabase: ) -> CultNetRawSnapshotResponse: requested_schema_ids = set(schema_ids or []) requested_record_keys = set(record_keys or []) + documents_by_type = {document.type: document for document in self.documents} + documents_by_schema_id = schema_document_map(self.documents) shard_record_keys = ( self._live_shard_record_keys(shard_id) if shard_id is not None and shard_id in self._shard_logs @@ -521,7 +767,8 @@ class CultMeshDatabase: documents: list[CultNetRawDocumentRecord] = [] for envelope in self.cache.snapshot_envelopes(): schema_id = envelope.schema_id or envelope.type - if requested_schema_ids and schema_id not in requested_schema_ids: + document = documents_by_type.get(envelope.type) or documents_by_schema_id.get(schema_id) + if requested_schema_ids and not self._schema_matches_request(schema_id, document, requested_schema_ids): continue if requested_record_keys and envelope.key not in requested_record_keys: continue @@ -563,6 +810,101 @@ class CultMeshDatabase: ) return self.apply_snapshot_response(response) + def sync_document( + self, + client: CultNetRawClient, + document: DocumentDefinition[Any], + key: str, + *, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> Any: + if not key: + raise ValueError("key must be non-empty") + requested = document + registered = self._resolve_document_alias(document) + schema_id = registered.catalog_entry().schema_id + self.sync_snapshot( + client, + schema_ids=[schema_id], + record_keys=[key], + shard_id=shard_id, + shard_epoch=shard_epoch, + ) + return self.get_required(requested, key) + + def authoritative_writer( + self, + document: DocumentDefinition[Any], + key: str, + ) -> "CultMeshDocumentWriter": + if not key: + raise ValueError("key must be non-empty") + if not any(self._documents_alias(document, registered) for registered in self.documents): + self.register_document(document) + return CultMeshDocumentWriter(self, document, key) + + def observe_document( + self, + document: DocumentDefinition[Any], + key: str, + ) -> "CultMeshObservedDocument": + if not key: + raise ValueError("key must be non-empty") + if not any(self._documents_alias(document, registered) for registered in self.documents): + self.register_document(document) + return CultMeshObservedDocument(self, document, key) + + def sync_document_from_publication( + self, + source: CultMeshDocumentPublicationSource, + document: DocumentDefinition[Any], + key: str, + ) -> Any: + if not key: + raise ValueError("key must be non-empty") + if source.kind == "peer_snapshot": + if source.client is None: + raise ValueError("peer snapshot publication sources require a client") + return self.sync_document( + source.client, + document, + key, + shard_id=source.shard_id, + shard_epoch=source.shard_epoch, + ) + if source.kind == "single_file": + if source.path is None: + raise ValueError("single-file publication sources require a path") + registered = self._resolve_document_alias(document) + cache = CultCache() + cache.register_document_type(registered) + cache.add_generic_store(SingleFileMessagePackBackingStore(source.path)) + cache.pull_all_backing_stores() + value = cache.get_required(registered, key) + requested = self._convert_document_value(value, registered, document) + self.put(document, key, requested) + return self.get_required(document, key) + raise ValueError(f"Unsupported CultMesh document publication source: {source.kind!r}") + + def sync_documents_from_publication( + self, + source: CultMeshDocumentPublicationSource, + bindings: list[CultMeshPublicationDocumentBinding] | tuple[CultMeshPublicationDocumentBinding, ...], + ) -> list[Any]: + if source is None: + raise ValueError("publication source is required") + if bindings is None: + raise ValueError("publication bindings are required") + return [ + self.sync_document_from_publication( + binding.source or source, + binding.document, + binding.key, + ) + for binding in bindings + ] + def sync_shard_log( self, client: CultNetRawClient, @@ -716,7 +1058,7 @@ class CultMeshDatabase: def _publish(self, change: CultMeshDatabaseChange) -> None: for document, key, callback in list(self._subscribers): - if document is not None and document.type != change.document.type: + if document is not None and not self._documents_alias(document, change.document): continue if key is not None and key != change.record_key: continue @@ -782,15 +1124,37 @@ class CultMeshDatabase: schema_id = document.get("schemaId") record_key = document.get("recordKey") if schema_id and record_key: - records.add((str(schema_id), str(record_key))) + records.add(self._canonical_shard_record_key(str(schema_id), str(record_key), document)) delete = entry.get("delete") if isinstance(delete, dict): schema_id = delete.get("schemaId") record_key = delete.get("recordKey") if schema_id and record_key: - records.discard((str(schema_id), str(record_key))) + records.discard(self._canonical_shard_record_key(str(schema_id), str(record_key))) return records + def _canonical_shard_record_key( + self, + schema_id: str, + record_key: str, + raw_record: dict[str, Any] | None = None, + ) -> tuple[str, str]: + documents_by_schema_id = schema_document_map(self.documents) + if raw_record is not None: + try: + _, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( + documents_by_schema_id, + schema_id, + raw_record, + ) + return resolved_schema_id, record_key + except KeyError: + pass + document = documents_by_schema_id.get(schema_id) + if document is not None: + return document.catalog_entry().schema_id, record_key + return schema_id, record_key + def _shard_log_entries( self, shard_id: str, @@ -823,11 +1187,67 @@ class CultMeshDatabase: def _document_for_schema(self, schema_id: str) -> DocumentDefinition[Any] | None: for document in self.documents: - entry = document.catalog_entry() - if schema_id == entry.schema_id or schema_id in entry.compatible_schema_ids: + if _document_matches_schema_id(document, schema_id): return document return None + def _resolve_document_alias(self, document: DocumentDefinition[Any]) -> DocumentDefinition[Any]: + for registered in self.documents: + if self._documents_alias(document, registered): + return registered + return document + + def _convert_document_value( + self, + value: Any, + source: DocumentDefinition[Any], + target: DocumentDefinition[Any], + ) -> Any: + return target.decode_payload(source.encode_payload(value)) + + def _change_as_document( + self, + change: CultMeshDatabaseChange, + target: DocumentDefinition[Any], + ) -> CultMeshDatabaseChange: + if change.document.type == target.type: + return change + if not self._documents_alias(change.document, target): + return change + return CultMeshDatabaseChange( + document=target, + schema_id=target.catalog_entry().schema_id, + record_key=change.record_key, + change_kind=change.change_kind, + value=None if change.value is None else self._convert_document_value( + change.value, + change.document, + target, + ), + previous_value=None if change.previous_value is None else self._convert_document_value( + change.previous_value, + change.document, + target, + ), + ) + + def _documents_alias( + self, + left: DocumentDefinition[Any], + right: DocumentDefinition[Any], + ) -> bool: + if left.type == right.type: + return True + left_entry = left.catalog_entry() + right_entry = right.catalog_entry() + if left_entry.schema_id == right_entry.schema_id: + return True + if left_entry.schema_name == right_entry.schema_name and left_entry.schema_version == right_entry.schema_version: + return True + if left_entry.schema_id in right_entry.compatible_schema_ids: + return True + return right_entry.schema_id in left_entry.compatible_schema_ids + def _change_matches_extractor( self, change: CultMeshDatabaseChange, @@ -875,7 +1295,8 @@ class CultMeshDatabase: if document is None: continue record_key = str(delete.get("recordKey")) - previous[(schema_id, record_key)] = self.cache.get(document, record_key) + resolved_schema_id = document.catalog_entry().schema_id + previous[(resolved_schema_id, record_key)] = self.cache.get(document, record_key) return previous def _previous_values_for_raw_records(self, records: Any) -> dict[tuple[str, str], Any]: @@ -885,13 +1306,337 @@ class CultMeshDatabase: if not isinstance(record, dict): continue schema_id = str(record.get("schemaId")) - document = documents_by_schema_id.get(schema_id) - if document is None: + try: + document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( + documents_by_schema_id, + schema_id, + record, + ) + except KeyError: continue record_key = str(record.get("recordKey")) - previous[(schema_id, record_key)] = self.cache.get(document, record_key) + previous[(resolved_schema_id, record_key)] = self.cache.get(document, record_key) return previous + @staticmethod + def _schema_matches_request( + schema_id: str, + document: DocumentDefinition[Any] | None, + requested_schema_ids: set[str], + ) -> bool: + if not requested_schema_ids or schema_id in requested_schema_ids: + return True + if document is None: + return False + entry = document.catalog_entry() + if entry.schema_id in requested_schema_ids or entry.schema_name in requested_schema_ids: + return True + if any(schema_id in requested_schema_ids for schema_id in entry.compatible_schema_ids): + return True + return any(_infer_schema_name(schema_id) == entry.schema_name for schema_id in requested_schema_ids) + + +def _infer_schema_name(schema_id: str) -> str | None: + marker = schema_id.rfind(".v") + if marker <= 0 or marker + 2 >= len(schema_id): + return None + version = schema_id[marker + 2:] + return schema_id[:marker] if version.isdigit() else None + + +def _document_matches_schema_id(document: DocumentDefinition[Any], schema_id: str) -> bool: + entry = document.catalog_entry() + if schema_id in (entry.schema_id, entry.schema_name, entry.schema_version): + return True + if schema_id in entry.compatible_schema_ids: + return True + return _infer_schema_name(schema_id) == entry.schema_name + + +class CultMeshDocumentWriter: + def __init__( + self, + database: CultMeshDatabase, + document: DocumentDefinition[Any], + key: str, + ) -> None: + self.database = database + self.document = document + self.key = key + + def write(self, value: Any) -> None: + self.database.put(self.document, self.key, self._clone(value)) + + def update(self, callback: Callable[[Any], Any | None]) -> Any: + draft = self._clone(self.database.get_required(self.document, self.key)) + replacement = callback(draft) + next_value = draft if replacement is None else replacement + self.write(next_value) + return self._clone(next_value) + + def reactive( + self, + options: CultMeshReactiveDocumentOptions | None = None, + ) -> "CultMeshReactiveDocument": + return CultMeshReactiveDocument(self, options) + + def _clone(self, value: Any) -> Any: + return self.document.decode_payload(self.document.encode_payload(value)) + + +class CultMeshObservedDocument: + def __init__( + self, + database: CultMeshDatabase, + document: DocumentDefinition[Any], + key: str, + ) -> None: + self._database = database + self._document = document + self._key = key + self._lock = threading.RLock() + self._disposed = False + self._current = self._clone(database.get_required(document, key)) + self._unsubscribe = database.watch_record(document, key, self._apply_canonical_change) + + @property + def current(self) -> Any: + with self._lock: + self._raise_if_disposed() + return self._current + + def refresh(self) -> Any: + with self._lock: + self._raise_if_disposed() + self._current = self._clone( + self._database.get_required(self._document, self._key) + ) + return self._current + + def dispose(self) -> None: + with self._lock: + if self._disposed: + return + self._disposed = True + self._unsubscribe() + + def __enter__(self) -> "CultMeshObservedDocument": + return self + + def __exit__(self, _exc_type: Any, _exc: Any, _tb: Any) -> None: + self.dispose() + + def _apply_canonical_change(self, change: CultMeshDatabaseChange) -> None: + if change.value is None: + return + with self._lock: + if not self._disposed: + self._current = self._clone(change.value) + + def _clone(self, value: Any) -> Any: + return self._document.decode_payload(self._document.encode_payload(value)) + + def _raise_if_disposed(self) -> None: + if self._disposed: + raise RuntimeError("CultMeshObservedDocument has been disposed") + + +class CultMeshReactiveDocument: + def __init__( + self, + writer: CultMeshDocumentWriter, + options: CultMeshReactiveDocumentOptions | None = None, + ) -> None: + self.writer = writer + self._database = writer.database + self._document = writer.document + self._key = writer.key + self._options = options or CultMeshReactiveDocumentOptions() + self._lock = threading.RLock() + self._disposed = False + self._dirty = False + self._flushing = False + self._flush_queued = False + self._flush_timer: _CultMeshScheduledCall | None = None + self._reconciliation_version = 0 + self.reconciliation: CultMeshReactiveDocumentReconciliation | None = None + self._current = self._clone(self._database.get_required(self._document, self._key)) + self._unsubscribe = self._database.watch_record( + self._document, + self._key, + self._apply_canonical_change, + ) + + @property + def document(self) -> DocumentDefinition[Any]: + return self._document + + @property + def key(self) -> str: + return self._key + + @property + def is_dirty(self) -> bool: + with self._lock: + return self._dirty + + @property + def snapshot(self) -> Any: + with self._lock: + self._raise_if_disposed() + return self._clone(self._current) + + def update(self, callback: Callable[[Any], Any | None]) -> Any: + with self._lock: + self._raise_if_disposed() + draft = self._clone(self._current) + replacement = callback(draft) + self._current = self._clone(draft if replacement is None else replacement) + self._dirty = True + self._schedule_flush_locked() + return self._clone(self._current) + + def replace_local(self, value: Any) -> Any: + with self._lock: + self._raise_if_disposed() + self._current = self._clone(value) + self._dirty = True + self._schedule_flush_locked() + return self._clone(self._current) + + def refresh(self) -> Any: + with self._lock: + self._raise_if_disposed() + self._current = self._clone( + self._database.get_required(self._document, self._key) + ) + self._dirty = False + self.reconciliation = None + return self._clone(self._current) + + def flush(self) -> None: + with self._lock: + self._raise_if_disposed() + if not self._dirty: + return + if self._flushing: + self._flush_queued = True + return + if self._flush_timer is not None: + self._flush_timer.cancel() + self._flush_timer = None + self._flushing = True + self._dirty = False + predicted = self._clone(self._current) + + try: + self.writer.write(predicted) + finally: + should_flush_again = False + with self._lock: + self._flushing = False + should_flush_again = self._flush_queued or self._dirty + self._flush_queued = False + if should_flush_again: + self.flush() + + def clear_reconciliation(self) -> None: + with self._lock: + self._raise_if_disposed() + self.reconciliation = None + + def dispose(self) -> None: + with self._lock: + if self._disposed: + return + self._disposed = True + if self._flush_timer is not None: + self._flush_timer.cancel() + self._unsubscribe() + + def __enter__(self) -> "CultMeshReactiveDocument": + return self + + def __exit__(self, _exc_type: Any, _exc: Any, _tb: Any) -> None: + self.dispose() + + def _apply_canonical_change(self, change: CultMeshDatabaseChange) -> None: + if change.value is None: + return + with self._lock: + if self._disposed: + return + replacement = None + if self._dirty or self._flushing: + delta = self._create_delta(self._current, change.value) + if delta: + canonical = self._clone(change.value) + predicted = self._clone(self._current) + replacement = canonical + self._reconciliation_version += 1 + self.reconciliation = CultMeshReactiveDocumentReconciliation( + canonical=canonical, + predicted=predicted, + delta=delta, + version=self._reconciliation_version, + received_at=datetime.now(UTC), + ) + else: + self.reconciliation = None + if not self._options.replace_dirty_current_on_canonical_snapshot: + return + self._current = ( + replacement + if replacement is not None + else self._clone(change.value) + ) + self.reconciliation = None + + def _schedule_flush_locked(self) -> None: + if self._flush_timer is not None: + self._flush_timer.cancel() + self._flush_timer = _REACTIVE_SCHEDULER.schedule( + self._options.flush_delay_seconds, + self.flush, + ) + + def _clone(self, value: Any) -> Any: + return self._document.decode_payload(self._document.encode_payload(value)) + + @staticmethod + def _create_delta(predicted: Any, canonical: Any) -> dict[str, Any]: + delta: dict[str, Any] = {} + canonical_members = _public_document_members(canonical) + for name, predicted_value in _public_document_members(predicted).items(): + canonical_value = canonical_members.get(name) + if predicted_value == canonical_value: + continue + if isinstance(predicted_value, (int, float)) and isinstance(canonical_value, (int, float)): + delta[name] = predicted_value - canonical_value + else: + delta[name] = predicted_value + return delta + + def _raise_if_disposed(self) -> None: + if self._disposed: + raise RuntimeError("CultMeshReactiveDocument has been disposed") + + +def _public_document_members(value: Any) -> dict[str, Any]: + if hasattr(value, "__dataclass_fields__"): + return { + name: getattr(value, name) + for name in value.__dataclass_fields__ # type: ignore[attr-defined] + if not name.startswith("_") + } + if isinstance(value, dict): + return {str(key): item for key, item in value.items()} + return { + name: item + for name, item in vars(value).items() + if not name.startswith("_") + } + def create_node( cache_path: str | Path | None = None, diff --git a/packages/cultcache-py/src/cultmesh_py/py.typed b/packages/cultmesh-py/src/cultmesh_py/py.typed similarity index 100% rename from packages/cultcache-py/src/cultmesh_py/py.typed rename to packages/cultmesh-py/src/cultmesh_py/py.typed diff --git a/packages/cultcache-py/src/cultmesh_py/server.py b/packages/cultmesh-py/src/cultmesh_py/server.py similarity index 86% rename from packages/cultcache-py/src/cultmesh_py/server.py rename to packages/cultmesh-py/src/cultmesh_py/server.py index 986460ff..e0c7fdd0 100644 --- a/packages/cultcache-py/src/cultmesh_py/server.py +++ b/packages/cultmesh-py/src/cultmesh_py/server.py @@ -24,7 +24,7 @@ from cultnet_py import ( ) from .node import CultMeshNode -from .wire import ( +from cultnet_py.cultmesh_contracts import ( PEER_EXCHANGE_REQUEST, VERSE_CATALOG_REQUEST, CultMeshPeerCatalog, @@ -245,6 +245,8 @@ class CultMeshLocalServer: result = peer.session.receive(packet, now_ms) if result.reply is not None: self._send_rudp_packet(rudp_socket, remote_addr, result.reply) + for ready in result.ready_to_send: + self._send_rudp_packet(rudp_socket, remote_addr, ready) if result.disconnected: peers.pop(remote_addr, None) continue @@ -265,8 +267,12 @@ class CultMeshLocalServer: peer.session, msgpack.packb(response, use_bin_type=True), ) - if packet.packet_type == CultNetRudpPacketType.DATA or result.delivered: - self._send_rudp_packet(rudp_socket, remote_addr, peer.session.create_ack()) + if packet.reliable or packet.packet_type == CultNetRudpPacketType.DATA or result.delivered: + self._send_rudp_packet( + rudp_socket, + remote_addr, + peer.session.create_ack_for_received(packet.sequence), + ) def _poll_rudp_resends( self, @@ -354,7 +360,13 @@ class CultMeshLocalServer: message_id=str(message.get("messageId") or ""), code="unregistered_document_put", )] - return self._database_change_notifications(message, document_record, change.change_kind, subscriptions) + return self._database_change_notifications( + message, + document_record, + change.change_kind, + subscriptions, + resolved_schema_id=change.schema_id, + ) def _handle_raw_delete( self, @@ -376,7 +388,7 @@ class CultMeshLocalServer: message_id=str(message.get("messageId") or ""), code="unregistered_document_delete", )] - return self._database_delete_notifications(message, subscriptions) + return self._database_delete_notifications(message, subscriptions, resolved_schema_id=change.schema_id) def _database_change_notifications( self, @@ -384,10 +396,16 @@ class CultMeshLocalServer: document: dict[str, Any], change_kind: str, subscriptions: dict[str, _DatabaseSubscription], + *, + resolved_schema_id: str | None = None, ) -> list[dict[str, Any]]: notifications = [] for subscription in subscriptions.values(): - if not subscription.matches(document): + if not self._subscription_matches_document( + subscription, + document, + resolved_schema_id=resolved_schema_id, + ): continue message_id = message.get("messageId") or document.get("recordKey") or "change" notifications.append({ @@ -403,13 +421,19 @@ class CultMeshLocalServer: self, message: dict[str, Any], subscriptions: dict[str, _DatabaseSubscription], + *, + resolved_schema_id: str | None = None, ) -> list[dict[str, Any]]: schema_id = str(message.get("schemaId") or "") record_key = str(message.get("recordKey") or "") notifications = [] document = {"schemaId": schema_id, "recordKey": record_key} for subscription in subscriptions.values(): - if not subscription.matches(document): + if not self._subscription_matches_document( + subscription, + document, + resolved_schema_id=resolved_schema_id, + ): continue message_id = message.get("messageId") or record_key or "change" notifications.append({ @@ -422,6 +446,35 @@ class CultMeshLocalServer: }) return notifications + def _subscription_matches_document( + self, + subscription: _DatabaseSubscription, + document: dict[str, Any], + *, + resolved_schema_id: str | None = None, + ) -> bool: + record_key = str(document.get("recordKey") or "") + if subscription.record_keys and record_key not in subscription.record_keys: + return False + if not subscription.schema_ids: + return True + + document_schema_id = str(document.get("schemaId") or "") + documents_by_schema_id = _schema_document_map(self.node.documents) + if _schema_matches_request( + document_schema_id, + documents_by_schema_id.get(document_schema_id), + subscription.schema_ids, + ): + return True + if resolved_schema_id is None: + return False + return _schema_matches_request( + resolved_schema_id, + documents_by_schema_id.get(resolved_schema_id), + subscription.schema_ids, + ) + def _hello_response(self) -> dict[str, Any]: return { "schemaVersion": "cultnet.hello.v0", @@ -591,12 +644,21 @@ class CultMeshLocalServer: def _shard_catalog_response(self, request: dict[str, Any]) -> dict[str, Any]: requested_schema_ids = {str(value) for value in request.get("schemaIds") or []} document_schema_ids = [document.catalog_entry().schema_id for document in self.node.documents] + documents_by_schema_id = _schema_document_map(self.node.documents) shard_ids = self.node.database.shard_ids() or ["primary"] shards = [] for shard_id in shard_ids: schema_ids = self.node.database.shard_schema_ids(shard_id) or document_schema_ids if requested_schema_ids: - schema_ids = [schema_id for schema_id in schema_ids if schema_id in requested_schema_ids] + schema_ids = [ + schema_id + for schema_id in schema_ids + if _schema_matches_request( + schema_id, + documents_by_schema_id.get(schema_id), + requested_schema_ids, + ) + ] if not schema_ids: continue shards.append({ @@ -620,3 +682,40 @@ class CultMeshLocalServer: def _now_ms() -> int: return int(time.time() * 1000) + + +def _schema_document_map(documents: list[Any]) -> dict[str, Any]: + mapped: dict[str, Any] = {} + for document in documents: + entry = document.catalog_entry() + mapped[entry.schema_id] = document + mapped[entry.schema_name] = document + mapped[entry.schema_version] = document + for compatible_schema_id in entry.compatible_schema_ids: + mapped[compatible_schema_id] = document + return mapped + + +def _schema_matches_request( + schema_id: str, + document: Any | None, + requested_schema_ids: set[str], +) -> bool: + if not requested_schema_ids or schema_id in requested_schema_ids: + return True + if document is None: + return False + entry = document.catalog_entry() + if entry.schema_id in requested_schema_ids or entry.schema_name in requested_schema_ids: + return True + if any(compatible_schema_id in requested_schema_ids for compatible_schema_id in entry.compatible_schema_ids): + return True + return any(_infer_schema_name(requested_schema_id) == entry.schema_name for requested_schema_id in requested_schema_ids) + + +def _infer_schema_name(schema_id: str) -> str | None: + marker = schema_id.rfind(".v") + if marker <= 0 or marker + 2 >= len(schema_id): + return None + version = schema_id[marker + 2:] + return schema_id[:marker] if version.isdigit() else None diff --git a/packages/cultcache-py/src/cultmesh_py/session.py b/packages/cultmesh-py/src/cultmesh_py/session.py similarity index 98% rename from packages/cultcache-py/src/cultmesh_py/session.py rename to packages/cultmesh-py/src/cultmesh_py/session.py index 7e4b1bc1..c02696f0 100644 --- a/packages/cultcache-py/src/cultmesh_py/session.py +++ b/packages/cultmesh-py/src/cultmesh_py/session.py @@ -23,7 +23,7 @@ from .simulation import ( CultMeshSimulationFactCommitter, simulation_fact_document, ) -from .wire import CultMeshAuthorityLeaseCatalog, CultMeshPeerCatalog, CultMeshVerseCatalog +from cultnet_py.cultmesh_contracts import CultMeshAuthorityLeaseCatalog, CultMeshPeerCatalog, CultMeshVerseCatalog if TYPE_CHECKING: from .server import CultMeshLocalServer diff --git a/packages/cultmesh-py/src/cultmesh_py/simulation.py b/packages/cultmesh-py/src/cultmesh_py/simulation.py new file mode 100644 index 00000000..a89ba4a2 --- /dev/null +++ b/packages/cultmesh-py/src/cultmesh_py/simulation.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from cultcache_py.documents import DocumentDefinition +from cultnet_py.cultmesh_contracts import ( + SIMULATION_FACT_DOCUMENT_TYPE, + SIMULATION_FACT_SCHEMA_VERSION, + CultMeshSimulationFact, + simulation_fact_document, +) + +from .node import CultMeshNode + +__all__ = [ + "SIMULATION_FACT_DOCUMENT_TYPE", + "SIMULATION_FACT_SCHEMA_VERSION", + "CultMeshSimulationFact", + "CultMeshSimulationFactCommit", + "CultMeshSimulationFactCommitter", + "simulation_fact_document", +] + + +@dataclass(frozen=True) +class CultMeshSimulationFactCommit: + key: str + fact: CultMeshSimulationFact + + +@dataclass +class CultMeshSimulationFactCommitter: + node: CultMeshNode + document: DocumentDefinition[CultMeshSimulationFact] = simulation_fact_document + + def __post_init__(self) -> None: + if all(document.type != self.document.type for document in self.node.documents): + self.node.register_document(self.document) + + def commit(self, candidate: dict[str, Any], *, committed_at: str | None = None) -> CultMeshSimulationFactCommit: + if not bool(candidate.get("hasQuorum") if "hasQuorum" in candidate else candidate.get("has_quorum")): + raise ValueError("Simulation candidate cannot be committed before quorum") + key = CultMeshSimulationFact.create_record_key(candidate) + fact = CultMeshSimulationFact.from_candidate(candidate, committed_at=committed_at) + self.node.put(self.document, key, fact) + return CultMeshSimulationFactCommit(key=key, fact=fact) diff --git a/packages/cultcache-py/src/cultcache_py/verify.py b/packages/cultmesh-py/src/cultmesh_py/verify.py similarity index 98% rename from packages/cultcache-py/src/cultcache_py/verify.py rename to packages/cultmesh-py/src/cultmesh_py/verify.py index b6a85430..068f5dc2 100644 --- a/packages/cultcache-py/src/cultcache_py/verify.py +++ b/packages/cultmesh-py/src/cultmesh_py/verify.py @@ -6,7 +6,7 @@ import json from importlib import resources from typing import Any -from .benchmark import run_benchmark +from cultnet_py.benchmark import run_benchmark EXPECTED_EXPORTS: dict[str, tuple[str, ...]] = { @@ -150,7 +150,7 @@ EXPECTED_EXPORTS: dict[str, tuple[str, ...]] = { def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="cultcache-py-verify") + parser = argparse.ArgumentParser(prog="cultmesh-py-verify") parser.add_argument("--records", type=int, default=64) parser.add_argument("--json", action="store_true", dest="emit_json") args = parser.parse_args(argv) @@ -159,7 +159,7 @@ def main(argv: list[str] | None = None) -> int: if args.emit_json: print(json.dumps(result, indent=2, sort_keys=True)) else: - print(f"cultcache-py verify: {result['status']}") + print(f"cultmesh-py verify: {result['status']}") for check in result["checks"]: print(f"- {check['name']}: {check['status']}") return 0 if result["status"] == "ok" else 1 diff --git a/packages/cultmesh-py/tests/test_cultmesh.py b/packages/cultmesh-py/tests/test_cultmesh.py new file mode 100644 index 00000000..b6a21bb8 --- /dev/null +++ b/packages/cultmesh-py/tests/test_cultmesh.py @@ -0,0 +1,4491 @@ +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import patch +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, Callable +from cultcache_py import ( + CultCache, + SingleFileMessagePackBackingStore, + define_database_entry_type, + define_document_type, +) +from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document +from cultmesh_py.verify import verify +from cultnet_py import ( + compute_simulation_claim_hash, + CultNetClientAuthorityScope, + CultNetRawClient, + CultNetRawDocumentRecord, + CultNetRawSnapshotResponse, + CultNetSchemaCatalog, + CultNetShardCatalog, + CultNetShardDescriptor, + CultNetShardLogResponse, + CultNetFileShardReplicaCursorStore, + CultNetFileShardMutationLogStore, + CultNetInMemoryShardReplicaCursorStore, + CultNetSchemaWriteForwarder, + CultNetSchemaShardLogFetcher, + CultNetSchemaShardSnapshotFetcher, + CultNetPeerError, + CultNetShardReplicator, + CultNetShardReplicatorOptions, + CultNetShardMutationLogStore, + CultNetShardWriteForwarder, + CultNetSimulationConsensusOptions, + CultNetSimulationConsensusCandidate, + CultNetSimulationObservation, + CultNetSimulationObservationHub, + CultNetRudpSocketMode, + CultNetRudpSocketTransportConnection, + CultNetRudpSocketTransportOptions, + apply_shard_log_response, + document_delete, + document_put_raw, + hello, + simulation_observation, + snapshot_request, +) +from cultmesh_py import create_node +from cultmesh_py import ( + CultMesh, + CultMeshDatabase, + CultMeshDatabaseChange, + CultMeshGameSessionOptions, + CultMeshNodeOptions, + CultMeshReactiveDocumentOptions, + CultMeshPeerCard, + CultMeshPeerCatalog, + CultMeshSimulationFact, + CultMeshAuthorityLease, + CultMeshAuthorityLeaseCatalog, + CultMeshHmacAuthorityLeaseVerifier, + CultMeshDiscoveryClient, + CultMeshPeerExchangeClient, + CultMeshPeerHealthMonitor, + CultMeshSimulationObservationFanout, + CultMeshSnapshotFanout, + CultMeshStreamCatalog, + CultMeshStreamConsumerProfile, + CultMeshStreamDescriptor, + CultMeshStreamFrameHandle, + CultMeshVerseCatalog, + CultMeshVerseCompatibility, + CultMeshVerseDescriptor, + CultMeshVerseDiscoveryClient, + READY_SCHEMA_VERSION, + peer_exchange_request, + simulation_fact_document, + verse_catalog_request, +) + + +def bind_udp_socket() -> socket.socket: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + sock.settimeout(0.02) + return sock + + +def pump_rudp_handshake( + client: CultNetRudpSocketTransportConnection, + server: CultNetRudpSocketTransportConnection, +) -> None: + for _ in range(20): + server.receive_once() + client.receive_once() + server.receive_once() + if client.connected and server.connected: + return + time.sleep(0.005) + raise AssertionError("RUDP socket handshake did not complete") + + +def receive_rudp_frame(transport: CultNetRudpSocketTransportConnection): + for _ in range(20): + frame = transport.receive_once() + if frame is not None: + return frame + time.sleep(0.005) + raise AssertionError("RUDP socket frame was not delivered") + + +@dataclass +class Item: + name: str + category: str + value: int + + +class CultMeshTests(unittest.TestCase): + def _wait_until(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.01) + self.fail("Timed out waiting for condition") + + def test_verify_reports_python_runtime_surface_health(self) -> None: + result = verify(records=4) + + self.assertEqual(result["status"], "ok") + self.assertEqual( + {check["name"] for check in result["checks"]}, + { + "public_exports", + "typed_markers", + "local_cultmesh_wire_smoke", + "cultmesh_capability_truth", + "benchmark_sanity", + }, + ) + by_name = {check["name"]: check for check in result["checks"]} + self.assertEqual(by_name["public_exports"]["missing"], {}) + self.assertEqual(by_name["cultmesh_capability_truth"]["failures"], []) + + def test_cultmesh_client_uses_rudp_schema_transport_endpoint(self) -> None: + import msgpack # type: ignore + + server_socket = bind_udp_socket() + server_host, server_port = server_socket.getsockname()[:2] + connection_id = 0x43554C54 + server = CultNetRudpSocketTransportConnection( + CultNetRudpSocketTransportOptions( + runtime_id="python-rudp-schema-server", + socket=server_socket, + mode=CultNetRudpSocketMode.SERVER, + connection_id=connection_id, + initial_sequence=100, + resend_delay_ms=25, + ) + ) + received_messages: list[dict[str, object]] = [] + + def serve_once() -> None: + deadline = time.monotonic() + 2.0 + try: + while time.monotonic() < deadline: + frame = server.receive_once() + server.poll_resends() + if frame is None: + time.sleep(0.005) + continue + received = msgpack.unpackb(frame.payload, raw=False) + received_messages.append(received) + response = { + "schemaVersion": "cultnet.schema_catalog_response.v0", + "messageId": received["messageId"], + "schemas": [], + } + server.send("schema", msgpack.packb(response, use_bin_type=True)) + return + raise AssertionError("RUDP schema server did not receive a request") + finally: + server.close() + + server_thread = threading.Thread(target=serve_once, daemon=True) + server_thread.start() + client = CultMesh.create_client( + endpoint=f"rudp://{server_host}:{server_port}", + timeout_seconds=2.0, + connection_id=connection_id, + runtime_id="python-rudp-schema-client", + ) + + response = client.fetch_schema_catalog(message_id="rudp-transport-factory") + server_thread.join(timeout=2.0) + + self.assertFalse(server_thread.is_alive()) + self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0") + self.assertEqual(response["messageId"], "rudp-transport-factory") + self.assertEqual(received_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0") + self.assertEqual(received_messages[0]["messageId"], "rudp-transport-factory") + + def test_cultmesh_facade_creates_rudp_client_from_peer_endpoint(self) -> None: + connection_id = 0x10203042 + server = CultMesh.create_rudp_server( + "python-cultmesh-rudp-server", + connection_id, + initial_sequence=100, + resend_delay_ms=25, + max_fragment_bytes=1024, + max_pending_reliable_packets=16, + ) + server_host, server_port = server.socket.getsockname()[:2] + endpoint = CultMesh.parse_rudp_endpoint(f"rudp://{server_host}:{server_port}") + self.assertEqual(endpoint.host, server_host) + self.assertEqual(endpoint.port, server_port) + peer = CultMeshPeerCard( + peer_id="python-cultmesh-rudp-server", + verse_id="local", + endpoints=(endpoint.uri,), + roles=("schema",), + authority_lease_id="lease:python-cultmesh-rudp-server", + ) + peers = CultMesh.create_peer_catalog() + leases = CultMesh.create_authority_lease_catalog() + peers.upsert(peer) + with self.assertRaisesRegex(ValueError, "No authorized RUDP peer"): + CultMesh.create_rudp_client_for_authorized_peer( + "python-cultmesh-rudp-client", + connection_id, + peers, + leases, + "local", + "schema", + resend_delay_ms=25, + max_fragment_bytes=1024, + max_pending_reliable_packets=16, + ) + leases.upsert( + CultMeshAuthorityLease( + lease_id="lease:python-cultmesh-rudp-server", + verse_id="local", + peer_id="python-cultmesh-rudp-server", + roles=("schema",), + valid_from=datetime.now(UTC) - timedelta(seconds=1), + expires_at=datetime.now(UTC) + timedelta(seconds=30), + issuer_runtime_id="python-authority", + ) + ) + client = CultMesh.create_rudp_client_for_authorized_peer( + "python-cultmesh-rudp-client", + connection_id, + peers, + leases, + "local", + "schema", + resend_delay_ms=25, + max_fragment_bytes=1024, + max_pending_reliable_packets=16, + ) + + try: + client.connect(b"join") + pump_rudp_handshake(client, server) + self.assertTrue(client.connected) + self.assertTrue(server.connected) + + client.send("schema", b"client-state") + server_frame = receive_rudp_frame(server) + self.assertEqual(server_frame.channel_id, "schema") + self.assertEqual(server_frame.payload, b"client-state") + self.assertEqual(server.profile["transports"][0]["protocol"], "rudp") + self.assertEqual(client.profile["transports"][0]["protocol"], "rudp") + finally: + client.close() + server.close() + + def test_cultmesh_node_applies_foreign_schema_snapshot_as_local_update(self) -> None: + document = define_database_entry_type( + "mesh.runtime-policy", + [ + ("schema_version", 0), + ("name", 1), + ("value", 2), + ], + schema_id="mesh.runtime-policy.current", + schema_name="mesh.runtime_policy", + schema_version="mesh.runtime_policy.v1", + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + node.put(document, "policy:1", { + "schema_version": "mesh.runtime_policy.v1", + "name": "policy", + "value": "old", + }) + observed: list[CultMeshDatabaseChange] = [] + unsubscribe = node.database.watch_record(document, "policy:1", observed.append) + try: + applied = node.database.apply_snapshot_response({ + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "foreign-schema-snapshot", + "documents": [ + { + "schemaId": "sha256:foreign-mesh-runtime-policy", + "recordKey": "policy:1", + "storedAt": "2026-06-25T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload({ + "schema_version": "mesh.runtime_policy.v1", + "name": "policy", + "value": "new", + }), + }, + ], + }) + finally: + unsubscribe() + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(applied[0].schema_id, local_schema_id) + self.assertEqual(observed[0].schema_id, local_schema_id) + self.assertEqual(observed[0].change_kind, "updated") + self.assertEqual(observed[0].previous_value["value"], "old") + self.assertEqual(observed[0].value["value"], "new") + self.assertEqual(node.cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) + + def test_cultmesh_node_syncs_snapshot_and_shard_log_through_cultnet_client(self) -> None: + import msgpack # type: ignore + from cultnet_py import read_frame, write_frame + + document = define_database_entry_type( + "mesh.sync_item", + [ + ("name", 0), + ("category", 1), + ("value", 2, 0), + ], + cls=Item, + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + schema_id = document.catalog_entry().schema_id + ready = threading.Event() + server_error: list[BaseException] = [] + port_holder: list[int] = [] + + def serve_requests() -> None: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + port_holder.append(server.getsockname()[1]) + server.listen(2) + ready.set() + for _ in range(2): + connection, _ = server.accept() + with connection: + stream = connection.makefile("rwb") + request = msgpack.unpackb(read_frame(stream), raw=False) + if request["schemaVersion"] == "cultnet.snapshot_request.v0": + response = { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": request["messageId"], + "documents": [ + { + "schemaId": schema_id, + "recordKey": "item:node", + "storedAt": "2026-06-13T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(Item("node", "mesh", 1)), + } + ], + } + elif request["schemaVersion"] == "cultnet.shard_log_request.v0": + response = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": request["messageId"], + "shardId": request["shardId"], + "shardEpoch": request["shardEpoch"], + "resyncRequired": False, + "entries": [ + { + "sequence": 1, + "changeKind": "updated", + "put": { + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": "put-node", + "document": { + "schemaId": schema_id, + "recordKey": "item:node", + "storedAt": "2026-06-13T00:00:01Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(Item("node", "mesh", 9)), + }, + "shardId": request["shardId"], + "shardEpoch": request["shardEpoch"], + }, + } + ], + } + else: + raise AssertionError(f"unexpected request {request['schemaVersion']}") + write_frame(stream, msgpack.packb(response, use_bin_type=True)) + stream.flush() + except BaseException as error: + server_error.append(error) + ready.set() + + thread = threading.Thread(target=serve_requests, daemon=True) + thread.start() + self.assertTrue(ready.wait(2.0)) + self.assertFalse(server_error) + + client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) + snapshot_changes = node.sync_snapshot(client, schema_ids=[schema_id]) + log_changes = node.sync_shard_log(client, shard_id="mesh", shard_epoch=1) + + thread.join(2.0) + self.assertFalse(server_error) + self.assertEqual(snapshot_changes[0].record_key, "item:node") + self.assertEqual(log_changes[0].change_kind, "updated") + self.assertEqual(node.get_required(document, "item:node").value, 9) + + def test_cultmesh_node_sync_document_returns_requested_alias_type(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.sync_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.sync_alias_note.v1", + schema_name="mesh.sync_alias_note", + schema_version="mesh.sync_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.sync_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.sync_alias_note.v1", + schema_name="mesh.sync_alias_note", + schema_version="mesh.sync_alias_note.v1", + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + requests: list[dict[str, Any]] = [] + + class SnapshotClient: + def fetch_snapshot_response( + self, + *, + schema_ids: list[str] | None = None, + record_keys: list[str] | None = None, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> dict[str, Any]: + requests.append({ + "schema_ids": schema_ids, + "record_keys": record_keys, + "shard_id": shard_id, + "shard_epoch": shard_epoch, + }) + return { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "sync-document", + "documents": [ + { + "schemaId": document.catalog_entry().schema_id, + "recordKey": "note:remote", + "storedAt": "2026-06-27T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(CanonicalNote("synced-alias")), + } + ], + } + + synced = node.sync_document(SnapshotClient(), alias, "note:remote") + facade_synced = CultMesh.sync_document(node, SnapshotClient(), alias, "note:remote") + + self.assertIsInstance(synced, UiNote) + self.assertEqual(synced.body, "synced-alias") + self.assertIsInstance(facade_synced, UiNote) + self.assertEqual(facade_synced.body, "synced-alias") + self.assertEqual(node.get_required(document, "note:remote").body, "synced-alias") + self.assertIsInstance(node.get_required(alias, "note:remote"), UiNote) + self.assertEqual(requests, [ + { + "schema_ids": ["mesh.sync_alias_note.v1"], + "record_keys": ["note:remote"], + "shard_id": None, + "shard_epoch": None, + }, + { + "schema_ids": ["mesh.sync_alias_note.v1"], + "record_keys": ["note:remote"], + "shard_id": None, + "shard_epoch": None, + }, + ]) + + def test_cultmesh_node_sync_document_from_publication_returns_requested_alias_type(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.publication_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.publication_alias_note.v1", + schema_name="mesh.publication_alias_note", + schema_version="mesh.publication_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.publication_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.publication_alias_note.v1", + schema_name="mesh.publication_alias_note", + schema_version="mesh.publication_alias_note.v1", + ) + node = create_node(runtime_id="python-publication-node") + node.register_document(document) + requests: list[dict[str, Any]] = [] + + class SnapshotClient: + def fetch_snapshot_response( + self, + *, + schema_ids: list[str] | None = None, + record_keys: list[str] | None = None, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> dict[str, Any]: + requests.append({ + "schema_ids": schema_ids, + "record_keys": record_keys, + "shard_id": shard_id, + "shard_epoch": shard_epoch, + }) + return { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "publication-sync-document", + "documents": [ + { + "schemaId": document.catalog_entry().schema_id, + "recordKey": "note:published-peer", + "storedAt": "2026-06-27T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(CanonicalNote("peer-publication-alias")), + } + ], + } + + source = CultMesh.publication_source_from_peer_snapshot( + SnapshotClient(), + shard_id="notes", + shard_epoch=3, + ) + + synced = node.sync_document_from_publication(source, alias, "note:published-peer") + facade_synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-peer") + + self.assertIsInstance(synced, UiNote) + self.assertEqual(synced.body, "peer-publication-alias") + self.assertIsInstance(facade_synced, UiNote) + self.assertEqual(facade_synced.body, "peer-publication-alias") + self.assertEqual(node.get_required(document, "note:published-peer").body, "peer-publication-alias") + self.assertEqual(requests, [ + { + "schema_ids": ["mesh.publication_alias_note.v1"], + "record_keys": ["note:published-peer"], + "shard_id": "notes", + "shard_epoch": 3, + }, + { + "schema_ids": ["mesh.publication_alias_note.v1"], + "record_keys": ["note:published-peer"], + "shard_id": "notes", + "shard_epoch": 3, + }, + ]) + + def test_cultmesh_node_sync_document_from_single_file_publication_uses_node_aliases(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.file_publication_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.file_publication_alias_note.v1", + schema_name="mesh.file_publication_alias_note", + schema_version="mesh.file_publication_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.file_publication_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.file_publication_alias_note.v1", + schema_name="mesh.file_publication_alias_note", + schema_version="mesh.file_publication_alias_note.v1", + ) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "publication.ccmp" + writer = CultCache() + writer.register_document_type(document) + writer.add_generic_store(SingleFileMessagePackBackingStore(path)) + writer.put(document, "note:published-file", CanonicalNote("file-publication-alias")) + + node = create_node(runtime_id="python-file-publication-node") + node.register_document(document) + source = CultMesh.publication_source_from_single_file(path) + + synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-file") + + self.assertIsInstance(synced, UiNote) + self.assertEqual(synced.body, "file-publication-alias") + self.assertEqual(node.get_required(document, "note:published-file").body, "file-publication-alias") + self.assertIsInstance(node.get_required(alias, "note:published-file"), UiNote) + + def test_cultmesh_node_sync_documents_from_publication_uses_binding_sources_and_aliases(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.catalog_publication_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.catalog_publication_alias_note.v1", + schema_name="mesh.catalog_publication_alias_note", + schema_version="mesh.catalog_publication_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.catalog_publication_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.catalog_publication_alias_note.v1", + schema_name="mesh.catalog_publication_alias_note", + schema_version="mesh.catalog_publication_alias_note.v1", + ) + with tempfile.TemporaryDirectory() as tmp: + first_path = Path(tmp) / "first.ccmp" + second_path = Path(tmp) / "second.ccmp" + first = CultCache() + first.register_document_type(document) + first.add_generic_store(SingleFileMessagePackBackingStore(first_path)) + first.put(document, "note:first", CanonicalNote("first catalog publication")) + second = CultCache() + second.register_document_type(document) + second.add_generic_store(SingleFileMessagePackBackingStore(second_path)) + second.put(document, "note:second", CanonicalNote("second catalog publication")) + + node = create_node(runtime_id="python-catalog-publication-node") + node.register_document(document) + source = CultMesh.publication_source_from_single_file(first_path) + bindings = [ + CultMesh.publication_document(alias, "note:first"), + CultMesh.publication_document( + alias, + "note:second", + source=CultMesh.publication_source_from_single_file(second_path), + ), + ] + + synced = node.sync_documents_from_publication(source, bindings) + facade_synced = CultMesh.sync_documents_from_publication(node, source, bindings) + + self.assertEqual([value.body for value in synced], [ + "first catalog publication", + "second catalog publication", + ]) + self.assertTrue(all(isinstance(value, UiNote) for value in synced)) + self.assertEqual([value.body for value in facade_synced], [ + "first catalog publication", + "second catalog publication", + ]) + self.assertEqual(node.get_required(document, "note:first").body, "first catalog publication") + self.assertEqual(node.get_required(document, "note:second").body, "second catalog publication") + self.assertIsInstance(node.get_required(alias, "note:second"), UiNote) + + def test_cultmesh_node_emits_raw_put_and_delete_messages_for_local_writes(self) -> None: + document = define_database_entry_type( + "mesh.emit_item", + [ + ("name", 0), + ("category", 1), + ("value", 2, 0), + ], + cls=Item, + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + + put = node.put_raw_message( + document, + "item:emit", + Item("wand", "gear", 4), + message_id="put-emit", + shard_id="mesh", + shard_epoch=1, + ).to_wire() + self.assertEqual(node.get_required(document, "item:emit").name, "wand") + self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0") + self.assertEqual(put["messageId"], "put-emit") + self.assertEqual(put["document"]["schemaId"], document.catalog_entry().schema_id) + self.assertEqual(put["document"]["recordKey"], "item:emit") + self.assertEqual(put["document"]["sourceRuntimeId"], "python-node") + self.assertEqual(put["shardId"], "mesh") + self.assertEqual(put["shardEpoch"], 1) + + delete = node.delete_raw_message( + document, + "item:emit", + message_id="delete-emit", + shard_id="mesh", + shard_epoch=1, + ).to_wire() + self.assertIsNone(node.get(document, "item:emit")) + self.assertEqual(delete["schemaVersion"], "cultnet.document_delete.v0") + self.assertEqual(delete["messageId"], "delete-emit") + self.assertEqual(delete["schemaId"], document.catalog_entry().schema_id) + self.assertEqual(delete["recordKey"], "item:emit") + self.assertEqual(delete["shardId"], "mesh") + self.assertEqual(delete["shardEpoch"], 1) + + def test_cultmesh_node_resolves_same_schema_aliases_for_local_state(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.alias_note.v1", + schema_name="mesh.alias_note", + schema_version="mesh.alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.alias_note.v1", + schema_name="mesh.alias_note", + schema_version="mesh.alias_note.v1", + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + seen: list[CultMeshDatabaseChange] = [] + all_seen: list[CultMeshDatabaseChange] = [] + node.database.watch(all_seen.append, document=alias) + node.database.watch_record(alias, "note:alias", seen.append) + + node.put(alias, "note:alias", UiNote("from-alias-put")) + self.assertEqual(node.get_required(document, "note:alias").body, "from-alias-put") + alias_value = node.get_required(alias, "note:alias") + self.assertIsInstance(alias_value, UiNote) + self.assertEqual(alias_value.body, "from-alias-put") + alias_collection = node.get_all(alias) + self.assertEqual(len(alias_collection), 1) + self.assertIsInstance(alias_collection[0], UiNote) + self.assertEqual(alias_collection[0].body, "from-alias-put") + self.assertEqual(seen[0].document.type, alias.type) + self.assertEqual(seen[0].schema_id, "mesh.alias_note.v1") + self.assertIsInstance(seen[0].value, UiNote) + self.assertEqual(seen[0].value.body, "from-alias-put") + self.assertEqual(all_seen[0].document.type, alias.type) + self.assertIsInstance(all_seen[0].value, UiNote) + self.assertEqual(all_seen[0].value.body, "from-alias-put") + + node.put(document, "note:alias", CanonicalNote("from-canonical-put")) + self.assertEqual(seen[1].document.type, alias.type) + self.assertIsInstance(seen[1].value, UiNote) + self.assertIsInstance(seen[1].previous_value, UiNote) + self.assertEqual(seen[1].value.body, "from-canonical-put") + self.assertEqual(seen[1].previous_value.body, "from-alias-put") + + put = node.put_raw_message( + alias, + "note:alias", + UiNote("from-alias-raw-put"), + message_id="alias-put", + ).to_wire() + self.assertEqual(put["document"]["schemaId"], "mesh.alias_note.v1") + self.assertEqual(put["document"]["recordKey"], "note:alias") + self.assertEqual(node.get_required(alias, "note:alias").body, "from-alias-raw-put") + + delete = node.delete_raw_message(alias, "note:alias", message_id="alias-delete").to_wire() + self.assertEqual(delete["schemaId"], "mesh.alias_note.v1") + self.assertIsNone(node.get(alias, "note:alias")) + self.assertEqual(node.get_all(alias), []) + + def test_cultmesh_reactive_document_coalesces_explicit_alias_updates(self) -> None: + @dataclass + class CanonicalNote: + body: str + revision: int + + @dataclass + class UiNote: + body: str + revision: int + + document = define_database_entry_type( + "mesh.reactive_alias_note", + [("body", 0), ("revision", 1)], + cls=CanonicalNote, + schema_id="mesh.reactive_alias_note.v1", + schema_name="mesh.reactive_alias_note", + schema_version="mesh.reactive_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.reactive_alias_note.ui", + [("body", 0), ("revision", 1)], + cls=UiNote, + schema_id="mesh.reactive_alias_note.v1", + schema_name="mesh.reactive_alias_note", + schema_version="mesh.reactive_alias_note.v1", + ) + node = create_node(runtime_id="python-reactive") + node.register_document(document) + node.put(document, "note:reactive", CanonicalNote("initial", 1)) + seen: list[CultMeshDatabaseChange] = [] + node.database.watch_record(alias, "note:reactive", seen.append) + + reactive = node.authoritative_writer(alias, "note:reactive").reactive( + CultMeshReactiveDocumentOptions(flush_delay_seconds=0.02) + ) + try: + self.assertIsInstance(reactive.snapshot, UiNote) + reactive.update(lambda note: setattr(note, "body", "first-local-edit")) + reactive.update(lambda note: setattr(note, "body", "second-local-edit")) + reactive.update(lambda note: setattr(note, "revision", 2)) + + self._wait_until(lambda: node.get_required(alias, "note:reactive").body == "second-local-edit") + + self.assertFalse(reactive.is_dirty) + self.assertEqual(node.get_required(document, "note:reactive").revision, 2) + self.assertEqual(seen[-1].value.body, "second-local-edit") + finally: + reactive.dispose() + + def test_cultmesh_reactive_document_tracks_canonical_reconciliation_delta(self) -> None: + @dataclass + class Note: + body: str + revision: int + + document = define_database_entry_type( + "mesh.reactive_reconcile_note", + [("body", 0), ("revision", 1)], + cls=Note, + schema_id="mesh.reactive_reconcile_note.v1", + schema_name="mesh.reactive_reconcile_note", + schema_version="mesh.reactive_reconcile_note.v1", + ) + node = create_node(runtime_id="python-reactive-reconcile") + node.register_document(document) + node.put(document, "note:reconcile", Note("initial", 1)) + + reactive = CultMesh.authoritative_writer( + node, + document, + "note:reconcile", + ).reactive(CultMeshReactiveDocumentOptions(flush_delay_seconds=60)) + try: + reactive.update(lambda note: setattr(note, "body", "local-prediction")) + reactive.update(lambda note: setattr(note, "revision", 2)) + + node.put(document, "note:reconcile", Note("authoritative", 7)) + + self.assertEqual(reactive.snapshot.body, "local-prediction") + self.assertIsNotNone(reactive.reconciliation) + self.assertEqual(reactive.reconciliation.canonical.body, "authoritative") + self.assertEqual(reactive.reconciliation.predicted.body, "local-prediction") + self.assertEqual(reactive.reconciliation.delta["body"], "local-prediction") + self.assertEqual(reactive.reconciliation.delta["revision"], -5) + + reactive.flush() + self.assertEqual(node.get_required(document, "note:reconcile").body, "local-prediction") + self.assertIsNone(reactive.reconciliation) + finally: + reactive.dispose() + + def test_cultmesh_reactive_document_is_idle_until_explicit_nested_update(self) -> None: + document = define_document_type( + "mesh.nested_loadout", + encode=lambda value: value, + decode=lambda value: value, + ) + node = create_node(runtime_id="python-reactive-nested") + node.register_document(document) + node.put( + document, + "ship:nested", + {"loadout": {"weapon": {"tuning": {"damage": 10}}}}, + ) + + with patch("cultmesh_py.node.threading.Timer") as timer: + reactive = node.authoritative_writer(document, "ship:nested").reactive() + timer.assert_not_called() + + try: + borrowed = reactive.snapshot + borrowed["loadout"]["weapon"]["tuning"]["damage"] = 99 + self.assertEqual( + node.get_required(document, "ship:nested")["loadout"]["weapon"]["tuning"]["damage"], + 10, + ) + + reactive.update( + lambda draft: draft["loadout"]["weapon"]["tuning"].__setitem__("damage", 12) + ) + reactive.flush() + + self.assertEqual(reactive.snapshot["loadout"]["weapon"]["tuning"]["damage"], 12) + self.assertEqual( + node.get_required(document, "ship:nested")["loadout"]["weapon"]["tuning"]["damage"], + 12, + ) + finally: + reactive.dispose() + + def test_cultmesh_reactive_scheduling_scales_with_changed_documents_only(self) -> None: + document = define_document_type( + "mesh.reactive_scale_note", + encode=lambda value: value, + decode=lambda value: value, + ) + + for document_count in (1, 100, 1000): + node = create_node(runtime_id=f"python-reactive-scale-{document_count}") + node.register_document(document) + for index in range(document_count): + node.put(document, f"note:{index}", {"body": "initial", "revision": 1}) + + scheduled_timers: list[Any] = [] + + class FakeScheduledCall: + def __init__(self, delay_seconds: float, callback: Callable[[], None]) -> None: + self.delay_seconds = delay_seconds + self.callback = callback + self.cancelled = False + + def cancel(self) -> None: + self.cancelled = True + + class FakeScheduler: + def schedule( + self, + delay_seconds: float, + callback: Callable[[], None], + ) -> FakeScheduledCall: + call = FakeScheduledCall(delay_seconds, callback) + scheduled_timers.append(call) + return call + + reactive_documents: list[Any] = [] + with patch("cultmesh_py.node._REACTIVE_SCHEDULER", FakeScheduler()): + try: + for index in range(document_count): + reactive_documents.append( + node.authoritative_writer(document, f"note:{index}").reactive() + ) + + self.assertEqual( + len(scheduled_timers), + 0, + f"{document_count} idle documents must schedule no timers", + ) + + changed_document_count = max(1, document_count // 100) + for index in range(changed_document_count): + reactive_documents[index].update( + lambda draft, changed=index: draft.update( + body=f"changed-{changed}", + revision=2, + ) + ) + + self.assertEqual(len(scheduled_timers), changed_document_count) + self.assertEqual( + sum(reactive.is_dirty for reactive in reactive_documents), + changed_document_count, + ) + finally: + for reactive in reactive_documents: + reactive.dispose() + + self.assertTrue(all(timer.cancelled for timer in scheduled_timers)) + + def test_cultmesh_reactive_documents_share_one_lazy_scheduler_thread(self) -> None: + document = define_document_type( + "mesh.reactive_shared_scheduler_note", + encode=lambda value: value, + decode=lambda value: value, + ) + node = create_node(runtime_id="python-reactive-shared-scheduler") + node.register_document(document) + reactive_documents: list[Any] = [] + try: + for index in range(100): + node.put(document, f"note:{index}", {"body": "initial", "revision": 1}) + reactive_documents.append( + node.authoritative_writer(document, f"note:{index}").reactive( + CultMeshReactiveDocumentOptions(flush_delay_seconds=60.0) + ) + ) + + before = sum( + thread.name == "cultmesh-reactive-scheduler" + for thread in threading.enumerate() + ) + for index, reactive in enumerate(reactive_documents): + reactive.update( + lambda draft, changed=index: draft.update( + body=f"changed-{changed}", + revision=2, + ) + ) + after = sum( + thread.name == "cultmesh-reactive-scheduler" + for thread in threading.enumerate() + ) + + self.assertLessEqual(before, 1) + self.assertEqual(after, 1) + finally: + for reactive in reactive_documents: + reactive.dispose() + + def test_cultmesh_observed_document_cannot_publish_borrowed_mutation(self) -> None: + document = define_document_type( + "mesh.observed_note", + encode=lambda value: value, + decode=lambda value: value, + ) + node = create_node(runtime_id="python-observed") + node.register_document(document) + node.put(document, "note:observed", {"body": "initial"}) + + observed = node.observe_document(document, "note:observed") + try: + observed.current["body"] = "borrowed-local-edit" + self.assertEqual(node.get_required(document, "note:observed")["body"], "initial") + + node.put(document, "note:observed", {"body": "canonical"}) + self.assertEqual(observed.current["body"], "canonical") + finally: + observed.dispose() + + def test_cultmesh_node_uses_cultcache_store(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + document = define_database_entry_type("mesh.note", [("body", 0)]) + store_path = Path(tmp) / "mesh.cc" + node = create_node(store_path, runtime_id="mesh-test") + self.assertIsInstance(node.database, CultMeshDatabase) + node.database.register_document(document) + node.database.put(document, "note:1", {"body": "hello"}) + self.assertEqual(node.get_required(document, "note:1")["body"], "hello") + + reopened = create_node(store_path, runtime_id="mesh-test") + reopened.database.register_document(document) + reopened.database.pull() + self.assertEqual(reopened.database.get(document, "note:1")["body"], "hello") + + def test_cultmesh_node_options_attach_default_durable_shard_log_store(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + document = define_database_entry_type( + "mesh.durable_option_note", + [("body", 0)], + schema_id="mesh.durable_option_note.v1", + ) + cache_path = Path(tmp) / "world.cc" + node = CultMesh.create_node( + cache_path, + runtime_id="durable-option-primary", + options=CultMeshNodeOptions(enable_durable_shard_logs=True), + ) + node.database.register_document(document) + node.database.put_raw_message(document, "note:1", {"body": "stored"}, shard_id="notes", shard_epoch=5) + + log_path = Path(tmp) / "world.cultmesh" / "shard-logs" + self.assertTrue(log_path.exists()) + + reopened = CultMesh.start_node( + cache_path, + runtime_id="durable-option-primary", + enable_durable_shard_logs=True, + ) + reopened.database.register_document(document) + response = reopened.database.build_shard_log_response(shard_id="notes", shard_epoch=5) + + self.assertEqual(reopened.database.shard_ids(), ["notes"]) + self.assertEqual([entry.sequence for entry in response.entries], [1]) + self.assertEqual(response.entries[0].raw_document.record_key, "note:1") + + def test_cultmesh_database_watchers_observe_local_changes_and_unsubscribe(self) -> None: + document = define_database_entry_type("mesh.watch", [("body", 0)]) + node = CultMesh.create_node(runtime_id="mesh-watch") + node.database.register_document(document) + changes: list[CultMeshDatabaseChange] = [] + all_changes: list[CultMeshDatabaseChange] = [] + + unsubscribe_record = node.database.watch_record(document, "note:1", changes.append) + unsubscribe_all = node.database.watch(all_changes.append, document=document) + + node.database.put(document, "note:1", {"body": "hello"}) + node.database.put(document, "note:2", {"body": "skip"}) + node.database.put(document, "note:1", {"body": "updated"}) + node.database.delete(document, "note:1") + unsubscribe_record() + node.database.put(document, "note:1", {"body": "after-unsubscribe"}) + unsubscribe_all() + + self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"]) + self.assertEqual([change.record_key for change in changes], ["note:1", "note:1", "note:1"]) + self.assertEqual(changes[1].previous_value, {"body": "hello"}) + self.assertIsNone(changes[2].value) + self.assertEqual([change.record_key for change in all_changes], ["note:1", "note:2", "note:1", "note:1", "note:1"]) + + def test_cultmesh_database_register_document_is_idempotent(self) -> None: + document = define_database_entry_type("mesh.idempotent", [("body", 0)]) + conflicting = define_database_entry_type("mesh.idempotent", [("title", 0)]) + node = CultMesh.create_node(runtime_id="mesh-idempotent") + + node.database.register_document(document) + node.database.register_document(document) + node.database.put(document, "note:1", {"body": "still registered once"}) + + self.assertEqual(node.database.get_required(document, "note:1")["body"], "still registered once") + self.assertEqual([registered.type for registered in node.documents], ["mesh.idempotent"]) + with self.assertRaises(ValueError): + node.database.register_document(conflicting) + + def test_cultmesh_database_global_document_facade_uses_singleton_key_and_watchers(self) -> None: + document = define_database_entry_type( + "mesh.global_settings", + [("theme", 0)], + global_document=True, + ) + node = CultMesh.create_node(runtime_id="mesh-global") + node.database.register_document(document) + changes: list[CultMeshDatabaseChange] = [] + + node.database.watch_global(document, changes.append) + node.database.put_global(document, {"theme": "ash"}) + node.put_global(document, {"theme": "ember"}) + node.delete_global(document) + + self.assertIsNone(node.database.get_global(document)) + self.assertEqual([change.record_key for change in changes], [node.cache.GLOBAL_KEY] * 3) + self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"]) + self.assertEqual(changes[1].previous_value, {"theme": "ash"}) + self.assertEqual(changes[2].previous_value, {"theme": "ember"}) + with self.assertRaises(Exception): + node.database.put(document, "settings:wrong", {"theme": "bad"}) + + def test_cultmesh_database_creates_raw_snapshot_response_from_envelopes(self) -> None: + document = define_database_entry_type( + "mesh.snapshot_note", + [("body", 0)], + schema_id="mesh.snapshot_note.v1", + ) + source = CultMesh.create_node(runtime_id="mesh-snapshot-source") + source.database.register_document(document) + source.database.put(document, "note:1", {"body": "include"}) + source.database.put(document, "note:2", {"body": "skip"}) + + typed_response = source.database.build_snapshot_response( + message_id="snapshot-1", + schema_ids=["mesh.snapshot_note.v1"], + record_keys=["note:1"], + shard_id="notes", + shard_epoch=4, + shard_log_sequence=7, + ) + response = source.database.create_snapshot_response( + message_id="snapshot-1", + schema_ids=["mesh.snapshot_note.v1"], + record_keys=["note:1"], + shard_id="notes", + shard_epoch=4, + shard_log_sequence=7, + ) + + self.assertIsInstance(typed_response, CultNetRawSnapshotResponse) + self.assertEqual(typed_response.message_id, "snapshot-1") + self.assertEqual(typed_response.documents[0].record_key, "note:1") + self.assertEqual(typed_response.to_wire(), response) + self.assertEqual(source.build_snapshot_response(schema_ids=["mesh.snapshot_note.v1"]).documents[0].record_key, "note:1") + self.assertEqual(response["schemaVersion"], "cultnet.snapshot_response_raw.v0") + self.assertEqual(response["messageId"], "snapshot-1") + self.assertEqual(response["shardId"], "notes") + self.assertEqual(response["shardEpoch"], 4) + self.assertEqual(response["shardLogSequence"], 7) + self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:1"]) + self.assertEqual(response["documents"][0]["schemaId"], "mesh.snapshot_note.v1") + self.assertEqual(response["documents"][0]["payloadEncoding"], "messagepack") + + target = CultMesh.create_node(runtime_id="mesh-snapshot-target") + target.database.register_document(document) + applied = target.database.apply_snapshot_response(response) + self.assertEqual([(record.schema_id, record.record_key) for record in applied], [("mesh.snapshot_note.v1", "note:1")]) + self.assertEqual(target.database.get_required(document, "note:1"), {"body": "include"}) + self.assertIsNone(target.database.get(document, "note:2")) + + typed_target = CultMesh.create_node(runtime_id="mesh-snapshot-typed-target") + typed_target.database.register_document(document) + typed_applied = typed_target.database.apply_snapshot_response(typed_response) + self.assertEqual([(record.schema_id, record.record_key) for record in typed_applied], [("mesh.snapshot_note.v1", "note:1")]) + self.assertEqual(typed_target.database.get_required(document, "note:1"), {"body": "include"}) + + def test_cultmesh_database_filters_snapshot_by_logged_shard_membership(self) -> None: + document = define_database_entry_type( + "mesh.sharded_snapshot_note", + [("body", 0)], + schema_id="mesh.sharded_snapshot_note.v1", + ) + source = CultMesh.create_node(runtime_id="mesh-sharded-snapshot-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:notes", {"body": "include"}, shard_id="notes", shard_epoch=2) + source.database.put_raw_message(document, "note:other", {"body": "skip"}, shard_id="other", shard_epoch=1) + + response = source.database.build_snapshot_response( + schema_ids=["mesh.sharded_snapshot_note.v1"], + shard_id="notes", + shard_epoch=2, + ) + + self.assertEqual(response.shard_id, "notes") + self.assertEqual(response.shard_epoch, 2) + self.assertEqual([record.record_key for record in response.documents], ["note:notes"]) + + def test_cultmesh_database_creates_shard_log_response_from_raw_mutations(self) -> None: + document = define_database_entry_type( + "mesh.log_note", + [("body", 0)], + schema_id="mesh.log_note.v1", + ) + source = CultMesh.create_node(runtime_id="mesh-log-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="notes", shard_epoch=3) + source.database.put_raw_message(document, "note:1", {"body": "second"}, shard_id="notes", shard_epoch=3) + source.database.put_raw_message(document, "note:2", {"body": "other"}, shard_id="other", shard_epoch=1) + source.database.delete_raw_message(document, "note:1", shard_id="notes", shard_epoch=3) + + self.assertEqual(source.database.shard_ids(), ["notes", "other"]) + self.assertEqual(source.database.shard_schema_ids("notes"), ["mesh.log_note.v1"]) + + typed_response = source.database.build_shard_log_response( + message_id="log-1", + shard_id="notes", + shard_epoch=3, + after_sequence=1, + limit=2, + ) + response = source.database.create_shard_log_response( + message_id="log-1", + shard_id="notes", + shard_epoch=3, + after_sequence=1, + limit=2, + ) + + self.assertIsInstance(typed_response, CultNetShardLogResponse) + self.assertEqual(typed_response.message_id, "log-1") + self.assertEqual([entry.sequence for entry in typed_response.entries], [2, 3]) + self.assertEqual(typed_response.to_wire(), response) + self.assertEqual([entry.sequence for entry in source.build_shard_log_response(shard_id="notes").entries], [1, 2, 3]) + self.assertEqual(response["schemaVersion"], "cultnet.shard_log_response.v0") + self.assertEqual(response["messageId"], "log-1") + self.assertEqual(response["shardId"], "notes") + self.assertEqual(response["shardEpoch"], 3) + self.assertFalse(response["resyncRequired"]) + self.assertEqual([entry["sequence"] for entry in response["entries"]], [2, 3]) + self.assertEqual([entry["changeKind"] for entry in response["entries"]], ["updated", "removed"]) + self.assertEqual(response["entries"][0]["put"]["document"]["recordKey"], "note:1") + self.assertEqual(response["entries"][1]["delete"]["recordKey"], "note:1") + empty = source.database.create_shard_log_response(shard_id="missing") + self.assertEqual(empty["shardEpoch"], 0) + self.assertEqual(empty["entries"], []) + self.assertTrue(empty["resyncRequired"]) + self.assertEqual(empty["reason"], "unknown_shard") + stale = source.database.create_shard_log_response(shard_id="notes", shard_epoch=2) + self.assertEqual(stale["shardEpoch"], 3) + self.assertEqual(stale["entries"], []) + self.assertTrue(stale["resyncRequired"]) + self.assertEqual(stale["reason"], "stale_epoch") + with self.assertRaises(ValueError): + source.database.create_shard_log_response(shard_id="notes", after_sequence=-1) + with self.assertRaises(ValueError): + source.database.create_shard_log_response(shard_id="notes", limit=-1) + + target = CultMesh.create_node(runtime_id="mesh-log-target") + target.database.register_document(document) + target.database.put(document, "note:1", {"body": "first"}) + applied = target.database.apply_shard_log_response(response) + self.assertEqual([record.change_kind for record in applied], ["updated", "removed"]) + self.assertIsNone(target.database.get(document, "note:1")) + self.assertIsNone(target.database.get(document, "note:2")) + + typed_target = CultMesh.create_node(runtime_id="mesh-log-typed-target") + typed_target.database.register_document(document) + typed_target.database.put(document, "note:1", {"body": "first"}) + typed_applied = typed_target.database.apply_shard_log_response(typed_response) + self.assertEqual([record.change_kind for record in typed_applied], ["updated", "removed"]) + self.assertIsNone(typed_target.database.get(document, "note:1")) + + def test_cultnet_file_shard_mutation_log_store_persists_authoritative_entries(self) -> None: + document = define_database_entry_type( + "mesh.durable_log_note", + [("body", 0)], + schema_id="mesh.durable_log_note.v1", + ) + with tempfile.TemporaryDirectory() as tmp: + store: CultNetShardMutationLogStore = CultNetFileShardMutationLogStore(Path(tmp) / "logs") + source = CultMesh.create_node(runtime_id="durable-primary") + source.database.register_document(document) + source.database.use_shard_mutation_log_store(store) + source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="durable", shard_epoch=4) + source.database.put_raw_message(document, "note:2", {"body": "second"}, shard_id="durable", shard_epoch=4) + + reopened = CultMesh.create_node(runtime_id="durable-primary") + reopened.database.register_document(document) + reopened.database.use_shard_mutation_log_store(CultNetFileShardMutationLogStore(Path(tmp) / "logs")) + response = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4) + self.assertEqual(reopened.database.shard_ids(), ["durable"]) + self.assertEqual(reopened.database.shard_schema_ids("durable"), ["mesh.durable_log_note.v1"]) + self.assertEqual([entry.sequence for entry in response.entries], [1, 2]) + self.assertEqual(response.entries[0].raw_document.record_key, "note:1") + + store.compact_through("durable", 1) + compacted = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=0) + retained = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=1) + self.assertTrue(compacted.resync_required) + self.assertEqual(compacted.reason, "compacted_history") + self.assertEqual(compacted.compacted_through, 1) + self.assertEqual([entry.sequence for entry in retained.entries], [2]) + + def test_cultnet_shard_replicator_pulls_logs_and_resyncs_from_snapshot(self) -> None: + document = define_database_entry_type( + "mesh.replica_note", + [("body", 0)], + schema_id="mesh.replica_note.v1", + ) + source = CultMesh.create_node(runtime_id="primary") + source.database.register_document(document) + source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="notes", shard_epoch=3) + source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="notes", shard_epoch=3) + with tempfile.TemporaryDirectory() as tmp: + cursor_path = Path(tmp) / "replica-cursors.msgpack" + server = CultMesh.serve_node(source) + try: + shard = CultNetShardDescriptor( + shard_id="notes", + owner_runtime_id="primary", + epoch=3, + is_primary=False, + schema_ids=("mesh.replica_note.v1",), + primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",), + ) + target = CultMesh.create_node(runtime_id="replica") + target.database.register_document(document) + cursor_store = CultNetFileShardReplicaCursorStore(cursor_path) + replicator = CultNetShardReplicator( + target.database, + CultNetShardReplicatorOptions( + fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0), + snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0), + cursor_store=cursor_store, + batch_size=16, + ), + ) + + first_sequence = replicator.pull_once(shard) + restarted_replicator = CultNetShardReplicator( + target.database, + CultNetShardReplicatorOptions( + fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0), + snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0), + cursor_store=CultNetFileShardReplicaCursorStore(cursor_path), + batch_size=16, + ), + ) + second_sequence = restarted_replicator.pull_once(shard) + + self.assertEqual(first_sequence, 2) + self.assertEqual(second_sequence, 2) + self.assertEqual(target.get_required(document, "note:1")["body"], "one") + self.assertEqual(target.get_required(document, "note:2")["body"], "two") + cursor = CultNetFileShardReplicaCursorStore(cursor_path).read("notes") + self.assertIsNotNone(cursor) + assert cursor is not None + self.assertEqual(cursor.last_applied_sequence, 2) + self.assertEqual(cursor.shard_epoch, 3) + finally: + server.stop() + + resync_target = CultMesh.create_node(runtime_id="resync-replica") + resync_target.database.register_document(document) + snapshot = source.database.build_snapshot_response(shard_id="notes", shard_epoch=3, shard_log_sequence=2) + + class CompactingFetcher: + def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse: + return CultNetShardLogResponse( + message_id="compact", + shard_id=shard.shard_id, + shard_epoch=shard.epoch, + entries=(), + resync_required=True, + reason="compacted_history", + compacted_through=after_sequence, + ) + + class SnapshotFetcher: + def fetch(self, shard: CultNetShardDescriptor) -> CultNetRawSnapshotResponse: + return snapshot + + resync_replicator = CultNetShardReplicator( + resync_target.database, + CultNetShardReplicatorOptions( + fetcher=CompactingFetcher(), + snapshot_fetcher=SnapshotFetcher(), + cursor_store=CultNetInMemoryShardReplicaCursorStore(), + ), + ) + resync_sequence = resync_replicator.pull_once(CultNetShardDescriptor( + shard_id="notes", + owner_runtime_id="primary", + epoch=3, + is_primary=False, + schema_ids=("mesh.replica_note.v1",), + primary_endpoints=("cultnet://primary:3075",), + )) + + self.assertEqual(resync_sequence, 2) + self.assertEqual(resync_target.get_required(document, "note:1")["body"], "one") + + def test_cultnet_shard_replicator_background_loop_polls_non_primary_shards(self) -> None: + document = define_database_entry_type( + "mesh.background_replica_note", + [("body", 0)], + schema_id="mesh.background_replica_note.v1", + ) + source = CultMesh.create_node(runtime_id="background-primary") + source.database.register_document(document) + target = CultMesh.create_node(runtime_id="background-replica") + target.database.register_document(document) + cursor_store = CultNetInMemoryShardReplicaCursorStore() + errors: list[Exception] = [] + + class FlakySourceFetcher: + def __init__(self) -> None: + self.calls = 0 + + def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse: + self.calls += 1 + if self.calls == 1: + raise RuntimeError("transient fetch failure") + return source.database.build_shard_log_response( + shard_id=shard.shard_id, + shard_epoch=shard.epoch, + after_sequence=after_sequence, + limit=limit, + ) + + fetcher = FlakySourceFetcher() + shard = CultNetShardDescriptor( + shard_id="background", + owner_runtime_id="background-primary", + epoch=7, + is_primary=False, + schema_ids=("mesh.background_replica_note.v1",), + primary_endpoints=("cultnet://127.0.0.1:3075",), + ) + replicator = CultNetShardReplicator( + target.database, + CultNetShardReplicatorOptions( + fetcher=fetcher, + cursor_store=cursor_store, + batch_size=1, + poll_interval_seconds=0.01, + on_error=errors.append, + ), + ) + try: + source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="background", shard_epoch=7) + replicator.start([shard]) + self._wait_until(lambda: target.database.get(document, "note:1") is not None) + source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="background", shard_epoch=7) + self._wait_until(lambda: target.database.get(document, "note:2") is not None) + finally: + replicator.stop() + + cursor = cursor_store.read("background") + self.assertEqual(target.get_required(document, "note:1")["body"], "one") + self.assertEqual(target.get_required(document, "note:2")["body"], "two") + self.assertIsNotNone(cursor) + assert cursor is not None + self.assertEqual(cursor.last_applied_sequence, 2) + self.assertEqual(cursor.shard_epoch, 7) + self.assertEqual(len(errors), 1) + self.assertEqual(str(errors[0]), "transient fetch failure") + + def test_cultnet_schema_write_forwarder_sends_raw_writes_to_primary(self) -> None: + document = define_database_entry_type( + "mesh.forward_note", + [("body", 0)], + schema_id="mesh.forward_note.v1", + ) + primary = CultMesh.create_node(runtime_id="forward-primary") + primary.database.register_document(document) + server = CultMesh.serve_node(primary) + try: + shard = CultNetShardDescriptor( + shard_id="forward", + owner_runtime_id="forward-primary", + epoch=9, + is_primary=False, + schema_ids=("mesh.forward_note.v1",), + primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",), + ) + forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0) + put = document_put_raw( + message_id="forward-put", + key="note:forward", + schema_id=document.catalog_entry().schema_id, + stored_at="2026-06-14T00:00:00Z", + payload=document.encode_payload({"body": "forwarded"}), + ).to_wire() + delete = document_delete( + message_id="forward-delete", + schema_id=document.catalog_entry().schema_id, + record_key="note:forward", + ).to_wire() + + forwarder.forward_put(shard, put) + self._eventually(lambda: primary.database.get(document, "note:forward") is not None) + self.assertEqual(primary.database.get_required(document, "note:forward")["body"], "forwarded") + forwarder.forward_delete(shard, delete) + self._eventually(lambda: primary.database.get(document, "note:forward") is None) + + log = primary.database.build_shard_log_response(shard_id="forward", shard_epoch=9) + self.assertIsNone(primary.database.get(document, "note:forward")) + self.assertEqual([entry.change_kind for entry in log.entries], ["added", "removed"]) + self.assertEqual(log.entries[0].put["shardId"], "forward") + self.assertEqual(log.entries[1].delete["shardEpoch"], 9) + finally: + server.stop() + + def _eventually(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.01) + self.assertTrue(predicate()) + + def test_cultmesh_database_watchers_observe_name_and_index_changes(self) -> None: + document = define_database_entry_type( + "mesh.named_watch", + [("name", 0), ("kind", 1)], + name="name", + indexes={"kind": "kind"}, + ) + node = CultMesh.create_node(runtime_id="mesh-named-watch") + node.database.register_document(document) + name_changes: list[CultMeshDatabaseChange] = [] + index_changes: list[CultMeshDatabaseChange] = [] + + node.database.watch_by_name(document, "Potion", name_changes.append) + node.database.watch_by_index(document, "kind", "consumable", index_changes.append) + + node.database.put(document, "item:1", {"name": "Potion", "kind": "consumable"}) + node.database.put(document, "item:2", {"name": "Sword", "kind": "weapon"}) + node.database.put(document, "item:1", {"name": "Elixir", "kind": "rare"}) + node.database.delete(document, "item:1") + + self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"]) + self.assertEqual([change.record_key for change in name_changes], ["item:1", "item:1"]) + self.assertEqual(name_changes[1].previous_value, {"name": "Potion", "kind": "consumable"}) + self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"]) + self.assertEqual([change.record_key for change in index_changes], ["item:1", "item:1"]) + self.assertEqual(index_changes[1].value, {"name": "Elixir", "kind": "rare"}) + + def test_cultmesh_database_collection_reads_resolve_same_schema_aliases(self) -> None: + @dataclass + class CanonicalItem: + name: str + kind: str + + @dataclass + class UiItem: + name: str + kind: str + + document = define_database_entry_type( + "mesh.alias_collection_item", + [("name", 0), ("kind", 1)], + cls=CanonicalItem, + name="name", + indexes={"kind": "kind"}, + schema_id="mesh.alias_collection_item.v1", + schema_name="mesh.alias_collection_item", + schema_version="mesh.alias_collection_item.v1", + ) + alias = define_database_entry_type( + "mesh.alias_collection_item.ui", + [("name", 0), ("kind", 1)], + cls=UiItem, + name="name", + indexes={"kind": "kind"}, + schema_id="mesh.alias_collection_item.v1", + schema_name="mesh.alias_collection_item", + schema_version="mesh.alias_collection_item.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-collection") + node.database.register_document(document) + + node.database.put(alias, "item:potion", UiItem("Potion", "consumable")) + node.database.put(document, "item:sword", CanonicalItem("Sword", "weapon")) + + all_items = node.database.get_all(alias) + potion = node.database.get_by_name(alias, "Potion") + sword = node.get_by_index(alias, "kind", "weapon") + + self.assertEqual([item.name for item in all_items], ["Potion", "Sword"]) + self.assertTrue(all(isinstance(item, UiItem) for item in all_items)) + self.assertIsInstance(potion, UiItem) + self.assertEqual(potion.kind, "consumable") + self.assertIsInstance(sword, UiItem) + self.assertEqual(sword.name, "Sword") + self.assertIsNone(node.database.get_by_name(alias, "Missing")) + + def test_cultmesh_database_alias_collection_watches_emit_alias_values(self) -> None: + @dataclass + class CanonicalItem: + name: str + kind: str + + @dataclass + class UiItem: + name: str + kind: str + + document = define_database_entry_type( + "mesh.alias_collection_watch_item", + [("name", 0), ("kind", 1)], + cls=CanonicalItem, + name="name", + indexes={"kind": "kind"}, + schema_id="mesh.alias_collection_watch_item.v1", + schema_name="mesh.alias_collection_watch_item", + schema_version="mesh.alias_collection_watch_item.v1", + ) + alias = define_database_entry_type( + "mesh.alias_collection_watch_item.ui", + [("name", 0), ("kind", 1)], + cls=UiItem, + name="name", + indexes={"kind": "kind"}, + schema_id="mesh.alias_collection_watch_item.v1", + schema_name="mesh.alias_collection_watch_item", + schema_version="mesh.alias_collection_watch_item.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-collection-watch") + node.database.register_document(document) + name_changes: list[CultMeshDatabaseChange] = [] + index_changes: list[CultMeshDatabaseChange] = [] + + node.database.watch_by_name(alias, "Potion", name_changes.append) + node.database.watch_by_index(alias, "kind", "consumable", index_changes.append) + + node.database.put(document, "item:potion", CanonicalItem("Potion", "consumable")) + node.database.put(document, "item:potion", CanonicalItem("Elixir", "rare")) + + self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"]) + self.assertTrue(all(change.document.type == alias.type for change in name_changes)) + self.assertIsInstance(name_changes[0].value, UiItem) + self.assertIsInstance(name_changes[1].value, UiItem) + self.assertIsInstance(name_changes[1].previous_value, UiItem) + self.assertEqual(name_changes[1].previous_value.name, "Potion") + self.assertEqual(name_changes[1].value.name, "Elixir") + self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"]) + self.assertTrue(all(change.document.type == alias.type for change in index_changes)) + self.assertIsInstance(index_changes[0].value, UiItem) + self.assertIsInstance(index_changes[1].previous_value, UiItem) + + def test_cultmesh_database_watch_by_name_and_index_validate_lookup_shape(self) -> None: + document = define_database_entry_type("mesh.unnamed_watch", [("body", 0)]) + node = CultMesh.create_node(runtime_id="mesh-watch-validation") + node.database.register_document(document) + + with self.assertRaises(ValueError): + node.database.watch_by_name(document, "missing", lambda _: None) + with self.assertRaises(ValueError): + node.database.watch_by_index(document, "missing", "value", lambda _: None) + with self.assertRaises(ValueError): + node.database.watch_by_index(document, "", "value", lambda _: None) + with self.assertRaises(ValueError): + node.database.watch_by_index(document, "missing", "", lambda _: None) + + def test_cultmesh_facade_matches_peer_runtime_entrypoints(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store_path = Path(tmp) / "mesh.cc" + node = CultMesh.start_node(store_path, runtime_id="mesh-facade") + + self.assertEqual(node.runtime_id, "mesh-facade") + self.assertIsInstance(CultMesh.create_node(), type(node)) + self.assertIsInstance(CultMesh.create_verse_catalog(), CultMeshVerseCatalog) + self.assertIsInstance(CultMesh.create_peer_catalog(), CultMeshPeerCatalog) + self.assertIsInstance(CultMesh.create_authority_lease_catalog(), CultMeshAuthorityLeaseCatalog) + self.assertIsInstance(CultMesh.create_stream_catalog(), CultMeshStreamCatalog) + self.assertIsInstance(CultMesh.create_schema_catalog(), CultNetSchemaCatalog) + self.assertIsInstance(CultMesh.create_builtin_schema_catalog(), CultNetSchemaCatalog) + self.assertIsInstance(CultMesh.create_shard_catalog(), CultNetShardCatalog) + builtins = CultMesh.create_builtin_schema_catalog( + include_schema_json=False, + kinds=["shared_contract"], + ) + self.assertEqual( + builtins.list()[0].schema_id, + "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json", + ) + self.assertIsNone(builtins.list()[0].schema_json) + peer_exchange_schema_id = ( + "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.peer-exchange-request.schema.json" + ) + filtered_builtin = CultMesh.create_builtin_schema_catalog( + include_schema_json=True, + schema_ids=[peer_exchange_schema_id], + ) + self.assertEqual( + filtered_builtin.list()[0].schema_version, + "cultmesh.peer_exchange_request.v0", + ) + self.assertIn( + "cultmesh.peer_exchange_request.v0", + filtered_builtin.list()[0].schema_json or "", + ) + shard_catalog = CultMesh.create_shard_catalog() + shard_catalog.upsert( + CultNetShardDescriptor( + shard_id="python-notes-a", + owner_runtime_id="mesh-facade", + epoch=7, + is_primary=True, + schema_ids=("python.note.v1",), + key_prefix="note:", + ) + ) + self.assertEqual( + [ + shard.shard_id + for shard in shard_catalog.list(schema_ids=["python.note.v1"], record_keys=["note:1"]) + ], + ["python-notes-a"], + ) + verse_client = CultMesh.create_verse_discovery_client("127.0.0.1", 4010, timeout_seconds=1.5) + peer_client = CultMesh.create_peer_exchange_client("127.0.0.1", 4011, timeout_seconds=1.5) + raw_client = CultMesh.create_client("127.0.0.1", 4012, timeout_seconds=1.5) + connected_client = CultMesh.connect_client("127.0.0.1", 4013, timeout_seconds=1.5) + self.assertIsInstance(verse_client, CultMeshVerseDiscoveryClient) + self.assertIsInstance(peer_client, CultMeshPeerExchangeClient) + self.assertIsInstance(verse_client, CultMeshDiscoveryClient) + self.assertIsInstance(peer_client, CultMeshDiscoveryClient) + self.assertIsInstance(raw_client, CultNetRawClient) + self.assertIsInstance(connected_client, CultNetRawClient) + self.assertEqual((verse_client.host, verse_client.port, verse_client.timeout_seconds), ("127.0.0.1", 4010, 1.5)) + self.assertEqual((peer_client.host, peer_client.port, peer_client.timeout_seconds), ("127.0.0.1", 4011, 1.5)) + self.assertEqual((raw_client.host, raw_client.port, raw_client.timeout_seconds), ("127.0.0.1", 4012, 1.5)) + self.assertEqual((connected_client.host, connected_client.port, connected_client.timeout_seconds), ("127.0.0.1", 4013, 1.5)) + + def test_cultmesh_simulation_fact_uses_csharp_slot_contract(self) -> None: + import msgpack # type: ignore + + candidate = { + "shardId": "arena", + "shardEpoch": 4, + "frame": 100, + "subjectId": "bob", + "claimKind": "hit", + "claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"), + "claimSummary": "alice shot bob first", + "witnessCount": 2, + "supportWeight": 2.0, + "totalWeight": 2.0, + "confidence": 1.0, + "hasQuorum": True, + } + + fact = CultMeshSimulationFact.from_candidate(candidate, committed_at="2026-06-13T00:00:00Z") + payload = simulation_fact_document.encode_payload(fact) + slots = msgpack.unpackb(payload, raw=False) + + self.assertEqual(CultMeshSimulationFact.create_record_key(candidate), f"simulation:{fact.fact_id}") + self.assertEqual(slots[0], fact.fact_id) + self.assertEqual(slots[1], "arena") + self.assertEqual(slots[2], 4) + self.assertEqual(slots[3], 100) + self.assertEqual(slots[4], "bob") + self.assertEqual(slots[5], "hit") + self.assertEqual(slots[6], candidate["claimHash"]) + self.assertEqual(slots[7], "alice shot bob first") + self.assertEqual(slots[8], 2) + self.assertEqual(slots[9], 2.0) + self.assertEqual(slots[10], 2.0) + self.assertEqual(slots[11], 1.0) + self.assertEqual(slots[12], "2026-06-13T00:00:00Z") + + def test_cultmesh_simulation_fact_committer_rejects_without_quorum_and_stores_fact(self) -> None: + candidate = { + "shardId": "arena", + "shardEpoch": 4, + "frame": 100, + "subjectId": "bob", + "claimKind": "hit", + "claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"), + "claimSummary": "alice shot bob first", + "witnessCount": 2, + "supportWeight": 2.0, + "totalWeight": 2.0, + "confidence": 1.0, + "hasQuorum": True, + } + + with tempfile.TemporaryDirectory() as tmp: + node = CultMesh.create_node(Path(tmp) / "facts.cc", runtime_id="mesh-facts") + committer = CultMesh.create_simulation_fact_committer(node) + + rejected = dict(candidate) + rejected["hasQuorum"] = False + with self.assertRaisesRegex(ValueError, "before quorum"): + committer.commit(rejected) + + committed = committer.commit(candidate, committed_at="2026-06-13T00:00:00Z") + stored = node.get_required(simulation_fact_document, committed.key) + + self.assertEqual(stored.claim_hash, candidate["claimHash"]) + self.assertEqual(stored.committed_at, "2026-06-13T00:00:00Z") + self.assertEqual(committed.fact.fact_id, stored.fact_id) + + def test_cultmesh_game_session_submits_observations_and_commits_quorum_once(self) -> None: + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") + first = simulation_observation( + message_id="obs-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", + ).to_wire() + second = simulation_observation( + message_id="obs-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", + ).to_wire() + + with tempfile.TemporaryDirectory() as tmp: + node = CultMesh.create_node(Path(tmp) / "session.cc", runtime_id="mesh-session") + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0) + ), + ) + seen_candidates: list[CultNetSimulationConsensusCandidate] = [] + seen_facts: list[CultMeshDatabaseChange] = [] + unsubscribe_candidates = session.watch_candidates(seen_candidates.append) + unsubscribe_facts = session.watch_simulation_facts(seen_facts.append) + + self.assertEqual(session.submit_and_commit(CultNetSimulationObservation.from_wire(first)), []) + typed_candidates = session.submit_observation_candidates(CultNetSimulationObservation.from_wire(second)) + commits = session.commit_quorum_candidate_objects(typed_candidates) + replay = session.commit_quorum_candidates(session.submit_observation(second)) + unsubscribe_candidates() + unsubscribe_facts() + + self.assertEqual(len(typed_candidates), 1) + self.assertTrue(typed_candidates[0].has_quorum) + self.assertEqual(len(commits), 1) + self.assertEqual(replay, []) + self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash, claim_hash]) + self.assertEqual(len(seen_facts), 1) + self.assertEqual(seen_facts[0].change_kind, "added") + self.assertEqual(seen_facts[0].value.claim_hash, claim_hash) + stored = node.get_required(simulation_fact_document, commits[0].key) + 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_can_disable_served_simulation_observations(self) -> None: + verse_catalog = CultMeshVerseCatalog() + verse_catalog.upsert(CultMeshVerseDescriptor( + verse_id="session-quiet", + display_name="Quiet Session", + authority_model="local", + compatibility=CultMeshVerseCompatibility("cultmesh.v0", "quiet-rules"), + )) + session = CultMesh.create_game_session( + CultMesh.create_node(runtime_id="quiet-session-peer"), + CultMeshGameSessionOptions( + verse_catalog=verse_catalog, + serve_simulation_observations=False, + ), + ) + server = session.serve(display_name="Quiet 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="quiet-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") + with self.assertRaisesRegex(CultNetPeerError, "Simulation observations are not enabled") as raised: + raw_client.request( + simulation_observation( + message_id="quiet-session-sim", + witness_runtime_id="watcher-1", + shard_id="arena", + shard_epoch=4, + frame=100, + subject_id="bob", + claim_kind="hit", + claim_hash=compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"), + ), + expected_schema_version="cultnet.simulation_consensus_candidate.v0", + ) + finally: + server.stop() + + self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) + self.assertEqual(verses[0].verse_id, "session-quiet") + self.assertEqual(raised.exception.response["code"], "simulation_observations_disabled") + + def test_cultmesh_game_session_prediction_requires_scope_and_reconciles_shard_log(self) -> None: + note_doc = define_database_entry_type( + "mesh.input", + [("body", 0)], + schema_id="mesh.input.v1", + ) + with tempfile.TemporaryDirectory() as tmp: + node = CultMesh.create_node(Path(tmp) / "prediction.cc", runtime_id="client-a") + node.register_document(note_doc) + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope( + "client-a", + schema_ids=("mesh.input.v1",), + key_prefix="input:client-a", + ), + ) + ), + ) + + with self.assertRaisesRegex(ValueError, "does not have client prediction authority"): + session.predict(note_doc, "input:other", {"body": "nope"}) + + prediction = session.predict(note_doc, "input:client-a:move", {"body": "predicted"}) + put = document_put_raw( + message_id="authoritative", + key=prediction.key, + schema_id=prediction.schema_id, + stored_at="2026-06-13T00:00:00Z", + payload=note_doc.encode_payload({"body": "authoritative"}), + shard_id="inputs", + shard_epoch=1, + ) + authoritative_response = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "inputs-log", + "shardId": "inputs", + "shardEpoch": 1, + "entries": [ + { + "sequence": 1, + "committedAt": "2026-06-13T00:00:00Z", + "changeKind": "updated", + "put": put.to_wire(), + } + ], + "resyncRequired": False, + } + changes = session.apply_shard_log_response(CultNetShardLogResponse.from_wire(authoritative_response)) + + self.assertEqual(changes[0].change_kind, "reconciled") + self.assertEqual(node.get_required(note_doc, prediction.key)["body"], "authoritative") + self.assertEqual(session.pending_predictions(), ()) + self.assertEqual(session.resimulation_inputs(), ()) + + def test_cultmesh_game_session_partial_reconciliation_keeps_unmatched_predictions(self) -> None: + note_doc = define_database_entry_type( + "mesh.partial_input", + [("body", 0)], + schema_id="mesh.partial_input.v1", + ) + node = CultMesh.create_node(runtime_id="client-partial") + node.register_document(note_doc) + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope( + "client-partial", + schema_ids=("mesh.partial_input.v1",), + key_prefix="input:client-partial", + ), + ) + ), + ) + pending = session.predict(note_doc, "input:client-partial:pending", {"body": "pending-local"}) + accepted = session.predict(note_doc, "input:client-partial:accepted", {"body": "accepted-local"}) + put = document_put_raw( + message_id="authoritative-partial", + key=accepted.key, + schema_id=accepted.schema_id, + stored_at="2026-06-13T00:00:00Z", + payload=note_doc.encode_payload({"body": "accepted-authority"}), + shard_id="inputs", + shard_epoch=1, + ) + + changes = session.apply_shard_log_response({ + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "inputs-partial-log", + "shardId": "inputs", + "shardEpoch": 1, + "entries": [ + { + "sequence": 1, + "committedAt": "2026-06-13T00:00:00Z", + "changeKind": "updated", + "put": put.to_wire(), + } + ], + "resyncRequired": False, + }) + + self.assertEqual([(change.change_kind, change.record_key) for change in changes], [ + ("reconciled", accepted.key), + ]) + self.assertEqual(node.get_required(note_doc, accepted.key)["body"], "accepted-authority") + self.assertEqual(node.get_required(note_doc, pending.key)["body"], "pending-local") + self.assertEqual([prediction.key for prediction in session.pending_predictions()], [pending.key]) + self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [pending.key]) + + def test_cultmesh_game_session_rolls_back_pending_predictions(self) -> None: + note_doc = define_database_entry_type( + "mesh.rollback_input", + [("body", 0)], + schema_id="mesh.rollback_input.v1", + ) + node = CultMesh.create_node(runtime_id="client-rollback") + node.register_document(note_doc) + node.put(note_doc, "input:client-rollback:existing", {"body": "previous"}) + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope( + "client-rollback", + schema_ids=("mesh.rollback_input.v1",), + key_prefix="input:client-rollback", + ), + ) + ), + ) + + existing = session.predict(note_doc, "input:client-rollback:existing", {"body": "predicted-existing"}) + created = session.predict(note_doc, "input:client-rollback:new", {"body": "predicted-new"}) + self.assertEqual( + [prediction.key for prediction in session.pending_predictions()], + ["input:client-rollback:existing", "input:client-rollback:new"], + ) + self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [ + "input:client-rollback:existing", + "input:client-rollback:new", + ]) + + restored = session.rollback_prediction(existing) + self.assertIsNotNone(restored) + assert restored is not None + self.assertEqual(restored.change_kind, "rolled_back") + self.assertEqual(restored.value, {"body": "previous"}) + self.assertEqual(node.get_required(note_doc, "input:client-rollback:existing")["body"], "previous") + + rolled_back = session.rollback_predictions() + self.assertEqual([(change.change_kind, change.record_key, change.value) for change in rolled_back], [ + ("rolled_back", created.key, None), + ]) + self.assertIsNone(node.get(note_doc, created.key)) + self.assertEqual(session.pending_predictions(), ()) + + def test_cultmesh_game_session_authoritative_delete_rolls_back_prediction(self) -> None: + note_doc = define_database_entry_type( + "mesh.deleted_input", + [("body", 0)], + schema_id="mesh.deleted_input.v1", + ) + node = CultMesh.create_node(runtime_id="client-delete") + node.register_document(note_doc) + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope( + "client-delete", + schema_ids=("mesh.deleted_input.v1",), + key_prefix="input:client-delete", + ), + ) + ), + ) + + prediction = session.predict(note_doc, "input:client-delete:move", {"body": "predicted"}) + delete = document_delete( + message_id="authoritative-delete", + schema_id=prediction.schema_id, + record_key=prediction.key, + shard_id="inputs", + shard_epoch=1, + ) + changes = session.apply_shard_log_response({ + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "inputs-delete-log", + "shardId": "inputs", + "shardEpoch": 1, + "entries": [ + { + "sequence": 1, + "committedAt": "2026-06-13T00:00:00Z", + "changeKind": "removed", + "delete": delete.to_wire(), + } + ], + "resyncRequired": False, + }) + + self.assertEqual(changes[0].change_kind, "rolled_back") + self.assertIsNone(node.get(note_doc, prediction.key)) + self.assertEqual(session.pending_predictions(), ()) + + def test_cultmesh_database_watchers_observe_authoritative_shard_log_reconciliation(self) -> None: + note_doc = define_database_entry_type( + "mesh.watched_input", + [("body", 0)], + schema_id="mesh.watched_input.v1", + ) + node = CultMesh.create_node(runtime_id="client-watch") + node.database.register_document(note_doc) + seen: list[CultMeshDatabaseChange] = [] + node.database.watch_record(note_doc, "input:client-watch:move", seen.append) + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope( + "client-watch", + schema_ids=("mesh.watched_input.v1",), + key_prefix="input:client-watch", + ), + ) + ), + ) + + prediction = session.predict(note_doc, "input:client-watch:move", {"body": "predicted"}) + put = document_put_raw( + message_id="authoritative-watch", + key=prediction.key, + schema_id=prediction.schema_id, + stored_at="2026-06-13T00:00:00Z", + payload=note_doc.encode_payload({"body": "authoritative"}), + shard_id="inputs", + shard_epoch=1, + ) + session_changes = session.apply_shard_log_response({ + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "inputs-watch-log", + "shardId": "inputs", + "shardEpoch": 1, + "entries": [ + { + "sequence": 1, + "committedAt": "2026-06-13T00:00:00Z", + "changeKind": "updated", + "put": put.to_wire(), + } + ], + "resyncRequired": False, + }) + + self.assertEqual([change.change_kind for change in seen], ["added", "updated"]) + self.assertEqual(seen[1].value, {"body": "authoritative"}) + self.assertEqual(seen[1].previous_value, {"body": "predicted"}) + self.assertEqual(session_changes[0].change_kind, "reconciled") + + def test_cultmesh_verse_catalog_response_matches_schema_v0_wire_shape(self) -> None: + import msgpack # type: ignore + + catalog = CultMeshVerseCatalog() + catalog.upsert( + CultMeshVerseDescriptor( + verse_id="aetheria-main", + display_name="Aetheria", + authority_model="OperatorCluster", + compatibility=CultMeshVerseCompatibility( + transport_version="cultmesh.v0", + rules_hash="rules", + compatible_verse_ids=("aetheria-modded",), + required_plugin_ids=("core",), + optional_plugin_ids=("skylands",), + ), + discovery_endpoints=("cultmesh://aetheria.example.test:3075",), + authority_runtime_ids=("runtime-a",), + description="main branch", + ) + ) + + response = catalog.create_response(verse_catalog_request("verses-1")) + decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False) + self.assertEqual(decoded["schemaVersion"], "cultmesh.verse_catalog_response.v0") + self.assertEqual(decoded["messageId"], "verses-1") + self.assertEqual(decoded["verses"][0]["verseId"], "aetheria-main") + self.assertEqual(decoded["verses"][0]["compatibility"]["requiredPluginIds"], ["core"]) + + def test_cultmesh_verse_catalog_watches_updates_and_finds_transfer_targets(self) -> None: + source = CultMeshVerseDescriptor( + verse_id="aetheria-main", + display_name="Aetheria", + authority_model="federated", + compatibility=CultMeshVerseCompatibility( + transport_version="cultmesh.v0", + rules_hash="rules-main", + ), + ) + compatible = CultMeshVerseDescriptor( + verse_id="aetheria-modded", + display_name="Aetheria Modded", + authority_model="federated", + compatibility=CultMeshVerseCompatibility( + transport_version="cultmesh.v0", + rules_hash="rules-modded", + compatible_verse_ids=("aetheria-main",), + ), + ) + incompatible = CultMeshVerseDescriptor( + verse_id="old-world", + display_name="Old World", + authority_model="solo", + compatibility=CultMeshVerseCompatibility( + transport_version="cultmesh.v0", + rules_hash="rules-old", + ), + ) + catalog = CultMeshVerseCatalog() + seen: list[str] = [] + unsubscribe = catalog.watch(lambda verse: seen.append(verse.verse_id)) + + catalog.upsert(source) + catalog.apply_response({ + "schemaVersion": "cultmesh.verse_catalog_response.v0", + "messageId": "verses-watch", + "verses": [compatible.to_wire(), incompatible.to_wire()], + }) + unsubscribe() + catalog.upsert(CultMeshVerseDescriptor( + verse_id="after-unsubscribe", + display_name="After", + authority_model="none", + compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-after"), + )) + + self.assertEqual(seen, ["aetheria-main", "aetheria-modded", "old-world"]) + self.assertEqual([verse.verse_id for verse in catalog.verses], ["aetheria-main", "aetheria-modded", "after-unsubscribe", "old-world"]) + self.assertEqual(catalog.get("aetheria-modded"), compatible) + self.assertEqual([verse.verse_id for verse in catalog.find_transfer_targets(source)], ["aetheria-modded"]) + + def test_cultmesh_peer_exchange_response_matches_schema_v0_wire_shape(self) -> None: + import msgpack # type: ignore + + catalog = CultMeshPeerCatalog() + catalog.upsert( + CultMeshPeerCard( + peer_id="peer-a", + verse_id="aetheria-main", + endpoints=("cultnet://peer-a.example.test:3075",), + roles=("discovery", "read-replica"), + shard_ids=("players",), + region="eu-west", + authority_lease_id="lease-1", + expires_at="2026-05-20T12:00:00.0000000Z", + signature="sig", + ) + ) + + response = catalog.create_response( + peer_exchange_request("pex-1", verse_id="aetheria-main", roles=["read-replica"]) + ) + decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False) + self.assertEqual(decoded["schemaVersion"], "cultmesh.peer_exchange_response.v0") + self.assertEqual(decoded["messageId"], "pex-1") + self.assertEqual(decoded["peers"][0]["peerId"], "peer-a") + self.assertIn("read-replica", decoded["peers"][0]["roles"]) + self.assertEqual(decoded["peers"][0]["authorityLeaseId"], "lease-1") + + def test_cultmesh_peer_catalog_watches_updates_and_gets_peers(self) -> None: + first = CultMeshPeerCard( + peer_id="peer-a", + verse_id="aetheria-main", + endpoints=("cultnet://peer-a.example.test:3075",), + roles=("discovery", "read-replica"), + ) + second = CultMeshPeerCard( + peer_id="peer-b", + verse_id="aetheria-main", + endpoints=("cultnet://peer-b.example.test:3075",), + roles=("shard-primary",), + ) + catalog = CultMeshPeerCatalog() + seen: list[str] = [] + unsubscribe = catalog.watch(lambda peer: seen.append(peer.peer_id)) + + catalog.upsert(first) + catalog.apply_response({ + "schemaVersion": "cultmesh.peer_exchange_response.v0", + "messageId": "peers-watch", + "peers": [second.to_wire()], + }) + unsubscribe() + catalog.upsert(CultMeshPeerCard( + peer_id="peer-c", + verse_id="aetheria-main", + endpoints=("cultnet://peer-c.example.test:3075",), + roles=("read-replica",), + )) + + self.assertEqual(seen, ["peer-a", "peer-b"]) + self.assertTrue(first.has_role("read-replica")) + self.assertEqual([peer.peer_id for peer in catalog.peers], ["peer-a", "peer-b", "peer-c"]) + self.assertEqual(catalog.get("peer-b"), second) + self.assertEqual([peer.peer_id for peer in catalog.find("aetheria-main", role="read-replica")], ["peer-a", "peer-c"]) + + def test_cultmesh_discovery_client_fetches_typed_catalogs_over_cultnet_frames(self) -> None: + import msgpack # type: ignore + from cultnet_py import read_frame, write_frame + + verses = CultMeshVerseCatalog() + verses.upsert( + CultMeshVerseDescriptor( + verse_id="aetheria-main", + display_name="Aetheria", + authority_model="federated", + compatibility=CultMeshVerseCompatibility( + transport_version="cultmesh.v0", + rules_hash="rules-1", + required_plugin_ids=("core",), + ), + discovery_endpoints=("cultmesh://aetheria.example.test:3075",), + authority_runtime_ids=("runtime-a",), + ) + ) + peers = CultMeshPeerCatalog() + peers.upsert( + CultMeshPeerCard( + peer_id="peer-a", + verse_id="aetheria-main", + endpoints=("cultnet://peer-a.example.test:3075",), + roles=("read-replica",), + shard_ids=("players",), + ) + ) + + ready = threading.Event() + server_error: list[BaseException] = [] + + def serve_requests() -> None: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + port_holder.append(server.getsockname()[1]) + server.listen(8) + ready.set() + for _ in range(8): + connection, _ = server.accept() + with connection: + stream = connection.makefile("rwb") + request = msgpack.unpackb(read_frame(stream), raw=False) + if request["schemaVersion"] == "cultmesh.verse_catalog_request.v0": + response = verses.create_response(request) + elif request["schemaVersion"] == "cultmesh.peer_exchange_request.v0": + response = peers.create_response(request) + else: + raise AssertionError(f"unexpected request {request['schemaVersion']}") + write_frame(stream, msgpack.packb(response, use_bin_type=True)) + stream.flush() + except BaseException as error: + server_error.append(error) + ready.set() + + port_holder: list[int] = [] + thread = threading.Thread(target=serve_requests, daemon=True) + thread.start() + self.assertTrue(ready.wait(2.0)) + self.assertFalse(server_error) + + client = CultMeshDiscoveryClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) + fetched_verses = client.fetch_verses(transport_version="cultmesh.v0") + fetched_peers = client.fetch_peers(verse_id="aetheria-main", roles=["read-replica"]) + local_verses = CultMeshVerseCatalog() + local_peers = CultMeshPeerCatalog() + synced_verses = client.sync_verse_catalog(local_verses, transport_version="cultmesh.v0") + synced_peers = client.sync_peer_catalog(local_peers, verse_id="aetheria-main", roles=["read-replica"]) + discovered_verses = CultMeshVerseCatalog() + discovered_peers = CultMeshPeerCatalog() + endpoint = f"cultnet://127.0.0.1:{port_holder[0]}" + verse_discovery = CultMeshVerseDiscoveryClient.from_endpoint(endpoint, timeout_seconds=2.0) + peer_discovery = CultMeshPeerExchangeClient.from_endpoint(endpoint, timeout_seconds=2.0) + discovered_verse_count = verse_discovery.discover( + discovered_verses, + endpoints=[endpoint, endpoint, ""], + transport_version="cultmesh.v0", + ) + discovered_peer_count = peer_discovery.discover( + discovered_peers, + verse_id="aetheria-main", + endpoints=[endpoint, endpoint, ""], + roles=["read-replica"], + ) + fanout_verses = CultMeshVerseCatalog() + fanout_verses.upsert( + CultMeshVerseDescriptor( + verse_id="aetheria-main", + display_name="Known Aetheria", + authority_model="federated", + compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-1"), + discovery_endpoints=(endpoint, endpoint, ""), + ) + ) + fanout_peers = CultMeshPeerCatalog() + fanout_peers.upsert( + CultMeshPeerCard( + peer_id="known-peer", + verse_id="aetheria-main", + endpoints=(endpoint, endpoint, ""), + roles=("read-replica",), + ) + ) + fanout_verse_count = verse_discovery.fanout(fanout_verses, transport_version="cultmesh.v0") + fanout_peer_count = peer_discovery.fanout( + fanout_peers, + verse_id="aetheria-main", + roles=["read-replica"], + ) + + thread.join(2.0) + self.assertFalse(server_error) + self.assertEqual(fetched_verses[0].verse_id, "aetheria-main") + self.assertEqual(fetched_verses[0].compatibility.required_plugin_ids, ("core",)) + self.assertEqual(fetched_peers[0].peer_id, "peer-a") + self.assertEqual(fetched_peers[0].shard_ids, ("players",)) + self.assertEqual(synced_verses[0].verse_id, "aetheria-main") + self.assertEqual(local_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a") + self.assertEqual(synced_peers[0].roles, ("read-replica",)) + self.assertEqual(discovered_verse_count, 1) + self.assertEqual(discovered_peer_count, 1) + self.assertEqual(discovered_verses.get("aetheria-main").display_name, "Aetheria") + self.assertEqual(discovered_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a") + self.assertEqual(fanout_verse_count, 1) + self.assertEqual(fanout_peer_count, 1) + self.assertEqual(fanout_verses.get("aetheria-main").display_name, "Aetheria") + self.assertIn( + "peer-a", + {peer.peer_id for peer in fanout_peers.find("aetheria-main", role="read-replica")}, + ) + + def test_cultmesh_discovery_client_fans_out_simulation_observations_to_peers(self) -> None: + first_hub = CultNetSimulationObservationHub( + CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) + ) + second_hub = CultNetSimulationObservationHub( + CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) + ) + first_server = CultMesh.serve_node( + CultMesh.create_node(runtime_id="sim-peer-a"), + observation_hub=first_hub, + ) + second_server = CultMesh.serve_node( + CultMesh.create_node(runtime_id="sim-peer-b"), + observation_hub=second_hub, + ) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="sim-peer-a", + verse_id="arena", + endpoints=(f"cultnet://127.0.0.1:{first_server.port}",), + roles=("simulation-witness",), + )) + peers.upsert(CultMeshPeerCard( + peer_id="sim-peer-b", + verse_id="arena", + endpoints=(f"cultnet://127.0.0.1:{second_server.port}",), + roles=("simulation-witness",), + )) + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:200") + message = simulation_observation( + message_id="fanout-observation", + witness_runtime_id="python-fanout", + shard_id="arena", + shard_epoch=1, + frame=200, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice hit bob", + ).to_wire() + + client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0) + candidates = client.fanout_simulation_observation( + peers, + message, + verse_id="arena", + roles=["simulation-witness"], + ) + finally: + first_server.stop() + second_server.stop() + + self.assertEqual([candidate["schemaVersion"] for candidate in candidates], [ + "cultnet.simulation_consensus_candidate.v0", + "cultnet.simulation_consensus_candidate.v0", + ]) + self.assertEqual({candidate["claimHash"] for candidate in candidates}, {claim_hash}) + self.assertEqual({candidate["messageId"] for candidate in candidates}, {"fanout-observation"}) + self.assertEqual({candidate["hasQuorum"] for candidate in candidates}, {True}) + + def test_cultmesh_simulation_observation_fanout_flushes_queued_messages(self) -> None: + hub = CultNetSimulationObservationHub( + CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) + ) + server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-flush-peer"), observation_hub=hub) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="sim-flush-peer", + verse_id="arena", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("simulation-witness",), + )) + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:210") + seen_candidates: list[dict[str, object]] = [] + fanout = CultMeshSimulationObservationFanout( + CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), + peers, + verse_id="arena", + roles=["simulation-witness"], + on_candidate=seen_candidates.append, + ) + fanout.enqueue(simulation_observation( + message_id="queued-observation", + witness_runtime_id="python-fanout", + shard_id="arena", + shard_epoch=1, + frame=210, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice hit bob", + ).to_wire()) + + candidates = fanout.flush() + finally: + server.stop() + + self.assertEqual(fanout.pending_count(), 0) + self.assertEqual([candidate["claimHash"] for candidate in candidates], [claim_hash]) + self.assertEqual(seen_candidates[0]["messageId"], "queued-observation") + + def test_cultmesh_simulation_observation_fanout_keeps_failed_messages_pending(self) -> None: + closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + closed_socket.bind(("127.0.0.1", 0)) + closed_port = closed_socket.getsockname()[1] + closed_socket.close() + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="closed-simulation-peer", + verse_id="arena", + endpoints=(f"cultnet://127.0.0.1:{closed_port}",), + roles=("simulation-witness",), + )) + errors: list[tuple[str, str]] = [] + fanout = CultMeshSimulationObservationFanout( + CultMeshDiscoveryClient("127.0.0.1", closed_port, timeout_seconds=0.2), + peers, + verse_id="arena", + roles=["simulation-witness"], + on_error=lambda endpoint, error: errors.append((endpoint, str(error))), + ) + fanout.enqueue(simulation_observation( + message_id="retry-observation", + witness_runtime_id="python-fanout", + shard_id="arena", + shard_epoch=1, + frame=212, + subject_id="bob", + claim_kind="miss", + claim_hash=compute_simulation_claim_hash("miss", "alice", "bob", "frame:212"), + claim_summary="alice missed bob", + ).to_wire()) + + candidates = fanout.flush() + + self.assertEqual(candidates, []) + self.assertEqual(fanout.pending_count(), 1) + self.assertEqual(len(errors), 1) + self.assertIn(f"127.0.0.1:{closed_port}", errors[0][0]) + + def test_cultmesh_simulation_observation_fanout_runs_background_loop(self) -> None: + hub = CultNetSimulationObservationHub( + CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) + ) + server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-loop-peer"), observation_hub=hub) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="sim-loop-peer", + verse_id="arena", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("simulation-witness",), + )) + claim_hash = compute_simulation_claim_hash("block", "dana", "eve", "frame:211") + seen_candidates: list[dict[str, object]] = [] + fanout = CultMeshSimulationObservationFanout( + CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), + peers, + verse_id="arena", + roles=["simulation-witness"], + interval_seconds=0.05, + on_candidate=seen_candidates.append, + ) + fanout.start() + fanout.enqueue(simulation_observation( + message_id="loop-observation", + witness_runtime_id="python-fanout", + shard_id="arena", + shard_epoch=1, + frame=211, + subject_id="eve", + claim_kind="block", + claim_hash=claim_hash, + claim_summary="dana blocked eve", + ).to_wire()) + deadline = time.monotonic() + 2.0 + while not seen_candidates and time.monotonic() < deadline: + time.sleep(0.01) + fanout.stop() + finally: + server.stop() + + self.assertEqual(fanout.pending_count(), 0) + self.assertTrue(seen_candidates) + self.assertEqual(seen_candidates[0]["claimHash"], claim_hash) + self.assertEqual(seen_candidates[0]["messageId"], "loop-observation") + + def test_cultmesh_discovery_client_fans_out_snapshots_to_peers(self) -> None: + document = define_database_entry_type( + "mesh.snapshot_fanout_note", + [("body", 0)], + schema_id="mesh.snapshot_fanout_note.v1", + ) + first = CultMesh.create_node(runtime_id="snapshot-peer-a") + first.database.register_document(document) + first.database.put_raw_message(document, "note:a", {"body": "from-a"}, shard_id="notes", shard_epoch=1) + second = CultMesh.create_node(runtime_id="snapshot-peer-b") + second.database.register_document(document) + second.database.put_raw_message(document, "note:b", {"body": "from-b"}, shard_id="notes", shard_epoch=1) + target = CultMesh.create_node(runtime_id="snapshot-target") + target.database.register_document(document) + first_server = CultMesh.serve_node(first) + second_server = CultMesh.serve_node(second) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-peer-a", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{first_server.port}", f"cultnet://127.0.0.1:{first_server.port}"), + roles=("read-replica",), + )) + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-peer-b", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{second_server.port}",), + roles=("read-replica",), + )) + + client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0) + responses = client.fanout_snapshot_responses( + peers, + verse_id="mesh", + roles=["read-replica"], + schema_ids=["mesh.snapshot_fanout_note.v1"], + ) + applied = client.sync_snapshots( + target.database, + peers, + verse_id="mesh", + roles=["read-replica"], + schema_ids=["mesh.snapshot_fanout_note.v1"], + ) + finally: + first_server.stop() + second_server.stop() + + self.assertEqual(len(responses), 2) + self.assertTrue(all(isinstance(response, CultNetRawSnapshotResponse) for response in responses)) + self.assertEqual( + sorted(record.record_key for response in responses for record in response.documents), + ["note:a", "note:b"], + ) + self.assertEqual(sorted(record.record_key for record in applied), ["note:a", "note:b"]) + self.assertEqual(target.database.get_required(document, "note:a")["body"], "from-a") + self.assertEqual(target.database.get_required(document, "note:b")["body"], "from-b") + + def test_cultmesh_discovery_client_sync_documents_returns_requested_aliases(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.snapshot_fanout_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.snapshot_fanout_alias_note.v1", + schema_name="mesh.snapshot_fanout_alias_note", + schema_version="mesh.snapshot_fanout_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.snapshot_fanout_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.snapshot_fanout_alias_note.v1", + schema_name="mesh.snapshot_fanout_alias_note", + schema_version="mesh.snapshot_fanout_alias_note.v1", + ) + source = CultMesh.create_node(runtime_id="snapshot-alias-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:alias", CanonicalNote("from-source")) + target = CultMesh.create_node(runtime_id="snapshot-alias-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-alias-source", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + + values = CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0).sync_documents( + target.database, + peers, + verse_id="mesh", + roles=["read-replica"], + documents=[(alias, "note:alias")], + ) + finally: + server.stop() + + self.assertEqual(len(values), 1) + self.assertIsInstance(values[0], UiNote) + self.assertEqual(values[0].body, "from-source") + self.assertEqual(target.database.get_required(document, "note:alias").body, "from-source") + self.assertIsInstance(target.database.get_required(alias, "note:alias"), UiNote) + + def test_cultmesh_document_subscription_syncs_alias_snapshot_and_changes(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.subscription_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.subscription_alias_note.v1", + schema_name="mesh.subscription_alias_note", + schema_version="mesh.subscription_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.subscription_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.subscription_alias_note.v1", + schema_name="mesh.subscription_alias_note", + schema_version="mesh.subscription_alias_note.v1", + ) + source = CultMesh.create_node(runtime_id="subscription-alias-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:live", CanonicalNote("initial")) + target = CultMesh.create_node(runtime_id="subscription-alias-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription: + initial = subscription.sync_initial() + subscription.send(document_put_raw( + message_id="subscription-alias-put", + key="note:live", + schema_id="mesh.subscription_alias_note.v1", + stored_at="2026-06-14T00:00:00Z", + payload=document.encode_payload(CanonicalNote("updated")), + )) + updated = subscription.read_next_change() + subscription.send(document_delete( + message_id="subscription-alias-delete", + schema_id="mesh.subscription_alias_note.v1", + record_key="note:live", + )) + removed = subscription.read_next_change() + finally: + server.stop() + + self.assertIsInstance(initial, UiNote) + self.assertEqual(initial.body, "initial") + self.assertEqual(updated.change_kind, "updated") + self.assertIsInstance(updated.value, UiNote) + self.assertEqual(updated.value.body, "updated") + self.assertIsInstance(updated.previous_value, UiNote) + self.assertEqual(updated.previous_value.body, "initial") + self.assertEqual(removed.change_kind, "removed") + self.assertIsNone(removed.value) + self.assertIsInstance(removed.previous_value, UiNote) + self.assertEqual(removed.previous_value.body, "updated") + self.assertIsNone(target.database.get(alias, "note:live")) + + def test_cultmesh_snapshot_fanout_syncs_once_and_reports_applied_records(self) -> None: + document = define_database_entry_type( + "mesh.snapshot_loop_note", + [("body", 0)], + schema_id="mesh.snapshot_loop_note.v1", + ) + source = CultMesh.create_node(runtime_id="snapshot-loop-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:sync-once", {"body": "from-source"}) + target = CultMesh.create_node(runtime_id="snapshot-loop-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-loop-source", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + seen: list[str] = [] + fanout = CultMeshSnapshotFanout( + CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), + target.database, + peers, + verse_id="mesh", + roles=["read-replica"], + schema_ids=["mesh.snapshot_loop_note.v1"], + on_applied=lambda record: seen.append(record.record_key), + ) + + applied = fanout.sync_once() + finally: + server.stop() + + self.assertEqual([record.record_key for record in applied], ["note:sync-once"]) + self.assertEqual(seen, ["note:sync-once"]) + self.assertEqual(target.database.get_required(document, "note:sync-once")["body"], "from-source") + + def test_cultmesh_snapshot_fanout_syncs_typed_documents_and_reports_alias_values(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.snapshot_loop_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.snapshot_loop_alias_note.v1", + schema_name="mesh.snapshot_loop_alias_note", + schema_version="mesh.snapshot_loop_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.snapshot_loop_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.snapshot_loop_alias_note.v1", + schema_name="mesh.snapshot_loop_alias_note", + schema_version="mesh.snapshot_loop_alias_note.v1", + ) + source = CultMesh.create_node(runtime_id="snapshot-loop-alias-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:typed-loop", CanonicalNote("from-source")) + target = CultMesh.create_node(runtime_id="snapshot-loop-alias-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-loop-alias-source", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + seen: list[UiNote] = [] + fanout = CultMeshSnapshotFanout( + CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), + target.database, + peers, + verse_id="mesh", + roles=["read-replica"], + documents=[(alias, "note:typed-loop")], + on_document=seen.append, + ) + + values = fanout.sync_once() + finally: + server.stop() + + self.assertEqual(len(values), 1) + self.assertIsInstance(values[0], UiNote) + self.assertEqual(values[0].body, "from-source") + self.assertEqual(seen, [values[0]]) + self.assertEqual(target.database.get_required(document, "note:typed-loop").body, "from-source") + self.assertIsInstance(target.database.get_required(alias, "note:typed-loop"), UiNote) + + def test_cultmesh_snapshot_fanout_runs_background_loop(self) -> None: + document = define_database_entry_type( + "mesh.snapshot_background_note", + [("body", 0)], + schema_id="mesh.snapshot_background_note.v1", + ) + source = CultMesh.create_node(runtime_id="snapshot-background-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:background", {"body": "from-background"}) + target = CultMesh.create_node(runtime_id="snapshot-background-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-background-source", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + seen: list[str] = [] + fanout = CultMeshSnapshotFanout( + CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), + target.database, + peers, + verse_id="mesh", + roles=["read-replica"], + schema_ids=["mesh.snapshot_background_note.v1"], + interval_seconds=0.05, + on_applied=lambda record: seen.append(record.record_key), + ) + fanout.start() + deadline = time.monotonic() + 2.0 + while not seen and time.monotonic() < deadline: + time.sleep(0.01) + fanout.stop() + finally: + server.stop() + + self.assertTrue(seen) + self.assertEqual(target.database.get_required(document, "note:background")["body"], "from-background") + + def test_cultmesh_peer_health_monitor_probes_reachable_and_failed_endpoints(self) -> None: + document = define_database_entry_type( + "mesh.health_note", + [("body", 0)], + schema_id="mesh.health_note.v1", + ) + node = CultMesh.create_node(runtime_id="health-peer") + node.database.register_document(document) + server = CultMesh.serve_node(node) + closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + closed_socket.bind(("127.0.0.1", 0)) + closed_port = closed_socket.getsockname()[1] + closed_socket.close() + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="health-peer", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + peers.upsert(CultMeshPeerCard( + peer_id="closed-peer", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{closed_port}",), + roles=("read-replica",), + )) + seen: list[tuple[str, bool]] = [] + monitor = CultMeshPeerHealthMonitor( + runtime_id="health-prober", + timeout_seconds=0.2, + on_update=lambda health: seen.append((health.peer_id, health.is_reachable)), + ) + + results = monitor.probe_catalog(peers, verse_id="mesh", roles=["read-replica"]) + finally: + server.stop() + + by_peer = {result.peer_id: result for result in results} + self.assertTrue(by_peer["health-peer"].is_reachable) + self.assertEqual(by_peer["health-peer"].runtime_id, "health-peer") + self.assertEqual(by_peer["health-peer"].runtime_kind, "python") + self.assertEqual(by_peer["health-peer"].display_name, "health-peer") + self.assertEqual(by_peer["health-peer"].supported_document_types, ("mesh.health_note",)) + self.assertIn("cultnet.hello.v0", by_peer["health-peer"].supported_message_versions) + self.assertIn("cultnet.error.v0", by_peer["health-peer"].supported_message_versions) + self.assertEqual(by_peer["health-peer"].supported_mutation_contracts[0]["documentType"], "mesh.health_note") + self.assertIn("shardLog", by_peer["health-peer"].supported_mutation_contracts[0]["operations"]) + self.assertFalse(by_peer["closed-peer"].is_reachable) + self.assertIsNone(by_peer["closed-peer"].runtime_kind) + self.assertEqual(by_peer["closed-peer"].supported_document_types, ()) + self.assertEqual(by_peer["closed-peer"].supported_message_versions, ()) + self.assertEqual(by_peer["closed-peer"].supported_mutation_contracts, ()) + self.assertTrue(by_peer["closed-peer"].error) + self.assertEqual({peer_id for peer_id, _ in seen}, {"health-peer", "closed-peer"}) + self.assertEqual(len(monitor.latest()), 2) + + def test_cultmesh_peer_health_monitor_runs_background_probe_loop(self) -> None: + server = CultMesh.serve_node(CultMesh.create_node(runtime_id="health-loop-peer")) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="health-loop-peer", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + seen: list[str] = [] + monitor = CultMeshPeerHealthMonitor( + runtime_id="health-loop-prober", + timeout_seconds=0.2, + interval_seconds=0.05, + on_update=lambda health: seen.append(health.peer_id), + ) + monitor.start(peers, verse_id="mesh", roles=["read-replica"]) + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + latest = monitor.latest() + if latest and latest[0].is_reachable: + break + time.sleep(0.01) + monitor.stop() + finally: + server.stop() + + self.assertTrue(seen) + latest = monitor.latest() + self.assertEqual(latest[0].peer_id, "health-loop-peer") + self.assertTrue(latest[0].is_reachable) + + def test_cultmesh_daemon_entrypoint_serves_framed_cultnet_hello(self) -> None: + with tempfile.TemporaryDirectory() as temp: + ready_path = Path(temp) / "ready.json" + package_src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "daemon-test-peer", + "--display-name", + "Daemon Test Peer", + "--port", + "0", + "--ready-file", + str(ready_path), + "--seed-interop-note", + "--seed-shard-id", + "daemon-interop", + "--verse-id", + "daemon-verse", + "--verse-display-name", + "Daemon Verse", + "--role", + "shard-primary", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + deadline = time.monotonic() + 5.0 + while not ready_path.exists() and time.monotonic() < deadline: + if process.poll() is not None: + break + time.sleep(0.05) + if not ready_path.exists(): + _, stderr = process.communicate(timeout=2.0) + self.fail(f"cultmesh daemon did not publish readiness: {stderr}") + ready = json.loads(ready_path.read_text(encoding="utf-8")) + self.assertEqual(ready["schemaVersion"], READY_SCHEMA_VERSION) + self.assertEqual(ready["runtimeId"], "daemon-test-peer") + self.assertEqual(ready["runtimeKind"], "python") + self.assertEqual(ready["displayName"], "Daemon Test Peer") + self.assertEqual(ready["endpoint"], f"cultnet://127.0.0.1:{ready['port']}") + self.assertEqual(ready["shardIds"], ["daemon-interop"]) + self.assertEqual(ready["supportedDocumentTypes"], ["cultcache.interop-note"]) + self.assertIn("cultnet.hello.v0", ready["supportedMessageVersions"]) + self.assertIn("cultnet.document_put_raw.v0", ready["supportedMessageVersions"]) + self.assertNotIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"]) + self.assertEqual(ready["supportedMutationContracts"][0]["documentType"], "cultcache.interop-note") + self.assertIn("shardLog", ready["supportedMutationContracts"][0]["operations"]) + self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": None}) + self.assertEqual(ready["verses"][0]["verseId"], "daemon-verse") + self.assertEqual(ready["verses"][0]["displayName"], "Daemon Verse") + self.assertEqual(ready["verses"][0]["discoveryEndpoints"], [ready["endpoint"]]) + self.assertEqual(ready["verses"][0]["authorityRuntimeIds"], ["daemon-test-peer"]) + self.assertEqual(ready["peers"][0]["peerId"], "daemon-test-peer") + self.assertEqual(ready["peers"][0]["endpoints"], [ready["endpoint"]]) + self.assertEqual(ready["peers"][0]["roles"], ["shard-primary"]) + self.assertEqual(ready["peers"][0]["shardIds"], ["daemon-interop"]) + + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + hello_response = client.request( + hello(runtime_id="daemon-test-prober"), + expected_schema_version="cultnet.hello.v0", + ) + + self.assertEqual(hello_response["runtimeId"], "daemon-test-peer") + self.assertEqual(hello_response["displayName"], "Daemon Test Peer") + self.assertIn("cultnet.hello.v0", hello_response["supportedMessageVersions"]) + self.assertIn("cultnet.schema_catalog_request.v0", hello_response["supportedMessageVersions"]) + self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) + self.assertEqual(hello_response["supportedDocumentTypes"], ["cultcache.interop-note"]) + self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed") + self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], int(ready["port"])) + self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"]) + self.assertEqual(ready["supportedMutationContracts"], hello_response["supportedMutationContracts"]) + self.assertEqual(ready["transportProfiles"], hello_response["transportProfiles"]) + + schema_response = client.fetch_schema_catalog(schema_ids=["cultcache.interop-note"], include_schema_json=True) + self.assertEqual(schema_response["schemas"][0]["schemaVersion"], INTEROP_SCHEMA_VERSION) + self.assertEqual(schema_response["schemas"][0]["documentType"], "cultcache.interop-note") + + snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) + self.assertEqual(snapshot_response.documents[0].record_key, "note:daemon-test-peer") + note = interop_note_document.decode_payload(snapshot_response.documents[0].payload) + self.assertEqual(note["authorRuntimeId"], "daemon-test-peer") + self.assertIn("daemon", note["tags"]) + + shard_catalog = client.fetch_shard_catalog(schema_ids=["cultcache.interop-note"]) + self.assertEqual(shard_catalog["shards"][0]["shardId"], "daemon-interop") + self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["cultcache.interop-note"]) + + shard_log = client.fetch_shard_log_response(shard_id="daemon-interop", shard_epoch=1) + self.assertFalse(shard_log.resync_required) + self.assertEqual(shard_log.entries[0].sequence, 1) + self.assertEqual(shard_log.entries[0].change_kind, "added") + self.assertIsNotNone(shard_log.entries[0].raw_document) + assert shard_log.entries[0].raw_document is not None + self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:daemon-test-peer") + + discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") + self.assertEqual(verses[0].verse_id, "daemon-verse") + self.assertEqual(verses[0].display_name, "Daemon Verse") + self.assertEqual(verses[0].discovery_endpoints, (ready["endpoint"],)) + self.assertEqual(verses[0].authority_runtime_ids, ("daemon-test-peer",)) + self.assertEqual([verse.to_wire() for verse in verses], ready["verses"]) + + peers = discovery_client.fetch_peers(verse_id="daemon-verse", roles=["shard-primary"]) + self.assertEqual(peers[0].peer_id, "daemon-test-peer") + self.assertEqual(peers[0].endpoints, (ready["endpoint"],)) + self.assertEqual(peers[0].roles, ("shard-primary",)) + self.assertEqual(peers[0].shard_ids, ("daemon-interop",)) + self.assertEqual([peer.to_wire() for peer in peers], ready["peers"]) + + monitor = CultMeshPeerHealthMonitor(runtime_id="daemon-health-prober", timeout_seconds=2.0) + health = monitor.probe_peer(peers[0])[0] + self.assertTrue(health.is_reachable) + self.assertEqual(health.runtime_id, "daemon-test-peer") + self.assertEqual(health.supported_document_types, ("cultcache.interop-note",)) + self.assertEqual(health.supported_mutation_contracts[0]["documentType"], "cultcache.interop-note") + finally: + process.terminate() + try: + process.communicate(timeout=5.0) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=5.0) + + def test_cultmesh_daemon_restarts_with_persisted_cache_and_shard_log(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + cache_path = root / "mesh.cc" + shard_log_path = root / "shard-log" + env = dict(os.environ) + package_src = Path(__file__).resolve().parents[1] / "src" + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + + first_ready = root / "first-ready.json" + first = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "durable-daemon-peer", + "--port", + "0", + "--cache-file", + str(cache_path), + "--enable-durable-shard-logs", + "--shard-log-file", + str(shard_log_path), + "--seed-interop-note", + "--seed-shard-id", + "durable-interop", + "--ready-file", + str(first_ready), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + self._wait_for_ready_file(first, first_ready) + finally: + self._terminate_process(first) + + second_ready = root / "second-ready.json" + second = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "durable-daemon-peer", + "--port", + "0", + "--cache-file", + str(cache_path), + "--enable-durable-shard-logs", + "--shard-log-file", + str(shard_log_path), + "--register-interop-note", + "--verse-id", + "durable-verse", + "--role", + "shard-primary", + "--ready-file", + str(second_ready), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + ready = self._wait_for_ready_file(second, second_ready) + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + + snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) + self.assertEqual(snapshot_response.documents[0].record_key, "note:durable-daemon-peer") + note = interop_note_document.decode_payload(snapshot_response.documents[0].payload) + self.assertEqual(note["authorRuntimeId"], "durable-daemon-peer") + + shard_log = client.fetch_shard_log_response(shard_id="durable-interop", shard_epoch=1) + self.assertFalse(shard_log.resync_required) + self.assertEqual(shard_log.entries[0].sequence, 1) + self.assertIsNotNone(shard_log.entries[0].raw_document) + assert shard_log.entries[0].raw_document is not None + self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:durable-daemon-peer") + + discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + peers = discovery_client.fetch_peers(verse_id="durable-verse", roles=["shard-primary"]) + self.assertEqual(peers[0].shard_ids, ("durable-interop",)) + finally: + self._terminate_process(second) + + def test_cultmesh_daemon_serves_live_database_subscription_mutations(self) -> None: + with tempfile.TemporaryDirectory() as temp: + ready_path = Path(temp) / "ready.json" + package_src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "live-daemon-peer", + "--port", + "0", + "--seed-interop-note", + "--seed-shard-id", + "live-interop", + "--ready-file", + str(ready_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + ready = self._wait_for_ready_file(process, ready_path) + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + live_note = { + "schemaVersion": INTEROP_SCHEMA_VERSION, + "documentId": "note:daemon-live", + "authorRuntimeId": "daemon-live-client", + "title": "daemon live subscription note", + "body": "live subscription put from Python daemon smoke", + "tags": ["daemon", "subscription", "interop"], + } + + with client.subscribe_database( + subscription_id="daemon-live-sub", + schema_ids=["cultcache.interop-note"], + ) as subscription: + snapshot = subscription.read_next_snapshot_response() + subscription.send(document_put_raw( + message_id="daemon-live-put", + key="note:daemon-live", + schema_id="cultcache.interop-note", + stored_at="2026-06-14T00:00:00Z", + payload=interop_note_document.encode_payload(live_note), + source_runtime_id="daemon-live-client", + shard_id="live-interop", + shard_epoch=1, + )) + change = subscription.read_next_change() + + self.assertEqual(snapshot.documents[0].record_key, "note:live-daemon-peer") + self.assertEqual(change.change_kind, "added") + self.assertEqual(change.record_key, "note:daemon-live") + self.assertIsNotNone(change.raw_document) + assert change.raw_document is not None + self.assertEqual( + interop_note_document.decode_payload(change.raw_document.payload), + live_note, + ) + + shard_log = client.fetch_shard_log_response(shard_id="live-interop", shard_epoch=1) + self.assertFalse(shard_log.resync_required) + self.assertEqual([entry.sequence for entry in shard_log.entries], [1, 2]) + self.assertEqual(shard_log.entries[1].change_kind, "added") + self.assertIsNotNone(shard_log.entries[1].raw_document) + assert shard_log.entries[1].raw_document is not None + self.assertEqual(shard_log.entries[1].raw_document.record_key, "note:daemon-live") + finally: + self._terminate_process(process) + + def test_cultmesh_daemon_serves_compacted_shard_log_resync_boundary(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + cache_path = root / "mesh.cc" + shard_log_path = root / "shard-log" + store = CultNetFileShardMutationLogStore(shard_log_path) + node = CultMesh.create_node( + cache_path, + runtime_id="compacted-daemon-peer", + enable_durable_shard_logs=True, + shard_log_path=shard_log_path, + ) + node.database.register_document(interop_note_document) + node.database.put_raw_message( + interop_note_document, + "note:compacted-one", + { + "schemaVersion": INTEROP_SCHEMA_VERSION, + "documentId": "note:compacted-one", + "authorRuntimeId": "compacted-daemon-peer", + "title": "compacted daemon note one", + "body": "first retained cache value", + "tags": ["daemon", "compaction"], + }, + message_id="compacted-put-1", + shard_id="compacted-interop", + shard_epoch=7, + ) + node.database.put_raw_message( + interop_note_document, + "note:compacted-two", + { + "schemaVersion": INTEROP_SCHEMA_VERSION, + "documentId": "note:compacted-two", + "authorRuntimeId": "compacted-daemon-peer", + "title": "compacted daemon note two", + "body": "second retained cache value", + "tags": ["daemon", "compaction"], + }, + message_id="compacted-put-2", + shard_id="compacted-interop", + shard_epoch=7, + ) + store.compact_through("compacted-interop", 1) + + ready_path = root / "ready.json" + package_src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "compacted-daemon-peer", + "--port", + "0", + "--cache-file", + str(cache_path), + "--enable-durable-shard-logs", + "--shard-log-file", + str(shard_log_path), + "--register-interop-note", + "--ready-file", + str(ready_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + ready = self._wait_for_ready_file(process, ready_path) + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + + snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) + self.assertEqual( + {document.record_key for document in snapshot_response.documents}, + {"note:compacted-one", "note:compacted-two"}, + ) + + compacted = client.fetch_shard_log_response( + shard_id="compacted-interop", + shard_epoch=7, + after_sequence=0, + ) + retained = client.fetch_shard_log_response( + shard_id="compacted-interop", + shard_epoch=7, + after_sequence=1, + ) + + self.assertTrue(compacted.resync_required) + self.assertEqual(compacted.reason, "compacted_history") + self.assertEqual(compacted.compacted_through, 1) + self.assertEqual(compacted.entries, ()) + self.assertFalse(retained.resync_required) + self.assertEqual([entry.sequence for entry in retained.entries], [2]) + self.assertIsNotNone(retained.entries[0].raw_document) + assert retained.entries[0].raw_document is not None + self.assertEqual(retained.entries[0].raw_document.record_key, "note:compacted-two") + finally: + self._terminate_process(process) + + def test_cultmesh_daemon_accepts_schema_write_forwarder_mutations(self) -> None: + with tempfile.TemporaryDirectory() as temp: + ready_path = Path(temp) / "ready.json" + package_src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "forwarder-daemon-peer", + "--port", + "0", + "--register-interop-note", + "--ready-file", + str(ready_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + ready = self._wait_for_ready_file(process, ready_path) + endpoint = f"cultnet://127.0.0.1:{ready['port']}" + shard = CultNetShardDescriptor( + shard_id="forwarded-interop", + owner_runtime_id="forwarder-daemon-peer", + epoch=3, + is_primary=False, + schema_ids=("cultcache.interop-note",), + primary_endpoints=(endpoint,), + ) + forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0) + note = { + "schemaVersion": INTEROP_SCHEMA_VERSION, + "documentId": "note:forwarded-daemon", + "authorRuntimeId": "forwarding-client", + "title": "forwarded daemon note", + "body": "forwarded through the schema write-forwarder", + "tags": ["daemon", "forwarded"], + } + + forwarder.forward_put(shard, document_put_raw( + message_id="forwarded-daemon-put", + key="note:forwarded-daemon", + schema_id="cultcache.interop-note", + stored_at="2026-06-14T00:00:00Z", + payload=interop_note_document.encode_payload(note), + source_runtime_id="forwarding-client", + ).to_wire()) + + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + self._eventually(lambda: bool(client.fetch_snapshot_response( + schema_ids=["cultcache.interop-note"], + record_keys=["note:forwarded-daemon"], + ).documents)) + snapshot = client.fetch_snapshot_response( + schema_ids=["cultcache.interop-note"], + record_keys=["note:forwarded-daemon"], + ) + self.assertEqual( + interop_note_document.decode_payload(snapshot.documents[0].payload), + note, + ) + + forwarder.forward_delete(shard, document_delete( + message_id="forwarded-daemon-delete", + schema_id="cultcache.interop-note", + record_key="note:forwarded-daemon", + ).to_wire()) + self._eventually(lambda: not client.fetch_snapshot_response( + schema_ids=["cultcache.interop-note"], + record_keys=["note:forwarded-daemon"], + ).documents) + + shard_log = client.fetch_shard_log_response(shard_id="forwarded-interop", shard_epoch=3) + self.assertEqual([entry.change_kind for entry in shard_log.entries], ["added", "removed"]) + self.assertEqual(shard_log.entries[0].put["shardId"], "forwarded-interop") + self.assertEqual(shard_log.entries[0].put["shardEpoch"], 3) + self.assertEqual(shard_log.entries[1].delete["shardId"], "forwarded-interop") + self.assertEqual(shard_log.entries[1].delete["shardEpoch"], 3) + finally: + self._terminate_process(process) + + def test_cultmesh_daemon_enforces_snapshot_document_limit_as_peer_error(self) -> None: + with tempfile.TemporaryDirectory() as temp: + ready_path = Path(temp) / "ready.json" + package_src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "limited-daemon-peer", + "--port", + "0", + "--seed-interop-note", + "--max-snapshot-documents", + "0", + "--ready-file", + str(ready_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + ready = self._wait_for_ready_file(process, ready_path) + self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": 0}) + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + + with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded") as raised: + client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) + + self.assertEqual(raised.exception.response["schemaVersion"], "cultnet.error.v0") + self.assertEqual(raised.exception.response["code"], "snapshot_document_limit_exceeded") + self.assertEqual(raised.exception.response["details"]["documentCount"], 1) + self.assertEqual(raised.exception.response["details"]["maxSnapshotDocuments"], 0) + finally: + self._terminate_process(process) + + def test_cultmesh_daemon_serves_opt_in_simulation_observations(self) -> None: + with tempfile.TemporaryDirectory() as temp: + ready_path = Path(temp) / "ready.json" + package_src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "simulation-daemon-peer", + "--port", + "0", + "--enable-simulation-observations", + "--simulation-minimum-witnesses", + "2", + "--simulation-quorum-ratio", + "1.0", + "--verse-id", + "simulation-verse", + "--ready-file", + str(ready_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + ready = self._wait_for_ready_file(process, ready_path) + self.assertIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"]) + self.assertIn("cultnet.simulation_consensus_candidate.v0", ready["supportedMessageVersions"]) + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + hello_response = client.request( + hello(runtime_id="simulation-daemon-prober"), + expected_schema_version="cultnet.hello.v0", + ) + self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"]) + discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + peers = discovery_client.fetch_peers(verse_id="simulation-verse", roles=["simulation-observer"]) + self.assertEqual(peers[0].peer_id, "simulation-daemon-peer") + self.assertIn("simulation-observer", peers[0].roles) + self.assertIn("read-replica", peers[0].roles) + + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") + first = client.request( + simulation_observation( + message_id="daemon-sim-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 = client.request( + simulation_observation( + message_id="daemon-sim-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", + ) + + self.assertEqual(first["messageId"], "daemon-sim-1") + self.assertFalse(first["hasQuorum"]) + self.assertEqual(first["witnessCount"], 1) + self.assertEqual(second["messageId"], "daemon-sim-2") + self.assertTrue(second["hasQuorum"]) + self.assertEqual(second["witnessCount"], 2) + self.assertEqual(second["claimHash"], claim_hash) + finally: + self._terminate_process(process) + + def _wait_for_ready_file(self, process: subprocess.Popen[str], ready_path: Path) -> dict[str, object]: + deadline = time.monotonic() + 5.0 + while not ready_path.exists() and time.monotonic() < deadline: + if process.poll() is not None: + break + time.sleep(0.05) + if not ready_path.exists(): + _, stderr = process.communicate(timeout=2.0) + self.fail(f"cultmesh daemon did not publish readiness: {stderr}") + return json.loads(ready_path.read_text(encoding="utf-8")) + + def _terminate_process(self, process: subprocess.Popen[str]) -> None: + process.terminate() + try: + process.communicate(timeout=5.0) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=5.0) + + def test_cultmesh_local_server_serves_node_and_catalogs_over_clients(self) -> None: + document = define_database_entry_type( + "mesh.server_note", + [("body", 0)], + schema_id="mesh.server_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-server") + node.database.register_document(document) + node.database.put_raw_message(document, "note:1", {"body": "served"}, shard_id="notes", shard_epoch=1) + observation_hub = CultNetSimulationObservationHub( + CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0) + ) + seen_candidates: list[CultNetSimulationConsensusCandidate] = [] + unsubscribe_candidates = observation_hub.watch_candidates(seen_candidates.append) + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") + verses = CultMeshVerseCatalog() + verses.upsert( + CultMeshVerseDescriptor( + verse_id="server-verse", + display_name="Server Verse", + authority_model="local", + compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules"), + ) + ) + peers = CultMeshPeerCatalog() + peers.upsert( + CultMeshPeerCard( + peer_id="mesh-server", + verse_id="server-verse", + endpoints=("cultnet://127.0.0.1:0",), + roles=("read-replica",), + ) + ) + + server = CultMesh.serve_node( + node, + verse_catalog=verses, + peer_catalog=peers, + observation_hub=observation_hub, + display_name="Mesh Server", + ) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + hello_response = raw_client.request(hello(runtime_id="probe"), expected_schema_version="cultnet.hello.v0") + schema_response = raw_client.fetch_schema_catalog(schema_ids=["mesh.server_note.v1"], include_schema_json=True) + rudp_client = CultMesh.create_client( + endpoint=f"rudp://127.0.0.1:{server.port}", + timeout_seconds=2.0, + connection_id=server.rudp_connection_id, + runtime_id="rudp-probe", + ) + rudp_hello_response = rudp_client.request( + hello(runtime_id="rudp-probe"), + expected_schema_version="cultnet.hello.v0", + ) + rudp_schema_response = rudp_client.fetch_schema_catalog( + schema_ids=["mesh.server_note.v1"], + include_schema_json=True, + ) + wire_schema_response = raw_client.fetch_schema_catalog(kinds=["wire_message"], include_schema_json=True) + synced_schema_catalog = CultNetSchemaCatalog() + synced_wire_descriptors = raw_client.sync_schema_catalog( + synced_schema_catalog, + kinds=["wire_message"], + include_schema_json=True, + ) + snapshot_response = raw_client.fetch_snapshot(schema_ids=["mesh.server_note.v1"]) + typed_snapshot_response = raw_client.fetch_snapshot_response(schema_ids=["mesh.server_note.v1"]) + shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.server_note.v1"]) + synced_shard_catalog = CultNetShardCatalog() + synced_shards = raw_client.sync_shard_catalog(synced_shard_catalog, schema_ids=["mesh.server_note.v1"]) + shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=1) + typed_shard_log = raw_client.fetch_shard_log_response(shard_id="notes", shard_epoch=1) + self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed") + self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], server.port) + stale_shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=0) + discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0) + fetched_verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") + fetched_peers = discovery_client.fetch_peers(verse_id="server-verse", roles=["read-replica"]) + first_candidate = raw_client.request( + simulation_observation( + message_id="obs-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", + ) + quorum_candidate = raw_client.request( + simulation_observation( + message_id="obs-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", + ) + with raw_client.subscribe_database(subscription_id="sub-server", schema_ids=["mesh.server_note.v1"]) as subscription: + subscription_snapshot = subscription.read_next_snapshot_response() + subscription.send(document_put_raw( + message_id="server-put", + key="note:2", + schema_id="mesh.server_note.v1", + stored_at="2026-06-14T00:00:00Z", + payload=document.encode_payload({"body": "subscribed"}), + shard_id="primary", + shard_epoch=1, + )) + subscription_change = subscription.read_next_change() + subscription.send(document_delete( + message_id="server-delete", + schema_id="mesh.server_note.v1", + record_key="note:2", + shard_id="primary", + shard_epoch=1, + )) + subscription_delete = subscription.read_next_change() + finally: + unsubscribe_candidates() + server.stop() + + self.assertEqual(hello_response["runtimeId"], "mesh-server") + self.assertEqual(hello_response["displayName"], "Mesh Server") + self.assertIn("cultnet.database_subscribe.v0", hello_response["supportedMessageVersions"]) + self.assertIn("cultnet.error.v0", hello_response["supportedMessageVersions"]) + self.assertIn("cultnet.document_put_raw.v0", hello_response["supportedMessageVersions"]) + self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) + self.assertEqual(hello_response["supportedMutationContracts"][0]["documentType"], "mesh.server_note") + self.assertIn("documentDelete", hello_response["supportedMutationContracts"][0]["operations"]) + self.assertIn("shardLog", hello_response["supportedMutationContracts"][0]["operations"]) + advertised_transports = { + transport["protocol"]: transport + for profile in hello_response["transportProfiles"] + for transport in profile["transports"] + } + self.assertEqual(advertised_transports["tcp_framed"]["port"], server.port) + self.assertEqual(advertised_transports["rudp"]["port"], server.port) + self.assertEqual(rudp_hello_response["runtimeId"], "mesh-server") + self.assertEqual(rudp_schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1") + self.assertEqual(schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1") + self.assertIn("schemaJson", schema_response["schemas"][0]) + self.assertIn("cultnet.document_delete.v0", schema_response["schemas"][0]["wireContracts"]) + self.assertIn("cultnet.shard_log_response.v0", schema_response["schemas"][0]["wireContracts"]) + wire_descriptors = {schema["schemaVersion"]: schema for schema in wire_schema_response["schemas"]} + self.assertEqual(wire_descriptors["cultnet.error.v0"]["kind"], "wire_message") + self.assertEqual(wire_descriptors["cultnet.document_put_raw.v0"]["kind"], "wire_message") + self.assertIn("schemaJson", wire_descriptors["cultnet.document_put_raw.v0"]) + self.assertIn("cultmesh.peer_exchange_response.v0", wire_descriptors) + self.assertIn("cultnet.error.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors}) + self.assertIn("cultnet.document_put_raw.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors}) + self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json")) + self.assertEqual(snapshot_response["documents"][0]["recordKey"], "note:1") + self.assertEqual(typed_snapshot_response.documents[0].record_key, "note:1") + self.assertEqual(typed_snapshot_response.documents[0].schema_id, "mesh.server_note.v1") + self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["mesh.server_note.v1"]) + self.assertEqual(shard_catalog["shards"][0]["epoch"], 1) + self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes") + self.assertEqual(synced_shards[0].shard_id, "notes") + self.assertEqual(synced_shards[0].epoch, 1) + self.assertEqual(synced_shard_catalog.get("notes"), synced_shards[0]) + self.assertEqual(shard_log["entries"][0]["changeKind"], "added") + self.assertTrue(stale_shard_log["resyncRequired"]) + self.assertEqual(stale_shard_log["reason"], "stale_epoch") + self.assertEqual(stale_shard_log["shardEpoch"], 1) + self.assertEqual(stale_shard_log["entries"], []) + self.assertEqual(typed_shard_log.last_sequence, 1) + self.assertEqual(typed_shard_log.entries[0].put["document"]["recordKey"], "note:1") + self.assertIsInstance(typed_shard_log.entries[0].raw_document, CultNetRawDocumentRecord) + self.assertEqual(typed_shard_log.entries[0].raw_document.record_key, "note:1") + self.assertEqual(fetched_verses[0].verse_id, "server-verse") + self.assertEqual(fetched_peers[0].peer_id, "mesh-server") + self.assertEqual(first_candidate["messageId"], "obs-1") + self.assertFalse(first_candidate["hasQuorum"]) + self.assertEqual(quorum_candidate["messageId"], "obs-2") + self.assertEqual(quorum_candidate["claimHash"], claim_hash) + self.assertTrue(quorum_candidate["hasQuorum"]) + self.assertEqual(quorum_candidate["witnessCount"], 2) + self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash]) + self.assertIsInstance(subscription_snapshot, CultNetRawSnapshotResponse) + self.assertEqual(subscription_snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0") + self.assertEqual(subscription_change.change_kind, "added") + self.assertEqual(subscription_change.record_key, "note:2") + self.assertEqual(subscription_change.document["recordKey"], "note:2") + self.assertIsInstance(subscription_change.raw_document, CultNetRawDocumentRecord) + self.assertEqual(subscription_change.raw_document.schema_id, "mesh.server_note.v1") + self.assertEqual(subscription_delete.change_kind, "removed") + self.assertEqual(subscription_delete.record_key, "note:2") + self.assertIsNone(subscription_delete.raw_document) + self.assertIsNone(node.database.get(document, "note:2")) + + def test_cultmesh_local_server_resolves_snapshot_and_shard_catalog_schema_aliases(self) -> None: + document = define_database_entry_type( + "mesh.alias_server_note", + [("body", 0)], + schema_id="sha256:mesh-alias-server-note", + schema_name="mesh.alias_server_note", + schema_version="mesh.alias_server_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-server") + node.database.register_document(document) + node.database.put_raw_message( + document, + "note:alias", + {"body": "served through alias"}, + shard_id="notes", + shard_epoch=1, + ) + + server = CultMesh.serve_node(node) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + snapshot = raw_client.fetch_snapshot(schema_ids=["mesh.alias_server_note.v1"]) + typed_snapshot = raw_client.fetch_snapshot_response(schema_ids=["mesh.alias_server_note.v1"]) + shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.alias_server_note.v1"]) + finally: + server.stop() + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(snapshot["documents"][0]["schemaId"], local_schema_id) + self.assertEqual(snapshot["documents"][0]["recordKey"], "note:alias") + self.assertEqual(typed_snapshot.documents[0].schema_id, local_schema_id) + self.assertEqual(shard_catalog["shards"][0]["schemaIds"], [local_schema_id]) + self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes") + + def test_cultmesh_shard_snapshot_keeps_foreign_schema_alias_membership(self) -> None: + document = define_database_entry_type( + "mesh.alias_shard_note", + [ + ("schema_version", 0), + ("body", 1), + ], + schema_id="sha256:mesh-alias-shard-note", + schema_name="mesh.alias_shard_note", + schema_version="mesh.alias_shard_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-shard-server") + node.database.register_document(document) + + node.database.apply_raw_put_message(document_put_raw( + message_id="put-foreign-shard-alias", + key="note:foreign-shard", + schema_id="runtime.generated.mesh.alias_shard_note.ui.99", + stored_at="2026-06-27T00:00:00Z", + payload=document.encode_payload({ + "schema_version": "mesh.alias_shard_note.v1", + "body": "visible through shard snapshot", + }), + shard_id="notes", + shard_epoch=1, + ).to_wire()) + node.database.put_raw_message( + document, + "note:other-shard", + { + "schema_version": "mesh.alias_shard_note.v1", + "body": "hidden by shard filter", + }, + shard_id="other", + shard_epoch=1, + ) + + response = node.database.build_snapshot_response( + schema_ids=["mesh.alias_shard_note.v1"], + shard_id="notes", + shard_epoch=1, + ) + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(response.shard_id, "notes") + self.assertEqual(response.shard_epoch, 1) + self.assertEqual([(record.schema_id, record.record_key) for record in response.documents], [ + (local_schema_id, "note:foreign-shard"), + ]) + self.assertEqual(node.cache.get_required_envelope(document, "note:foreign-shard").schema_id, local_schema_id) + + node.database.apply_raw_delete_message(document_delete( + message_id="delete-foreign-shard-alias", + schema_id="mesh.alias_shard_note.v1", + record_key="note:foreign-shard", + shard_id="notes", + shard_epoch=1, + ).to_wire()) + deleted_response = node.database.build_snapshot_response( + schema_ids=["mesh.alias_shard_note.v1"], + shard_id="notes", + shard_epoch=1, + ) + self.assertEqual(deleted_response.documents, ()) + + def test_cultmesh_local_server_notifies_schema_alias_subscription_for_raw_put(self) -> None: + document = define_database_entry_type( + "mesh.alias_sub_note", + [ + ("schema_version", 0), + ("body", 1), + ], + schema_id="sha256:mesh-alias-sub-note", + schema_name="mesh.alias_sub_note", + schema_version="mesh.alias_sub_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-sub-server") + node.database.register_document(document) + + server = CultMesh.serve_node(node) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + with raw_client.subscribe_database( + subscription_id="sub-alias", + schema_ids=["mesh.alias_sub_note.v1"], + include_snapshot=False, + ) as subscription: + subscription.send(document_put_raw( + message_id="put-foreign-alias", + key="note:alias-sub", + schema_id="runtime.generated.mesh.alias_sub_note.ui.99", + stored_at="2026-06-27T00:00:00Z", + payload=document.encode_payload({ + "schema_version": "mesh.alias_sub_note.v1", + "body": "notified", + }), + )) + change = subscription.read_next_change() + finally: + server.stop() + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(change.subscription_id, "sub-alias") + self.assertEqual(change.change_kind, "added") + self.assertEqual(change.record_key, "note:alias-sub") + self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord) + self.assertEqual(change.raw_document.schema_id, "runtime.generated.mesh.alias_sub_note.ui.99") + self.assertEqual(node.database.get_required(document, "note:alias-sub")["body"], "notified") + self.assertEqual(node.cache.get_required_envelope(document, "note:alias-sub").schema_id, local_schema_id) + + def test_cultmesh_local_server_notifies_schema_alias_subscription_for_delete(self) -> None: + document = define_database_entry_type( + "mesh.alias_delete_note", + [("body", 0)], + schema_id="sha256:mesh-alias-delete-note", + schema_name="mesh.alias_delete_note", + schema_version="mesh.alias_delete_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-delete-server") + node.database.register_document(document) + node.database.put_raw_message( + document, + "note:delete-alias", + {"body": "remove me"}, + shard_id="notes", + shard_epoch=1, + ) + + server = CultMesh.serve_node(node) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + with raw_client.subscribe_database( + subscription_id="sub-delete-alias", + schema_ids=[document.catalog_entry().schema_id], + include_snapshot=False, + ) as subscription: + subscription.send(document_delete( + message_id="delete-foreign-alias", + schema_id="mesh.alias_delete_note.v1", + record_key="note:delete-alias", + shard_id="notes", + shard_epoch=1, + )) + change = subscription.read_next_change() + finally: + server.stop() + + self.assertEqual(change.subscription_id, "sub-delete-alias") + self.assertEqual(change.change_kind, "removed") + self.assertEqual(change.schema_id, "mesh.alias_delete_note.v1") + self.assertEqual(change.record_key, "note:delete-alias") + self.assertIsNone(change.raw_document) + self.assertIsNone(node.database.get(document, "note:delete-alias")) + + def test_cultmesh_local_server_rejects_oversized_snapshot_responses(self) -> None: + document = define_database_entry_type( + "mesh.snapshot_limit_note", + [("body", 0)], + schema_id="mesh.snapshot_limit_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-snapshot-limits") + node.database.register_document(document) + node.database.put_raw_message(document, "note:1", {"body": "first"}) + node.database.put_raw_message(document, "note:2", {"body": "second"}) + + server = CultMesh.serve_node(node, max_snapshot_documents=1) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + filtered_snapshot = raw_client.fetch_snapshot(record_keys=["note:1"]) + error = raw_client.request( + snapshot_request(schema_ids=["mesh.snapshot_limit_note.v1"]), + expected_schema_version="cultnet.error.v0", + ) + with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded"): + raw_client.fetch_snapshot(schema_ids=["mesh.snapshot_limit_note.v1"]) + finally: + server.stop() + + self.assertEqual(filtered_snapshot["schemaVersion"], "cultnet.snapshot_response_raw.v0") + self.assertEqual([document["recordKey"] for document in filtered_snapshot["documents"]], ["note:1"]) + self.assertEqual(error["schemaVersion"], "cultnet.error.v0") + self.assertIn("Snapshot document limit exceeded", error["error"]) + self.assertEqual(error["code"], "snapshot_document_limit_exceeded") + self.assertEqual(error["details"]["documentCount"], 2) + self.assertEqual(error["details"]["maxSnapshotDocuments"], 1) + + def test_cultmesh_local_server_returns_errors_for_bad_requests(self) -> None: + document = define_database_entry_type( + "mesh.error_note", + [("body", 0)], + schema_id="mesh.error_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-error-server") + node.database.register_document(document) + + server = CultMesh.serve_node(node) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + unsupported = raw_client.request( + {"schemaVersion": "cultnet.nope.v0", "messageId": "bad-schema"}, + expected_schema_version="cultnet.error.v0", + ) + malformed_put = raw_client.request( + {"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "bad-put"}, + expected_schema_version="cultnet.error.v0", + ) + malformed_delete = raw_client.request( + {"schemaVersion": "cultnet.document_delete.v0", "messageId": "bad-delete", "schemaId": "mesh.error_note.v1"}, + expected_schema_version="cultnet.error.v0", + ) + finally: + server.stop() + + self.assertEqual(unsupported["messageId"], "bad-schema") + self.assertEqual(unsupported["code"], "unsupported_schema_version") + self.assertEqual(unsupported["details"]["schemaVersion"], "cultnet.nope.v0") + self.assertIn("Unsupported CultNet message schema", unsupported["error"]) + self.assertEqual(malformed_put["messageId"], "bad-put") + self.assertEqual(malformed_put["code"], "malformed_document_put") + self.assertEqual(malformed_delete["messageId"], "bad-delete") + self.assertEqual(malformed_delete["code"], "malformed_document_delete") + + def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None: + peer = CultMeshPeerCard( + peer_id="voidbot-local", + verse_id="local", + endpoints=("cultmesh://localhost",), + roles=("shard-primary",), + authority_lease_id="lease:voidbot-local", + ) + leases = CultMeshAuthorityLeaseCatalog() + peers = CultMeshPeerCatalog() + peers.upsert(peer) + now = datetime.now(UTC) + self.assertFalse(leases.is_authorized(peer, "shard-primary", at=now)) + self.assertEqual(peers.find_authorized("local", "shard-primary", leases, at=now), []) + + leases.upsert( + CultMeshAuthorityLease( + lease_id="lease:voidbot-local", + verse_id="local", + peer_id="voidbot-local", + roles=("shard-primary", "shard-primary", ""), + valid_from=now - timedelta(seconds=1), + expires_at=now + timedelta(seconds=1), + shard_ids=("shard-a",), + issuer_runtime_id="odin", + signature="signed", + ) + ) + + lease = leases.get("lease:voidbot-local") + self.assertIsNotNone(lease) + assert lease is not None + self.assertEqual(leases.leases, (lease,)) + self.assertEqual(lease.roles, ("shard-primary",)) + self.assertEqual(lease.issuer_runtime_id, "odin") + self.assertEqual(lease.signature, "signed") + self.assertTrue(lease.is_valid_at(now)) + self.assertTrue(lease.covers(peer, "shard-primary", shard_id="shard-a", at=now)) + self.assertFalse(lease.covers(peer, "shard-primary", shard_id="shard-b", at=now)) + self.assertTrue(leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) + self.assertEqual( + peers.find_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now), + [peer], + ) + self.assertEqual( + peers.first_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now), + peer, + ) + self.assertIsNone(peers.first_authorized("local", "read-replica", leases, at=now)) + self.assertFalse(leases.is_authorized(peer, "shard-primary", shard_id="shard-b", at=now)) + self.assertFalse(leases.is_authorized(peer, "read-replica", at=now)) + self.assertIsNone(leases.get("missing")) + + def test_cultmesh_authority_lease_catalog_can_require_verified_signatures(self) -> None: + peer = CultMeshPeerCard( + peer_id="voidbot-local", + verse_id="local", + endpoints=("cultmesh://localhost",), + roles=("shard-primary",), + shard_ids=("shard-a",), + authority_lease_id="lease:voidbot-local", + ) + now = datetime.now(UTC) + unsigned = CultMeshAuthorityLease( + lease_id="lease:voidbot-local", + verse_id="local", + peer_id="voidbot-local", + roles=("shard-primary",), + valid_from=now - timedelta(seconds=1), + expires_at=now + timedelta(seconds=30), + shard_ids=("shard-a",), + issuer_runtime_id="odin", + ) + verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"lease-secret"}) + signed = verifier.issue(unsigned) + strict_leases = CultMesh.create_authority_lease_catalog( + signature_verifier=verifier.verify, + require_verified_signatures=True, + ) + strict_leases.upsert(unsigned) + self.assertFalse(strict_leases.is_verified(unsigned.lease_id)) + self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) + + strict_leases.upsert(signed) + self.assertTrue(strict_leases.is_verified(signed.lease_id)) + self.assertTrue(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) + + wrong_key = CultMesh.create_authority_lease_catalog( + signature_verifier=CultMeshHmacAuthorityLeaseVerifier({"odin": b"wrong-secret"}).verify, + require_verified_signatures=True, + ) + wrong_key.upsert(signed) + self.assertFalse(wrong_key.is_verified(signed.lease_id)) + self.assertFalse(wrong_key.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) + + tampered = CultMeshAuthorityLease( + lease_id=signed.lease_id, + verse_id=signed.verse_id, + peer_id=signed.peer_id, + roles=("shard-primary", "read-replica"), + valid_from=signed.valid_from, + expires_at=signed.expires_at, + shard_ids=signed.shard_ids, + issuer_runtime_id=signed.issuer_runtime_id, + signature=signed.signature, + ) + strict_leases.upsert(tampered) + self.assertFalse(strict_leases.is_verified(tampered.lease_id)) + self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) + + def test_cultmesh_stream_catalog_negotiates_transport_and_latest_frame(self) -> None: + streams = CultMeshStreamCatalog() + streams.declare( + CultMeshStreamDescriptor( + stream_id="mimir:kiyo-pro", + verse_id="studio", + owner_peer_id="starfire", + kind="video", + label="Kiyo Pro", + clock={"clockDomainId": "starfire-qpc", "confidence": 0.25}, + video={"width": 1920, "height": 1080, "pixelFormat": "YUY2", "framesPerSecond": 30}, + preferred_transports=("shared-d3d12-texture", "shared-memory", "cultcache-page"), + max_in_flight_frames=3, + ) + ) + + negotiation = streams.negotiate( + "mimir:kiyo-pro", + CultMeshStreamConsumerProfile( + peer_id="fensalir", + verse_id="studio", + supported_transports=("shared-d3d12-texture", "cultcache-page"), + accepted_kinds=("video",), + can_import_gpu_handles=True, + max_in_flight_frames=2, + ), + ) + + self.assertEqual(negotiation.transport, "shared-d3d12-texture") + self.assertEqual(negotiation.max_in_flight_frames, 2) + self.assertEqual(negotiation.copy_budget, "zero-copy-target") + + streams.publish_frame( + CultMeshStreamFrameHandle( + stream_id="mimir:kiyo-pro", + sequence=42, + timestamp_ns=1_000_000_000, + duration_ns=33_333_334, + transport="shared-d3d12-texture", + native_handle="0xfeed", + fence_handle="0xbeef", + fence_value=7, + unavoidable_copy_count=0, + ) + ) + self.assertEqual(streams.latest_frame("mimir:kiyo-pro").sequence, 42) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultmesh-py/tools/reactive_performance_probe.py b/packages/cultmesh-py/tools/reactive_performance_probe.py new file mode 100644 index 00000000..86ab9441 --- /dev/null +++ b/packages/cultmesh-py/tools/reactive_performance_probe.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import argparse +import json +import threading +import time +import tracemalloc +from typing import Any + +from cultcache_py import define_document_type +from cultmesh_py import CultMeshReactiveDocumentOptions, create_node + + +def percentile(ordered: list[float], ratio: float) -> float: + if not ordered: + return 0.0 + index = max(0, min(len(ordered) - 1, int(len(ordered) * ratio + 0.999999) - 1)) + return ordered[index] + + +def measure(document_count: int, idle_seconds: float, active_seconds: float) -> dict[str, Any]: + payload = "x" * (16 * 1024) + changed_document_count = max(1, document_count // 100) + document = define_document_type( + f"gamecult.mesh.python_performance_probe_{document_count}", + encode=lambda value: value, + decode=lambda value: value, + ) + node = create_node(runtime_id=f"python-performance-{document_count}") + node.register_document(document) + reactive_documents = [] + publish_events = [threading.Event() for _ in range(document_count)] + update_started_at = [0.0] * document_count + latencies: list[float] = [] + payload_bytes_published = 0 + publishes = 0 + measurement_lock = threading.Lock() + + for index in range(document_count): + key = f"performance:{index}" + node.put(document, key, {"id": key, "payload": payload, "revision": 1}) + writer = node.authoritative_writer(document, key) + original_write = writer.write + + def measured_write(value: Any, document_index: int = index, write=original_write) -> None: + nonlocal payload_bytes_published, publishes + encoded_bytes = len(document.encode_payload(value)) + latency_ms = (time.perf_counter() - update_started_at[document_index]) * 1000 + with measurement_lock: + payload_bytes_published += encoded_bytes + publishes += 1 + latencies.append(latency_ms) + write(value) + publish_events[document_index].set() + + writer.write = measured_write # type: ignore[method-assign] + reactive_documents.append( + writer.reactive(CultMeshReactiveDocumentOptions(flush_delay_seconds=0.0)) + ) + + tracemalloc.start() + idle_cpu_start = time.process_time() + time.sleep(idle_seconds) + idle_current_bytes, idle_peak_bytes = tracemalloc.get_traced_memory() + idle_cpu_ms = (time.process_time() - idle_cpu_start) * 1000 + tracemalloc.reset_peak() + active_current_start, _ = tracemalloc.get_traced_memory() + active_cpu_start = time.process_time() + active_started_at = time.perf_counter() + frames = 0 + while time.perf_counter() - active_started_at < active_seconds: + for index in range(changed_document_count): + publish_events[index].clear() + update_started_at[index] = time.perf_counter() + reactive_documents[index].update( + lambda draft: draft.__setitem__("revision", draft["revision"] + 1) + ) + for index in range(changed_document_count): + if not publish_events[index].wait(timeout=1.0): + raise RuntimeError(f"Python reactive publication timed out for document {index}") + frames += 1 + remaining = frames / 60 - (time.perf_counter() - active_started_at) + if remaining > 0: + time.sleep(remaining) + + active_elapsed_seconds = time.perf_counter() - active_started_at + active_cpu_ms = (time.process_time() - active_cpu_start) * 1000 + active_current_bytes, active_peak_bytes = tracemalloc.get_traced_memory() + tracemalloc.stop() + for reactive in reactive_documents: + reactive.dispose() + ordered = sorted(latencies) + return { + "documentCount": document_count, + "changedDocumentCount": changed_document_count, + "frames": frames, + "expectedPublishes": frames * changed_document_count, + "actualPublishes": publishes, + "payloadBytesPublished": payload_bytes_published, + "idleTracedCurrentBytes": idle_current_bytes, + "idleTracedPeakBytes": idle_peak_bytes, + "idleCpuMilliseconds": idle_cpu_ms, + "activeTracedRetainedGrowthBytes": active_current_bytes - active_current_start, + "activeTracedPeakGrowthBytes": active_peak_bytes - active_current_start, + "activeCpuMilliseconds": active_cpu_ms, + "publishedPayloadBytesPerSecond": payload_bytes_published / active_elapsed_seconds, + "p50PublishLatencyMilliseconds": percentile(ordered, 0.50), + "p95PublishLatencyMilliseconds": percentile(ordered, 0.95), + "p99PublishLatencyMilliseconds": percentile(ordered, 0.99), + "schedulerThreads": sum( + thread.name == "cultmesh-reactive-scheduler" for thread in threading.enumerate() + ), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--quick", action="store_true") + args = parser.parse_args() + idle_seconds = 0.25 if args.quick else 10.0 + active_seconds = 0.5 if args.quick else 10.0 + results = [measure(count, idle_seconds, active_seconds) for count in (1, 100, 1000)] + print(json.dumps({ + "runtime": "python", + "workload": { + "documentCounts": [1, 100, 1000], + "payloadBytes": 16 * 1024, + "idleSeconds": idle_seconds, + "activeSeconds": active_seconds, + "updateRateHz": 60, + "changedFraction": 0.01, + }, + "results": results, + }, indent=2)) + return 0 if all( + result["actualPublishes"] == result["expectedPublishes"] + and result["p99PublishLatencyMilliseconds"] < 250 + and result["activeTracedPeakGrowthBytes"] < 128 * 1024 * 1024 + and result["schedulerThreads"] == 1 + for result in results + ) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/cultmesh-rs/Cargo.lock b/packages/cultmesh-rs/Cargo.lock new file mode 100644 index 00000000..945bc65b --- /dev/null +++ b/packages/cultmesh-rs/Cargo.lock @@ -0,0 +1,1183 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cultcache-rs" +version = "0.2.0" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "uuid", + "windows-sys 0.59.0", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cultmesh-rs" +version = "0.1.0" +dependencies = [ + "anyhow", + "cultcache-rs", + "cultnet-rs", + "pretty_assertions", + "rmp-serde", + "serde", + "tempfile", +] + +[[package]] +name = "cultnet-rs" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "base64", + "chrono", + "cultcache-rs", + "ed25519-dalek", + "hmac", + "rand", + "rmp-serde", + "rmpv", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "socket2", + "uuid", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redb" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36" +dependencies = [ + "libc", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/cultmesh-rs/Cargo.toml b/packages/cultmesh-rs/Cargo.toml new file mode 100644 index 00000000..820050c3 --- /dev/null +++ b/packages/cultmesh-rs/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "cultmesh-rs" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[lib] +name = "cultmesh_rs" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +cultcache-rs = { path = "../cultcache-rs" } +cultnet-rs = { path = "../cultnet-rs" } +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +pretty_assertions = "1.4.1" +rmp-serde = "1" +tempfile = "3" diff --git a/packages/cultmesh-rs/src/lib.rs b/packages/cultmesh-rs/src/lib.rs new file mode 100644 index 00000000..8cfe8cec --- /dev/null +++ b/packages/cultmesh-rs/src/lib.rs @@ -0,0 +1,1609 @@ +use anyhow::{Context, Result}; +use cultcache_rs::CultCache; +use cultcache_rs::CultCacheEnvelope; +use cultcache_rs::CultSoaTable; +use cultcache_rs::DatabaseEntry; +use cultcache_rs::SingleFileMessagePackBackingStore; +use cultcache_rs::SoaDocument; +use cultnet_rs::CultNetDocumentPutOptions; +use cultnet_rs::CultNetDocumentRegistry; +use cultnet_rs::CultNetRudpSocketTransportConnection; +use cultnet_rs::CultNetRudpSocketTransportOptions; +use cultnet_rs::CultNetWireContract; +use cultnet_rs::decode_cultnet_message_from_slice; +use cultnet_rs::encode_cultnet_message_to_vec; +use serde::Serialize; +use std::collections::BTreeMap; +use std::collections::hash_map::RandomState; +use std::env; +use std::hash::{BuildHasher, Hasher}; +use std::net::SocketAddr; +use std::net::UdpSocket; +use std::path::Path; +use std::thread; +use std::time::Duration; +use std::time::Instant; + +mod rudp_document_server; + +pub use rudp_document_server::*; + +pub const CULTMESH_RUDP_DOCUMENT_CATALOG_CONNECTION_ID: u32 = 0x0d1d_0002; + +pub trait CultMeshDocumentSet: Clone + Send + Sync + 'static { + fn register_cache(&self, cache: &mut CultCache) -> Result<()>; + fn register_documents(&self, registry: &mut CultNetDocumentRegistry) -> Result<()>; +} + +#[macro_export] +macro_rules! cultmesh_documents { + ($name:ident { $($entry:ty => $schema_version:expr),* $(,)? }) => { + #[derive(Clone, Copy, Debug, Default)] + pub struct $name; + + impl $crate::CultMeshDocumentSet for $name { + fn register_cache( + &self, + cache: &mut cultcache_rs::CultCache, + ) -> ::anyhow::Result<()> { + $( + cache.register_entry_type::<$entry>()?; + )* + Ok(()) + } + + fn register_documents( + &self, + registry: &mut cultnet_rs::CultNetDocumentRegistry, + ) -> ::anyhow::Result<()> { + $( + registry.register( + cultnet_rs::CultNetDocumentBinding::for_entry_with_schema_id::<$entry>( + $schema_version.to_string(), + $schema_version.to_string(), + ), + ); + )* + Ok(()) + } + } + }; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshNodeOptions { + pub runtime_id: String, + pub pull_on_start: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshRudpDocumentPublishOptions { + pub target: SocketAddr, + pub runtime_id: String, + pub connect_timeout: Duration, + pub flush_timeout: Duration, + pub poll_interval: Duration, + pub resend_delay_ms: u64, + pub source_agent_id: Option, + pub source_role: Option, + pub tags: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshRudpSnapshotOptions { + pub target: SocketAddr, + pub runtime_id: String, + /// Exact correlation id for the SnapshotRequest. Raw callers must supply + /// this; `CultMeshNode::pull_rudp_catalog_snapshot` generates one when it + /// owns the request. + pub message_id: String, + pub connect_timeout: Duration, + pub response_timeout: Duration, + pub poll_interval: Duration, + pub resend_delay_ms: u64, + pub schema_ids: Option>, + pub record_keys: Option>, +} + +impl CultMeshRudpSnapshotOptions { + pub fn odin(target: SocketAddr, runtime_id: impl Into) -> Self { + Self { + target, + runtime_id: runtime_id.into(), + ..Self::default() + } + } +} + +impl Default for CultMeshRudpSnapshotOptions { + fn default() -> Self { + Self { + target: SocketAddr::from(([0, 0, 0, 0], 0)), + runtime_id: "cultmesh-rudp-snapshot-client".to_string(), + message_id: String::new(), + connect_timeout: Duration::from_secs(3), + response_timeout: Duration::from_secs(3), + poll_interval: Duration::from_millis(10), + resend_delay_ms: 50, + schema_ids: None, + record_keys: None, + } + } +} + +impl CultMeshRudpDocumentPublishOptions { + pub fn odin(target: SocketAddr, runtime_id: impl Into) -> Self { + Self { + target, + runtime_id: runtime_id.into(), + ..Self::default() + } + } +} + +impl Default for CultMeshRudpDocumentPublishOptions { + fn default() -> Self { + Self { + target: SocketAddr::from(([0, 0, 0, 0], 0)), + runtime_id: "cultmesh-rudp-document-publisher".to_string(), + connect_timeout: Duration::from_secs(3), + flush_timeout: Duration::from_millis(300), + poll_interval: Duration::from_millis(10), + resend_delay_ms: 50, + source_agent_id: None, + source_role: None, + tags: Vec::new(), + } + } +} + +impl Default for CultMeshNodeOptions { + fn default() -> Self { + Self { + runtime_id: "cultmesh-local".to_string(), + pull_on_start: true, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshRudpEndpoint { + pub host: String, + pub port: u16, + pub addr: SocketAddr, +} + +pub struct CultMeshNode { + runtime_id: String, + cache: CultCache, + documents: CultNetDocumentRegistry, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CultMeshStreamKind { + Audio, + Video, + Tensor, + Bytes, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CultMeshStreamBodyTransport { + SharedMemory, + SharedD3d12Texture, + SharedD3d11Texture, + DmaBuf, + IoSurface, + AHardwareBuffer, + CultCachePage, + InlineBytes, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CultMeshStreamAccess { + Read, + Write, + ReadWrite, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CultMeshStreamCopyBudget { + ZeroCopyTarget, + OneCopyFallback, + OpaqueRuntime, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CultMeshStreamClock { + pub clock_domain_id: String, + pub source_id: Option, + pub sample_rate: i32, + pub offset_to_verse_time_ns: i64, + pub confidence: f64, + pub evidence_kind: Option, +} + +impl CultMeshStreamClock { + pub fn new(clock_domain_id: impl Into) -> Result { + let clock_domain_id = require_non_empty(clock_domain_id.into(), "clock_domain_id")?; + Ok(Self { + clock_domain_id, + source_id: None, + sample_rate: 0, + offset_to_verse_time_ns: 0, + confidence: 0.0, + evidence_kind: None, + }) + } + + pub fn source_id(mut self, source_id: impl Into) -> Result { + self.source_id = Some(require_non_empty(source_id.into(), "source_id")?); + Ok(self) + } + + pub fn confidence(mut self, confidence: f64) -> Self { + self.confidence = confidence; + self + } + + pub fn evidence_kind(mut self, evidence_kind: impl Into) -> Result { + self.evidence_kind = Some(require_non_empty(evidence_kind.into(), "evidence_kind")?); + Ok(self) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CultMeshStreamDescriptor { + pub stream_id: String, + pub verse_id: String, + pub owner_peer_id: String, + pub kind: CultMeshStreamKind, + pub clock: CultMeshStreamClock, + pub preferred_transports: Vec, + pub label: Option, + pub required_access: CultMeshStreamAccess, + pub max_in_flight_frames: u32, + pub metadata_schema_id: Option, +} + +impl CultMeshStreamDescriptor { + pub fn new( + stream_id: impl Into, + verse_id: impl Into, + owner_peer_id: impl Into, + kind: CultMeshStreamKind, + clock: CultMeshStreamClock, + preferred_transports: Vec, + ) -> Result { + if preferred_transports.is_empty() { + anyhow::bail!("preferred_transports must not be empty"); + } + Ok(Self { + stream_id: require_non_empty(stream_id.into(), "stream_id")?, + verse_id: require_non_empty(verse_id.into(), "verse_id")?, + owner_peer_id: require_non_empty(owner_peer_id.into(), "owner_peer_id")?, + kind, + clock, + preferred_transports, + label: None, + required_access: CultMeshStreamAccess::Read, + max_in_flight_frames: 0, + metadata_schema_id: None, + }) + } + + pub fn label(mut self, label: impl Into) -> Result { + self.label = Some(require_non_empty(label.into(), "label")?); + Ok(self) + } + + pub fn max_in_flight_frames(mut self, max_in_flight_frames: u32) -> Self { + self.max_in_flight_frames = max_in_flight_frames; + self + } + + pub fn metadata_schema_id(mut self, metadata_schema_id: impl Into) -> Result { + self.metadata_schema_id = Some(require_non_empty( + metadata_schema_id.into(), + "metadata_schema_id", + )?); + Ok(self) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshStreamConsumerProfile { + pub peer_id: String, + pub verse_id: String, + pub supported_transports: Vec, + pub accepted_kinds: Vec, + pub max_in_flight_frames: u32, +} + +impl CultMeshStreamConsumerProfile { + pub fn new( + peer_id: impl Into, + verse_id: impl Into, + supported_transports: Vec, + ) -> Result { + if supported_transports.is_empty() { + anyhow::bail!("supported_transports must not be empty"); + } + Ok(Self { + peer_id: require_non_empty(peer_id.into(), "peer_id")?, + verse_id: require_non_empty(verse_id.into(), "verse_id")?, + supported_transports, + accepted_kinds: Vec::new(), + max_in_flight_frames: 0, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshStreamNegotiation { + pub stream_id: String, + pub producer_peer_id: String, + pub consumer_peer_id: String, + pub transport: CultMeshStreamBodyTransport, + pub access: CultMeshStreamAccess, + pub max_in_flight_frames: u32, + pub copy_budget: CultMeshStreamCopyBudget, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshStreamFrameHandle { + pub stream_id: String, + pub sequence: u64, + pub timestamp_ns: i64, + pub duration_ns: i64, + pub transport: CultMeshStreamBodyTransport, + pub byte_length: usize, + pub resource_key: Option, + pub unavoidable_copy_count: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshSharedMemoryFrameRingStats { + pub stream_id: String, + pub slot_count: usize, + pub slot_byte_length: usize, + pub published_frames: u64, + pub dropped_frames: u64, + pub blocked_writes: u64, + pub latest_sequence: u64, + pub unavoidable_copy_count: u64, +} + +pub struct CultMeshFrameReadLease<'a> { + pub handle: CultMeshStreamFrameHandle, + bytes: &'a [u8], +} + +impl CultMeshFrameReadLease<'_> { + pub fn bytes(&self) -> &[u8] { + self.bytes + } +} + +#[derive(Clone, Debug)] +pub struct CultMeshSharedMemoryFrameRing { + stream_id: String, + slot_byte_length: usize, + slots: Vec)>>, + write_cursor: usize, + latest_slot: Option, + next_sequence: u64, + published_frames: u64, + dropped_frames: u64, + blocked_writes: u64, + unavoidable_copy_count: u64, +} + +impl CultMeshSharedMemoryFrameRing { + pub fn new( + stream_id: impl Into, + slot_count: usize, + slot_byte_length: usize, + ) -> Result { + let stream_id = require_non_empty(stream_id.into(), "stream_id")?; + if slot_count == 0 { + anyhow::bail!("slot_count must be greater than zero"); + } + if slot_byte_length == 0 { + anyhow::bail!("slot_byte_length must be greater than zero"); + } + Ok(Self { + stream_id, + slot_byte_length, + slots: vec![None; slot_count], + write_cursor: 0, + latest_slot: None, + next_sequence: 0, + published_frames: 0, + dropped_frames: 0, + blocked_writes: 0, + unavoidable_copy_count: 0, + }) + } + + pub fn try_publish_copy( + &mut self, + bytes: &[u8], + timestamp_ns: i64, + duration_ns: i64, + ) -> Result> { + if bytes.len() > self.slot_byte_length { + anyhow::bail!("frame byte length exceeds slot size"); + } + if self.slots.is_empty() { + self.blocked_writes += 1; + return Ok(None); + } + + let slot_index = self.write_cursor; + if self.slots[slot_index].is_some() { + self.dropped_frames += 1; + } + let handle = CultMeshStreamFrameHandle { + stream_id: self.stream_id.clone(), + sequence: self.next_sequence, + timestamp_ns, + duration_ns, + transport: CultMeshStreamBodyTransport::SharedMemory, + byte_length: bytes.len(), + resource_key: Some(format!("{}:slot:{slot_index}", self.stream_id)), + unavoidable_copy_count: 1, + }; + self.slots[slot_index] = Some((handle.clone(), bytes.to_vec())); + self.latest_slot = Some(slot_index); + self.write_cursor = (slot_index + 1) % self.slots.len(); + self.next_sequence = self.next_sequence.saturating_add(1); + self.published_frames = self.published_frames.saturating_add(1); + self.unavoidable_copy_count = self.unavoidable_copy_count.saturating_add(1); + Ok(Some(handle)) + } + + pub fn try_acquire_latest_read(&self) -> Option> { + let slot_index = self.latest_slot?; + let (handle, bytes) = self.slots[slot_index].as_ref()?; + Some(CultMeshFrameReadLease { + handle: handle.clone(), + bytes, + }) + } + + pub fn stats(&self) -> CultMeshSharedMemoryFrameRingStats { + let latest_sequence = self + .latest_slot + .and_then(|slot| self.slots[slot].as_ref().map(|(handle, _)| handle.sequence)) + .unwrap_or(0); + CultMeshSharedMemoryFrameRingStats { + stream_id: self.stream_id.clone(), + slot_count: self.slots.len(), + slot_byte_length: self.slot_byte_length, + published_frames: self.published_frames, + dropped_frames: self.dropped_frames, + blocked_writes: self.blocked_writes, + latest_sequence, + unavoidable_copy_count: self.unavoidable_copy_count, + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct CultMeshStreamCatalog { + streams: BTreeMap, + latest_frames: BTreeMap, + rings: BTreeMap, +} + +impl CultMeshStreamCatalog { + pub fn declare(&mut self, stream: CultMeshStreamDescriptor) { + self.streams.insert(stream.stream_id.clone(), stream); + } + + pub fn get(&self, stream_id: &str) -> Option<&CultMeshStreamDescriptor> { + self.streams.get(stream_id) + } + + pub fn streams(&self) -> Vec<&CultMeshStreamDescriptor> { + self.streams.values().collect() + } + + pub fn find( + &self, + verse_id: &str, + kind: Option, + ) -> Vec<&CultMeshStreamDescriptor> { + self.streams + .values() + .filter(|stream| stream.verse_id == verse_id && kind.is_none_or(|k| stream.kind == k)) + .collect() + } + + pub fn negotiate( + &self, + stream_id: &str, + consumer: &CultMeshStreamConsumerProfile, + ) -> Result { + let stream = self + .streams + .get(stream_id) + .ok_or_else(|| anyhow::anyhow!("unknown CultMesh stream '{stream_id}'"))?; + if stream.verse_id != consumer.verse_id { + anyhow::bail!("stream and consumer must belong to the same Verse"); + } + if !consumer.accepted_kinds.is_empty() && !consumer.accepted_kinds.contains(&stream.kind) { + anyhow::bail!("consumer does not accept stream kind"); + } + let transport = stream + .preferred_transports + .iter() + .copied() + .find(|candidate| consumer.supported_transports.contains(candidate)) + .ok_or_else(|| { + anyhow::anyhow!("stream and consumer have no compatible body transport") + })?; + let producer_max = if stream.max_in_flight_frames == 0 { + u32::MAX + } else { + stream.max_in_flight_frames + }; + let consumer_max = if consumer.max_in_flight_frames == 0 { + u32::MAX + } else { + consumer.max_in_flight_frames + }; + Ok(CultMeshStreamNegotiation { + stream_id: stream.stream_id.clone(), + producer_peer_id: stream.owner_peer_id.clone(), + consumer_peer_id: consumer.peer_id.clone(), + transport, + access: stream.required_access, + max_in_flight_frames: producer_max.min(consumer_max), + copy_budget: copy_budget_for(transport), + }) + } + + pub fn create_shared_memory_ring( + &mut self, + stream_id: &str, + slot_count: usize, + slot_byte_length: usize, + ) -> Result<&mut CultMeshSharedMemoryFrameRing> { + if !self.streams.contains_key(stream_id) { + anyhow::bail!("unknown CultMesh stream '{stream_id}'"); + } + self.rings.insert( + stream_id.to_string(), + CultMeshSharedMemoryFrameRing::new(stream_id, slot_count, slot_byte_length)?, + ); + Ok(self + .rings + .get_mut(stream_id) + .expect("ring was inserted for stream")) + } + + pub fn ring(&self, stream_id: &str) -> Option<&CultMeshSharedMemoryFrameRing> { + self.rings.get(stream_id) + } + + pub fn ring_mut(&mut self, stream_id: &str) -> Option<&mut CultMeshSharedMemoryFrameRing> { + self.rings.get_mut(stream_id) + } + + pub fn publish_frame(&mut self, handle: CultMeshStreamFrameHandle) -> Result<()> { + if !self.streams.contains_key(&handle.stream_id) { + anyhow::bail!("unknown CultMesh stream '{}'", handle.stream_id); + } + self.latest_frames.insert(handle.stream_id.clone(), handle); + Ok(()) + } + + pub fn latest_frame(&self, stream_id: &str) -> Option<&CultMeshStreamFrameHandle> { + self.latest_frames.get(stream_id) + } +} + +fn copy_budget_for(transport: CultMeshStreamBodyTransport) -> CultMeshStreamCopyBudget { + match transport { + CultMeshStreamBodyTransport::SharedMemory + | CultMeshStreamBodyTransport::SharedD3d12Texture + | CultMeshStreamBodyTransport::SharedD3d11Texture + | CultMeshStreamBodyTransport::DmaBuf + | CultMeshStreamBodyTransport::IoSurface + | CultMeshStreamBodyTransport::AHardwareBuffer => CultMeshStreamCopyBudget::ZeroCopyTarget, + CultMeshStreamBodyTransport::CultCachePage => CultMeshStreamCopyBudget::OneCopyFallback, + CultMeshStreamBodyTransport::InlineBytes => CultMeshStreamCopyBudget::OpaqueRuntime, + } +} + +fn require_non_empty(value: String, name: &str) -> Result { + if value.trim().is_empty() { + anyhow::bail!("{name} must be non-empty"); + } + Ok(value) +} + +impl CultMeshNode { + pub fn runtime_id(&self) -> &str { + &self.runtime_id + } + + pub fn cache(&self) -> &CultCache { + &self.cache + } + + pub fn documents(&self) -> &CultNetDocumentRegistry { + &self.documents + } + + pub fn get(&self, key: &str) -> Result> { + self.cache.get::(key) + } + + pub fn get_required(&self, key: &str) -> Result { + self.cache.get_required::(key) + } + + pub fn get_all_with_keys(&self) -> Result> { + self.cache.get_all_with_keys::() + } + + pub fn soa(&self) -> Result> { + self.cache.soa::() + } + + pub fn put(&mut self, key: impl Into, value: &T) -> Result { + self.cache.put(key, value) + } + + pub fn pull_all_backing_stores(&mut self) -> Result<()> { + self.cache.pull_all_backing_stores() + } + + pub fn delete(&mut self, key: &str) -> Result { + self.cache.delete::(key) + } + + pub fn flush(&mut self) -> Result<()> { + Ok(()) + } + + pub fn publish_document_to_rudp_catalog( + &self, + key: impl Into, + value: &T, + options: CultMeshRudpDocumentPublishOptions, + ) -> Result<()> + where + T: DatabaseEntry + Serialize, + { + let message = self.create_rudp_document_message(key, value, &options)?; + publish_cultnet_message_to_rudp_catalog(&message, options) + } + + pub fn create_rudp_document_message( + &self, + key: impl Into, + value: &T, + options: &CultMeshRudpDocumentPublishOptions, + ) -> Result + where + T: DatabaseEntry + Serialize, + { + let key = key.into(); + self.documents.create_raw_document_put_message( + format!("{}:{}:{}", options.runtime_id, T::TYPE, key), + key, + value, + CultNetDocumentPutOptions { + source_runtime_id: Some(options.runtime_id.clone()), + source_agent_id: options.source_agent_id.clone(), + source_role: options.source_role.clone(), + tags: if options.tags.is_empty() { + None + } else { + Some(options.tags.clone()) + }, + ..CultNetDocumentPutOptions::default() + }, + ) + } + + pub fn pull_rudp_catalog_snapshot( + &mut self, + mut options: CultMeshRudpSnapshotOptions, + ) -> Result { + if options.message_id.trim().is_empty() { + options.message_id = format!( + "{}:snapshot:{}:{}", + options.runtime_id, + unix_millis(), + fresh_rudp_connection_epoch() + ); + } + let response = request_raw_snapshot_from_rudp_catalog(options)?; + let cultnet_rs::CultNetMessage::SnapshotResponseRaw { documents, .. } = response else { + anyhow::bail!("expected cultnet.snapshot_response_raw.v0"); + }; + let mut applied = 0usize; + for document in documents { + let Some(document_type) = + registered_document_type_for_schema(&self.documents, &document.schema_id) + else { + continue; + }; + self.cache.put_raw_envelope(CultCacheEnvelope { + key: document.record_key, + r#type: document_type, + payload: document.payload, + stored_at: document.stored_at, + schema_id: Some(document.schema_id), + })?; + applied += 1; + } + Ok(applied) + } +} + +fn registered_document_type_for_schema( + documents: &CultNetDocumentRegistry, + schema_id: &str, +) -> Option { + let normalized = strip_schema_version_suffix(schema_id); + if normalized != schema_id { + return Some(normalized.to_string()); + } + documents + .binding_by_schema_id(schema_id) + .map(|binding| binding.document_type.clone()) + .or_else(|| documents.binding(schema_id).map(|_| schema_id.to_string())) +} + +fn strip_schema_version_suffix(value: &str) -> &str { + let Some((prefix, suffix)) = value.rsplit_once(".v") else { + return value; + }; + if suffix.chars().all(|character| character.is_ascii_digit()) { + prefix + } else { + value + } +} + +/// Request a raw CultNet snapshot without importing it into a local cache. +/// +/// The returned value is the exact validated `SnapshotResponseRaw`, including +/// its correlation `message_id`. Each call binds a fresh source port, chooses a +/// fresh connection epoch, and succeeds only after the reliable request is +/// acknowledged. +pub fn request_raw_snapshot_from_rudp_catalog( + options: CultMeshRudpSnapshotOptions, +) -> Result { + if options.runtime_id.trim().is_empty() { + anyhow::bail!("runtime_id must be non-empty"); + } + if options.message_id.trim().is_empty() { + anyhow::bail!("raw CultMesh RUDP snapshot requires a message_id"); + } + let message_id = options.message_id.clone(); + let mut client = connect_rudp_client( + options.target, + &options.runtime_id, + options.connect_timeout, + options.poll_interval, + options.resend_delay_ms, + "cultmesh-rudp-snapshot-client", + )?; + + let request = cultnet_rs::CultNetMessage::SnapshotRequest { + message_id: message_id.clone(), + schema_ids: options.schema_ids, + record_keys: options.record_keys, + }; + let payload = encode_cultnet_message_to_vec(&request, CultNetWireContract::CultNetSchemaV0)?; + let receipt = client.send_reliable("schema", payload)?; + let response_deadline = Instant::now() + options.response_timeout; + let mut snapshot_response = None; + while Instant::now() < response_deadline { + if let Some(frame) = client.receive_once()? { + if frame.channel_id == "schema" { + let message = decode_cultnet_message_from_slice( + &frame.payload, + CultNetWireContract::CultNetSchemaV0, + )?; + match &message { + cultnet_rs::CultNetMessage::SnapshotResponseRaw { + message_id: received, + .. + } if received == &message_id => { + snapshot_response = Some(message); + } + cultnet_rs::CultNetMessage::Error { error } => { + let _ = client.disconnect(b"snapshot-error".to_vec()); + anyhow::bail!("CultMesh RUDP snapshot failed: {error}"); + } + _ => {} + } + } + } + match client.reliable_send_status(&receipt) { + cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged + if snapshot_response.is_some() => + { + let _ = client.disconnect(b"snapshot-complete".to_vec()); + return Ok(snapshot_response.expect("response was checked as present")); + } + cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => { + anyhow::bail!("CultMesh RUDP snapshot request receipt was invalidated") + } + _ => {} + } + client.poll_resends()?; + thread::sleep(options.poll_interval); + } + let _ = client.disconnect(b"snapshot-timeout".to_vec()); + if snapshot_response.is_some() { + anyhow::bail!( + "timed out waiting for reliable acknowledgement of CultMesh RUDP snapshot request to {}", + options.target + ); + } + anyhow::bail!( + "timed out waiting for CultMesh RUDP snapshot from {}", + options.target + ) +} + +fn unix_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +/// Publish one raw CultNet schema message to a RUDP catalog. +/// +/// Each call owns a fresh connection epoch and returns only after the catalog +/// acknowledges reliable application admission. +pub fn publish_cultnet_message_to_rudp_catalog( + message: &cultnet_rs::CultNetMessage, + options: CultMeshRudpDocumentPublishOptions, +) -> Result<()> { + if options.runtime_id.trim().is_empty() { + anyhow::bail!("runtime_id must be non-empty"); + } + let mut client = connect_rudp_client( + options.target, + &options.runtime_id, + options.connect_timeout, + options.poll_interval, + options.resend_delay_ms, + "cultmesh-rudp-document-publisher", + )?; + + let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?; + let receipt = client.send_reliable("schema", payload)?; + let flush_deadline = Instant::now() + options.flush_timeout; + while Instant::now() < flush_deadline { + let _ = client.receive_once()?; + match client.reliable_send_status(&receipt) { + cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged => { + let _ = client.disconnect(b"document-acknowledged".to_vec()); + return Ok(()); + } + cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => { + anyhow::bail!("CultMesh RUDP document receipt was invalidated") + } + cultnet_rs::CultNetRudpReliableSendStatus::Pending => {} + } + client.poll_resends()?; + thread::sleep(options.poll_interval); + } + let _ = client.disconnect(b"document-unacknowledged".to_vec()); + anyhow::bail!( + "timed out waiting for reliable acknowledgement from CultMesh RUDP catalog {}", + options.target + ) +} + +/// Publish a batch of raw CultNet schema messages to a RUDP catalog. +/// +/// Unlike [`publish_cultnet_message_to_rudp_catalog`], which polls for +/// per-message application-level acknowledgement, this sends every message on +/// one connection and waits only for a transport-level `flush_reliable`. It +/// trades per-receipt confirmation for batch throughput; callers that need a +/// stronger delivery guarantee should use the singular publish path instead. +pub fn publish_cultnet_messages_to_rudp_catalog( + messages: &[cultnet_rs::CultNetMessage], + options: CultMeshRudpDocumentPublishOptions, +) -> Result<()> { + if options.runtime_id.trim().is_empty() { + anyhow::bail!("runtime_id must be non-empty"); + } + let mut client = connect_rudp_client( + options.target, + &options.runtime_id, + options.connect_timeout, + options.poll_interval, + options.resend_delay_ms, + "cultmesh-rudp-document-publisher", + )?; + + for message in messages { + let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?; + client.send("schema", payload)?; + } + client.flush_reliable(options.flush_timeout) +} + +fn connect_rudp_client( + target: SocketAddr, + runtime_id: &str, + connect_timeout: Duration, + poll_interval: Duration, + resend_delay_ms: u64, + transport_id: &str, +) -> Result { + let bind_addr = if target.is_ipv4() { + SocketAddr::from(([0, 0, 0, 0], 0)) + } else { + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], 0)) + }; + let socket = UdpSocket::bind(bind_addr)?; + socket.set_read_timeout(Some(poll_interval))?; + let mut client = + CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { + media_delivery: None, + runtime_id: runtime_id.to_string(), + socket, + mode: cultnet_rs::CultNetRudpSocketMode::Client, + remote_addr: Some(target), + connection_id: fresh_rudp_connection_epoch(), + initial_sequence: 1, + resend_delay_ms, + transport_id: Some(transport_id.to_string()), + max_payload_bytes: None, + max_fragment_bytes: Some(1200), + max_pending_reliable_packets: Some(1024), + media_reliable_expire_after_ms: None, + reconnect_policy: None, + })?; + client.connect(Vec::new())?; + let connect_deadline = Instant::now() + connect_timeout; + while !client.connected() && Instant::now() < connect_deadline { + let _ = client.receive_once()?; + client.poll_resends()?; + thread::sleep(poll_interval); + } + if !client.connected() { + anyhow::bail!("timed out connecting CultMesh RUDP client {runtime_id} to {target}"); + } + Ok(client) +} + +fn fresh_rudp_connection_epoch() -> u32 { + let mut hasher = RandomState::new().build_hasher(); + hasher.write_u128(unix_millis()); + let epoch = hasher.finish() as u32; + if epoch == 0 { 1 } else { epoch } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct CultMesh; + +impl CultMesh { + pub fn resolve_rudp_endpoint(endpoint: &str) -> Result { + let text = require_non_empty(endpoint.trim().to_string(), "endpoint")?; + if text.starts_with("rudp://") { + return parse_rudp_socket_addr(&text); + } + if text.starts_with("cultmesh://") { + let resolved = default_cultmesh_rudp_endpoint_resolver(&text)?.ok_or_else(|| { + anyhow::anyhow!("CultMesh URI {text} did not resolve to a RUDP endpoint") + })?; + return parse_rudp_socket_addr(&resolved); + } + parse_socket_addr(&text) + } + + pub fn create_node( + store_path: impl AsRef, + documents: D, + options: CultMeshNodeOptions, + ) -> Result + where + D: CultMeshDocumentSet, + { + let mut cache = CultCache::new(); + documents.register_cache(&mut cache)?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(store_path.as_ref()))?; + if options.pull_on_start { + cache.pull_all_backing_stores()?; + } + + let mut registry = CultNetDocumentRegistry::new(); + documents.register_documents(&mut registry)?; + + Ok(CultMeshNode { + runtime_id: options.runtime_id, + cache, + documents: registry, + }) + } + + pub fn start_node( + store_path: impl AsRef, + documents: D, + options: CultMeshNodeOptions, + ) -> Result + where + D: CultMeshDocumentSet, + { + Self::create_node(store_path, documents, options) + } + + pub fn create_stream_catalog() -> CultMeshStreamCatalog { + CultMeshStreamCatalog::default() + } +} + +pub fn create_node( + store_path: impl AsRef, + documents: D, + options: CultMeshNodeOptions, +) -> Result +where + D: CultMeshDocumentSet, +{ + CultMesh::create_node(store_path, documents, options) +} + +pub fn start_node( + store_path: impl AsRef, + documents: D, + options: CultMeshNodeOptions, +) -> Result +where + D: CultMeshDocumentSet, +{ + CultMesh::start_node(store_path, documents, options) +} + +pub fn create_stream_catalog() -> CultMeshStreamCatalog { + CultMesh::create_stream_catalog() +} + +pub fn resolve_rudp_endpoint(endpoint: &str) -> Result { + CultMesh::resolve_rudp_endpoint(endpoint) +} + +fn parse_rudp_socket_addr(value: &str) -> Result { + let text = value.trim().strip_prefix("rudp://").unwrap_or(value.trim()); + parse_socket_addr(text) +} + +fn parse_socket_addr(value: &str) -> Result { + value + .parse() + .with_context(|| format!("RUDP endpoint must be a socket address, got {value:?}")) +} + +fn default_cultmesh_rudp_endpoint_resolver(uri: &str) -> Result> { + let authority = cultmesh_uri_authority(uri)?; + let slug = authority + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_uppercase() + } else { + '_' + } + }) + .collect::() + .trim_matches('_') + .to_string(); + if slug.is_empty() { + return Ok(None); + } + + for key in [ + format!("CULTMESH_URI_{slug}_RUDP"), + format!("{slug}_CULTMESH_RUDP_ENDPOINT"), + ] { + if let Ok(value) = env::var(&key) { + let text = value.trim(); + if !text.is_empty() { + return Ok(Some(if text.starts_with("rudp://") { + text.to_string() + } else { + format!("rudp://{text}") + })); + } + } + } + Ok(None) +} + +fn cultmesh_uri_authority(uri: &str) -> Result { + let rest = uri + .strip_prefix("cultmesh://") + .ok_or_else(|| anyhow::anyhow!("CultMesh URI must start with cultmesh://"))?; + let authority = rest.split(['/', '?', '#']).next().unwrap_or("").trim(); + if authority.is_empty() { + anyhow::bail!("CultMesh URI must include an authority"); + } + Ok(authority.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[derive(Clone, Debug, PartialEq, Eq, cultcache_rs::DatabaseEntry)] + #[cultcache(type = "cultmesh.test.note", schema = "CultMeshTestNote")] + struct Note { + #[cultcache(key = 0)] + body: String, + } + + cultmesh_documents!(TestDocuments { + Note => "cultmesh.test.note.v0", + }); + + #[test] + fn node_round_trips_registered_documents() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("cultmesh.cc"); + let note = Note { + body: "blessed circuit".to_string(), + }; + + let mut node = CultMesh::create_node( + &store_path, + TestDocuments, + CultMeshNodeOptions { + runtime_id: "test-runtime".to_string(), + ..CultMeshNodeOptions::default() + }, + )?; + assert_eq!(node.runtime_id(), "test-runtime"); + node.put("note", ¬e)?; + node.flush()?; + + let reloaded = CultMesh::create_node(&store_path, TestDocuments, Default::default())?; + assert_eq!(reloaded.get_required::("note")?, note); + assert!( + reloaded + .documents() + .binding_by_schema_id("cultmesh.test.note.v0") + .is_some() + ); + Ok(()) + } + + #[test] + fn node_publishes_registered_document_to_rudp_catalog() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("cultmesh.cc"); + let note = Note { + body: "respect odin once".to_string(), + }; + let node = CultMesh::create_node( + &store_path, + TestDocuments, + CultMeshNodeOptions { + runtime_id: "muninn-test".to_string(), + ..CultMeshNodeOptions::default() + }, + )?; + + let socket = UdpSocket::bind("127.0.0.1:0")?; + socket.set_read_timeout(Some(Duration::from_millis(10)))?; + let target = socket.local_addr()?; + let (sender, receiver) = std::sync::mpsc::channel(); + let delivered = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let delivered_by_sink = delivered.clone(); + let server = thread::spawn(move || -> Result<()> { + let mut server = CultMeshRudpDocumentServer::new( + socket, + move |receipt: CultMeshRudpRawDocumentReceipt| { + sender + .send(( + receipt.document.schema_id, + receipt.document.record_key, + receipt.document.source_runtime_id, + )) + .ok(); + delivered_by_sink.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }, + |_query: &CultMeshRudpSnapshotQuery| Ok(Vec::new()), + CultMeshSystemClock::default(), + CultMeshRudpDocumentServerOptions::default(), + )?; + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + server.poll_once()?; + if delivered.load(std::sync::atomic::Ordering::SeqCst) { + return Ok(()); + } + thread::sleep(Duration::from_millis(1)); + } + anyhow::bail!("timed out waiting for RUDP document put") + }); + + node.publish_document_to_rudp_catalog( + "note", + ¬e, + CultMeshRudpDocumentPublishOptions::odin(target, "muninn-test"), + )?; + let received = receiver.recv_timeout(Duration::from_secs(2))?; + server.join().expect("server thread should not panic")?; + + assert_eq!(received.0, "cultmesh.test.note.v0"); + assert_eq!(received.1, "note"); + assert_eq!(received.2.as_deref(), Some("muninn-test")); + Ok(()) + } + + #[test] + fn document_publish_fails_when_catalog_is_unreachable() -> Result<()> { + let temp = tempfile::tempdir()?; + let node = CultMesh::create_node( + temp.path().join("cultmesh.cc"), + TestDocuments, + Default::default(), + )?; + let unused = UdpSocket::bind("127.0.0.1:0")?; + let target = unused.local_addr()?; + drop(unused); + + let error = node + .publish_document_to_rudp_catalog( + "note", + &Note { + body: "no false success".into(), + }, + CultMeshRudpDocumentPublishOptions { + target, + connect_timeout: Duration::from_millis(30), + poll_interval: Duration::from_millis(2), + ..CultMeshRudpDocumentPublishOptions::default() + }, + ) + .expect_err("unreachable catalog must fail closed"); + assert!(error.to_string().contains("timed out connecting")); + Ok(()) + } + + #[test] + fn document_publish_requires_application_acknowledgement() -> Result<()> { + let temp = tempfile::tempdir()?; + let node = CultMesh::create_node( + temp.path().join("cultmesh.cc"), + TestDocuments, + Default::default(), + )?; + let socket = UdpSocket::bind("127.0.0.1:0")?; + let target = socket.local_addr()?; + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let server_stop = stop.clone(); + let server = thread::spawn(move || -> Result<()> { + let mut server = CultMeshRudpDocumentServer::new( + socket, + |_receipt: CultMeshRudpRawDocumentReceipt| { + anyhow::bail!("injected durable sink failure") + }, + |_query: &CultMeshRudpSnapshotQuery| Ok(Vec::new()), + CultMeshSystemClock::default(), + CultMeshRudpDocumentServerOptions::default(), + )?; + while !server_stop.load(std::sync::atomic::Ordering::SeqCst) { + let _ = server.poll_once(); + thread::sleep(Duration::from_millis(1)); + } + Ok(()) + }); + + let error = node + .publish_document_to_rudp_catalog( + "note", + &Note { + body: "still not accepted".into(), + }, + CultMeshRudpDocumentPublishOptions { + target, + connect_timeout: Duration::from_millis(100), + flush_timeout: Duration::from_millis(50), + poll_interval: Duration::from_millis(2), + resend_delay_ms: 5, + ..CultMeshRudpDocumentPublishOptions::default() + }, + ) + .expect_err("unacknowledged document must fail closed"); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + server.join().expect("server thread should not panic")?; + assert!(error.to_string().contains("reliable acknowledgement")); + Ok(()) + } + + #[test] + fn raw_snapshot_preserves_caller_challenge_and_raw_response() -> Result<()> { + let expected = cultnet_rs::CultNetRawDocumentRecord { + schema_id: "cultmesh.test.raw.v0".into(), + record_key: "route-proof".into(), + stored_at: "2026-09-03T00:00:00Z".into(), + payload_encoding: cultnet_rs::CultNetRawPayloadEncoding::Messagepack, + payload: vec![0xc4, 0x03, 0, 0xff, 1], + source_runtime_id: Some("odin-test".into()), + source_agent_id: None, + source_role: None, + tags: None, + }; + let socket = UdpSocket::bind("127.0.0.1:0")?; + let target = socket.local_addr()?; + let challenge = "9aa63ad8-83b5-4cc4-ae4a-91d361d5f845".to_string(); + let server_document = expected.clone(); + let server_challenge = challenge.clone(); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let server_stop = stop.clone(); + let server = thread::spawn(move || -> Result<()> { + let mut server = CultMeshRudpDocumentServer::new( + socket, + |_receipt: CultMeshRudpRawDocumentReceipt| Ok(()), + move |query: &CultMeshRudpSnapshotQuery| { + if query.message_id != server_challenge { + anyhow::bail!("snapshot challenge changed") + } + Ok(vec![server_document.clone()]) + }, + CultMeshSystemClock::default(), + CultMeshRudpDocumentServerOptions::default(), + )?; + while !server_stop.load(std::sync::atomic::Ordering::SeqCst) { + server.poll_once()?; + thread::sleep(Duration::from_millis(1)); + } + Ok(()) + }); + + let response = request_raw_snapshot_from_rudp_catalog(CultMeshRudpSnapshotOptions { + target, + runtime_id: "route-proof-test".into(), + message_id: challenge.clone(), + connect_timeout: Duration::from_millis(100), + response_timeout: Duration::from_millis(500), + poll_interval: Duration::from_millis(2), + resend_delay_ms: 5, + schema_ids: Some(vec!["cultmesh.test.raw.v0".into()]), + record_keys: Some(vec!["route-proof".into()]), + }); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + server.join().expect("server thread should not panic")?; + let response = response?; + assert_eq!( + response, + cultnet_rs::CultNetMessage::SnapshotResponseRaw { + message_id: challenge, + documents: vec![expected], + } + ); + Ok(()) + } + + #[test] + fn raw_snapshot_rejects_response_without_request_acknowledgement() -> Result<()> { + let socket = UdpSocket::bind("127.0.0.1:0")?; + socket.set_read_timeout(Some(Duration::from_millis(200)))?; + let target = socket.local_addr()?; + let server = thread::spawn(move || -> Result<()> { + let mut wire = vec![0_u8; 65_535]; + let (received, peer) = socket.recv_from(&mut wire)?; + let connect = cultnet_rs::decode_rudp_packet(&wire[..received])?; + let mut session = + cultnet_rs::CultNetRudpSession::new(cultnet_rs::CultNetRudpSessionOptions { + connection_id: connect.connection_id, + initial_sequence: 1, + resend_delay_ms: 5, + max_pending_reliable_packets: Some(64), + }); + let accept = session.accept_connect(&connect, 1, Vec::new())?; + socket.send_to(&cultnet_rs::encode_rudp_packet(&accept)?, peer)?; + + loop { + let (received, received_peer) = socket.recv_from(&mut wire)?; + if received_peer != peer { + continue; + } + let packet = cultnet_rs::decode_rudp_packet(&wire[..received])?; + let result = session.receive(&packet, 2)?; + let Some(frame) = result.delivered.into_iter().next() else { + continue; + }; + let request = decode_cultnet_message_from_slice( + &frame.payload, + CultNetWireContract::CultNetSchemaV0, + )?; + let cultnet_rs::CultNetMessage::SnapshotRequest { message_id, .. } = request else { + anyhow::bail!("expected snapshot request") + }; + let payload = encode_cultnet_message_to_vec( + &cultnet_rs::CultNetMessage::SnapshotResponseRaw { + message_id, + documents: Vec::new(), + }, + CultNetWireContract::CultNetSchemaV0, + )?; + let mut packets = session.send_many( + "schema", + payload, + cultnet_rs::CultNetRudpSendOptions { + reliable: true, + ordered: true, + sequenced: false, + now_ms: 2, + reliable_expire_after_ms: None, + }, + Some(1200), + )?; + for packet in &mut packets { + packet.ack = 0; + packet.ack_mask = 0; + socket.send_to(&cultnet_rs::encode_rudp_packet(packet)?, peer)?; + } + return Ok(()); + } + }); + + let error = request_raw_snapshot_from_rudp_catalog(CultMeshRudpSnapshotOptions { + target, + runtime_id: "unacknowledged-snapshot-test".into(), + message_id: "b7987efb-69c5-4c42-9355-b6e09535ad21".into(), + connect_timeout: Duration::from_millis(100), + response_timeout: Duration::from_millis(50), + poll_interval: Duration::from_millis(2), + resend_delay_ms: 5, + schema_ids: None, + record_keys: None, + }) + .expect_err("response without request ACK must fail closed"); + server.join().expect("server thread should not panic")?; + assert!(error.to_string().contains("reliable acknowledgement")); + Ok(()) + } + + #[test] + fn resolves_rudp_endpoint_from_cultmesh_uri_bootstrap_env() -> Result<()> { + let previous = env::var("CULTMESH_URI_ODIN_RUDP").ok(); + unsafe { + env::set_var("CULTMESH_URI_ODIN_RUDP", "127.0.0.1:17871"); + } + let result = CultMesh::resolve_rudp_endpoint("cultmesh://odin/rendezvous/provider-catalog"); + unsafe { + if let Some(value) = previous { + env::set_var("CULTMESH_URI_ODIN_RUDP", value); + } else { + env::remove_var("CULTMESH_URI_ODIN_RUDP"); + } + } + + assert_eq!(result?, "127.0.0.1:17871".parse()?); + Ok(()) + } + + #[test] + fn rejects_unresolved_cultmesh_rudp_endpoint() { + let previous = env::var("CULTMESH_URI_MISSING_ODIN_RUDP").ok(); + unsafe { + env::remove_var("CULTMESH_URI_MISSING_ODIN_RUDP"); + } + let error = + CultMesh::resolve_rudp_endpoint("cultmesh://missing-odin/rendezvous/provider-catalog") + .expect_err("unresolved CultMesh URI should fail"); + unsafe { + if let Some(value) = previous { + env::set_var("CULTMESH_URI_MISSING_ODIN_RUDP", value); + } + } + assert!(error.to_string().contains("did not resolve")); + } + + #[test] + fn raw_snapshot_application_uses_registered_document_type() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("cultmesh.cc"); + let mut node = CultMesh::create_node( + &store_path, + TestDocuments, + CultMeshNodeOptions { + runtime_id: "snapshot-apply-test".to_string(), + ..CultMeshNodeOptions::default() + }, + )?; + let payload = rmp_serde::to_vec(&Note { + body: "catalog truth".to_string(), + })?; + + node.cache + .put_raw_envelope(cultcache_rs::CultCacheEnvelope { + key: "note".to_string(), + r#type: node + .documents + .binding_by_schema_id("cultmesh.test.note.v0") + .expect("test binding") + .document_type + .clone(), + payload, + stored_at: "2026-07-03T00:00:00Z".to_string(), + schema_id: Some("cultmesh.test.note.v0".to_string()), + })?; + node.flush()?; + + let reloaded = CultMesh::create_node(&store_path, TestDocuments, Default::default())?; + assert_eq!(reloaded.get_required::("note")?.body, "catalog truth"); + assert_eq!(reloaded.cache().snapshot()[0].r#type, Note::TYPE); + Ok(()) + } + + #[test] + fn stream_catalog_negotiates_and_publishes_latest_shared_memory_frame() -> Result<()> { + let mut catalog = CultMesh::create_stream_catalog(); + let stream = CultMeshStreamDescriptor::new( + "muninn:nightwing:move-evidence", + "mimir-live", + "muninn:nightwing", + CultMeshStreamKind::Bytes, + CultMeshStreamClock::new("muninn:nightwing:clock")? + .source_id("muninn:nightwing:move-evidence")? + .confidence(1.0) + .evidence_kind("muninn-move-evidence")?, + vec![ + CultMeshStreamBodyTransport::SharedMemory, + CultMeshStreamBodyTransport::CultCachePage, + ], + )? + .label("Muninn Move evidence")? + .max_in_flight_frames(4) + .metadata_schema_id("mimir.muninn_move_evidence_stream_frame.v1")?; + catalog.declare(stream); + + let consumer = CultMeshStreamConsumerProfile::new( + "mimir:starfire", + "mimir-live", + vec![CultMeshStreamBodyTransport::SharedMemory], + )?; + let negotiation = catalog.negotiate("muninn:nightwing:move-evidence", &consumer)?; + + assert_eq!( + negotiation.transport, + CultMeshStreamBodyTransport::SharedMemory + ); + assert_eq!( + negotiation.copy_budget, + CultMeshStreamCopyBudget::ZeroCopyTarget + ); + + let handle = { + let ring = + catalog.create_shared_memory_ring("muninn:nightwing:move-evidence", 4, 128)?; + ring.try_publish_copy(&[1, 2, 3], 42, 0)? + .expect("frame should publish") + }; + catalog.publish_frame(handle.clone())?; + + assert_eq!( + catalog.latest_frame("muninn:nightwing:move-evidence"), + Some(&handle) + ); + let lease = catalog + .ring("muninn:nightwing:move-evidence") + .and_then(CultMeshSharedMemoryFrameRing::try_acquire_latest_read) + .expect("latest frame should be readable"); + assert_eq!(lease.handle.sequence, 0); + assert_eq!(lease.bytes(), &[1, 2, 3]); + Ok(()) + } +} diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs new file mode 100644 index 00000000..bebeb6c3 --- /dev/null +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -0,0 +1,647 @@ +use anyhow::{Result, anyhow}; +use cultnet_rs::{ + CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, + CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract, + decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec, + encode_rudp_packet, +}; +use std::collections::BTreeMap; +use std::io::ErrorKind; +use std::net::{SocketAddr, UdpSocket}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const MAX_UDP_DATAGRAM_BYTES: usize = 65_535; + +/// The transport identity of one remote CultNet RUDP session. +/// +/// Connection identifiers are scoped to a remote socket address. Two clients +/// may therefore use the same connection identifier without sharing ordering, +/// acknowledgement, resend, or fragment state. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct CultMeshRudpSessionKey { + pub remote_addr: SocketAddr, + pub connection_id: u32, +} + +/// A raw document plus the receipt fact owned by the receiving process. +/// +/// `document.payload` is the exact byte vector decoded from the CultNet raw +/// record. The trusted receipt time lives beside the sender-owned record so the +/// transport never rewrites its metadata or payload. +#[derive(Clone, Debug, PartialEq)] +pub struct CultMeshRudpRawDocumentReceipt { + pub session: CultMeshRudpSessionKey, + pub message_id: String, + pub transport_sequence: u32, + pub received_at_unix_millis: u64, + pub document: CultNetRawDocumentRecord, +} + +/// The caller-visible query for a read-only raw snapshot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshRudpSnapshotQuery { + pub session: CultMeshRudpSessionKey, + pub message_id: String, + pub requested_at_unix_millis: u64, + pub schema_ids: Option>, + pub record_keys: Option>, +} + +/// Caller-owned admission/persistence port for received raw documents. +pub trait CultMeshRudpRawDocumentSink { + fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()>; +} + +impl CultMeshRudpRawDocumentSink for F +where + F: FnMut(CultMeshRudpRawDocumentReceipt) -> Result<()>, +{ + fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> { + self(receipt) + } +} + +/// Caller-owned catalog port for serving raw snapshot requests. +/// +/// The caller decides which records the requester may see. CultMesh preserves +/// those records as raw CultNet documents and does not interpret their schemas. +pub trait CultMeshRudpSnapshotSource { + fn raw_snapshot( + &mut self, + query: &CultMeshRudpSnapshotQuery, + ) -> Result>; +} + +impl CultMeshRudpSnapshotSource for F +where + F: FnMut(&CultMeshRudpSnapshotQuery) -> Result>, +{ + fn raw_snapshot( + &mut self, + query: &CultMeshRudpSnapshotQuery, + ) -> Result> { + self(query) + } +} + +/// Trusted local clocks for receipt facts and transport maintenance. +/// +/// Receipt time is wall time. Expiry and resend decisions use the monotonic +/// value, so a wall-clock correction cannot resurrect or pin a session. +pub trait CultMeshRudpServerClock { + fn now_unix_millis(&self) -> u64; + fn now_monotonic_millis(&self) -> u64; +} + +#[derive(Clone, Debug)] +pub struct CultMeshSystemClock { + monotonic_origin: Instant, +} + +impl Default for CultMeshSystemClock { + fn default() -> Self { + Self { + monotonic_origin: Instant::now(), + } + } +} + +impl CultMeshRudpServerClock for CultMeshSystemClock { + fn now_unix_millis(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) + } + + fn now_monotonic_millis(&self) -> u64 { + self.monotonic_origin + .elapsed() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshRudpDocumentServerOptions { + pub max_sessions: usize, + pub session_idle_timeout: Duration, + pub session_max_lifetime: Duration, + /// Conservative payload admission budget across all live sessions. + /// + /// Admitted ingress and queued snapshot-response bytes are charged until + /// the session ends even when CultNet releases them sooner. Together with + /// CultNet's per-session receive caps, this makes retained payload memory + /// finite without duplicating its fragment/ordering machinery here. + pub max_admitted_payload_bytes: usize, + pub max_admitted_payload_bytes_per_session: usize, + pub max_snapshot_response_bytes: usize, + pub max_snapshot_documents: usize, + pub resend_delay: Duration, + pub max_pending_reliable_packets_per_session: usize, + pub max_fragment_bytes: usize, +} + +impl Default for CultMeshRudpDocumentServerOptions { + fn default() -> Self { + Self { + max_sessions: 64, + session_idle_timeout: Duration::from_secs(30), + session_max_lifetime: Duration::from_secs(15 * 60), + max_admitted_payload_bytes: 32 * 1024 * 1024, + max_admitted_payload_bytes_per_session: 4 * 1024 * 1024, + max_snapshot_response_bytes: 1024 * 1024, + max_snapshot_documents: 4096, + resend_delay: Duration::from_millis(50), + max_pending_reliable_packets_per_session: 1024, + max_fragment_bytes: 1200, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CultMeshRudpMaintenance { + pub sessions_expired: usize, + pub packets_resent: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CultMeshRudpApplicationOperation { + DocumentPutRaw, + SnapshotRequest, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshRudpApplicationRejection { + pub session: CultMeshRudpSessionKey, + pub operation: CultMeshRudpApplicationOperation, + pub message_id: String, + pub reason: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CultMeshRudpPollOutcome { + Idle, + Handled, + ApplicationRejected(CultMeshRudpApplicationRejection), +} + +struct SessionEntry { + session: CultNetRudpSession, + created_at_monotonic_millis: u64, + last_activity_monotonic_millis: u64, + admitted_payload_bytes: usize, +} + +/// A synchronous, multi-session CultNet RUDP raw-document server. +/// +/// The server owns exactly one UDP socket. Call `poll_once` from an existing +/// daemon loop; no executor or background thread is created here. +pub struct CultMeshRudpDocumentServer { + socket: UdpSocket, + sessions: BTreeMap, + sink: S, + snapshot_source: Q, + clock: C, + options: CultMeshRudpDocumentServerOptions, +} + +impl CultMeshRudpDocumentServer +where + S: CultMeshRudpRawDocumentSink, + Q: CultMeshRudpSnapshotSource, + C: CultMeshRudpServerClock, +{ + pub fn new( + socket: UdpSocket, + sink: S, + snapshot_source: Q, + clock: C, + options: CultMeshRudpDocumentServerOptions, + ) -> Result { + validate_options(&options)?; + socket.local_addr().map_err(|error| { + anyhow!("CultMesh RUDP server requires a bound UDP socket: {error}") + })?; + socket.set_nonblocking(true)?; + Ok(Self { + socket, + sessions: BTreeMap::new(), + sink, + snapshot_source, + clock, + options, + }) + } + + pub fn local_addr(&self) -> Result { + Ok(self.socket.local_addr()?) + } + + pub fn session_count(&self) -> usize { + self.sessions.len() + } + + /// Poll transport maintenance and receive at most one UDP datagram. + /// + /// Application rejection invalidates only the responsible peer session and + /// is returned as data so a daemon can log it and keep serving. Local + /// socket and server-state failures remain errors. + pub fn poll_once(&mut self) -> Result { + self.maintain()?; + let mut wire = vec![0_u8; MAX_UDP_DATAGRAM_BYTES]; + let (received, remote_addr) = match self.socket.recv_from(&mut wire) { + Ok(value) => value, + Err(error) if error.kind() == ErrorKind::WouldBlock => { + return Ok(CultMeshRudpPollOutcome::Idle); + } + Err(error) => return Err(error.into()), + }; + wire.truncate(received); + + let packet = match decode_rudp_packet(&wire) { + Ok(packet) => packet, + Err(_) => return Ok(CultMeshRudpPollOutcome::Handled), + }; + let now_unix = self.clock.now_unix_millis(); + let now_monotonic = self.clock.now_monotonic_millis(); + let key = CultMeshRudpSessionKey { + remote_addr, + connection_id: packet.connection_id, + }; + + if packet.packet_type == CultNetRudpPacketType::Connect { + self.accept_connection(key, &packet, now_monotonic)?; + return Ok(CultMeshRudpPollOutcome::Handled); + } + + if !self.sessions.contains_key(&key) { + return Ok(CultMeshRudpPollOutcome::Handled); + } + if packet.packet_type == CultNetRudpPacketType::Data + && packet.channel_id == "schema" + && !packet.reliable + { + return Ok(CultMeshRudpPollOutcome::Handled); + } + let payload_charge = if packet.packet_type == CultNetRudpPacketType::Data { + packet.payload.len() + } else { + 0 + }; + if !self.payload_budget_allows(key, payload_charge) { + return Ok(CultMeshRudpPollOutcome::Handled); + } + + let result = { + let entry = self + .sessions + .get_mut(&key) + .expect("checked session must remain present"); + entry.last_activity_monotonic_millis = now_monotonic; + entry.admitted_payload_bytes = + entry.admitted_payload_bytes.saturating_add(payload_charge); + entry.session.receive(&packet, now_monotonic) + }; + let result = match result { + Ok(result) => result, + Err(_) => { + self.sessions.remove(&key); + return Ok(CultMeshRudpPollOutcome::Handled); + } + }; + + if let Some(reply) = result.reply { + self.send_packet(key.remote_addr, &reply)?; + } + + if result.disconnected { + self.sessions.remove(&key); + return Ok(CultMeshRudpPollOutcome::Handled); + } + + for frame in result.delivered { + if frame.channel_id != "schema" { + continue; + } + let message = match decode_cultnet_message_from_slice( + &frame.payload, + CultNetWireContract::CultNetSchemaV0, + ) { + Ok(message) => message, + Err(_) => continue, + }; + if let Some(rejection) = self.deliver_application_message( + key, + frame.sequence, + message, + now_unix, + now_monotonic, + )? { + self.sessions.remove(&key); + return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); + } + } + + if packet.reliable { + let ack = self + .sessions + .get_mut(&key) + .ok_or_else(|| anyhow!("CultMesh RUDP session disappeared before ACK"))? + .session + .create_ack(); + self.send_packet(key.remote_addr, &ack)?; + } + Ok(CultMeshRudpPollOutcome::Handled) + } + + /// Expire idle sessions and resend reliable packets whose deadline passed. + pub fn maintain(&mut self) -> Result { + let now = self.clock.now_monotonic_millis(); + let idle_timeout_ms = duration_millis(self.options.session_idle_timeout); + let lifetime_ms = duration_millis(self.options.session_max_lifetime); + let before = self.sessions.len(); + self.sessions.retain(|_, entry| { + now.saturating_sub(entry.last_activity_monotonic_millis) <= idle_timeout_ms + && now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms + }); + let expired = before - self.sessions.len(); + + let mut resends = Vec::new(); + for (key, entry) in &mut self.sessions { + for packet in entry.session.due_resends(now) { + resends.push((key.remote_addr, packet)); + } + } + for (remote_addr, packet) in &resends { + self.send_packet(*remote_addr, packet)?; + } + Ok(CultMeshRudpMaintenance { + sessions_expired: expired, + packets_resent: resends.len(), + }) + } + + fn accept_connection( + &mut self, + key: CultMeshRudpSessionKey, + packet: &CultNetRudpPacket, + now: u64, + ) -> Result<()> { + if !self.sessions.contains_key(&key) { + if self.sessions.len() >= self.options.max_sessions { + return Ok(()); + } + self.sessions.insert( + key, + SessionEntry { + session: CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: key.connection_id, + initial_sequence: 1, + resend_delay_ms: duration_millis(self.options.resend_delay), + max_pending_reliable_packets: Some( + self.options.max_pending_reliable_packets_per_session, + ), + }), + created_at_monotonic_millis: now, + last_activity_monotonic_millis: now, + admitted_payload_bytes: 0, + }, + ); + } + + let accept = { + let entry = self + .sessions + .get_mut(&key) + .expect("accepted session must be present"); + // The same key is a retransmitted Connect for the existing epoch. + // A genuinely fresh client incarnation must choose a fresh id. + entry.last_activity_monotonic_millis = now; + entry.session.accept_connect(packet, now, Vec::new())? + }; + self.send_packet(key.remote_addr, &accept) + } + + fn deliver_application_message( + &mut self, + key: CultMeshRudpSessionKey, + transport_sequence: u32, + message: CultNetMessage, + now_unix: u64, + now: u64, + ) -> Result> { + match message { + CultNetMessage::DocumentPutRaw { + message_id, + document, + } => { + let receipt = CultMeshRudpRawDocumentReceipt { + session: key, + message_id: message_id.clone(), + transport_sequence, + received_at_unix_millis: now_unix, + document, + }; + if let Err(error) = self.sink.accept_raw_document(receipt) { + return Ok(Some(CultMeshRudpApplicationRejection { + session: key, + operation: CultMeshRudpApplicationOperation::DocumentPutRaw, + message_id, + reason: format!("{error:#}"), + })); + } + } + CultNetMessage::SnapshotRequest { + message_id, + schema_ids, + record_keys, + } => { + let query = CultMeshRudpSnapshotQuery { + session: key, + message_id: message_id.clone(), + requested_at_unix_millis: now_unix, + schema_ids, + record_keys, + }; + let documents = match self.snapshot_source.raw_snapshot(&query) { + Ok(documents) => documents, + Err(error) => { + return Ok(Some(CultMeshRudpApplicationRejection { + session: key, + operation: CultMeshRudpApplicationOperation::SnapshotRequest, + message_id, + reason: format!("{error:#}"), + })); + } + }; + if documents.len() > self.options.max_snapshot_documents { + return Ok(Some(CultMeshRudpApplicationRejection { + session: key, + operation: CultMeshRudpApplicationOperation::SnapshotRequest, + message_id, + reason: format!( + "snapshot returned {} documents; limit is {}", + documents.len(), + self.options.max_snapshot_documents + ), + })); + } + let response = CultNetMessage::SnapshotResponseRaw { + message_id: message_id.clone(), + documents, + }; + let payload = match encode_cultnet_message_to_vec( + &response, + CultNetWireContract::CultNetSchemaV0, + ) { + Ok(payload) => payload, + Err(error) => { + return Ok(Some(CultMeshRudpApplicationRejection { + session: key, + operation: CultMeshRudpApplicationOperation::SnapshotRequest, + message_id, + reason: format!("snapshot response could not be encoded: {error:#}"), + })); + } + }; + if payload.len() > self.options.max_snapshot_response_bytes { + return Ok(Some(CultMeshRudpApplicationRejection { + session: key, + operation: CultMeshRudpApplicationOperation::SnapshotRequest, + message_id, + reason: format!( + "snapshot response is {} bytes; limit is {}", + payload.len(), + self.options.max_snapshot_response_bytes + ), + })); + } + if !self.payload_budget_allows(key, payload.len()) { + return Ok(Some(CultMeshRudpApplicationRejection { + session: key, + operation: CultMeshRudpApplicationOperation::SnapshotRequest, + message_id, + reason: "retained payload budget is full".into(), + })); + } + let payload_bytes = payload.len(); + let entry = self + .sessions + .get_mut(&key) + .ok_or_else(|| anyhow!("CultMesh RUDP session disappeared before response"))?; + let packets = match entry.session.send_many( + "schema", + payload, + CultNetRudpSendOptions { + reliable: true, + ordered: true, + sequenced: false, + now_ms: now, + reliable_expire_after_ms: None, + }, + Some(self.options.max_fragment_bytes), + ) { + Ok(packets) => packets, + Err(error) => { + return Ok(Some(CultMeshRudpApplicationRejection { + session: key, + operation: CultMeshRudpApplicationOperation::SnapshotRequest, + message_id, + reason: format!("snapshot response could not be queued: {error:#}"), + })); + } + }; + entry.admitted_payload_bytes = + entry.admitted_payload_bytes.saturating_add(payload_bytes); + for packet in &packets { + self.send_packet(key.remote_addr, packet)?; + } + } + _ => {} + } + Ok(None) + } + + fn payload_budget_allows(&self, key: CultMeshRudpSessionKey, bytes: usize) -> bool { + let Some(session_bytes) = self + .sessions + .get(&key) + .map(|entry| entry.admitted_payload_bytes) + else { + return false; + }; + if session_bytes + .checked_add(bytes) + .is_none_or(|total| total > self.options.max_admitted_payload_bytes_per_session) + { + return false; + } + self.sessions + .values() + .try_fold(0_usize, |total, entry| { + total.checked_add(entry.admitted_payload_bytes) + }) + .and_then(|total| total.checked_add(bytes)) + .is_some_and(|total| total <= self.options.max_admitted_payload_bytes) + } + + fn send_packet(&mut self, remote_addr: SocketAddr, packet: &CultNetRudpPacket) -> Result<()> { + let wire = encode_rudp_packet(packet)?; + self.socket.send_to(&wire, remote_addr)?; + Ok(()) + } +} + +fn validate_options(options: &CultMeshRudpDocumentServerOptions) -> Result<()> { + if options.max_sessions == 0 { + return Err(anyhow!("max_sessions must be greater than zero")); + } + if options.session_idle_timeout.is_zero() { + return Err(anyhow!("session_idle_timeout must be greater than zero")); + } + if options.session_max_lifetime.is_zero() { + return Err(anyhow!("session_max_lifetime must be greater than zero")); + } + if options.max_admitted_payload_bytes == 0 { + return Err(anyhow!( + "max_admitted_payload_bytes must be greater than zero" + )); + } + if options.max_admitted_payload_bytes_per_session == 0 + || options.max_admitted_payload_bytes_per_session > options.max_admitted_payload_bytes + { + return Err(anyhow!( + "per-session admitted payload limit must be non-zero and no larger than the server limit" + )); + } + if options.max_snapshot_response_bytes == 0 + || options.max_snapshot_response_bytes > options.max_admitted_payload_bytes_per_session + { + return Err(anyhow!( + "snapshot response limit must be non-zero and fit in one session's payload budget" + )); + } + if options.max_snapshot_documents == 0 { + return Err(anyhow!("max_snapshot_documents must be greater than zero")); + } + if options.resend_delay.is_zero() { + return Err(anyhow!("resend_delay must be greater than zero")); + } + if options.max_pending_reliable_packets_per_session == 0 { + return Err(anyhow!( + "max_pending_reliable_packets_per_session must be greater than zero" + )); + } + if options.max_fragment_bytes == 0 { + return Err(anyhow!("max_fragment_bytes must be greater than zero")); + } + Ok(()) +} + +fn duration_millis(duration: Duration) -> u64 { + duration.as_millis().try_into().unwrap_or(u64::MAX) +} diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs new file mode 100644 index 00000000..df14b4ea --- /dev/null +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -0,0 +1,555 @@ +use anyhow::Result; +use cultmesh_rs::{ + CultMeshRudpApplicationOperation, CultMeshRudpDocumentServer, + CultMeshRudpDocumentServerOptions, CultMeshRudpPollOutcome, CultMeshRudpRawDocumentReceipt, + CultMeshRudpRawDocumentSink, CultMeshRudpServerClock, CultMeshRudpSnapshotQuery, + CultMeshRudpSnapshotSource, +}; +use cultnet_rs::{ + CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, + CultNetRudpReliableSendReceipt, CultNetRudpReliableSendStatus, CultNetRudpSocketMode, + CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract, + decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, +}; +use pretty_assertions::assert_eq; +use std::net::{SocketAddr, UdpSocket}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +#[derive(Default)] +struct SinkState { + receipts: Vec, + failures_remaining: usize, +} + +#[derive(Clone, Default)] +struct Sink(Arc>); + +impl Sink { + fn fail_once() -> Self { + Self(Arc::new(Mutex::new(SinkState { + failures_remaining: 1, + ..Default::default() + }))) + } +} + +impl CultMeshRudpRawDocumentSink for Sink { + fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> { + let mut state = self.0.lock().unwrap(); + if state.failures_remaining > 0 { + state.failures_remaining -= 1; + anyhow::bail!("injected sink failure"); + } + state.receipts.push(receipt); + Ok(()) + } +} + +#[derive(Default)] +struct SourceState { + documents: Vec, + failures_remaining: usize, + calls: usize, +} + +#[derive(Clone, Default)] +struct Source(Arc>); + +impl Source { + fn documents(documents: Vec) -> Self { + Self(Arc::new(Mutex::new(SourceState { + documents, + ..Default::default() + }))) + } + + fn fail_once(documents: Vec) -> Self { + Self(Arc::new(Mutex::new(SourceState { + documents, + failures_remaining: 1, + calls: 0, + }))) + } +} + +impl CultMeshRudpSnapshotSource for Source { + fn raw_snapshot( + &mut self, + _: &CultMeshRudpSnapshotQuery, + ) -> Result> { + let mut state = self.0.lock().unwrap(); + state.calls += 1; + if state.failures_remaining > 0 { + state.failures_remaining -= 1; + anyhow::bail!("injected source failure"); + } + Ok(state.documents.clone()) + } +} + +#[derive(Clone)] +struct Clock { + unix: Arc, + monotonic: Arc, +} + +impl Clock { + fn new(now: u64) -> Self { + Self { + unix: Arc::new(AtomicU64::new(now)), + monotonic: Arc::new(AtomicU64::new(now)), + } + } + + fn set(&self, now: u64) { + self.unix.store(now, Ordering::SeqCst); + self.monotonic.store(now, Ordering::SeqCst); + } + + fn set_unix(&self, now: u64) { + self.unix.store(now, Ordering::SeqCst); + } + + fn set_monotonic(&self, now: u64) { + self.monotonic.store(now, Ordering::SeqCst); + } +} + +impl CultMeshRudpServerClock for Clock { + fn now_unix_millis(&self) -> u64 { + self.unix.load(Ordering::SeqCst) + } + + fn now_monotonic_millis(&self) -> u64 { + self.monotonic.load(Ordering::SeqCst) + } +} + +type Server = CultMeshRudpDocumentServer; + +fn document(key: &str, payload: Vec) -> CultNetRawDocumentRecord { + CultNetRawDocumentRecord { + schema_id: "test.raw.v1".into(), + record_key: key.into(), + stored_at: "2026-09-03T00:00:00Z".into(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload, + source_runtime_id: Some(format!("runtime-{key}")), + source_agent_id: None, + source_role: None, + tags: None, + } +} + +fn server( + options: CultMeshRudpDocumentServerOptions, + clock: Clock, + sink: Sink, + source: Source, +) -> Result { + CultMeshRudpDocumentServer::new( + UdpSocket::bind("127.0.0.1:0")?, + sink, + source, + clock, + options, + ) +} + +fn client(target: SocketAddr, id: u32) -> Result { + let socket = UdpSocket::bind("127.0.0.1:0")?; + socket.set_nonblocking(true)?; + CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { + media_delivery: None, + runtime_id: "test-client".into(), + socket, + mode: CultNetRudpSocketMode::Client, + remote_addr: Some(target), + connection_id: id, + initial_sequence: 1, + resend_delay_ms: 10, + transport_id: None, + max_payload_bytes: None, + max_fragment_bytes: Some(1200), + max_pending_reliable_packets: Some(64), + media_reliable_expire_after_ms: None, + reconnect_policy: None, + }) +} + +fn connect( + server: &mut Server, + clients: &mut [&mut CultNetRudpSocketTransportConnection], +) -> Result<()> { + for client in &mut *clients { + client.connect(Vec::new())?; + } + for _ in 0..500 { + server.poll_once()?; + for client in &mut *clients { + client.receive_once()?; + } + if clients.iter().all(|client| client.connected()) { + for _ in 0..clients.len() { + server.poll_once()?; + } + return Ok(()); + } + thread::sleep(Duration::from_millis(1)); + } + anyhow::bail!("client connection timed out") +} + +fn send(client: &mut CultNetRudpSocketTransportConnection, message: &CultNetMessage) -> Result<()> { + client.send( + "schema", + encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?, + ) +} + +fn send_reliable( + client: &mut CultNetRudpSocketTransportConnection, + message: &CultNetMessage, +) -> Result { + client.send_reliable( + "schema", + encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?, + ) +} + +#[test] +fn same_connection_id_is_peer_scoped_and_raw_bytes_are_untouched() -> Result<()> { + let clock = Clock::new(42_000); + let sink = Sink::default(); + let mut server = server(Default::default(), clock, sink.clone(), Source::default())?; + let mut a = client(server.local_addr()?, 77)?; + let mut b = client(server.local_addr()?, 77)?; + connect(&mut server, &mut [&mut a, &mut b])?; + + let a_bytes = vec![0xc4, 0x04, 0x00, 0xff, 0x80, 0x01]; + let b_bytes = vec![0x92, 0x01, 0xc4, 0x02, 0x00, 0xff]; + send( + &mut a, + &CultNetMessage::DocumentPutRaw { + message_id: "a".into(), + document: document("a", a_bytes.clone()), + }, + )?; + send( + &mut b, + &CultNetMessage::DocumentPutRaw { + message_id: "b".into(), + document: document("b", b_bytes.clone()), + }, + )?; + for _ in 0..500 { + server.poll_once()?; + a.receive_once()?; + b.receive_once()?; + if sink.0.lock().unwrap().receipts.len() == 2 { + break; + } + } + + let receipts = sink.0.lock().unwrap(); + assert_eq!(server.session_count(), 2); + assert_eq!(receipts.receipts.len(), 2); + assert_ne!( + receipts.receipts[0].session.remote_addr, + receipts.receipts[1].session.remote_addr + ); + assert_eq!( + receipts + .receipts + .iter() + .find(|r| r.message_id == "a") + .unwrap() + .document + .payload, + a_bytes + ); + assert_eq!( + receipts + .receipts + .iter() + .find(|r| r.message_id == "b") + .unwrap() + .document + .payload, + b_bytes + ); + assert!( + receipts + .receipts + .iter() + .all(|r| r.received_at_unix_millis == 42_000) + ); + Ok(()) +} + +#[test] +fn duplicate_connect_preserves_epoch_and_fresh_epoch_is_separate() -> Result<()> { + let clock = Clock::new(45_000); + let sink = Sink::default(); + let mut server = server(Default::default(), clock, sink.clone(), Source::default())?; + let target = server.local_addr()?; + let mut incumbent = client(target, 91)?; + connect(&mut server, &mut [&mut incumbent])?; + + incumbent.connect(Vec::new())?; + server.poll_once()?; + incumbent.receive_once()?; + assert_eq!(server.session_count(), 1); + + send( + &mut incumbent, + &CultNetMessage::DocumentPutRaw { + message_id: "after-duplicate".into(), + document: document("incumbent", vec![1, 2, 3]), + }, + )?; + for _ in 0..20 { + server.poll_once()?; + if sink.0.lock().unwrap().receipts.len() == 1 { + break; + } + } + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); + + let mut fresh_epoch = client(target, 92)?; + connect(&mut server, &mut [&mut fresh_epoch])?; + assert_eq!(server.session_count(), 2); + Ok(()) +} + +#[test] +fn application_rejection_is_nonfatal_peer_scoped_and_unacknowledged() -> Result<()> { + let clock = Clock::new(48_000); + let sink = Sink::fail_once(); + let source = Source::fail_once(vec![document("snapshot", vec![4, 5, 6])]); + let mut server = server( + Default::default(), + clock.clone(), + sink.clone(), + source.clone(), + )?; + let target = server.local_addr()?; + + let mut publisher = client(target, 101)?; + let mut snapshot_client = client(target, 102)?; + let mut survivor = client(target, 103)?; + connect( + &mut server, + &mut [&mut publisher, &mut snapshot_client, &mut survivor], + )?; + let publish_receipt = send_reliable( + &mut publisher, + &CultNetMessage::DocumentPutRaw { + message_id: "rejected-put".into(), + document: document("rejected", vec![0, 255, 1]), + }, + )?; + let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = server.poll_once()? else { + panic!("sink rejection must be returned as a nonfatal poll outcome"); + }; + assert_eq!(rejection.session.connection_id, 101); + assert_eq!( + rejection.operation, + CultMeshRudpApplicationOperation::DocumentPutRaw + ); + assert_eq!(rejection.message_id, "rejected-put"); + assert!(rejection.reason.contains("injected sink failure")); + assert_eq!(server.session_count(), 2); + publisher.receive_once()?; + assert_eq!( + publisher.reliable_send_status(&publish_receipt), + CultNetRudpReliableSendStatus::Pending + ); + + let snapshot_receipt = send_reliable( + &mut snapshot_client, + &CultNetMessage::SnapshotRequest { + message_id: "rejected-snapshot".into(), + schema_ids: None, + record_keys: None, + }, + )?; + let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = server.poll_once()? else { + panic!("source rejection must be returned as a nonfatal poll outcome"); + }; + assert_eq!(rejection.session.connection_id, 102); + assert_eq!( + rejection.operation, + CultMeshRudpApplicationOperation::SnapshotRequest + ); + assert_eq!(rejection.message_id, "rejected-snapshot"); + assert!(rejection.reason.contains("injected source failure")); + assert_eq!(server.session_count(), 1); + snapshot_client.receive_once()?; + assert_eq!( + snapshot_client.reliable_send_status(&snapshot_receipt), + CultNetRudpReliableSendStatus::Pending + ); + + let survivor_receipt = send_reliable( + &mut survivor, + &CultNetMessage::DocumentPutRaw { + message_id: "accepted-after-rejections".into(), + document: document("survivor", vec![9, 8, 7]), + }, + )?; + assert_eq!(server.poll_once()?, CultMeshRudpPollOutcome::Handled); + for _ in 0..20 { + survivor.receive_once()?; + if survivor.reliable_send_status(&survivor_receipt) + == CultNetRudpReliableSendStatus::Acknowledged + { + break; + } + } + assert_eq!( + survivor.reliable_send_status(&survivor_receipt), + CultNetRudpReliableSendStatus::Acknowledged + ); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); + assert_eq!(source.0.lock().unwrap().calls, 1); + Ok(()) +} + +#[test] +fn snapshot_response_resends_until_acknowledged() -> Result<()> { + let clock = Clock::new(51_000); + let expected = document("catalog", vec![0x81, 0xa1, 0x78, 0x2a]); + let source = Source::documents(vec![expected.clone()]); + let options = CultMeshRudpDocumentServerOptions { + resend_delay: Duration::from_millis(10), + ..Default::default() + }; + let mut server = server(options, clock.clone(), Sink::default(), source)?; + let mut client = client(server.local_addr()?, 88)?; + connect(&mut server, &mut [&mut client])?; + send( + &mut client, + &CultNetMessage::SnapshotRequest { + message_id: "snapshot".into(), + schema_ids: Some(vec!["test.raw.v1".into()]), + record_keys: Some(vec!["catalog".into()]), + }, + )?; + server.poll_once()?; + + clock.set(51_011); + assert_eq!(server.maintain()?.packets_resent, 1); + let response = loop { + if let Some(frame) = client.receive_once()? { + break decode_cultnet_message_from_slice( + &frame.payload, + CultNetWireContract::CultNetSchemaV0, + )?; + } + }; + assert_eq!( + response, + CultNetMessage::SnapshotResponseRaw { + message_id: "snapshot".into(), + documents: vec![expected], + } + ); + + for _ in 0..20 { + server.poll_once()?; + } + clock.set(51_022); + assert_eq!(server.maintain()?.packets_resent, 0); + Ok(()) +} + +#[test] +fn payload_and_snapshot_output_budgets_fail_closed() -> Result<()> { + let clock = Clock::new(55_000); + let sink = Sink::default(); + let message = CultNetMessage::DocumentPutRaw { + message_id: "budget".into(), + document: document("budget", vec![7; 64]), + }; + let encoded = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)?; + let options = CultMeshRudpDocumentServerOptions { + max_admitted_payload_bytes: encoded.len(), + max_admitted_payload_bytes_per_session: encoded.len(), + max_snapshot_response_bytes: encoded.len(), + ..Default::default() + }; + let mut budget_server = server(options, clock, sink.clone(), Source::default())?; + let target = budget_server.local_addr()?; + let mut first = client(target, 111)?; + let mut second = client(target, 112)?; + connect(&mut budget_server, &mut [&mut first, &mut second])?; + send(&mut first, &message)?; + budget_server.poll_once()?; + send(&mut second, &message)?; + budget_server.poll_once()?; + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); + + let oversized = Source::documents(vec![document("oversized", vec![8; 512])]); + let options = CultMeshRudpDocumentServerOptions { + max_snapshot_response_bytes: 128, + ..Default::default() + }; + let mut snapshot_server = server(options, Clock::new(56_000), Sink::default(), oversized)?; + let mut snapshot_client = client(snapshot_server.local_addr()?, 113)?; + connect(&mut snapshot_server, &mut [&mut snapshot_client])?; + let receipt = send_reliable( + &mut snapshot_client, + &CultNetMessage::SnapshotRequest { + message_id: "too-large".into(), + schema_ids: None, + record_keys: None, + }, + )?; + let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = snapshot_server.poll_once()? + else { + panic!("oversized snapshot must reject only its session"); + }; + assert_eq!( + rejection.operation, + CultMeshRudpApplicationOperation::SnapshotRequest + ); + assert_eq!(rejection.message_id, "too-large"); + assert!(rejection.reason.contains("snapshot response is")); + assert_eq!(snapshot_server.session_count(), 0); + snapshot_client.receive_once()?; + assert_eq!( + snapshot_client.reliable_send_status(&receipt), + CultNetRudpReliableSendStatus::Pending + ); + Ok(()) +} + +#[test] +fn session_cap_and_expiry_use_monotonic_time() -> Result<()> { + let clock = Clock::new(60_000); + let options = CultMeshRudpDocumentServerOptions { + max_sessions: 1, + session_idle_timeout: Duration::from_secs(1), + session_max_lifetime: Duration::from_millis(100), + ..Default::default() + }; + let mut server = server(options, clock.clone(), Sink::default(), Source::default())?; + let target = server.local_addr()?; + let mut admitted = client(target, 1)?; + connect(&mut server, &mut [&mut admitted])?; + let mut rejected = client(target, 1)?; + rejected.connect(Vec::new())?; + server.poll_once()?; + assert_eq!(server.session_count(), 1); + assert!(!rejected.connected()); + + clock.set_unix(1); + clock.set_monotonic(60_101); + assert_eq!(server.maintain()?.sessions_expired, 1); + assert_eq!(server.session_count(), 0); + Ok(()) +} diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index e0481ed7..d3fe565b 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -11,6 +11,195 @@ frame body path. The TypeScript projection keeps the same local catalog ergonomics as the other runtimes: sorted views, direct lookup, and unsubscribe-able watches. +## Shared Primitives + +CultMesh primitives are shared cross-runtime developer affordances, not just +DTOs. They bundle the typed surface, metadata, routing hints, diagnostics, and +fast-path hooks that let application code feel like it is talking to native +state while CultMesh chooses the right local, shared-memory, IPC, network, or +WASM route. Runtime code can ask for a whole XY rect, then adapt it to the +current wire schema at the edge: + +```ts +import { CultMesh } from "cultmesh-ts"; + +const viewport = CultMesh.rectFromBounds(-500, -300, 500, 300); +const request = CultMesh.viewportRequest(viewport, [3, 8]); +``` + +The request currently projects to the compact `{ minX, minY, maxX, maxY }` +shape used by Aetheria viewport queries, while the authored code keeps the +designer-facing concept as a `CultMeshRect`. + +Typed operations follow the same rule: client code holds a method-shaped handle +and a runtime context, while CultMesh hides the route and authority metadata +from call sites that do not need to inspect it. + +```ts +const move = CultMesh.operation("game.pilot.move.v1", async (request, context) => { + return submitToBestRoute(request, context); +}); + +await move.invoke({ direction: CultMesh.vec2(1, 0) }, CultMesh.operationContext("rts-client", { + routeHint: CultMesh.routeHint("network", "starfire Verse"), +})); +``` + +Typed query surfaces can be read once or watched through the same handle. A +projection recipe keeps its source metadata, route preference, and reactive +watcher when it becomes a query surface: + +```ts +const objects = CultMesh.projectionRecipe( + "aetheria.zone.objects.visible", + [CultMesh.projectionSource("daemon:aetheria.frame.latest.v1")], + projectObjects, + { + routeHint: CultMesh.routeHint("shared-memory", "co-located frame slab"), + watchProjection: watchObjects, + }, +).asQuerySurface(); + +const stop = objects.watch( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-10, -10, 10, 10)) }, + "browser-starfire", + (next) => render(next), +); +``` + +Typed document handles use the same shape for local cache reads, network +snapshots, replacement, and client prediction. Authority-specific code plugs in +the submitter once; gameplay callers keep using the document: + +```ts +const input = CultMesh.document( + "input:pilot-a:thermal", + pilotInputDocument, + async (context) => readInput(context), + { + routeHint: CultMesh.routeHint("network", "Starbridge Verse"), + watchDocument: watchInput, + submitPrediction: async (context, value) => submitPredictedInput(context, value), + }, +); + +await input.submitPrediction("pilot-a", predictedInput); +const stop = input.watch("pilot-a", latest => reconcileInput(latest)); +``` + +Durable nodes resolve same-schema aliases at the boundary. Register the +canonical document that owns storage and replication, then ask the node for the +typed view the current runtime wants: + +```ts +const station = await CultMesh.startNode(statePath, { + documents: [stationStockDocument], +}); + +const stock = station.document(stationStockUiDocument, "station:starbridge:stock"); +const current = await stock.latest(); + +await stock.authoritativeWriter().write(updatedStockFromUi); + +const reactive = stock.authoritativeWriter().reactive(); +await reactive.ready; +reactive.update(draft => { + draft.availableMissiles -= 4; +}); +``` + +State pointers are the same kind of managed surface for UI and tools. They can +advertise source documents, inherit a Verse route when bound, and resolve +through that Verse without caller-side context plumbing: + +```ts +const framePointer = CultMesh.statePointer( + "daemon:aetheria.frame.latest.v1", + async (context) => readFrameViaBestRoute(context), + undefined, + { + sources: [CultMesh.projectionSource("daemon:aetheria.frame.latest.v1")], + routeHint: CultMesh.routeHint("shared-memory", "co-located daemon frame"), + }, +); + +const frame = await CultMesh + .bindStatePointer(verse.withRoute("ipc", "Bifrost tool bridge"), framePointer) + .resolve(); + +const stateBinding = CultMesh.stateBinding("value", framePointer); +const stateBindingRecord = CultMesh.stateBindingRecord(stateBinding); +const restoredStateBinding = CultMesh.stateBindingFromRecord(stateBindingRecord); +``` + +Eve/CultUI lowerers that still receive authored state refs should use a named +state-ref resolver instead of local string utilities. Resolvers compose, carry +source and route metadata, and can be described by tools: + +```ts +const daemonRefs = CultMesh.stateRefResolver( + "aetheria.daemon.refs", + (stateRef, context) => resolveDaemonStateRef(stateRef, context), + { + sources: [CultMesh.projectionSource("daemon:aetheria.frame.latest.v1")], + routeHint: CultMesh.routeHint("shared-memory", "co-located daemon state"), + }, +); + +const catalogRefs = CultMesh.stateRefResolver( + "aetheria.item_stats.refs", + (stateRef) => resolveItemStatRef(stateRef), +); + +const resolver = daemonRefs.or(catalogRefs); +const value = resolver.resolve("aetheria:daemon:item-stat:laser.output"); +const diagnostic = CultMesh.describeStateRefResolver(resolver); +``` + +Operation surfaces should preserve typed CultMesh identity all the way through +UI/runtime boundaries. Use invocation records and payload records at persistence +edges instead of local route parsers or string-map conventions: + +```ts +const binding = CultMesh.operationBinding("gamecult.aetheria.pilot.set_target.v1", { + schemaId: "gamecult.aetheria.pilot.set_target.request.v1", + routeHint: CultMesh.routeHint("network", "co-op Verse"), +}); +const bindingRecord = CultMesh.operationBindingRecord(binding); +const restoredBinding = CultMesh.operationBindingFromRecord(bindingRecord); + +const invocation = CultMesh.operationInvocation(binding, { + idempotencyKey: "target:starfire:42", +}); +const invocationRecord = CultMesh.operationInvocationRecord(invocation); +const payloadRecord = CultMesh.operationPayload({ targetEntityKey: "bandit-7" }).toRecord(); +const restored = CultMesh.operationInvocationFromRecord(invocationRecord); +``` + +Route fields use the same shared primitive: + +```ts +const routeRecord = CultMesh.routeRecord(CultMesh.routeHint("shared-memory", "co-located slab")); +const route = CultMesh.routeFromRecord(routeRecord); +``` + +When a client needs one coherent live view composed from several query surfaces, +use a live feed instead of putting a refresh loop in the UI: + +```ts +const viewportFeed = CultMesh.liveFeed( + "aetheria.rts.viewport.feed", + snapshotViewport, + { + sources: objects.sources, + routeHint: objects.routeHint, + watchFeed: CultMesh.pollingQueryWatcher(snapshotViewport, { intervalMs: 50 }), + }, +); + +const stopFeed = viewportFeed.watch(request, "browser-starfire", render); +``` + ```ts import { CultMesh } from "cultmesh-ts"; @@ -75,6 +264,71 @@ Schema and shard catalog factories delegate to `cultnet-ts`; the branded `CultMesh` entrypoint does not create a second owner for schema discovery or topology truth. +## Long-Lived Providers + +TypeScript provider daemons use `CultMeshProviderSession` to retain one logical +provider lifecycle across physical reconnects. The session owns registration, +lease renewal, desired typed publication replay, command dispatch, receipt +publication, and withdrawal. Provider code owns domain state and idempotent +command transactions; the injected transport owns CultNet wire details. + +```ts +import { + CultMeshProviderRudpTransport, + CultMeshProviderSession, +} from "cultmesh-ts"; + +const odinTransport = new CultMeshProviderRudpTransport({ + endpoint: "rudp://127.0.0.1:17871", + runtimeId: "voidbot-worker-7", + connectionId: 0x43554c54, + sessionToken: process.env.ODIN_PROVIDER_SESSION_TOKEN, +}); + +const session = new CultMeshProviderSession({ + identity: { + providerId: "voidbot.swarm", + serviceInstanceId: "voidbot-worker-7", + endpointId: "odin:voidbot-worker-7", + verseId: "voidbot.local", + }, + transport: odinTransport, + receiptStore: durableReceiptStore, + publications: [advertisementPublication, surfacePublication], + commandHandlers: { + "swarm.set_heat": applyHeatCommandTransaction, + }, +}); + +await session.start(); +await session.upsertPublication(nextSurfacePublication); +``` + +Provider, service-instance, endpoint, and body-producer identities are separate +contracts. The provider session never infers one from another. A durable +receipt store is required; `CultMeshMemoryProviderReceiptStore` is intended +only for tests and disposable tools. The store is a durable outbox: a receipt +is persisted before transport publication, remains pending across reconnects, +and is marked published only after the current connection accepts it. Receipt +store failure degrades command intake without inventing a network outage. + +The RUDP provider transport carries lifecycle payloads inside the existing +`cultnet.operation_request.v0` and `cultnet.operation_response.v0` envelopes. +RUDP acknowledgements prove byte delivery only. Registration, renewal, +publication changes, receipts, and withdrawal complete only after the +provider-session broker returns a correlated application response. The broker +owns lease fencing and accepted publication membership; the provider owns its +desired documents and durable receipt outbox. + +`CultMeshProviderRudpTransport` is currently a private-development transport. +Do not advertise it as a public provider boundary until the surrounding +CultNet session authenticates a claim authorizing the four-part provider +identity. Source address and successful contact are not authority. +The adapter carries `sessionToken` in a typed RUDP Connect-evidence map beside +a fresh `clientSessionId`. Packet retransmits reuse that id; an actual reconnect +mints another. The receiver must decode and validate the token as authorization +evidence for the requested provider identity. + ## RUDP Helpers Node runtimes can build the shared CultNet reliable-UDP transport from the @@ -117,6 +371,30 @@ const client = await CultMesh.createRudpPeerForAuthorizedPeer( ); const schemas = CultMesh.createSchemaCatalog(); await client.syncSchemaCatalog(schemas, { kinds: ["document_payload"] }); + +const health = CultMesh.documentFromPeerSnapshot( + () => client, + daemonHealthDocument, + "daemon:aetheria.health.v1", + { + routeHint: CultMesh.routeHint("network", endpoint.uri), + timeoutMs: 5_000, + }, +); + +const latestHealth = await health.latest(); + +const syncedHealth = await station.syncDocumentFromPeerSnapshot( + () => client, + daemonHealthUiDocument, + "daemon:aetheria.health.v1", + { + timeoutMs: 5_000, + }, +); +const localHealth = await station + .document(daemonHealthUiDocument, "daemon:aetheria.health.v1") + .latest(); ``` `CultMesh.createRudpPeerForPeer(...)` remains available for already trusted @@ -127,6 +405,38 @@ Discovery-first schema/catalog paths should prefer `CultMeshPeerCatalog.firstAuthorized(...)` with the authority lease catalog before using the peer-card endpoint as a dial target. +`CultMesh.documentFromPeerSnapshot(...)` is the document-level path for RUDP +snapshots. It hides the snapshot request message id, response listener, raw +payload binary normalization, and MessagePack decode behind the same +`CultMeshDocumentHandle` shape as local documents. The helper prefers an exact +schema id match and falls back to the requested record key, so runtimes that +alias the same logical document through different generated schema names can +still read the publication with one CultMesh call. Pass a CultCache document +definition instead of a raw schema id when you want a typed handle whose +`latest()` result is parsed through that definition. + +Use `node.syncDocumentFromPeerSnapshot(...)` or +`CultMesh.syncDocumentFromPeerSnapshot(...)` when a runtime should hydrate its +local node from the remote snapshot. The helper requests the raw snapshot, +applies it through the node's document registry, and returns the requested +typed definition. Same-schema aliases stay local after the call: the caller can +keep using `node.document(aliasDefinition, key)` and explicitly select +`authoritativeWriter()`, `predictionWriter()`, or `observe()` +without re-threading the RUDP peer. + +The same typed-definition overload is available for +`CultMesh.documentFromStore(...)` and `CultMesh.documentFromSingleFile(...)`, so +local schema publications can be read as typed reactive documents without +manually pulling records, decoding MessagePack, or parsing the payload. +When the caller wants source selection to be configuration, use +`CultMesh.documentFromPublication(...)` with a `single-file`, `store`, or +`peer-snapshot` source; it returns the same typed handle while hiding whether +sync came from local persistence or a remote CultNet snapshot. +Use `node.syncDocumentFromPublication(...)` or +`CultMesh.syncDocumentFromPublication(...)` for the same source-agnostic shape +when the publication should hydrate a local node before the caller continues +through `node.document(...)` and its explicit writer or observer. + ## Streaming Mode CultMesh streaming mode is for audio, video, tensor, and opaque byte frames that diff --git a/packages/cultmesh-ts/package.json b/packages/cultmesh-ts/package.json index b233d448..0ca8afb3 100644 --- a/packages/cultmesh-ts/package.json +++ b/packages/cultmesh-ts/package.json @@ -1,16 +1,24 @@ { "name": "cultmesh-ts", - "version": "0.1.0", + "version": "0.1.1", "description": "CultMesh local typed state and mesh catalog primitives for TypeScript runtimes.", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "default": "./dist/index.js" + } + }, "files": [ "dist" ], "scripts": { - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --pretty false", - "test": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js" + "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", + "prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", + "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false", + "test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js" }, "keywords": [ "cultmesh", @@ -21,10 +29,22 @@ ], "author": "metacratic", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/GameCult/CultLib.git", + "directory": "packages/cultmesh-ts" + }, + "bugs": { + "url": "https://github.com/GameCult/CultLib/issues" + }, + "homepage": "https://github.com/GameCult/CultLib/tree/main/packages/cultmesh-ts#readme", + "engines": { + "node": ">=18" + }, "dependencies": { "@msgpack/msgpack": "^3.1.2", - "cultcache-ts": "file:../cultcache-ts", - "cultnet-ts": "file:../cultnet-ts" + "@gamecult/cultcache-ts": "^0.14.0", + "cultnet-ts": "^0.2.0" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 53225705..e9fbb8be 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -6,8 +6,9 @@ import { SingleFileMessagePackBackingStore, type AnyCultCacheDocumentDefinition, type CacheBackingStore, + type CultCacheEnvelope, type CultCacheDocumentValue, -} from "cultcache-ts"; +} from "@gamecult/cultcache-ts"; import { CultNetDocumentRegistry, CultNetRudpSession, @@ -21,14 +22,3974 @@ import { encodeCultNetMessageForWire, encodeRudpPacket, parseCultNetMessage, + type CultNetErrorMessage, type CultNetDocumentBinding, type CultNetMessage, + type CultNetOperationRequestMessage, + type CultNetOperationResponseMessage, type CultNetRawDocumentRecord, type CultNetReconnectPolicy, type CultNetSchemaCatalogOptions, + type CultNetSnapshotResponseRawMessage, type CultNetWireContract, } from "cultnet-ts"; +export * from "./provider-session"; +export * from "./provider-session-wire"; +export * from "./provider-rudp-transport"; +export * from "./provider-session-broker"; + +export interface CultMeshVec2 { + readonly x: number; + readonly y: number; +} + +export interface CultMeshRect { + readonly min: CultMeshVec2; + readonly max: CultMeshVec2; +} + +export interface CultMeshViewportRequest { + readonly minX: number; + readonly minY: number; + readonly maxX: number; + readonly maxY: number; + readonly controlledEntityIndices?: readonly number[]; +} + +export type CultMeshLocalityKind = + | "automatic" + | "in-process" + | "shared-memory" + | "ipc" + | "network" + | "wasm"; + +export interface CultMeshRouteHint { + readonly kind: CultMeshLocalityKind; + readonly description?: string; +} + +export interface CultMeshRouteRecord { + readonly kind: string; + readonly description: string; +} + +/** + * Consumer view of provider-session publications. The provider-session client + * owns ordering, replay, and reconnect; document handles only project that + * live publication state into typed latest/watch semantics. + */ +export interface CultMeshLivePublicationSource { + latest(schemaId: string, recordKey: string): Promise; + watch( + schemaId: string, + recordKey: string, + callback: (value: unknown) => void, + ): CultMeshUnsubscribe; +} + +export type CultMeshDocumentPublicationSource = + | { + readonly kind: "single-file"; + readonly path: string; + } + | { + readonly kind: "store"; + readonly store: CacheBackingStore; + } + | { + readonly kind: "peer-snapshot"; + readonly peer: CultNetPeer | (() => CultNetPeer | Promise); + readonly endpoint?: string; + } + | { + readonly kind: "live-publication"; + readonly source: CultMeshLivePublicationSource; + readonly endpoint?: string; + }; + +export interface CultMeshPublicationDocumentBinding { + readonly schema: string | AnyCultCacheDocumentDefinition; + readonly recordKey: string; + readonly documentId?: string; + readonly sourceId?: string; + readonly source?: CultMeshDocumentPublicationSource; +} + +export type CultMeshDocumentPublicationSourceResolver = + | CultMeshDocumentPublicationSource + | ((binding: CultMeshPublicationDocumentBinding) => CultMeshDocumentPublicationSource); + +export interface CultMeshAuthorityClaim { + readonly role: string; + readonly shardId?: string; + readonly leaseId?: string; +} + +export interface CultMeshVerseContext { + readonly verseId: string; + readonly runtimeId: string; + readonly routeHint: CultMeshRouteHint; + readonly claims: readonly CultMeshAuthorityClaim[]; +} + +export interface CultMeshOperationContext { + readonly runtimeId: string; + readonly claims: readonly CultMeshAuthorityClaim[]; + readonly routeHint: CultMeshRouteHint; + readonly idempotencyKey?: string; +} + +export interface CultMeshOperationReceipt { + readonly operationId: string; + readonly accepted: boolean; + readonly route: CultMeshRouteHint; + readonly diagnostic?: string; +} + +export interface CultMeshQueryContext { + readonly runtimeId: string; + readonly routeHint: CultMeshRouteHint; +} + +export type CultMeshUnsubscribe = () => void; + +export type CultMeshDocumentWatcher = ( + context: CultMeshQueryContext, + callback: (value: TDocument) => void, +) => CultMeshUnsubscribe; + +export type CultMeshDocumentReplacer = ( + context: CultMeshQueryContext, + value: TDocument, +) => Promise; + +export type CultMeshDocumentPredictionSubmitter = ( + context: CultMeshQueryContext, + value: TDocument, +) => Promise; + +export type CultMeshDocumentUpdater = ( + current: TDocument, +) => TDocument | Promise; + +export type CultMeshCollectionSnapshot = readonly TDocument[]; + +export type CultMeshCollectionChangeKind = "added" | "updated" | "removed" | "reset"; + +export interface CultMeshCollectionChange { + readonly kind: CultMeshCollectionChangeKind; + readonly key?: string; + readonly value?: TDocument; +} + +export type CultMeshCollectionWatcher = ( + context: CultMeshQueryContext, + callback: (change: CultMeshCollectionChange) => void, +) => CultMeshUnsubscribe; + +export interface CultMeshDocumentSchemaDescriptor { + readonly type?: string; + readonly schemaId?: string; + readonly schemaName?: string; + readonly schemaVersion?: string; +} + +export type CultMeshStatePointerResolver = ( + context: CultMeshQueryContext, +) => Promise; + +export type CultMeshStatePointerWatcher = ( + context: CultMeshQueryContext, + callback: (value: T) => void, +) => CultMeshUnsubscribe; + +export type CultMeshMutableStatePointerReplacer = ( + context: CultMeshQueryContext, + value: T, +) => Promise; + +export type CultMeshQueryWatcher = ( + parameters: TParameters, + context: CultMeshQueryContext, + callback: (value: TResult) => void, +) => CultMeshUnsubscribe; + +export type CultMeshLiveFeedWatcher = ( + parameters: TParameters, + context: CultMeshQueryContext, + callback: (value: TResult) => void, +) => CultMeshUnsubscribe; + +export interface CultMeshPollingWatchOptions { + readonly intervalMs?: number; + readonly emitInitial?: boolean; + readonly equals?: (left: TResult, right: TResult) => boolean; +} + +export interface CultMeshReactiveDocumentOptions { + readonly context?: CultMeshQueryContext | string; + readonly watch?: boolean; + readonly debounceMs?: number; + readonly replaceDirtyCurrentOnCanonicalSnapshot?: boolean; + readonly onError?: (error: unknown) => void; +} + +export type CultMeshReactiveDocumentDelta = Partial<{ + readonly [K in keyof TDocument]: TDocument[K] extends number ? number : TDocument[K]; +}>; + +export interface CultMeshReactiveDocumentReconciliation { + readonly version: number; + readonly canonical: TDocument; + readonly predicted: TDocument; + readonly delta: CultMeshReactiveDocumentDelta; + readonly receivedAtMs: number; +} + +export interface CultMeshReactiveDocument { + readonly writer: CultMeshDocumentWriter; + readonly snapshot: TDocument; + readonly dirty: boolean; + readonly reconciliation: CultMeshReactiveDocumentReconciliation | undefined; + readonly ready: Promise; + readonly disposed: boolean; + update(update: (draft: TDocument) => void | TDocument): TDocument; + replaceLocal(value: TDocument): TDocument; + flush(): Promise; + clearReconciliation(): void; + refresh(): Promise; + dispose(): void; +} + +export interface CultMeshObservedDocument { + readonly current: TDocument; + readonly ready: Promise; + readonly disposed: boolean; + refresh(): Promise; + dispose(): void; +} + +export type CultMeshDocumentWriteKind = "authoritative-replacement" | "prediction"; + +export interface CultMeshNativeSliceColumn { + readonly name: string; + readonly valueType: string; + readonly elementSizeBytes: number; +} + +export interface CultMeshNativeSliceViewDescriptor { + readonly viewId: string; + readonly schemaId: string; + readonly rowCount: number; + readonly columns: readonly CultMeshNativeSliceColumn[]; + readonly route: CultMeshRouteHint; + readonly nativeHandle?: string; +} + +export interface CultMeshProjectionSource { + readonly sourceId: string; + readonly schemaId?: string; + readonly description?: string; +} + +export interface CultMeshStateBindingDescriptor { + readonly targetProp: string; + readonly pointerId: string; + readonly sourceId?: string; + readonly schemaId?: string; + readonly routeHint: CultMeshRouteHint; + readonly bindingName?: string; + readonly documentId?: string; + readonly fieldPath?: string; + readonly valueKind?: "string" | "number" | "boolean" | "choice" | "string-list"; + readonly accessMode?: "read" | "write" | "read-write" | "local-draft"; + readonly authority?: string; + readonly writeCommand?: string; +} + +export interface CultMeshStateBindingRecord { + readonly targetProp: string; + readonly pointerId: string; + readonly sourceId: string; + readonly schemaId: string; + readonly routeKind: string; + readonly routeDescription: string; + readonly bindingName: string; + readonly documentId: string; + readonly fieldPath: string; + readonly valueKind: string; + readonly accessMode: string; + readonly authority: string; + readonly writeCommand: string; +} + +export interface CultMeshOperationBindingDescriptor { + readonly operationId: string; + readonly label: string; + readonly schemaId: string; + readonly routeHint: CultMeshRouteHint; +} + +export interface CultMeshOperationBindingRecord { + readonly operationId: string; + readonly label: string; + readonly schemaId: string; + readonly routeKind: string; + readonly routeDescription: string; +} + +export interface CultMeshOperationInvocationDescriptor { + readonly operationId: string; + readonly schemaId: string; + readonly routeHint: CultMeshRouteHint; + readonly idempotencyKey?: string; +} + +export interface CultMeshOperationInvocationRecord { + readonly operationId: string; + readonly schemaId: string; + readonly routeKind: string; + readonly routeDescription: string; + readonly idempotencyKey: string; +} + +export interface CultMeshOperationPayload { + readonly fields: Readonly>; + getString(key: string, defaultValue?: string): string; + getInt(key: string, defaultValue?: number): number; + getDouble(key: string, defaultValue?: number): number; + getBoolean(key: string, defaultValue?: boolean): boolean; + with(key: string, value: string | number | boolean): CultMeshOperationPayload; + toRecord(): Readonly>; +} + +export interface CultMeshQuerySurfaceDiagnostic { + readonly queryId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; +} + +export interface CultMeshOperationHandleDiagnostic { + readonly operationId: string; +} + +export interface CultMeshStatePointerDiagnostic { + readonly pointerId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; +} + +export interface CultMeshStateRefResolverDiagnostic { + readonly resolverId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; +} + +export interface CultMeshProjectionRecipeDiagnostic { + readonly projectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; +} + +export interface CultMeshLiveFeedDiagnostic { + readonly feedId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; +} + +export type CultMeshSurfaceKind = + | "query" + | "projection-recipe" + | "live-feed" + | "operation" + | "document" + | "collection" + | "state-pointer" + | "native-slice-view"; + +export interface CultMeshSurfaceDiagnostic { + readonly kind: CultMeshSurfaceKind; + readonly surfaceId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; +} + +export interface CultMeshSurfaceCatalogDiagnostic { + readonly catalogId: string; + readonly surfaces: readonly CultMeshSurfaceDiagnostic[]; +} + +export interface CultMeshSurfaceCatalogIndexDiagnostic { + readonly catalogId: string; + readonly queries: readonly CultMeshSurfaceDiagnostic[]; + readonly projectionRecipes: readonly CultMeshSurfaceDiagnostic[]; + readonly liveFeeds: readonly CultMeshSurfaceDiagnostic[]; + readonly operations: readonly CultMeshSurfaceDiagnostic[]; + readonly documents: readonly CultMeshSurfaceDiagnostic[]; + readonly collections: readonly CultMeshSurfaceDiagnostic[]; + readonly statePointers: readonly CultMeshSurfaceDiagnostic[]; + readonly nativeSliceViews: readonly CultMeshSurfaceDiagnostic[]; +} + +export interface CultMeshNativeSliceViewDiagnostic { + readonly viewId: string; + readonly schemaId: string; + readonly rowCount: number; + readonly columns: readonly CultMeshNativeSliceColumn[]; + readonly route: CultMeshRouteHint; + readonly nativeHandle?: string; + readonly denseRowStrideBytes: number; +} + +export class CultMeshOperationContextBuilder { + readonly #runtimeId: string; + #claims: CultMeshAuthorityClaim[] = []; + #routeHint: CultMeshRouteHint = cultMeshRouteHint(); + #idempotencyKey: string | undefined; + + public constructor(runtimeId: string) { + requireNonEmpty(runtimeId, "runtimeId"); + this.#runtimeId = runtimeId; + } + + public claim( + role: string, + options: { shardId?: string; leaseId?: string } = {}, + ): this { + this.#claims.push(cultMeshAuthorityClaim(role, options)); + return this; + } + + public claims(claims: readonly CultMeshAuthorityClaim[]): this { + this.#claims.push(...claims); + return this; + } + + public route(kind: CultMeshLocalityKind, description?: string): this { + this.#routeHint = cultMeshRouteHint(kind, description); + return this; + } + + public idempotency(key: string): this { + requireNonEmpty(key, "idempotencyKey"); + this.#idempotencyKey = key; + return this; + } + + public build(): CultMeshOperationContext { + return cultMeshOperationContext(this.#runtimeId, { + claims: this.#claims, + routeHint: this.#routeHint, + idempotencyKey: this.#idempotencyKey, + }); + } +} + +export class CultMeshQueryContextBuilder { + readonly #runtimeId: string; + #routeHint: CultMeshRouteHint = cultMeshRouteHint(); + + public constructor(runtimeId: string) { + requireNonEmpty(runtimeId, "runtimeId"); + this.#runtimeId = runtimeId; + } + + public route(kind: CultMeshLocalityKind, description?: string): this { + this.#routeHint = cultMeshRouteHint(kind, description); + return this; + } + + public build(): CultMeshQueryContext { + return cultMeshQueryContext(this.#runtimeId, { + routeHint: this.#routeHint, + }); + } +} + +export class CultMeshVerse { + public constructor(public readonly context: CultMeshVerseContext) { + requireNonEmpty(context.verseId, "verseId"); + requireNonEmpty(context.runtimeId, "runtimeId"); + } + + public get verseId(): string { + return this.context.verseId; + } + + public get runtimeId(): string { + return this.context.runtimeId; + } + + public use(schemaFactory: (context: CultMeshVerseContext) => TSchema): TSchema { + return schemaFactory(this.context); + } + + public withRoute(kind: CultMeshLocalityKind, description?: string): CultMeshVerse { + return new CultMeshVerse({ + ...this.context, + routeHint: cultMeshRouteHint(kind, description), + }); + } + + public withClaim(role: string, options: { shardId?: string; leaseId?: string } = {}): CultMeshVerse { + return new CultMeshVerse({ + ...this.context, + claims: [...this.context.claims, cultMeshAuthorityClaim(role, options)], + }); + } + + public operationContext(options: { idempotencyKey?: string } = {}): CultMeshOperationContext { + return cultMeshOperationContextFromVerse(this.context, options); + } + + public queryContext(): CultMeshQueryContext { + return cultMeshQueryContextFromVerse(this.context); + } + + public bindOperation( + operation: CultMeshOperationHandle, + ): CultMeshBoundOperationHandle { + return cultMeshBindOperation(this.context, operation); + } + + public bindQuery( + query: CultMeshQuerySurface, + ): CultMeshBoundQuerySurface { + return cultMeshBindQuery(this.context, query); + } + + public bindLiveFeed( + feed: CultMeshLiveFeed, + ): CultMeshBoundLiveFeed { + return cultMeshBindLiveFeed(this.context, feed); + } + + public bindDocument( + document: CultMeshDocumentHandle, + ): CultMeshBoundDocumentHandle { + return cultMeshBindDocument(this.context, document); + } + + public bindCollection( + collection: CultMeshCollectionHandle, + ): CultMeshBoundCollectionHandle { + return cultMeshBindCollection(this.context, collection); + } + + public bindStatePointer( + pointer: CultMeshStatePointer, + ): CultMeshBoundStatePointer { + return cultMeshBindStatePointer(this.context, pointer); + } + + public bindMutableStatePointer( + pointer: CultMeshMutableStatePointer, + ): CultMeshBoundMutableStatePointer { + return cultMeshBindMutableStatePointer(this.context, pointer); + } +} + +export class CultMeshOperationHandle { + public constructor( + public readonly operationId: string, + private readonly invokeOperation: ( + request: TRequest, + context: CultMeshOperationContext, + ) => Promise, + ) { + requireNonEmpty(operationId, "operationId"); + } + + public invoke( + request: TRequest, + context: CultMeshOperationContext | string, + ): Promise { + return this.invokeOperation( + request, + typeof context === "string" ? cultMeshOperationContext(context) : context, + ); + } + + public bind( + verse: CultMeshVerseContext | CultMeshVerse, + ): CultMeshBoundOperationHandle { + return cultMeshBindOperation(verse, this); + } +} + +export class CultMeshBoundOperationHandle { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly operation: CultMeshOperationHandle, + ) {} + + public get operationId(): string { + return this.operation.operationId; + } + + public invoke( + request: TRequest, + options: { idempotencyKey?: string } = {}, + ): Promise { + return this.operation.invoke( + request, + cultMeshOperationContextFromVerse(this.verse, options), + ); + } +} + +export class CultMeshQuerySurface { + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly queryId: string, + private readonly executeQuery: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchQuery?: CultMeshQueryWatcher; + } = {}, + ) { + requireNonEmpty(queryId, "queryId"); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + this.watchQuery = options.watchQuery; + } + + private readonly watchQuery: CultMeshQueryWatcher | undefined; + + public execute( + parameters: TParameters, + context: CultMeshQueryContext | string, + ): Promise { + return this.executeQuery( + parameters, + typeof context === "string" ? cultMeshQueryContext(context) : context, + ); + } + + public query(parameters: TParameters, context: CultMeshQueryContext | string): Promise { + return this.execute(parameters, context); + } + + public watch( + parameters: TParameters, + context: CultMeshQueryContext | string, + callback: (value: TResult) => void, + ): CultMeshUnsubscribe { + if (!this.watchQuery) { + throw new Error(`Query surface '${this.queryId}' does not support watches.`); + } + + return this.watchQuery( + parameters, + typeof context === "string" ? cultMeshQueryContext(context) : context, + callback, + ); + } + + public bind( + verse: CultMeshVerseContext | CultMeshVerse, + ): CultMeshBoundQuerySurface { + return cultMeshBindQuery(verse, this); + } +} + +export class CultMeshBoundQuerySurface { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly query: CultMeshQuerySurface, + ) {} + + public get queryId(): string { + return this.query.queryId; + } + + public get sources(): readonly CultMeshProjectionSource[] { + return this.query.sources; + } + + public get routeHint(): CultMeshRouteHint { + return this.query.routeHint; + } + + public execute(parameters: TParameters): Promise { + return this.query.execute(parameters, cultMeshQueryContextFromVerse(this.verse)); + } + + public queryOnce(parameters: TParameters): Promise { + return this.execute(parameters); + } + + public watch( + parameters: TParameters, + callback: (value: TResult) => void, + ): CultMeshUnsubscribe { + return this.query.watch(parameters, cultMeshQueryContextFromVerse(this.verse), callback); + } +} + +export class CultMeshLiveFeed { + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly feedId: string, + private readonly snapshotFeed: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchFeed?: CultMeshLiveFeedWatcher; + } = {}, + ) { + requireNonEmpty(feedId, "feedId"); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + this.watchFeed = options.watchFeed; + } + + private readonly watchFeed: CultMeshLiveFeedWatcher | undefined; + + public snapshot( + parameters: TParameters, + context: CultMeshQueryContext | string, + ): Promise { + return this.snapshotFeed( + parameters, + this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), + ); + } + + public watch( + parameters: TParameters, + context: CultMeshQueryContext | string, + callback: (value: TResult) => void, + ): CultMeshUnsubscribe { + if (!this.watchFeed) { + throw new Error(`Live feed '${this.feedId}' does not support watches.`); + } + + return this.watchFeed( + parameters, + this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), + callback, + ); + } + + public bind( + verse: CultMeshVerseContext | CultMeshVerse, + ): CultMeshBoundLiveFeed { + return cultMeshBindLiveFeed(verse, this); + } + + private resolveContext(context: CultMeshQueryContext): CultMeshQueryContext { + if (context.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return context; + } + + return cultMeshQueryContext(context.runtimeId, { + routeHint: this.routeHint, + }); + } +} + +export class CultMeshBoundLiveFeed { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly feed: CultMeshLiveFeed, + ) {} + + public get feedId(): string { + return this.feed.feedId; + } + + public get sources(): readonly CultMeshProjectionSource[] { + return this.feed.sources; + } + + public get routeHint(): CultMeshRouteHint { + return this.feed.routeHint; + } + + public snapshot(parameters: TParameters): Promise { + return this.feed.snapshot(parameters, cultMeshQueryContextFromVerse(this.verse)); + } + + public watch( + parameters: TParameters, + callback: (value: TResult) => void, + ): CultMeshUnsubscribe { + return this.feed.watch(parameters, cultMeshQueryContextFromVerse(this.verse), callback); + } +} + +export class CultMeshDocumentHandle { + public readonly schema: CultMeshDocumentSchemaDescriptor; + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly documentId: string, + schema: CultMeshDocumentSchemaDescriptor, + private readonly snapshotDocument: (context: CultMeshQueryContext) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchDocument?: CultMeshDocumentWatcher; + replaceDocument?: CultMeshDocumentReplacer; + submitPrediction?: CultMeshDocumentPredictionSubmitter; + } = {}, + ) { + requireNonEmpty(documentId, "documentId"); + this.schema = normalizeCultMeshDocumentSchema(schema); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + this.watchDocument = options.watchDocument; + this.replaceDocument = options.replaceDocument; + this.submitPredictionDocument = options.submitPrediction; + } + + private readonly watchDocument: CultMeshDocumentWatcher | undefined; + private readonly replaceDocument: CultMeshDocumentReplacer | undefined; + private readonly submitPredictionDocument: + | CultMeshDocumentPredictionSubmitter + | undefined; + + public get canReplace(): boolean { + return this.replaceDocument !== undefined; + } + + public get canSubmitPrediction(): boolean { + return this.submitPredictionDocument !== undefined; + } + + public latest(context: CultMeshQueryContext | string = "local"): Promise { + return this.snapshotDocument( + this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), + ); + } + + public read(context: CultMeshQueryContext | string = "local"): Promise { + return this.latest(context); + } + + public watch(callback: (value: TDocument) => void): CultMeshUnsubscribe; + public watch(context: CultMeshQueryContext | string, callback: (value: TDocument) => void): CultMeshUnsubscribe; + public watch( + contextOrCallback: CultMeshQueryContext | string | ((value: TDocument) => void), + maybeCallback?: (value: TDocument) => void, + ): CultMeshUnsubscribe { + if (!this.watchDocument) { + throw new Error(`Document '${this.documentId}' does not support watches.`); + } + + const callback = + typeof contextOrCallback === "function" ? contextOrCallback : maybeCallback; + if (!callback) { + throw new Error(`Document '${this.documentId}' requires a watch callback.`); + } + + const context = + typeof contextOrCallback === "function" + ? cultMeshQueryContext("local") + : typeof contextOrCallback === "string" + ? cultMeshQueryContext(contextOrCallback) + : contextOrCallback; + + return this.watchDocument(this.resolveContext(context), callback); + } + + public replace(value: TDocument): Promise; + public replace(context: CultMeshQueryContext | string, value: TDocument): Promise; + public replace( + contextOrValue: CultMeshQueryContext | string | TDocument, + maybeValue?: TDocument, + ): Promise { + if (!this.replaceDocument) { + throw new Error(`Document '${this.documentId}' does not support replacement.`); + } + + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? typeof contextOrValue === "string" + ? cultMeshQueryContext(contextOrValue) + : contextOrValue as CultMeshQueryContext + : cultMeshQueryContext("local"); + const value = hasContext ? maybeValue : contextOrValue as TDocument; + if (value === undefined) { + throw new Error(`Document '${this.documentId}' requires a replacement value.`); + } + + return this.replaceDocument(this.resolveContext(context), value); + } + + public submitPrediction(value: TDocument): Promise; + public submitPrediction(context: CultMeshQueryContext | string, value: TDocument): Promise; + public submitPrediction( + contextOrValue: CultMeshQueryContext | string | TDocument, + maybeValue?: TDocument, + ): Promise { + if (!this.submitPredictionDocument) { + throw new Error(`Document '${this.documentId}' does not support prediction submission.`); + } + + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? typeof contextOrValue === "string" + ? cultMeshQueryContext(contextOrValue) + : contextOrValue as CultMeshQueryContext + : cultMeshQueryContext("local"); + const value = hasContext ? maybeValue : contextOrValue as TDocument; + if (value === undefined) { + throw new Error(`Document '${this.documentId}' requires a prediction value.`); + } + + return this.submitPredictionDocument(this.resolveContext(context), value); + } + + public authoritativeWriter( + context: CultMeshQueryContext | string = "local", + ): CultMeshDocumentWriter { + if (!this.replaceDocument) { + throw new Error(`Document '${this.documentId}' does not support authoritative replacement.`); + } + const resolved = this.resolveContext( + typeof context === "string" ? cultMeshQueryContext(context) : context, + ); + return new CultMeshDocumentWriter( + this as unknown as CultMeshDocumentHandle, + "authoritative-replacement", + resolved, + value => this.replaceDocument!(resolved, value as unknown as TDocument), + ); + } + + public predictionWriter( + context: CultMeshQueryContext | string = "local", + ): CultMeshDocumentWriter { + if (!this.submitPredictionDocument) { + throw new Error(`Document '${this.documentId}' does not support prediction submission.`); + } + const resolved = this.resolveContext( + typeof context === "string" ? cultMeshQueryContext(context) : context, + ); + return new CultMeshDocumentWriter( + this as unknown as CultMeshDocumentHandle, + "prediction", + resolved, + value => this.submitPredictionDocument!(resolved, value as unknown as TDocument), + ); + } + + public observe( + options: CultMeshReactiveDocumentOptions = {}, + ): CultMeshObservedDocument { + return new CultMeshObservedDocumentHandle( + this as unknown as CultMeshDocumentHandle, + options, + ); + } + + public asSchemaAlias( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TAlias } = {}, + ): CultMeshDocumentHandle { + const aliasSchema = normalizeCultMeshDocumentSchema(schema); + if (!cultMeshSchemasAreCompatible(this.schema, aliasSchema)) { + throw new Error( + `Document '${this.documentId}' schema ${cultMeshSchemaLabel(this.schema)} is not compatible with alias ${cultMeshSchemaLabel(aliasSchema)}.`, + ); + } + + const parse = options.parse ?? ((value: unknown) => value as TAlias); + return new CultMeshDocumentHandle( + this.documentId, + aliasSchema, + async (context) => parse(await this.latest(context)), + { + sources: this.sources, + routeHint: this.routeHint, + watchDocument: this.watchDocument + ? (context, callback) => this.watch(context, value => callback(parse(value))) + : undefined, + replaceDocument: this.replaceDocument + ? (context, value) => this.replace(context, value as unknown as TDocument) + : undefined, + submitPrediction: this.submitPredictionDocument + ? (context, value) => this.submitPrediction(context, value as unknown as TDocument) + : undefined, + }, + ); + } + + public bind(verse: CultMeshVerseContext | CultMeshVerse): CultMeshBoundDocumentHandle { + return cultMeshBindDocument(verse, this); + } + + private resolveContext(context: CultMeshQueryContext): CultMeshQueryContext { + if (context.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return context; + } + + return cultMeshQueryContext(context.runtimeId, { + routeHint: this.routeHint, + }); + } +} + +export class CultMeshDocumentWriter { + public constructor( + public readonly document: CultMeshDocumentHandle, + public readonly kind: CultMeshDocumentWriteKind, + public readonly context: CultMeshQueryContext, + private readonly writeDocument: (value: TDocument) => Promise, + ) {} + + public write(value: TDocument): Promise { + return this.writeDocument(cloneCultMeshDocument(value)); + } + + public async update(update: CultMeshDocumentUpdater): Promise { + const next = await update(cloneCultMeshDocument(await this.document.latest(this.context))); + await this.write(next); + return next; + } + + public reactive( + options: Omit = {}, + ): CultMeshReactiveDocument { + return new CultMeshReactiveDocumentHandle(this, options); + } +} + +class CultMeshObservedDocumentHandle + implements CultMeshObservedDocument { + readonly #document: CultMeshDocumentHandle; + readonly #context: CultMeshQueryContext | string; + readonly #onError: (error: unknown) => void; + #canonicalVersion = 0; + #current = {} as TDocument; + #disposed = false; + #unsubscribe: CultMeshUnsubscribe | undefined; + + public readonly ready: Promise; + + public constructor( + document: CultMeshDocumentHandle, + options: CultMeshReactiveDocumentOptions = {}, + ) { + this.#document = document; + this.#context = options.context ?? "local"; + this.#onError = options.onError ?? ((error) => { + queueMicrotask(() => { + throw error; + }); + }); + this.ready = this.refresh(); + if (options.watch !== false) { + try { + this.#unsubscribe = document.watch(this.#context, value => { + this.#current = cloneCultMeshDocument(value); + this.#canonicalVersion++; + }); + } catch (error) { + this.#onError(error); + } + } + } + + public get current(): TDocument { + this.throwIfDisposed(); + return this.#current; + } + + public get disposed(): boolean { + return this.#disposed; + } + + public async refresh(): Promise { + this.throwIfDisposed(); + const versionBeforeRead = this.#canonicalVersion; + const latest = cloneCultMeshDocument(await this.#document.latest(this.#context)); + if (versionBeforeRead === this.#canonicalVersion) { + this.#current = latest; + this.#canonicalVersion++; + } + return this.#current; + } + + public dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#unsubscribe?.(); + this.#unsubscribe = undefined; + } + + private throwIfDisposed(): void { + if (this.#disposed) { + throw new Error(`Observed document '${this.#document.documentId}' is disposed.`); + } + } +} + +class CultMeshReactiveDocumentHandle + implements CultMeshReactiveDocument { + readonly #writer: CultMeshDocumentWriter; + readonly #document: CultMeshDocumentHandle; + readonly #onError: (error: unknown) => void; + readonly #debounceMs: number; + readonly #replaceDirtyCurrentOnCanonicalSnapshot: boolean; + #current = {} as TDocument; + #disposed = false; + #pendingSet: Promise = Promise.resolve(); + #flushScheduled = false; + #cancelScheduledFlush: (() => void) | undefined; + #unsubscribe: CultMeshUnsubscribe | undefined; + #dirty = false; + #lastPredicted: TDocument | undefined; + #reconciliation: CultMeshReactiveDocumentReconciliation | undefined; + #reconciliationVersion = 0; + #canonicalVersion = 0; + + public readonly ready: Promise; + + public constructor( + writer: CultMeshDocumentWriter, + options: Omit = {}, + ) { + this.#writer = writer; + this.#document = writer.document; + this.#debounceMs = options.debounceMs ?? 0; + this.#replaceDirtyCurrentOnCanonicalSnapshot = + options.replaceDirtyCurrentOnCanonicalSnapshot ?? false; + this.#onError = options.onError ?? ((error) => { + queueMicrotask(() => { + throw error; + }); + }); + this.ready = this.refresh(); + + if (options.watch !== false) { + try { + this.#unsubscribe = this.#document.watch(this.#writer.context, value => { + this.applySnapshot(value); + }); + } catch (error) { + this.#onError(error); + } + } + } + + public get writer(): CultMeshDocumentWriter { + return this.#writer; + } + + public get snapshot(): TDocument { + this.throwIfDisposed(); + return cloneCultMeshDocument(this.#current); + } + + public get dirty(): boolean { + return this.#dirty; + } + + public get reconciliation(): CultMeshReactiveDocumentReconciliation | undefined { + return this.#reconciliation; + } + + public get disposed(): boolean { + return this.#disposed; + } + + public async refresh(): Promise { + this.throwIfDisposed(); + const versionBeforeRead = this.#canonicalVersion; + const latest = await this.#document.latest(this.#writer.context); + if (versionBeforeRead === this.#canonicalVersion) { + this.applySnapshot(latest); + } + return this.snapshot; + } + + public update(update: (draft: TDocument) => void | TDocument): TDocument { + this.throwIfDisposed(); + const draft = cloneCultMeshDocument(this.#current); + const returned = update(draft); + this.#current = cloneCultMeshDocument(returned ?? draft); + this.#dirty = true; + this.queueSet(); + return this.snapshot; + } + + public replaceLocal(value: TDocument): TDocument { + this.throwIfDisposed(); + this.#current = cloneCultMeshDocument(value); + this.#dirty = true; + this.queueSet(); + return this.snapshot; + } + + public async flush(): Promise { + this.throwIfDisposed(); + this.flushNow(); + await this.#pendingSet; + } + + public clearReconciliation(): void { + this.#reconciliation = undefined; + } + + public dispose(): void { + this.#disposed = true; + this.#cancelScheduledFlush?.(); + this.#cancelScheduledFlush = undefined; + this.#flushScheduled = false; + this.#unsubscribe?.(); + this.#unsubscribe = undefined; + } + + private applySnapshot(value: TDocument): void { + const canonical = cloneCultMeshDocument(value); + this.#canonicalVersion++; + this.captureReconciliation(canonical); + if (this.#dirty && !this.#replaceDirtyCurrentOnCanonicalSnapshot) { + return; + } + this.#current = canonical; + this.#dirty = false; + } + + private captureReconciliation(canonical: TDocument): void { + if (!this.#lastPredicted) { + return; + } + + const predicted = this.#lastPredicted; + const delta = createCultMeshReconciliationDelta(predicted, canonical) as + CultMeshReactiveDocumentDelta; + if (Reflect.ownKeys(delta).length === 0) { + this.#lastPredicted = undefined; + return; + } + + this.#reconciliation = { + version: ++this.#reconciliationVersion, + canonical: cloneCultMeshDocument(canonical), + predicted: cloneCultMeshDocument(predicted), + delta, + receivedAtMs: Date.now(), + }; + this.#lastPredicted = undefined; + } + + private queueSet(): void { + if (this.#disposed) { + return; + } + if (this.#flushScheduled) { + return; + } + + this.#flushScheduled = true; + const flush = () => this.flushNow(); + + const debounceMs = this.#debounceMs; + if (debounceMs > 0) { + const timer = setTimeout(flush, debounceMs); + this.#cancelScheduledFlush = () => clearTimeout(timer); + return; + } + + const requestFrame = (globalThis as { + requestAnimationFrame?: (callback: FrameRequestCallback) => number; + cancelAnimationFrame?: (handle: number) => void; + }).requestAnimationFrame; + if (typeof requestFrame === "function") { + const frame = requestFrame(() => flush()); + const cancelFrame = (globalThis as { + cancelAnimationFrame?: (handle: number) => void; + }).cancelAnimationFrame; + this.#cancelScheduledFlush = typeof cancelFrame === "function" + ? () => cancelFrame(frame) + : undefined; + return; + } + + let cancelled = false; + queueMicrotask(() => { + if (!cancelled) flush(); + }); + this.#cancelScheduledFlush = () => { cancelled = true; }; + } + + private flushNow(): void { + if (!this.#flushScheduled && !this.#dirty) return; + this.#flushScheduled = false; + this.#cancelScheduledFlush?.(); + this.#cancelScheduledFlush = undefined; + if (this.#disposed || !this.#dirty) return; + const snapshot = cloneCultMeshDocument(this.#current); + this.#dirty = false; + this.#lastPredicted = snapshot; + this.#pendingSet = this.#pendingSet + .catch(() => undefined) + .then(() => this.#writer.write(snapshot)) + .catch(error => this.#onError(error)); + } + + private throwIfDisposed(): void { + if (this.#disposed) { + throw new Error(`Reactive document '${this.#document.documentId}' is disposed.`); + } + } +} + +function cloneCultMeshDocument(value: TDocument): TDocument { + if (typeof structuredClone === "function") { + try { + return structuredClone(value); + } catch (error) { + if ((error as { name?: string }).name !== "DataCloneError") { + throw error; + } + } + } + return JSON.parse(JSON.stringify(value)) as TDocument; +} + +function createCultMeshReconciliationDelta( + predicted: TDocument, + canonical: TDocument, +): Partial> { + const delta: Partial> = {}; + const keys = new Set([ + ...Reflect.ownKeys(predicted), + ...Reflect.ownKeys(canonical), + ]); + + for (const key of keys) { + const predictedValue = Reflect.get(predicted, key); + const canonicalValue = Reflect.get(canonical, key); + if (cultMeshValuesEqual(predictedValue, canonicalValue)) { + continue; + } + + const deltaKey = key as keyof TDocument; + delta[deltaKey] = + typeof predictedValue === "number" && typeof canonicalValue === "number" + ? predictedValue - canonicalValue + : predictedValue; + } + + return delta; +} + +function cultMeshValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + if (typeof left !== "object" || left === null || + typeof right !== "object" || right === null) { + return false; + } + + return JSON.stringify(left) === JSON.stringify(right); +} + +export class CultMeshBoundDocumentHandle { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly document: CultMeshDocumentHandle, + ) {} + + public get documentId(): string { + return this.document.documentId; + } + + public get schema(): CultMeshDocumentSchemaDescriptor { + return this.document.schema; + } + + public get canReplace(): boolean { + return this.document.canReplace; + } + + public get canSubmitPrediction(): boolean { + return this.document.canSubmitPrediction; + } + + public latest(): Promise { + return this.document.latest(cultMeshQueryContextFromVerse(this.verse)); + } + + public read(): Promise { + return this.latest(); + } + + public watch(callback: (value: TDocument) => void): CultMeshUnsubscribe { + return this.document.watch(cultMeshQueryContextFromVerse(this.verse), callback); + } + + public replace(value: TDocument): Promise { + return this.document.replace(cultMeshQueryContextFromVerse(this.verse), value); + } + + public submitPrediction(value: TDocument): Promise { + return this.document.submitPrediction(cultMeshQueryContextFromVerse(this.verse), value); + } + + public authoritativeWriter(): CultMeshDocumentWriter { + return this.document.authoritativeWriter(cultMeshQueryContextFromVerse(this.verse)); + } + + public predictionWriter(): CultMeshDocumentWriter { + return this.document.predictionWriter(cultMeshQueryContextFromVerse(this.verse)); + } + + public observe( + options: Omit = {}, + ): CultMeshObservedDocument { + return this.document.observe({ + ...options, + context: cultMeshQueryContextFromVerse(this.verse), + }); + } + + public asSchemaAlias( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TAlias } = {}, + ): CultMeshBoundDocumentHandle { + return cultMeshBindDocument(this.verse, this.document.asSchemaAlias(schema, options)); + } +} + +export class CultMeshDocumentCatalog { + readonly #byDocumentId = new Map>(); + readonly #byType = new Map>(); + readonly #bySchemaId = new Map>(); + readonly #bySchemaNameVersion = new Map>(); + + public constructor(documents: Iterable>) { + for (const document of documents) { + this.add(document); + } + } + + public get documents(): readonly CultMeshDocumentHandle[] { + return [...this.#byDocumentId.values()]; + } + + public add(document: CultMeshDocumentHandle): this { + this.#byDocumentId.set(document.documentId, document as CultMeshDocumentHandle); + if (document.schema.type) { + this.#byType.set(document.schema.type, document as CultMeshDocumentHandle); + } + if (document.schema.schemaId) { + this.#bySchemaId.set(document.schema.schemaId, document as CultMeshDocumentHandle); + } + const key = cultMeshSchemaNameVersionKey(document.schema); + if (key) { + this.#bySchemaNameVersion.set(key, document as CultMeshDocumentHandle); + } + return this; + } + + public tryDocument( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TDocument } = {}, + ): CultMeshDocumentHandle | undefined { + const descriptor = normalizeCultMeshDocumentSchema(schema); + const exact = + (descriptor.type ? this.#byType.get(descriptor.type) : undefined) ?? + (descriptor.schemaId ? this.#bySchemaId.get(descriptor.schemaId) : undefined) ?? + (() => { + const key = cultMeshSchemaNameVersionKey(descriptor); + return key ? this.#bySchemaNameVersion.get(key) : undefined; + })(); + + if (!exact) { + return undefined; + } + + return cultMeshSchemasAreCompatible(exact.schema, descriptor) + ? exact.asSchemaAlias(descriptor, options) + : undefined; + } + + public document( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TDocument } = {}, + ): CultMeshDocumentHandle { + const document = this.tryDocument(schema, options); + if (!document) { + throw new Error(`Document catalog has no document for ${cultMeshSchemaLabel(schema)}.`); + } + return document; + } + + public latest( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string = "local", + options: { parse?: (value: unknown) => TDocument } = {}, + ): Promise { + return this.document(schema, options).latest(context); + } + + public canReplace(schema: CultMeshDocumentSchemaDescriptor): boolean { + return this.tryDocument(schema)?.canReplace ?? false; + } + + public replace( + schema: CultMeshDocumentSchemaDescriptor, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + ): Promise; + public replace( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument }, + ): Promise; + public replace( + schema: CultMeshDocumentSchemaDescriptor, + contextOrValue: CultMeshQueryContext | string | TDocument, + valueOrOptions?: TDocument | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + maybeOptions: { parse?: (value: unknown) => TDocument } = {}, + ): Promise { + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? contextOrValue as CultMeshQueryContext | string + : (valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; + const value = hasContext ? valueOrOptions as TDocument : contextOrValue as TDocument; + const options = hasContext ? maybeOptions : valueOrOptions as { parse?: (value: unknown) => TDocument } | undefined; + return this.document(schema, options).replace(context, value); + } + + public canSubmitPrediction(schema: CultMeshDocumentSchemaDescriptor): boolean { + return this.tryDocument(schema)?.canSubmitPrediction ?? false; + } + + public submitPrediction( + schema: CultMeshDocumentSchemaDescriptor, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + ): Promise; + public submitPrediction( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument }, + ): Promise; + public submitPrediction( + schema: CultMeshDocumentSchemaDescriptor, + contextOrValue: CultMeshQueryContext | string | TDocument, + valueOrOptions?: TDocument | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + maybeOptions: { parse?: (value: unknown) => TDocument } = {}, + ): Promise { + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? contextOrValue as CultMeshQueryContext | string + : (valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; + const value = hasContext ? valueOrOptions as TDocument : contextOrValue as TDocument; + const options = hasContext ? maybeOptions : valueOrOptions as { parse?: (value: unknown) => TDocument } | undefined; + return this.document(schema, options).submitPrediction(context, value); + } + + public authoritativeWriter( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string } = {}, + ): CultMeshDocumentWriter { + return this.document(schema, options).authoritativeWriter(options.context ?? "local"); + } + + public predictionWriter( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string } = {}, + ): CultMeshDocumentWriter { + return this.document(schema, options).predictionWriter(options.context ?? "local"); + } + + public watch( + schema: CultMeshDocumentSchemaDescriptor, + callback: (value: TDocument) => void, + options: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string } = {}, + ): CultMeshUnsubscribe { + return this.document(schema, options).watch(options.context ?? "local", callback); + } +} + +export class CultMeshCollectionHandle { + public readonly schema: CultMeshDocumentSchemaDescriptor; + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly collectionId: string, + schema: CultMeshDocumentSchemaDescriptor, + private readonly snapshotCollection: (context: CultMeshQueryContext) => Promise>, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchCollection?: CultMeshCollectionWatcher; + } = {}, + ) { + requireNonEmpty(collectionId, "collectionId"); + this.schema = normalizeCultMeshDocumentSchema(schema); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + this.watchCollection = options.watchCollection; + } + + private readonly watchCollection: CultMeshCollectionWatcher | undefined; + + public latest(context: CultMeshQueryContext | string = "local"): Promise> { + return this.snapshotCollection( + this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), + ); + } + + public watchChanges(callback: (change: CultMeshCollectionChange) => void): CultMeshUnsubscribe; + public watchChanges( + context: CultMeshQueryContext | string, + callback: (change: CultMeshCollectionChange) => void, + ): CultMeshUnsubscribe; + public watchChanges( + contextOrCallback: + | CultMeshQueryContext + | string + | ((change: CultMeshCollectionChange) => void), + maybeCallback?: (change: CultMeshCollectionChange) => void, + ): CultMeshUnsubscribe { + if (!this.watchCollection) { + throw new Error(`Collection '${this.collectionId}' does not support watches.`); + } + + const callback = + typeof contextOrCallback === "function" ? contextOrCallback : maybeCallback; + if (!callback) { + throw new Error(`Collection '${this.collectionId}' requires a watch callback.`); + } + + const context = + typeof contextOrCallback === "function" + ? cultMeshQueryContext("local") + : typeof contextOrCallback === "string" + ? cultMeshQueryContext(contextOrCallback) + : contextOrCallback; + + return this.watchCollection(this.resolveContext(context), callback); + } + + public asSchemaAlias( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TAlias } = {}, + ): CultMeshCollectionHandle { + const aliasSchema = normalizeCultMeshDocumentSchema(schema); + if (!cultMeshSchemasAreCompatible(this.schema, aliasSchema)) { + throw new Error( + `Collection '${this.collectionId}' schema ${cultMeshSchemaLabel(this.schema)} is not compatible with alias ${cultMeshSchemaLabel(aliasSchema)}.`, + ); + } + + const parse = options.parse ?? ((value: unknown) => value as TAlias); + return new CultMeshCollectionHandle( + this.collectionId, + aliasSchema, + async (context) => (await this.latest(context)).map(value => parse(value)), + { + sources: this.sources, + routeHint: this.routeHint, + watchCollection: this.watchCollection + ? (context, callback) => this.watchChanges(context, change => callback({ + ...change, + value: change.value === undefined ? undefined : parse(change.value), + })) + : undefined, + }, + ); + } + + public bind(verse: CultMeshVerseContext | CultMeshVerse): CultMeshBoundCollectionHandle { + return cultMeshBindCollection(verse, this); + } + + private resolveContext(context: CultMeshQueryContext): CultMeshQueryContext { + if (context.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return context; + } + + return cultMeshQueryContext(context.runtimeId, { + routeHint: this.routeHint, + }); + } +} + +export class CultMeshCollectionCatalog { + readonly #byCollectionId = new Map>(); + readonly #byType = new Map>(); + readonly #bySchemaId = new Map>(); + readonly #bySchemaNameVersion = new Map>(); + + public constructor(collections: Iterable>) { + for (const collection of collections) { + this.add(collection); + } + } + + public get collections(): readonly CultMeshCollectionHandle[] { + return [...this.#byCollectionId.values()]; + } + + public add(collection: CultMeshCollectionHandle): this { + this.#byCollectionId.set(collection.collectionId, collection as CultMeshCollectionHandle); + if (collection.schema.type) { + this.#byType.set(collection.schema.type, collection as CultMeshCollectionHandle); + } + if (collection.schema.schemaId) { + this.#bySchemaId.set(collection.schema.schemaId, collection as CultMeshCollectionHandle); + } + const key = cultMeshSchemaNameVersionKey(collection.schema); + if (key) { + this.#bySchemaNameVersion.set(key, collection as CultMeshCollectionHandle); + } + return this; + } + + public tryCollection( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TDocument } = {}, + ): CultMeshCollectionHandle | undefined { + const descriptor = normalizeCultMeshDocumentSchema(schema); + const exact = + (descriptor.type ? this.#byType.get(descriptor.type) : undefined) ?? + (descriptor.schemaId ? this.#bySchemaId.get(descriptor.schemaId) : undefined) ?? + (() => { + const key = cultMeshSchemaNameVersionKey(descriptor); + return key ? this.#bySchemaNameVersion.get(key) : undefined; + })(); + + if (!exact) { + return undefined; + } + + return cultMeshSchemasAreCompatible(exact.schema, descriptor) + ? exact.asSchemaAlias(descriptor, options) + : undefined; + } + + public collection( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TDocument } = {}, + ): CultMeshCollectionHandle { + const collection = this.tryCollection(schema, options); + if (!collection) { + throw new Error(`Collection catalog has no collection for ${cultMeshSchemaLabel(schema)}.`); + } + return collection; + } + + public latest( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string = "local", + options: { parse?: (value: unknown) => TDocument } = {}, + ): Promise> { + return this.collection(schema, options).latest(context); + } + + public watchChanges( + schema: CultMeshDocumentSchemaDescriptor, + callback: (change: CultMeshCollectionChange) => void, + options: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string } = {}, + ): CultMeshUnsubscribe { + return this.collection(schema, options).watchChanges(options.context ?? "local", callback); + } +} + +export class CultMeshBoundCollectionHandle { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly collection: CultMeshCollectionHandle, + ) {} + + public get collectionId(): string { + return this.collection.collectionId; + } + + public get schema(): CultMeshDocumentSchemaDescriptor { + return this.collection.schema; + } + + public latest(): Promise> { + return this.collection.latest(cultMeshQueryContextFromVerse(this.verse)); + } + + public watchChanges(callback: (change: CultMeshCollectionChange) => void): CultMeshUnsubscribe { + return this.collection.watchChanges(cultMeshQueryContextFromVerse(this.verse), callback); + } + + public asSchemaAlias( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TAlias } = {}, + ): CultMeshBoundCollectionHandle { + return cultMeshBindCollection(this.verse, this.collection.asSchemaAlias(schema, options)); + } +} + +export class CultMeshStatePointer { + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly pointerId: string, + private readonly resolvePointer: (() => Promise) | CultMeshStatePointerResolver, + private readonly watchPointer?: + | ((callback: (value: T) => void) => CultMeshUnsubscribe) + | CultMeshStatePointerWatcher, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, + ) { + requireNonEmpty(pointerId, "pointerId"); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + } + + public resolve(context?: CultMeshQueryContext | string): Promise { + return (this.resolvePointer as CultMeshStatePointerResolver)( + this.resolveContext( + typeof context === "string" ? cultMeshQueryContext(context) : context, + ), + ); + } + + public watch(callback: (value: T) => void): CultMeshUnsubscribe; + public watch(context: CultMeshQueryContext | string, callback: (value: T) => void): CultMeshUnsubscribe; + public watch( + contextOrCallback: CultMeshQueryContext | string | ((value: T) => void), + maybeCallback?: (value: T) => void, + ): CultMeshUnsubscribe { + if (!this.watchPointer) { + throw new Error(`State pointer '${this.pointerId}' does not support watches.`); + } + + const callback = + typeof contextOrCallback === "function" ? contextOrCallback : maybeCallback; + if (!callback) { + throw new Error(`State pointer '${this.pointerId}' requires a watch callback.`); + } + + if (this.watchPointer.length <= 1) { + return (this.watchPointer as (callback: (value: T) => void) => CultMeshUnsubscribe)(callback); + } + + const context = + typeof contextOrCallback === "function" + ? undefined + : typeof contextOrCallback === "string" + ? cultMeshQueryContext(contextOrCallback) + : contextOrCallback; + return (this.watchPointer as CultMeshStatePointerWatcher)( + this.resolveContext(context), + callback, + ); + } + + public bind( + verse: CultMeshVerseContext | CultMeshVerse, + ): CultMeshBoundStatePointer { + return cultMeshBindStatePointer(verse, this); + } + + private resolveContext(context?: CultMeshQueryContext): CultMeshQueryContext { + const resolved = context ?? cultMeshQueryContext("local"); + if (resolved.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return resolved; + } + + return cultMeshQueryContext(resolved.runtimeId, { + routeHint: this.routeHint, + }); + } +} + +export class CultMeshBoundStatePointer { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly pointer: CultMeshStatePointer, + ) {} + + public get pointerId(): string { + return this.pointer.pointerId; + } + + public get sources(): readonly CultMeshProjectionSource[] { + return this.pointer.sources; + } + + public get routeHint(): CultMeshRouteHint { + return this.pointer.routeHint; + } + + public resolve(): Promise { + return this.pointer.resolve(cultMeshQueryContextFromVerse(this.verse)); + } + + public watch(callback: (value: T) => void): CultMeshUnsubscribe { + return this.pointer.watch(cultMeshQueryContextFromVerse(this.verse), callback); + } +} + +export class CultMeshMutableStatePointer { + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly pointerId: string, + private readonly resolvePointer: (() => Promise) | CultMeshStatePointerResolver, + private readonly watchPointer: + | ((callback: (value: T) => void) => CultMeshUnsubscribe) + | CultMeshStatePointerWatcher, + private readonly replacePointer: + | ((value: T) => Promise) + | CultMeshMutableStatePointerReplacer, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, + ) { + requireNonEmpty(pointerId, "pointerId"); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + } + + public resolve(context?: CultMeshQueryContext | string): Promise { + return (this.resolvePointer as CultMeshStatePointerResolver)( + this.resolveContext( + typeof context === "string" ? cultMeshQueryContext(context) : context, + ), + ); + } + + public read(context?: CultMeshQueryContext | string): Promise { + return this.resolve(context); + } + + public replace(value: T): Promise; + public replace(context: CultMeshQueryContext | string, value: T): Promise; + public replace( + contextOrValue: CultMeshQueryContext | string | T, + maybeValue?: T, + ): Promise { + if (this.replacePointer.length <= 1) { + return (this.replacePointer as (value: T) => Promise)( + contextOrValue as T, + ); + } + + const context = + typeof contextOrValue === "string" + ? cultMeshQueryContext(contextOrValue) + : isCultMeshQueryContext(contextOrValue) + ? contextOrValue + : undefined; + const value = context ? maybeValue : (contextOrValue as T); + if (value === undefined) { + throw new Error(`Mutable state pointer '${this.pointerId}' requires a replacement value.`); + } + + return (this.replacePointer as CultMeshMutableStatePointerReplacer)( + this.resolveContext(context), + value, + ); + } + + public watch(callback: (value: T) => void): CultMeshUnsubscribe; + public watch(context: CultMeshQueryContext | string, callback: (value: T) => void): CultMeshUnsubscribe; + public watch( + contextOrCallback: CultMeshQueryContext | string | ((value: T) => void), + maybeCallback?: (value: T) => void, + ): CultMeshUnsubscribe { + const callback = + typeof contextOrCallback === "function" ? contextOrCallback : maybeCallback; + if (!callback) { + throw new Error(`Mutable state pointer '${this.pointerId}' requires a watch callback.`); + } + + if (this.watchPointer.length <= 1) { + return (this.watchPointer as (callback: (value: T) => void) => CultMeshUnsubscribe)(callback); + } + + const context = + typeof contextOrCallback === "function" + ? undefined + : typeof contextOrCallback === "string" + ? cultMeshQueryContext(contextOrCallback) + : contextOrCallback; + return (this.watchPointer as CultMeshStatePointerWatcher)( + this.resolveContext(context), + callback, + ); + } + + public asStatePointer(): CultMeshStatePointer { + return cultMeshStatePointer( + this.pointerId, + (context) => this.resolve(context), + (context, callback) => this.watch(context, callback), + { + routeHint: this.routeHint, + sources: this.sources, + }, + ); + } + + public bind( + verse: CultMeshVerseContext | CultMeshVerse, + ): CultMeshBoundMutableStatePointer { + return cultMeshBindMutableStatePointer(verse, this); + } + + private resolveContext(context?: CultMeshQueryContext): CultMeshQueryContext { + const resolved = context ?? cultMeshQueryContext("local"); + if (resolved.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return resolved; + } + + return cultMeshQueryContext(resolved.runtimeId, { + routeHint: this.routeHint, + }); + } +} + +export class CultMeshBoundMutableStatePointer { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly pointer: CultMeshMutableStatePointer, + ) {} + + public get pointerId(): string { + return this.pointer.pointerId; + } + + public get sources(): readonly CultMeshProjectionSource[] { + return this.pointer.sources; + } + + public get routeHint(): CultMeshRouteHint { + return this.pointer.routeHint; + } + + public resolve(): Promise { + return this.pointer.resolve(cultMeshQueryContextFromVerse(this.verse)); + } + + public read(): Promise { + return this.resolve(); + } + + public replace(value: T): Promise { + return this.pointer.replace(cultMeshQueryContextFromVerse(this.verse), value); + } + + public watch(callback: (value: T) => void): CultMeshUnsubscribe { + return this.pointer.watch(cultMeshQueryContextFromVerse(this.verse), callback); + } + + public asStatePointer(): CultMeshBoundStatePointer { + return cultMeshBindStatePointer(this.verse, this.pointer.asStatePointer()); + } +} + +export class CultMeshProjectionRecipe { + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly projectionId: string, + sources: readonly CultMeshProjectionSource[], + private readonly projectProjection: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + routeHint?: CultMeshRouteHint; + watchProjection?: ( + parameters: TParameters, + context: CultMeshQueryContext, + callback: (value: TResult) => void, + ) => CultMeshUnsubscribe; + } = {}, + ) { + requireNonEmpty(projectionId, "projectionId"); + this.sources = [...sources]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + this.watchProjection = options.watchProjection; + } + + private readonly watchProjection: + | (( + parameters: TParameters, + context: CultMeshQueryContext, + callback: (value: TResult) => void, + ) => CultMeshUnsubscribe) + | undefined; + + public project( + parameters: TParameters, + context: CultMeshQueryContext | string, + ): Promise { + return this.projectProjection( + parameters, + this.resolveContext( + typeof context === "string" ? cultMeshQueryContext(context) : context, + ), + ); + } + + public watch( + parameters: TParameters, + context: CultMeshQueryContext | string, + callback: (value: TResult) => void, + ): CultMeshUnsubscribe { + if (!this.watchProjection) { + throw new Error(`Projection recipe '${this.projectionId}' does not support watches.`); + } + + return this.watchProjection( + parameters, + this.resolveContext( + typeof context === "string" ? cultMeshQueryContext(context) : context, + ), + callback, + ); + } + + public asQuerySurface(): CultMeshQuerySurface { + return cultMeshQuery(this.projectionId, (parameters, context) => + this.project(parameters, context), + { + sources: this.sources, + routeHint: this.routeHint, + watchQuery: this.watchProjection + ? (parameters, context, callback) => this.watch(parameters, context, callback) + : undefined, + }, + ); + } + + private resolveContext(context: CultMeshQueryContext): CultMeshQueryContext { + if (context.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return context; + } + + return cultMeshQueryContext(context.runtimeId, { + routeHint: this.routeHint, + }); + } +} + +export function cultMeshOperationContextFor(runtimeId: string): CultMeshOperationContextBuilder { + return new CultMeshOperationContextBuilder(runtimeId); +} + +export function cultMeshQueryContextFor(runtimeId: string): CultMeshQueryContextBuilder { + return new CultMeshQueryContextBuilder(runtimeId); +} + +export function cultMeshVec2(x: number, y: number): CultMeshVec2 { + return { x, y }; +} + +export function cultMeshRect(min: CultMeshVec2, max: CultMeshVec2): CultMeshRect { + return { + min: cultMeshVec2(Math.min(min.x, max.x), Math.min(min.y, max.y)), + max: cultMeshVec2(Math.max(min.x, max.x), Math.max(min.y, max.y)), + }; +} + +export function cultMeshRectFromBounds( + minX: number, + minY: number, + maxX: number, + maxY: number, +): CultMeshRect { + return cultMeshRect(cultMeshVec2(minX, minY), cultMeshVec2(maxX, maxY)); +} + +export function cultMeshViewportRequest( + viewport: CultMeshRect, + controlledEntityIndices?: readonly number[], +): CultMeshViewportRequest { + return { + minX: viewport.min.x, + minY: viewport.min.y, + maxX: viewport.max.x, + maxY: viewport.max.y, + controlledEntityIndices, + }; +} + +export function cultMeshRouteHint( + kind: CultMeshLocalityKind = "automatic", + description?: string, +): CultMeshRouteHint { + return description ? { kind, description } : { kind }; +} + +export function cultMeshRouteRecord( + routeHint?: CultMeshRouteHint, +): CultMeshRouteRecord; +export function cultMeshRouteRecord( + kind?: string, + description?: string, +): CultMeshRouteRecord; +export function cultMeshRouteRecord( + routeOrKind?: CultMeshRouteHint | string, + description = "", +): CultMeshRouteRecord { + if (typeof routeOrKind === "string" || routeOrKind === undefined) { + return { + kind: routeOrKind ?? "", + description, + }; + } + + return { + kind: routeOrKind.kind, + description: routeOrKind.description ?? "", + }; +} + +export function cultMeshRouteFromRecord( + record: Partial, + fallback: CultMeshRouteHint = cultMeshRouteHint(), +): CultMeshRouteHint { + return cultMeshRouteHint( + parseCultMeshLocalityKind(record.kind, fallback.kind), + nonBlankOr(record.description, fallback.description), + ); +} + +export function cultMeshAuthorityClaim( + role: string, + options: { shardId?: string; leaseId?: string } = {}, +): CultMeshAuthorityClaim { + requireNonEmpty(role, "role"); + return { + role, + shardId: options.shardId, + leaseId: options.leaseId, + }; +} + +export function cultMeshVerseContext( + verseId: string, + runtimeId: string, + options: { + routeHint?: CultMeshRouteHint; + claims?: readonly CultMeshAuthorityClaim[]; + } = {}, +): CultMeshVerseContext { + requireNonEmpty(verseId, "verseId"); + requireNonEmpty(runtimeId, "runtimeId"); + return { + verseId, + runtimeId, + routeHint: options.routeHint ?? cultMeshRouteHint(), + claims: options.claims ? [...options.claims] : [], + }; +} + +export function cultMeshVerse( + verseId: string, + runtimeId: string, + options: { + routeHint?: CultMeshRouteHint; + claims?: readonly CultMeshAuthorityClaim[]; + } = {}, +): CultMeshVerse { + return new CultMeshVerse(cultMeshVerseContext(verseId, runtimeId, options)); +} + +export function cultMeshOperationContextFromVerse( + context: CultMeshVerseContext, + options: { idempotencyKey?: string } = {}, +): CultMeshOperationContext { + return cultMeshOperationContext(context.runtimeId, { + claims: context.claims, + routeHint: context.routeHint, + idempotencyKey: options.idempotencyKey, + }); +} + +export function cultMeshQueryContextFromVerse( + context: CultMeshVerseContext, +): CultMeshQueryContext { + return cultMeshQueryContext(context.runtimeId, { + routeHint: context.routeHint, + }); +} + +export function cultMeshBindOperation( + verse: CultMeshVerseContext | CultMeshVerse, + operation: CultMeshOperationHandle, +): CultMeshBoundOperationHandle { + return new CultMeshBoundOperationHandle(resolveCultMeshVerseContext(verse), operation); +} + +export function cultMeshBindQuery( + verse: CultMeshVerseContext | CultMeshVerse, + query: CultMeshQuerySurface, +): CultMeshBoundQuerySurface { + return new CultMeshBoundQuerySurface(resolveCultMeshVerseContext(verse), query); +} + +export function cultMeshBindLiveFeed( + verse: CultMeshVerseContext | CultMeshVerse, + feed: CultMeshLiveFeed, +): CultMeshBoundLiveFeed { + return new CultMeshBoundLiveFeed(resolveCultMeshVerseContext(verse), feed); +} + +export function cultMeshBindStatePointer( + verse: CultMeshVerseContext | CultMeshVerse, + pointer: CultMeshStatePointer, +): CultMeshBoundStatePointer { + return new CultMeshBoundStatePointer(resolveCultMeshVerseContext(verse), pointer); +} + +export function cultMeshBindMutableStatePointer( + verse: CultMeshVerseContext | CultMeshVerse, + pointer: CultMeshMutableStatePointer, +): CultMeshBoundMutableStatePointer { + return new CultMeshBoundMutableStatePointer(resolveCultMeshVerseContext(verse), pointer); +} + +export function cultMeshBindDocument( + verse: CultMeshVerseContext | CultMeshVerse, + document: CultMeshDocumentHandle, +): CultMeshBoundDocumentHandle { + return new CultMeshBoundDocumentHandle(resolveCultMeshVerseContext(verse), document); +} + +export function cultMeshBindCollection( + verse: CultMeshVerseContext | CultMeshVerse, + collection: CultMeshCollectionHandle, +): CultMeshBoundCollectionHandle { + return new CultMeshBoundCollectionHandle(resolveCultMeshVerseContext(verse), collection); +} + +function resolveCultMeshVerseContext(verse: CultMeshVerseContext | CultMeshVerse): CultMeshVerseContext { + return verse instanceof CultMeshVerse ? verse.context : verse; +} + +function isCultMeshQueryContext(value: unknown): value is CultMeshQueryContext { + if (typeof value !== "object" || value === null) { + return false; + } + + const maybeContext = value as Partial; + return typeof maybeContext.runtimeId === "string" && + typeof maybeContext.routeHint === "object" && + maybeContext.routeHint !== null; +} + +export function cultMeshOperationContext( + runtimeId: string, + options: { + claims?: readonly CultMeshAuthorityClaim[]; + routeHint?: CultMeshRouteHint; + idempotencyKey?: string; + } = {}, +): CultMeshOperationContext { + requireNonEmpty(runtimeId, "runtimeId"); + return { + runtimeId, + claims: options.claims ? [...options.claims] : [], + routeHint: options.routeHint ?? cultMeshRouteHint(), + idempotencyKey: options.idempotencyKey, + }; +} + +export function cultMeshQueryContext( + runtimeId: string, + options: { routeHint?: CultMeshRouteHint } = {}, +): CultMeshQueryContext { + requireNonEmpty(runtimeId, "runtimeId"); + return { + runtimeId, + routeHint: options.routeHint ?? cultMeshRouteHint(), + }; +} + +export function cultMeshOperationReceipt( + operationId: string, + accepted: boolean, + options: { route?: CultMeshRouteHint; diagnostic?: string } = {}, +): CultMeshOperationReceipt { + requireNonEmpty(operationId, "operationId"); + return { + operationId, + accepted, + route: options.route ?? cultMeshRouteHint(), + diagnostic: options.diagnostic, + }; +} + +export function cultMeshOperation( + operationId: string, + invokeOperation: ( + request: TRequest, + context: CultMeshOperationContext, + ) => Promise, +): CultMeshOperationHandle { + return new CultMeshOperationHandle(operationId, invokeOperation); +} + +export function cultMeshQuery( + queryId: string, + executeQuery: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchQuery?: CultMeshQueryWatcher; + } = {}, +): CultMeshQuerySurface { + return new CultMeshQuerySurface(queryId, executeQuery, options); +} + +export function cultMeshDescribeQuerySurface( + query: { + readonly queryId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + }, +): CultMeshQuerySurfaceDiagnostic { + return { + queryId: query.queryId, + routeHint: query.routeHint, + sources: [...query.sources], + }; +} + +export function cultMeshDescribeOperationHandle( + operation: { + readonly operationId: string; + }, +): CultMeshOperationHandleDiagnostic { + return { + operationId: operation.operationId, + }; +} + +export function cultMeshDescribeStatePointer( + pointer: { + readonly pointerId: string; + readonly routeHint?: CultMeshRouteHint; + readonly sources?: readonly CultMeshProjectionSource[]; + }, +): CultMeshStatePointerDiagnostic { + return { + pointerId: pointer.pointerId, + routeHint: pointer.routeHint ?? cultMeshRouteHint(), + sources: [...(pointer.sources ?? [])], + }; +} + +export function cultMeshLiveFeed( + feedId: string, + snapshotFeed: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchFeed?: CultMeshLiveFeedWatcher; + } = {}, +): CultMeshLiveFeed { + return new CultMeshLiveFeed(feedId, snapshotFeed, options); +} + +export function cultMeshDocument( + documentId: string, + schema: CultMeshDocumentSchemaDescriptor, + snapshotDocument: (context: CultMeshQueryContext) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchDocument?: CultMeshDocumentWatcher; + replaceDocument?: CultMeshDocumentReplacer; + submitPrediction?: CultMeshDocumentPredictionSubmitter; + } = {}, +): CultMeshDocumentHandle { + return new CultMeshDocumentHandle(documentId, schema, snapshotDocument, options); +} + +export function cultMeshDocumentFromCache( + cache: CultCache, + definition: TDefinition, + key: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, +): CultMeshDocumentHandle> { + requireNonEmpty(key, "key"); + const documentId = options.documentId ?? `${definition.type}:${key}`; + const schema = cultMeshSchemaFromDefinition(definition); + return cultMeshDocument( + documentId, + schema, + async () => cache.getRequired(definition, key), + { + routeHint: options.routeHint ?? cultMeshRouteHint("in-process", "CultCache"), + sources: [cultMeshProjectionSource(documentId, { schemaId: schema.schemaId })], + watchDocument: cultMeshPollingDocumentWatcher( + async () => cache.getRequired(definition, key), + { intervalMs: options.pollMs ?? 50 }, + ), + replaceDocument: async (_context, value) => { + await cache.put(definition, key, value); + }, + }, + ); +} + +export function cultMeshDocumentFromStore( + store: CacheBackingStore, + schemaId: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, +): CultMeshDocumentHandle; +export function cultMeshDocumentFromStore( + store: CacheBackingStore, + definition: TDefinition, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, +): CultMeshDocumentHandle>; +export function cultMeshDocumentFromStore( + store: CacheBackingStore, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + } = {}, +): CultMeshDocumentHandle { + const schema = typeof schemaOrDefinition === "string" + ? { schemaId: schemaOrDefinition } + : cultMeshSchemaFromDefinition(schemaOrDefinition); + const resolvedSchemaId = schema.schemaId ?? schema.type; + if (!resolvedSchemaId) { + throw new Error("CultMesh store document requires a schema id or document type."); + } + if (typeof schemaOrDefinition === "string") { + requireNonEmpty(schemaOrDefinition, "schemaId"); + } + const documentId = options.documentId ?? resolvedSchemaId; + const read = async () => { + const records = await store.pullAll(); + const record = resolveCultMeshStoreDocumentRecord(records, schema, schemaOrDefinition); + if (!record) { + throw new Error(`CultMesh store document '${documentId}' did not contain schema ${resolvedSchemaId}.`); + } + return parseCultMeshDocumentValue(schemaOrDefinition, decode(record.payload)); + }; + + return cultMeshDocument( + documentId, + schema, + async () => read(), + { + routeHint: options.routeHint ?? cultMeshRouteHint("in-process", "CultCache store"), + sources: [cultMeshProjectionSource(options.sourceId ?? documentId, { schemaId: resolvedSchemaId })], + watchDocument: cultMeshPollingDocumentWatcher(read, { + intervalMs: options.pollMs ?? 50, + }), + }, + ); +} + +export function cultMeshDocumentFromSingleFile( + path: string, + schemaId: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, +): CultMeshDocumentHandle; +export function cultMeshDocumentFromSingleFile( + path: string, + definition: TDefinition, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, +): CultMeshDocumentHandle>; +export function cultMeshDocumentFromSingleFile( + path: string, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + } = {}, +): CultMeshDocumentHandle { + requireNonEmpty(path, "path"); + const store = new SingleFileMessagePackBackingStore(path); + const resolvedOptions = { + ...options, + routeHint: options.routeHint ?? cultMeshRouteHint("shared-memory", path), + }; + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromStore(store, schemaOrDefinition, resolvedOptions) + : cultMeshDocumentFromStore(store, schemaOrDefinition, resolvedOptions); +} + +export function cultMeshDocumentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + schemaId: string, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, +): CultMeshDocumentHandle; +export function cultMeshDocumentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + definition: TDefinition, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, +): CultMeshDocumentHandle>; +export function cultMeshDocumentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, + recordKey: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, +): CultMeshDocumentHandle { + const schema = typeof schemaOrDefinition === "string" + ? { schemaId: schemaOrDefinition } + : cultMeshSchemaFromDefinition(schemaOrDefinition); + const resolvedSchemaId = schema.schemaId ?? schema.type; + if (!resolvedSchemaId) { + throw new Error("CultMesh peer snapshot document requires a schema id or document type."); + } + if (typeof schemaOrDefinition === "string") { + requireNonEmpty(schemaOrDefinition, "schemaId"); + } + requireNonEmpty(recordKey, "recordKey"); + const documentId = options.documentId ?? recordKey; + const read = async () => + parseCultMeshDocumentValue( + schemaOrDefinition, + decodeCultNetRawDocumentPayload( + await requestCultNetRawSnapshotDocument( + peer, + resolvedSchemaId, + recordKey, + { + timeoutMs: options.timeoutMs, + messageIdPrefix: options.messageIdPrefix ?? documentId, + accepts: typeof schemaOrDefinition === "string" + ? undefined + : record => { + try { + schemaOrDefinition.schema.parse(decodeCultNetRawDocumentPayload(record)); + return true; + } catch { + return false; + } + }, + }, + ), + ), + ); + + return cultMeshDocument( + documentId, + schema, + async () => read(), + { + routeHint: options.routeHint ?? cultMeshRouteHint("network", "CultNet snapshot"), + sources: [cultMeshProjectionSource(options.sourceId ?? documentId, { schemaId: resolvedSchemaId })], + watchDocument: cultMeshPollingDocumentWatcher(read, { + intervalMs: options.pollMs ?? 250, + }), + }, + ); +} + +export function cultMeshDocumentFromPublication( + source: CultMeshDocumentPublicationSource, + schemaId: string, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, +): CultMeshDocumentHandle; +export function cultMeshDocumentFromPublication( + source: CultMeshDocumentPublicationSource, + definition: TDefinition, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, +): CultMeshDocumentHandle>; +export function cultMeshDocumentFromPublication( + source: CultMeshDocumentPublicationSource, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, + recordKey: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, +): CultMeshDocumentHandle { + if (!source) { + throw new Error("CultMesh publication source is required."); + } + requireNonEmpty(recordKey, "recordKey"); + + switch (source.kind) { + case "live-publication": { + const schema = typeof schemaOrDefinition === "string" + ? { schemaId: schemaOrDefinition } + : cultMeshSchemaFromDefinition(schemaOrDefinition); + const schemaId = schema.schemaId ?? schema.type; + if (!schemaId) { + throw new Error("CultMesh live publication document requires a schema id or document type."); + } + const parse = (value: unknown) => parseCultMeshDocumentValue(schemaOrDefinition, value); + return cultMeshDocument( + options.documentId ?? recordKey, + schema, + async () => parse(await source.source.latest(schemaId, recordKey)), + { + routeHint: options.routeHint ?? cultMeshRouteHint( + "network", + source.endpoint ?? "CultMesh provider-session publication", + ), + sources: [cultMeshProjectionSource(options.sourceId ?? recordKey, { schemaId })], + watchDocument: (_context, callback) => source.source.watch( + schemaId, + recordKey, + value => callback(parse(value)), + ), + }, + ); + } + case "single-file": + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromSingleFile(source.path, schemaOrDefinition, { + documentId: options.documentId ?? recordKey, + routeHint: options.routeHint, + sourceId: options.sourceId ?? recordKey, + pollMs: options.pollMs, + }) + : cultMeshDocumentFromSingleFile(source.path, schemaOrDefinition, { + documentId: options.documentId ?? recordKey, + routeHint: options.routeHint, + sourceId: options.sourceId ?? recordKey, + pollMs: options.pollMs, + }); + case "store": + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromStore(source.store, schemaOrDefinition, { + documentId: options.documentId ?? recordKey, + routeHint: options.routeHint, + sourceId: options.sourceId ?? recordKey, + pollMs: options.pollMs, + }) + : cultMeshDocumentFromStore(source.store, schemaOrDefinition, { + documentId: options.documentId ?? recordKey, + routeHint: options.routeHint, + sourceId: options.sourceId ?? recordKey, + pollMs: options.pollMs, + }); + case "peer-snapshot": + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromPeerSnapshot(source.peer, schemaOrDefinition, recordKey, { + documentId: options.documentId ?? recordKey, + routeHint: options.routeHint ?? cultMeshRouteHint("network", source.endpoint ?? "CultNet snapshot"), + sourceId: options.sourceId ?? recordKey, + timeoutMs: options.timeoutMs, + pollMs: options.pollMs, + messageIdPrefix: options.messageIdPrefix, + }) + : cultMeshDocumentFromPeerSnapshot(source.peer, schemaOrDefinition, recordKey, { + documentId: options.documentId ?? recordKey, + routeHint: options.routeHint ?? cultMeshRouteHint("network", source.endpoint ?? "CultNet snapshot"), + sourceId: options.sourceId ?? recordKey, + timeoutMs: options.timeoutMs, + pollMs: options.pollMs, + messageIdPrefix: options.messageIdPrefix, + }); + default: + throw new Error(`Unsupported CultMesh publication source kind '${(source as { kind?: string }).kind}'.`); + } +} + +export async function cultMeshSyncDocumentFromPublication( + node: CultMeshNode, + source: CultMeshDocumentPublicationSource, + definition: TDefinition, + recordKey: string, + options: { + timeoutMs?: number; + messageIdPrefix?: string; + } = {}, +): Promise> { + if (!source) { + throw new Error("CultMesh publication source is required."); + } + requireNonEmpty(recordKey, "recordKey"); + + if (source.kind === "peer-snapshot") { + return node.syncDocumentFromPeerSnapshot(source.peer, definition, recordKey, options); + } + + const value = await cultMeshDocumentFromPublication( + source, + definition, + recordKey, + { + timeoutMs: options.timeoutMs, + messageIdPrefix: options.messageIdPrefix, + }, + ).latest(); + return node.put(definition, recordKey, value); +} + +export async function cultMeshSyncDocumentsFromPublication( + node: CultMeshNode, + source: CultMeshDocumentPublicationSourceResolver, + bindings: readonly CultMeshPublicationDocumentBinding[], + options: { + routeHint?: CultMeshRouteHint; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, +): Promise { + if (!node) { + throw new Error("CultMesh node is required."); + } + if (!source) { + throw new Error("CultMesh publication source is required."); + } + if (!bindings) { + throw new Error("CultMesh publication bindings are required."); + } + + const documents = await Promise.all(bindings.map(async binding => { + if (!binding) { + throw new Error("CultMesh publication binding is required."); + } + if (typeof binding.schema === "string") { + throw new Error( + `CultMesh synced publication binding '${binding.recordKey}' requires a document definition, not only a schema id.`, + ); + } + const resolvedSource = binding.source ?? + (typeof source === "function" ? source(binding) : source); + await node.syncDocumentFromPublication( + resolvedSource, + binding.schema, + binding.recordKey, + { + timeoutMs: options.timeoutMs, + messageIdPrefix: options.messageIdPrefix, + }, + ); + return node.document(binding.schema, binding.recordKey, { + documentId: binding.documentId ?? binding.recordKey, + routeHint: options.routeHint, + pollMs: options.pollMs, + }); + })); + + return cultMeshDocuments(...documents); +} + +export function cultMeshGlobalDocumentFromCache( + cache: CultCache, + definition: TDefinition, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, +): CultMeshDocumentHandle> { + const documentId = options.documentId ?? `${definition.type}:global`; + const schema = cultMeshSchemaFromDefinition(definition); + return cultMeshDocument( + documentId, + schema, + async () => cache.getRequiredGlobal(definition), + { + routeHint: options.routeHint ?? cultMeshRouteHint("in-process", "CultCache"), + sources: [cultMeshProjectionSource(documentId, { schemaId: schema.schemaId })], + watchDocument: cultMeshPollingDocumentWatcher( + async () => cache.getRequiredGlobal(definition), + { intervalMs: options.pollMs ?? 50 }, + ), + replaceDocument: async (_context, value) => { + await cache.putGlobal(definition, value); + }, + }, + ); +} + +export function cultMeshDocuments( + ...documents: readonly CultMeshDocumentHandle[] +): CultMeshDocumentCatalog { + return new CultMeshDocumentCatalog(documents); +} + +export function cultMeshPublicationDocument( + schema: string | AnyCultCacheDocumentDefinition, + recordKey: string, + options: { + documentId?: string; + sourceId?: string; + source?: CultMeshDocumentPublicationSource; + } = {}, +): CultMeshPublicationDocumentBinding { + requireNonEmpty(recordKey, "recordKey"); + return { + schema, + recordKey, + documentId: options.documentId, + sourceId: options.sourceId, + source: options.source, + }; +} + +export function cultMeshDocumentsFromPublication( + source: CultMeshDocumentPublicationSourceResolver, + bindings: readonly CultMeshPublicationDocumentBinding[], + options: { + routeHint?: CultMeshRouteHint; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, +): CultMeshDocumentCatalog { + if (!source) { + throw new Error("CultMesh publication source is required."); + } + if (!bindings) { + throw new Error("CultMesh publication bindings are required."); + } + + return cultMeshDocuments( + ...bindings.map(binding => { + if (!binding) { + throw new Error("CultMesh publication binding is required."); + } + const resolvedSource = binding.source ?? + (typeof source === "function" ? source(binding) : source); + return typeof binding.schema === "string" + ? cultMeshDocumentFromPublication( + resolvedSource, + binding.schema, + binding.recordKey, + { + documentId: binding.documentId ?? binding.recordKey, + sourceId: binding.sourceId ?? binding.documentId ?? binding.recordKey, + routeHint: options.routeHint, + timeoutMs: options.timeoutMs, + pollMs: options.pollMs, + messageIdPrefix: options.messageIdPrefix, + }, + ) + : cultMeshDocumentFromPublication( + resolvedSource, + binding.schema, + binding.recordKey, + { + documentId: binding.documentId ?? binding.recordKey, + sourceId: binding.sourceId ?? binding.documentId ?? binding.recordKey, + routeHint: options.routeHint, + timeoutMs: options.timeoutMs, + pollMs: options.pollMs, + messageIdPrefix: options.messageIdPrefix, + }, + ); + }), + ); +} + +export function cultMeshCollection( + collectionId: string, + schema: CultMeshDocumentSchemaDescriptor, + snapshotCollection: (context: CultMeshQueryContext) => Promise>, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchCollection?: CultMeshCollectionWatcher; + } = {}, +): CultMeshCollectionHandle { + return new CultMeshCollectionHandle(collectionId, schema, snapshotCollection, options); +} + +export function cultMeshCollections( + ...collections: readonly CultMeshCollectionHandle[] +): CultMeshCollectionCatalog { + return new CultMeshCollectionCatalog(collections); +} + +export function cultMeshCollectionFromCache( + cache: CultCache, + definition: TDefinition, + options: { + collectionId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, +): CultMeshCollectionHandle> { + const collectionId = options.collectionId ?? definition.type; + const schema = cultMeshSchemaFromDefinition(definition); + return cultMeshCollection( + collectionId, + schema, + async () => cache.getAll(definition), + { + routeHint: options.routeHint ?? cultMeshRouteHint("in-process", "CultCache"), + sources: [cultMeshProjectionSource(collectionId, { schemaId: schema.schemaId })], + watchCollection: cultMeshPollingCollectionWatcher( + async () => cache.getAll(definition), + { intervalMs: options.pollMs ?? 50 }, + ), + }, + ); +} + +export function cultMeshDescribeLiveFeed( + feed: { + readonly feedId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + }, +): CultMeshLiveFeedDiagnostic { + return { + feedId: feed.feedId, + routeHint: feed.routeHint, + sources: [...feed.sources], + }; +} + +export function cultMeshPollingQueryWatcher( + executeQuery: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: CultMeshPollingWatchOptions = {}, +): CultMeshQueryWatcher { + const intervalMs = Math.max(1, options.intervalMs ?? 50); + const emitInitial = options.emitInitial ?? true; + return (parameters, context, callback) => { + let disposed = false; + let running = false; + let last: TResult | undefined; + let hasLast = false; + let timer: ReturnType | undefined; + + const poll = async () => { + if (disposed || running) { + return; + } + + running = true; + try { + const next = await executeQuery(parameters, context); + const changed = !hasLast || !(options.equals?.(last as TResult, next) ?? Object.is(last, next)); + const shouldEmit = hasLast ? changed : emitInitial; + last = next; + hasLast = true; + if (shouldEmit) { + callback(next); + } + } finally { + running = false; + } + }; + + void poll(); + timer = setInterval(() => { + void poll(); + }, intervalMs); + + return () => { + disposed = true; + if (timer) { + clearInterval(timer); + } + }; + }; +} + +export function cultMeshPollingDocumentWatcher( + readDocument: (context: CultMeshQueryContext) => Promise, + options: CultMeshPollingWatchOptions = {}, +): CultMeshDocumentWatcher { + const watch = cultMeshPollingQueryWatcher( + async (_parameters, context) => readDocument(context), + options, + ); + return (context, callback) => watch(undefined, context, callback); +} + +export function cultMeshPollingCollectionWatcher( + readCollection: (context: CultMeshQueryContext) => Promise>, + options: CultMeshPollingWatchOptions> = {}, +): CultMeshCollectionWatcher { + const watch = cultMeshPollingQueryWatcher>( + async (_parameters, context) => readCollection(context), + { + ...options, + equals: options.equals ?? ((left, right) => JSON.stringify(left) === JSON.stringify(right)), + }, + ); + return (context, callback) => watch(undefined, context, () => callback({ + kind: "reset", + })); +} + +export function cultMeshStatePointer( + pointerId: string, + resolvePointer: (() => Promise) | CultMeshStatePointerResolver, + watchPointer?: + | ((callback: (value: T) => void) => CultMeshUnsubscribe) + | CultMeshStatePointerWatcher, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, +): CultMeshStatePointer { + return new CultMeshStatePointer(pointerId, resolvePointer, watchPointer, options); +} + +export function cultMeshMutableStatePointer( + pointerId: string, + resolvePointer: (() => Promise) | CultMeshStatePointerResolver, + watchPointer: + | ((callback: (value: T) => void) => CultMeshUnsubscribe) + | CultMeshStatePointerWatcher, + replacePointer: + | ((value: T) => Promise) + | CultMeshMutableStatePointerReplacer, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, +): CultMeshMutableStatePointer { + return new CultMeshMutableStatePointer( + pointerId, + resolvePointer, + watchPointer, + replacePointer, + options, + ); +} + +export function cultMeshProjectionSource( + sourceId: string, + options: { schemaId?: string; description?: string } = {}, +): CultMeshProjectionSource { + requireNonEmpty(sourceId, "sourceId"); + return { + sourceId, + schemaId: options.schemaId, + description: options.description, + }; +} + +export class CultMeshStateRefResolver { + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly resolverId: string, + private readonly resolveRef: (stateRef: string, context: CultMeshQueryContext) => string | undefined, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, + ) { + requireNonEmpty(resolverId, "resolverId"); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + } + + public resolve(stateRef: string, context: CultMeshQueryContext | string = "local"): string { + if (!stateRef.trim()) { + return ""; + } + return ( + this.resolveRef( + stateRef, + this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), + ) ?? "" + ); + } + + public tryResolve( + stateRef: string, + context: CultMeshQueryContext | string = "local", + ): { resolved: boolean; value: string } { + const value = this.resolve(stateRef, context); + return { resolved: value.length > 0, value }; + } + + public or(fallback: CultMeshStateRefResolver): CultMeshStateRefResolver { + return new CultMeshStateRefResolver( + `${this.resolverId}|${fallback.resolverId}`, + (stateRef, context) => { + const value = this.resolve(stateRef, context); + return value.length > 0 ? value : fallback.resolve(stateRef, context); + }, + { + sources: [...this.sources, ...fallback.sources], + routeHint: this.routeHint.kind === "automatic" ? fallback.routeHint : this.routeHint, + }, + ); + } + + public asFunction(): (stateRef: string) => string { + return (stateRef) => this.resolve(stateRef); + } + + private resolveContext(context: CultMeshQueryContext): CultMeshQueryContext { + if (context.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return context; + } + return cultMeshQueryContext(context.runtimeId, { routeHint: this.routeHint }); + } +} + +export function cultMeshStateRefResolver( + resolverId: string, + resolveRef: + | ((stateRef: string) => string | undefined) + | ((stateRef: string, context: CultMeshQueryContext) => string | undefined), + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, +): CultMeshStateRefResolver { + return new CultMeshStateRefResolver( + resolverId, + (stateRef, context) => + resolveRef.length <= 1 + ? (resolveRef as (stateRef: string) => string | undefined)(stateRef) + : (resolveRef as (stateRef: string, context: CultMeshQueryContext) => string | undefined)( + stateRef, + context, + ), + options, + ); +} + +export function cultMeshDescribeStateRefResolver( + resolver: CultMeshStateRefResolver, +): CultMeshStateRefResolverDiagnostic { + return { + resolverId: resolver.resolverId, + routeHint: resolver.routeHint, + sources: [...resolver.sources], + }; +} + +function stateBindingMetadata( + options: Pick, +): Partial { + return Object.fromEntries( + Object.entries(options).filter(([, value]) => value !== undefined), + ) as Partial; +} + +export function cultMeshStateBinding( + targetProp: string, + pointer: + | { + readonly pointerId: string; + readonly routeHint?: CultMeshRouteHint; + readonly sources?: readonly CultMeshProjectionSource[]; + } + | string, + options: { + sourceId?: string; + schemaId?: string; + routeHint?: CultMeshRouteHint; + bindingName?: string; + documentId?: string; + fieldPath?: string; + valueKind?: CultMeshStateBindingDescriptor["valueKind"]; + accessMode?: CultMeshStateBindingDescriptor["accessMode"]; + authority?: string; + writeCommand?: string; + } = {}, +): CultMeshStateBindingDescriptor { + requireNonEmpty(targetProp, "targetProp"); + if (typeof pointer === "string") { + requireNonEmpty(pointer, "pointerId"); + return { + targetProp, + pointerId: pointer, + sourceId: options.sourceId, + schemaId: options.schemaId, + routeHint: options.routeHint ?? cultMeshRouteHint(), + ...stateBindingMetadata(options), + }; + } + + requireNonEmpty(pointer.pointerId, "pointerId"); + const source = pointer.sources?.[0]; + return { + targetProp, + pointerId: pointer.pointerId, + sourceId: options.sourceId ?? source?.sourceId, + schemaId: options.schemaId ?? source?.schemaId, + routeHint: options.routeHint ?? pointer.routeHint ?? cultMeshRouteHint(), + ...stateBindingMetadata(options), + }; +} + +export function cultMeshStateBindingRecord( + binding?: Partial, +): CultMeshStateBindingRecord; +export function cultMeshStateBindingRecord( + targetProp?: string, + pointerId?: string, + sourceId?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, + bindingName?: string, + documentId?: string, + fieldPath?: string, + valueKind?: string, + accessMode?: string, + authority?: string, + writeCommand?: string, +): CultMeshStateBindingRecord; +export function cultMeshStateBindingRecord( + bindingOrTargetProp?: Partial | string, + pointerId?: string, + sourceId?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, + bindingName?: string, + documentId?: string, + fieldPath?: string, + valueKind?: string, + accessMode?: string, + authority?: string, + writeCommand?: string, +): CultMeshStateBindingRecord { + if (typeof bindingOrTargetProp === "object" || bindingOrTargetProp === undefined) { + const binding = bindingOrTargetProp; + const route = cultMeshRouteRecord(binding?.routeHint); + return { + targetProp: binding?.targetProp?.trim() || "value", + pointerId: binding?.pointerId ?? "", + sourceId: binding?.sourceId ?? "", + schemaId: binding?.schemaId ?? "", + routeKind: route.kind, + routeDescription: route.description, + bindingName: binding?.bindingName ?? "", + documentId: binding?.documentId ?? "", + fieldPath: binding?.fieldPath ?? "", + valueKind: binding?.valueKind ?? "", + accessMode: binding?.accessMode ?? "", + authority: binding?.authority ?? "", + writeCommand: binding?.writeCommand ?? "", + }; + } + + return { + targetProp: bindingOrTargetProp?.trim() || "value", + pointerId: pointerId ?? "", + sourceId: sourceId ?? "", + schemaId: schemaId ?? "", + routeKind: routeKind ?? "", + routeDescription: routeDescription ?? "", + bindingName: bindingName ?? "", + documentId: documentId ?? "", + fieldPath: fieldPath ?? "", + valueKind: valueKind ?? "", + accessMode: accessMode ?? "", + authority: authority ?? "", + writeCommand: writeCommand ?? "", + }; +} + +export function cultMeshStateBindingFromRecord( + record: Partial, + options: { + fallbackRouteHint?: CultMeshRouteHint; + fallbackTargetProp?: string; + } = {}, +): CultMeshStateBindingDescriptor { + const targetProp = record.targetProp?.trim() || options.fallbackTargetProp?.trim() || "value"; + return { + targetProp, + pointerId: record.pointerId?.trim() || `${targetProp}.unknown`, + sourceId: record.sourceId ?? "", + schemaId: record.schemaId ?? "", + routeHint: cultMeshRouteFromRecord( + { + kind: record.routeKind, + description: record.routeDescription, + }, + options.fallbackRouteHint, + ), + ...stateBindingMetadata({ + bindingName: record.bindingName || undefined, + documentId: record.documentId || undefined, + fieldPath: record.fieldPath || undefined, + valueKind: (record.valueKind || undefined) as CultMeshStateBindingDescriptor["valueKind"], + accessMode: (record.accessMode || undefined) as CultMeshStateBindingDescriptor["accessMode"], + authority: record.authority || undefined, + writeCommand: record.writeCommand || undefined, + }), + }; +} + +export function cultMeshOperationBinding( + operation: + | { + readonly operationId: string; + } + | string, + options: { + label?: string; + schemaId?: string; + routeHint?: CultMeshRouteHint; + } = {}, +): CultMeshOperationBindingDescriptor { + const operationId = typeof operation === "string" ? operation : operation.operationId; + requireNonEmpty(operationId, "operationId"); + return { + operationId, + label: options.label ?? "", + schemaId: options.schemaId ?? "", + routeHint: options.routeHint ?? cultMeshRouteHint(), + }; +} + +export function cultMeshOperationBindingRecord( + binding?: Partial, +): CultMeshOperationBindingRecord; +export function cultMeshOperationBindingRecord( + operationId?: string, + label?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, +): CultMeshOperationBindingRecord; +export function cultMeshOperationBindingRecord( + bindingOrOperationId?: Partial | string, + label?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, +): CultMeshOperationBindingRecord { + if (typeof bindingOrOperationId === "object" || bindingOrOperationId === undefined) { + const binding = bindingOrOperationId; + const route = cultMeshRouteRecord(binding?.routeHint); + return { + operationId: binding?.operationId ?? "", + label: binding?.label ?? "", + schemaId: binding?.schemaId ?? "", + routeKind: route.kind, + routeDescription: route.description, + }; + } + + return { + operationId: bindingOrOperationId ?? "", + label: label ?? "", + schemaId: schemaId ?? "", + routeKind: routeKind ?? "", + routeDescription: routeDescription ?? "", + }; +} + +export function cultMeshOperationBindingFromRecord( + record: Partial, + options: { + fallbackRouteHint?: CultMeshRouteHint; + fallbackOperationId?: string; + } = {}, +): CultMeshOperationBindingDescriptor { + return { + operationId: record.operationId?.trim() || options.fallbackOperationId || "", + label: record.label ?? "", + schemaId: record.schemaId ?? "", + routeHint: cultMeshRouteFromRecord( + { + kind: record.routeKind, + description: record.routeDescription, + }, + options.fallbackRouteHint, + ), + }; +} + +export function cultMeshOperationInvocation( + operation: + | { + readonly operationId: string; + readonly schemaId?: string; + readonly routeHint?: CultMeshRouteHint; + } + | string, + options: { + schemaId?: string; + routeHint?: CultMeshRouteHint; + idempotencyKey?: string; + } = {}, +): CultMeshOperationInvocationDescriptor { + const operationId = typeof operation === "string" ? operation : operation.operationId; + requireNonEmpty(operationId, "operationId"); + return { + operationId, + schemaId: options.schemaId ?? (typeof operation === "string" ? "" : operation.schemaId) ?? "", + routeHint: + options.routeHint ?? + (typeof operation === "string" ? undefined : operation.routeHint) ?? + cultMeshRouteHint(), + idempotencyKey: options.idempotencyKey, + }; +} + +export function cultMeshOperationInvocationRecord( + invocation?: CultMeshOperationInvocationDescriptor, + options: { + fallbackOperationId?: string; + fallbackSchemaId?: string; + fallbackRouteHint?: CultMeshRouteHint; + fallbackIdempotencyKey?: string; + } = {}, +): CultMeshOperationInvocationRecord { + const route = cultMeshRouteRecord(invocation?.routeHint ?? options.fallbackRouteHint); + return { + operationId: nonBlankOr(invocation?.operationId, options.fallbackOperationId), + schemaId: nonBlankOr(invocation?.schemaId, options.fallbackSchemaId), + routeKind: route.kind, + routeDescription: route.description, + idempotencyKey: nonBlankOr(invocation?.idempotencyKey, options.fallbackIdempotencyKey), + }; +} + +export function cultMeshOperationInvocationFromRecord( + record: Partial, + options: { + fallbackOperationId?: string; + fallbackSchemaId?: string; + fallbackRouteHint?: CultMeshRouteHint; + fallbackIdempotencyKey?: string; + } = {}, +): CultMeshOperationInvocationDescriptor { + const operationId = nonBlankOr(record.operationId, options.fallbackOperationId); + requireNonEmpty(operationId, "operationId"); + const fallbackRoute = options.fallbackRouteHint ?? cultMeshRouteHint(); + const route = cultMeshRouteFromRecord( + { + kind: record.routeKind, + description: record.routeDescription, + }, + fallbackRoute, + ); + return { + operationId, + schemaId: nonBlankOr(record.schemaId, options.fallbackSchemaId), + routeHint: route, + idempotencyKey: nonBlankOr(record.idempotencyKey, options.fallbackIdempotencyKey) || undefined, + }; +} + +export function cultMeshOperationPayload( + fields: Readonly> = {}, +): CultMeshOperationPayload { + const normalized: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (!key.trim() || value === undefined) { + continue; + } + normalized[key] = String(value); + } + + return { + fields: Object.freeze({ ...normalized }), + getString(key, defaultValue = "") { + return normalized[key] ?? defaultValue; + }, + getInt(key, defaultValue = 0) { + const raw = normalized[key]; + if (raw === undefined || raw.trim() === "") { + return defaultValue; + } + const parsed = Number.parseInt(raw, 10); + return Number.isNaN(parsed) ? defaultValue : parsed; + }, + getDouble(key, defaultValue = 0) { + const raw = normalized[key]; + if (raw === undefined || raw.trim() === "") { + return defaultValue; + } + const parsed = Number.parseFloat(raw); + return Number.isNaN(parsed) ? defaultValue : parsed; + }, + getBoolean(key, defaultValue = false) { + const raw = normalized[key]?.toLowerCase(); + if (raw === "true" || raw === "1" || raw === "yes" || raw === "on") { + return true; + } + if (raw === "false" || raw === "0" || raw === "no" || raw === "off") { + return false; + } + return defaultValue; + }, + with(key, value) { + requireNonEmpty(key, "key"); + return cultMeshOperationPayload({ + ...normalized, + [key]: value, + }); + }, + toRecord() { + return Object.freeze({ ...normalized }); + }, + }; +} + +export function cultMeshProjectionRecipe( + projectionId: string, + sources: readonly CultMeshProjectionSource[], + projectProjection: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + routeHint?: CultMeshRouteHint; + watchProjection?: ( + parameters: TParameters, + context: CultMeshQueryContext, + callback: (value: TResult) => void, + ) => CultMeshUnsubscribe; + } = {}, +): CultMeshProjectionRecipe { + return new CultMeshProjectionRecipe( + projectionId, + sources, + projectProjection, + options, + ); +} + +export function cultMeshDescribeProjectionRecipe( + recipe: { + readonly projectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + }, +): CultMeshProjectionRecipeDiagnostic { + return { + projectionId: recipe.projectionId, + routeHint: recipe.routeHint, + sources: [...recipe.sources], + }; +} + +export function cultMeshDescribeSurface( + surface: + | { + readonly queryId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly operationId: string; + } + | { + readonly projectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly feedId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly documentId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly collectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly pointerId: string; + readonly routeHint?: CultMeshRouteHint; + readonly sources?: readonly CultMeshProjectionSource[]; + } + | CultMeshNativeSliceViewDescriptor, +): CultMeshSurfaceDiagnostic { + if ("queryId" in surface) { + return cultMeshSurfaceDiagnostic("query", surface.queryId, { + routeHint: surface.routeHint, + sources: surface.sources, + }); + } + + if ("operationId" in surface) { + return cultMeshSurfaceDiagnostic("operation", surface.operationId); + } + + if ("pointerId" in surface) { + return cultMeshSurfaceDiagnostic("state-pointer", surface.pointerId, { + routeHint: surface.routeHint, + sources: surface.sources, + }); + } + + if ("documentId" in surface) { + return cultMeshSurfaceDiagnostic("document", surface.documentId, { + routeHint: surface.routeHint, + sources: surface.sources, + }); + } + + if ("collectionId" in surface) { + return cultMeshSurfaceDiagnostic("collection", surface.collectionId, { + routeHint: surface.routeHint, + sources: surface.sources, + }); + } + + if ("viewId" in surface) { + return cultMeshSurfaceDiagnostic("native-slice-view", surface.viewId, { + routeHint: surface.route, + }); + } + + if ("projectionId" in surface) { + return cultMeshSurfaceDiagnostic("projection-recipe", surface.projectionId, { + routeHint: surface.routeHint, + sources: surface.sources, + }); + } + + return cultMeshSurfaceDiagnostic("live-feed", surface.feedId, { + routeHint: surface.routeHint, + sources: surface.sources, + }); +} + +export function cultMeshSurfaceDiagnostic( + kind: CultMeshSurfaceKind, + surfaceId: string, + options: { + routeHint?: CultMeshRouteHint; + sources?: readonly CultMeshProjectionSource[]; + } = {}, +): CultMeshSurfaceDiagnostic { + requireNonEmpty(surfaceId, "surfaceId"); + return { + kind, + surfaceId, + routeHint: options.routeHint ?? cultMeshRouteHint(), + sources: [...(options.sources ?? [])], + }; +} + +export function cultMeshDescribeSurfaceCatalog( + catalogId: string, + surfaces: readonly CultMeshSurfaceDiagnostic[], +): CultMeshSurfaceCatalogDiagnostic { + requireNonEmpty(catalogId, "catalogId"); + return { + catalogId, + surfaces: surfaces.map(surface => cultMeshSurfaceDiagnostic(surface.kind, surface.surfaceId, { + routeHint: surface.routeHint, + sources: surface.sources, + })), + }; +} + +export function cultMeshFindSurface( + catalog: CultMeshSurfaceCatalogDiagnostic, + surfaceId: string, +): CultMeshSurfaceDiagnostic | undefined { + requireNonEmpty(surfaceId, "surfaceId"); + return catalog.surfaces.find(surface => surface.surfaceId === surfaceId); +} + +export function cultMeshSurfacesByKind( + catalog: CultMeshSurfaceCatalogDiagnostic, + kind: CultMeshSurfaceKind, +): CultMeshSurfaceDiagnostic[] { + return catalog.surfaces + .filter(surface => surface.kind === kind) + .map(surface => cultMeshSurfaceDiagnostic(surface.kind, surface.surfaceId, { + routeHint: surface.routeHint, + sources: surface.sources, + })); +} + +export function cultMeshSurfaceCatalogIndex( + catalog: CultMeshSurfaceCatalogDiagnostic, +): CultMeshSurfaceCatalogIndexDiagnostic { + return { + catalogId: catalog.catalogId, + queries: cultMeshSurfacesByKind(catalog, "query"), + projectionRecipes: cultMeshSurfacesByKind(catalog, "projection-recipe"), + liveFeeds: cultMeshSurfacesByKind(catalog, "live-feed"), + operations: cultMeshSurfacesByKind(catalog, "operation"), + documents: cultMeshSurfacesByKind(catalog, "document"), + collections: cultMeshSurfacesByKind(catalog, "collection"), + statePointers: cultMeshSurfacesByKind(catalog, "state-pointer"), + nativeSliceViews: cultMeshSurfacesByKind(catalog, "native-slice-view"), + }; +} + +export function cultMeshNativeSliceColumn( + name: string, + valueType: string, + elementSizeBytes: number, +): CultMeshNativeSliceColumn { + requireNonEmpty(name, "name"); + requireNonEmpty(valueType, "valueType"); + if (!Number.isInteger(elementSizeBytes) || elementSizeBytes <= 0) { + throw new Error("elementSizeBytes must be a positive integer."); + } + + return { name, valueType, elementSizeBytes }; +} + +export function cultMeshNativeSliceView( + viewId: string, + schemaId: string, + rowCount: number, + columns: readonly CultMeshNativeSliceColumn[], + options: { route?: CultMeshRouteHint; nativeHandle?: string } = {}, +): CultMeshNativeSliceViewDescriptor { + requireNonEmpty(viewId, "viewId"); + requireNonEmpty(schemaId, "schemaId"); + if (!Number.isInteger(rowCount) || rowCount < 0) { + throw new Error("rowCount must be a non-negative integer."); + } + + return { + viewId, + schemaId, + rowCount, + columns: [...columns], + route: options.route ?? cultMeshRouteHint(), + nativeHandle: options.nativeHandle, + }; +} + +export function cultMeshDenseRowStrideBytes( + view: CultMeshNativeSliceViewDescriptor, +): number { + return view.columns.reduce((sum, column) => sum + column.elementSizeBytes, 0); +} + +export function cultMeshFindNativeSliceColumn( + view: CultMeshNativeSliceViewDescriptor, + name: string, +): CultMeshNativeSliceColumn | undefined { + requireNonEmpty(name, "name"); + return view.columns.find((column) => column.name === name); +} + +export function cultMeshDescribeNativeSliceView( + view: CultMeshNativeSliceViewDescriptor, +): CultMeshNativeSliceViewDiagnostic { + return { + viewId: view.viewId, + schemaId: view.schemaId, + rowCount: view.rowCount, + columns: [...view.columns], + route: view.route, + nativeHandle: view.nativeHandle, + denseRowStrideBytes: cultMeshDenseRowStrideBytes(view), + }; +} + export interface CultMeshNodeOptions { documents?: Iterable; bindings?: Iterable; @@ -47,14 +4008,23 @@ export class CultMeshNode { definition: TDefinition, key: string, ): CultCacheDocumentValue | undefined { - return this.cache.get(definition, key); + const registered = this.resolveCacheDefinition(definition); + const value = this.cache.get(registered, key); + return value === undefined + ? undefined + : this.parseDocumentValue(definition, value); } public getRequired( definition: TDefinition, key: string, ): CultCacheDocumentValue { - return this.cache.getRequired(definition, key); + const value = this.get(definition, key); + if (value === undefined) { + throw new Error(`CultMesh node has no "${definition.type}" document at key "${key}".`); + } + + return value; } public put( @@ -62,19 +4032,173 @@ export class CultMeshNode { key: string, value: CultCacheDocumentValue, ): Promise> { - return this.cache.put(definition, key, value); + const registered = this.resolveCacheDefinition(definition); + return this.cache + .put(registered, key, this.parseDocumentValue(definition, value) as never) + .then(stored => this.parseDocumentValue(definition, stored)); } public delete( definition: TDefinition, key: string, ): Promise { - return this.cache.delete(definition, key); + return this.cache.delete(this.resolveCacheDefinition(definition), key); + } + + public document( + definition: TDefinition, + key: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshDocumentHandle> { + const documentId = options.documentId ?? `${definition.type}:${key}`; + return this.asRequestedDocument( + cultMeshDocumentFromCache( + this.cache, + this.resolveCacheDefinition(definition), + key, + { ...options, documentId }, + ), + definition, + ); + } + + public async syncDocumentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + definition: TDefinition, + key: string, + options: { + timeoutMs?: number; + messageIdPrefix?: string; + } = {}, + ): Promise> { + const registered = this.resolveCacheDefinition(definition); + const schema = cultMeshSchemaFromDefinition(definition); + const schemaId = schema.schemaId ?? schema.type; + if (!schemaId) { + throw new Error("CultMesh synced document requires a schema id or document type."); + } + const document = await requestCultNetRawSnapshotDocument( + peer, + schemaId, + key, + { + timeoutMs: options.timeoutMs, + messageIdPrefix: options.messageIdPrefix ?? key, + accepts: record => { + try { + definition.schema.parse(decodeCultNetRawDocumentPayload(record)); + return true; + } catch { + return false; + } + }, + }, + ); + await this.documents.applyRawDocumentPutMessage(this.cache, { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: options.messageIdPrefix ?? `sync:${key}`, + document, + }); + return this.parseDocumentValue(definition, this.cache.getRequired(registered, key)); + } + + public syncDocumentFromPublication( + source: CultMeshDocumentPublicationSource, + definition: TDefinition, + key: string, + options: { + timeoutMs?: number; + messageIdPrefix?: string; + } = {}, + ): Promise> { + return cultMeshSyncDocumentFromPublication(this, source, definition, key, options); + } + + public syncDocumentsFromPublication( + source: CultMeshDocumentPublicationSourceResolver, + bindings: readonly CultMeshPublicationDocumentBinding[], + options: { + routeHint?: CultMeshRouteHint; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, + ): Promise { + return cultMeshSyncDocumentsFromPublication(this, source, bindings, options); + } + + public globalDocument( + definition: TDefinition, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshDocumentHandle> { + const documentId = options.documentId ?? `${definition.type}:global`; + return this.asRequestedDocument( + cultMeshGlobalDocumentFromCache( + this.cache, + this.resolveCacheDefinition(definition), + { ...options, documentId }, + ), + definition, + ); + } + + public collection( + definition: TDefinition, + options: { + collectionId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshCollectionHandle> { + const collectionId = options.collectionId ?? definition.type; + return cultMeshCollectionFromCache( + this.cache, + this.resolveCacheDefinition(definition), + { ...options, collectionId }, + ).asSchemaAlias>( + cultMeshSchemaFromDefinition(definition), + { parse: value => this.parseDocumentValue(definition, value) }, + ); } public async flush(soft = false): Promise { await this.store.pushAll?.(this.cache.snapshot(), { soft }); } + + private resolveCacheDefinition( + definition: TDefinition, + ): AnyCultCacheDocumentDefinition { + const schema = cultMeshSchemaFromDefinition(definition); + return ( + this.documents.get(definition.type) ?? + (schema.schemaId ? this.documents.getBySchemaId(schema.schemaId) : undefined) + )?.definition ?? definition; + } + + private asRequestedDocument( + document: CultMeshDocumentHandle, + definition: TDefinition, + ): CultMeshDocumentHandle> { + return document.asSchemaAlias>( + cultMeshSchemaFromDefinition(definition), + { parse: value => this.parseDocumentValue(definition, value) }, + ); + } + + private parseDocumentValue( + definition: TDefinition, + value: unknown, + ): CultCacheDocumentValue { + return definition.schema.parse(value) as CultCacheDocumentValue; + } } export class CultMeshVerseCatalog { @@ -133,15 +4257,41 @@ export interface CultMeshRudpDocumentPut { }; } +export interface CultMeshRudpDocumentReceipt { + binding: CultNetDocumentBinding; + recordKey: string; + value: CultCacheDocumentValue; + messageId?: string; + sourceRuntimeId?: string; + sourceAgentId?: string; + sourceRole?: string; + tags?: string[]; +} + export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOptions { documents: CultNetDocumentRegistry; getCache?: () => Promise | CultCache; onError?: (error: Error) => void; - onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) => void | Promise; + onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) => + | CultMeshRudpDocumentReceipt + | void + | Promise; + onOperationRequest?: ( + request: CultNetOperationRequestMessage, + session: CultMeshRudpServerSession, + ) => CultNetOperationResponseMessage | Promise; + onSessionClosed?: (session: CultMeshRudpServerSession) => void | Promise; wireContract?: CultNetWireContract; sessionTimeoutMs?: number; } +export interface CultMeshRudpServerSession { + readonly sessionId: string; + readonly remote: { address: string; family: string; port: number }; + readonly connectPayload?: Uint8Array; + send(message: CultNetMessage): void; +} + export interface CultMeshRudpDocumentServer { readonly bind: { host: string; port: number }; start(): Promise; @@ -157,6 +4307,10 @@ export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOpti flushTimeoutMs?: number; } +export interface CultMeshRudpDocumentReceiptOptions extends CultMeshRudpDocumentPublishOptions { + receiptTimeoutMs?: number; +} + export interface CultMeshRudpEndpoint { host: string; port: number; @@ -544,6 +4698,974 @@ export class CultMeshStreamCatalog { } export class CultMesh { + public static vec2(x: number, y: number): CultMeshVec2 { + return cultMeshVec2(x, y); + } + + public static rect(min: CultMeshVec2, max: CultMeshVec2): CultMeshRect { + return cultMeshRect(min, max); + } + + public static rectFromBounds( + minX: number, + minY: number, + maxX: number, + maxY: number, + ): CultMeshRect { + return cultMeshRectFromBounds(minX, minY, maxX, maxY); + } + + public static viewportRequest( + viewport: CultMeshRect, + controlledEntityIndices?: readonly number[], + ): CultMeshViewportRequest { + return cultMeshViewportRequest(viewport, controlledEntityIndices); + } + + public static routeHint( + kind: CultMeshLocalityKind = "automatic", + description?: string, + ): CultMeshRouteHint { + return cultMeshRouteHint(kind, description); + } + + public static routeRecord(routeHint?: CultMeshRouteHint): CultMeshRouteRecord; + public static routeRecord(kind?: string, description?: string): CultMeshRouteRecord; + public static routeRecord( + routeOrKind?: CultMeshRouteHint | string, + description = "", + ): CultMeshRouteRecord { + return typeof routeOrKind === "string" || routeOrKind === undefined + ? cultMeshRouteRecord(routeOrKind, description) + : cultMeshRouteRecord(routeOrKind); + } + + public static routeFromRecord( + record: Partial, + fallback: CultMeshRouteHint = cultMeshRouteHint(), + ): CultMeshRouteHint { + return cultMeshRouteFromRecord(record, fallback); + } + + public static authorityClaim( + role: string, + options: { shardId?: string; leaseId?: string } = {}, + ): CultMeshAuthorityClaim { + return cultMeshAuthorityClaim(role, options); + } + + public static verseContext( + verseId: string, + runtimeId: string, + options: { + routeHint?: CultMeshRouteHint; + claims?: readonly CultMeshAuthorityClaim[]; + } = {}, + ): CultMeshVerseContext { + return cultMeshVerseContext(verseId, runtimeId, options); + } + + public static verse( + verseId: string, + runtimeId: string, + options: { + routeHint?: CultMeshRouteHint; + claims?: readonly CultMeshAuthorityClaim[]; + } = {}, + ): CultMeshVerse { + return cultMeshVerse(verseId, runtimeId, options); + } + + public static async connectVerse( + verseId: string, + runtimeId: string, + options: { + routeHint?: CultMeshRouteHint; + claims?: readonly CultMeshAuthorityClaim[]; + } = {}, + ): Promise { + return cultMeshVerse(verseId, runtimeId, options); + } + + public static operationContextFromVerse( + context: CultMeshVerseContext, + options: { idempotencyKey?: string } = {}, + ): CultMeshOperationContext { + return cultMeshOperationContextFromVerse(context, options); + } + + public static queryContextFromVerse( + context: CultMeshVerseContext, + ): CultMeshQueryContext { + return cultMeshQueryContextFromVerse(context); + } + + public static bindOperation( + verse: CultMeshVerseContext | CultMeshVerse, + operation: CultMeshOperationHandle, + ): CultMeshBoundOperationHandle { + return cultMeshBindOperation(verse, operation); + } + + public static bindQuery( + verse: CultMeshVerseContext | CultMeshVerse, + query: CultMeshQuerySurface, + ): CultMeshBoundQuerySurface { + return cultMeshBindQuery(verse, query); + } + + public static bindLiveFeed( + verse: CultMeshVerseContext | CultMeshVerse, + feed: CultMeshLiveFeed, + ): CultMeshBoundLiveFeed { + return cultMeshBindLiveFeed(verse, feed); + } + + public static operationContext( + runtimeId: string, + options: { + claims?: readonly CultMeshAuthorityClaim[]; + routeHint?: CultMeshRouteHint; + idempotencyKey?: string; + } = {}, + ): CultMeshOperationContext { + return cultMeshOperationContext(runtimeId, options); + } + + public static operationContextFor(runtimeId: string): CultMeshOperationContextBuilder { + return cultMeshOperationContextFor(runtimeId); + } + + public static queryContext( + runtimeId: string, + options: { routeHint?: CultMeshRouteHint } = {}, + ): CultMeshQueryContext { + return cultMeshQueryContext(runtimeId, options); + } + + public static queryContextFor(runtimeId: string): CultMeshQueryContextBuilder { + return cultMeshQueryContextFor(runtimeId); + } + + public static operationReceipt( + operationId: string, + accepted: boolean, + options: { route?: CultMeshRouteHint; diagnostic?: string } = {}, + ): CultMeshOperationReceipt { + return cultMeshOperationReceipt(operationId, accepted, options); + } + + public static operation( + operationId: string, + invokeOperation: ( + request: TRequest, + context: CultMeshOperationContext, + ) => Promise, + ): CultMeshOperationHandle { + return cultMeshOperation(operationId, invokeOperation); + } + + public static query( + queryId: string, + executeQuery: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchQuery?: CultMeshQueryWatcher; + } = {}, + ): CultMeshQuerySurface { + return cultMeshQuery(queryId, executeQuery, options); + } + + public static describeQuerySurface( + query: { + readonly queryId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + }, + ): CultMeshQuerySurfaceDiagnostic { + return cultMeshDescribeQuerySurface(query); + } + + public static describeOperationHandle( + operation: { + readonly operationId: string; + }, + ): CultMeshOperationHandleDiagnostic { + return cultMeshDescribeOperationHandle(operation); + } + + public static describeStatePointer( + pointer: { + readonly pointerId: string; + readonly routeHint?: CultMeshRouteHint; + readonly sources?: readonly CultMeshProjectionSource[]; + }, + ): CultMeshStatePointerDiagnostic { + return cultMeshDescribeStatePointer(pointer); + } + + public static liveFeed( + feedId: string, + snapshotFeed: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchFeed?: CultMeshLiveFeedWatcher; + } = {}, + ): CultMeshLiveFeed { + return cultMeshLiveFeed(feedId, snapshotFeed, options); + } + + public static document( + documentId: string, + schema: CultMeshDocumentSchemaDescriptor, + snapshotDocument: (context: CultMeshQueryContext) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchDocument?: CultMeshDocumentWatcher; + replaceDocument?: CultMeshDocumentReplacer; + submitPrediction?: CultMeshDocumentPredictionSubmitter; + } = {}, + ): CultMeshDocumentHandle { + return cultMeshDocument(documentId, schema, snapshotDocument, options); + } + + public static documentFromCache( + cache: CultCache, + definition: TDefinition, + key: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshDocumentHandle> { + return cultMeshDocumentFromCache(cache, definition, key, options); + } + + public static documentFromStore( + store: CacheBackingStore, + schemaId: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, + ): CultMeshDocumentHandle; + public static documentFromStore( + store: CacheBackingStore, + definition: TDefinition, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, + ): CultMeshDocumentHandle>; + public static documentFromStore( + store: CacheBackingStore, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + } = {}, + ): CultMeshDocumentHandle { + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromStore(store, schemaOrDefinition, options) + : cultMeshDocumentFromStore(store, schemaOrDefinition, options); + } + + public static documentFromSingleFile( + path: string, + schemaId: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, + ): CultMeshDocumentHandle; + public static documentFromSingleFile( + path: string, + definition: TDefinition, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, + ): CultMeshDocumentHandle>; + public static documentFromSingleFile( + path: string, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + } = {}, + ): CultMeshDocumentHandle { + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromSingleFile(path, schemaOrDefinition, options) + : cultMeshDocumentFromSingleFile(path, schemaOrDefinition, options); + } + + public static documentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + schemaId: string, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, + ): CultMeshDocumentHandle; + public static documentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + definition: TDefinition, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, + ): CultMeshDocumentHandle>; + public static documentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, + recordKey: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, + ): CultMeshDocumentHandle { + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromPeerSnapshot(peer, schemaOrDefinition, recordKey, options) + : cultMeshDocumentFromPeerSnapshot(peer, schemaOrDefinition, recordKey, options); + } + + public static documentFromPublication( + source: CultMeshDocumentPublicationSource, + schemaId: string, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, + ): CultMeshDocumentHandle; + public static documentFromPublication( + source: CultMeshDocumentPublicationSource, + definition: TDefinition, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, + ): CultMeshDocumentHandle>; + public static documentFromPublication( + source: CultMeshDocumentPublicationSource, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, + recordKey: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, + ): CultMeshDocumentHandle { + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromPublication(source, schemaOrDefinition, recordKey, options) + : cultMeshDocumentFromPublication(source, schemaOrDefinition, recordKey, options); + } + + public static globalDocumentFromCache( + cache: CultCache, + definition: TDefinition, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshDocumentHandle> { + return cultMeshGlobalDocumentFromCache(cache, definition, options); + } + + public static syncDocumentFromPeerSnapshot( + node: CultMeshNode, + peer: CultNetPeer | (() => CultNetPeer | Promise), + definition: TDefinition, + key: string, + options: { + timeoutMs?: number; + messageIdPrefix?: string; + } = {}, + ): Promise> { + return node.syncDocumentFromPeerSnapshot(peer, definition, key, options); + } + + public static syncDocumentFromPublication( + node: CultMeshNode, + source: CultMeshDocumentPublicationSource, + definition: TDefinition, + key: string, + options: { + timeoutMs?: number; + messageIdPrefix?: string; + } = {}, + ): Promise> { + return node.syncDocumentFromPublication(source, definition, key, options); + } + + public static syncDocumentsFromPublication( + node: CultMeshNode, + source: CultMeshDocumentPublicationSourceResolver, + bindings: readonly CultMeshPublicationDocumentBinding[], + options: { + routeHint?: CultMeshRouteHint; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, + ): Promise { + return node.syncDocumentsFromPublication(source, bindings, options); + } + + public static documents( + ...documents: readonly CultMeshDocumentHandle[] + ): CultMeshDocumentCatalog { + return cultMeshDocuments(...documents); + } + + public static publicationDocument( + schema: string | AnyCultCacheDocumentDefinition, + recordKey: string, + options: { + documentId?: string; + sourceId?: string; + source?: CultMeshDocumentPublicationSource; + } = {}, + ): CultMeshPublicationDocumentBinding { + return cultMeshPublicationDocument(schema, recordKey, options); + } + + public static documentsFromPublication( + source: CultMeshDocumentPublicationSourceResolver, + bindings: readonly CultMeshPublicationDocumentBinding[], + options: { + routeHint?: CultMeshRouteHint; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, + ): CultMeshDocumentCatalog { + return cultMeshDocumentsFromPublication(source, bindings, options); + } + + public static bindDocument( + verse: CultMeshVerseContext | CultMeshVerse, + document: CultMeshDocumentHandle, + ): CultMeshBoundDocumentHandle { + return cultMeshBindDocument(verse, document); + } + + public static collection( + collectionId: string, + schema: CultMeshDocumentSchemaDescriptor, + snapshotCollection: (context: CultMeshQueryContext) => Promise>, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchCollection?: CultMeshCollectionWatcher; + } = {}, + ): CultMeshCollectionHandle { + return cultMeshCollection(collectionId, schema, snapshotCollection, options); + } + + public static collections( + ...collections: readonly CultMeshCollectionHandle[] + ): CultMeshCollectionCatalog { + return cultMeshCollections(...collections); + } + + public static collectionFromCache( + cache: CultCache, + definition: TDefinition, + options: { + collectionId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshCollectionHandle> { + return cultMeshCollectionFromCache(cache, definition, options); + } + + public static bindCollection( + verse: CultMeshVerseContext | CultMeshVerse, + collection: CultMeshCollectionHandle, + ): CultMeshBoundCollectionHandle { + return cultMeshBindCollection(verse, collection); + } + + public static describeLiveFeed( + feed: { + readonly feedId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + }, + ): CultMeshLiveFeedDiagnostic { + return cultMeshDescribeLiveFeed(feed); + } + + public static pollingQueryWatcher( + executeQuery: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: CultMeshPollingWatchOptions = {}, + ): CultMeshQueryWatcher { + return cultMeshPollingQueryWatcher(executeQuery, options); + } + + public static statePointer( + pointerId: string, + resolvePointer: (() => Promise) | CultMeshStatePointerResolver, + watchPointer?: + | ((callback: (value: T) => void) => CultMeshUnsubscribe) + | CultMeshStatePointerWatcher, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, + ): CultMeshStatePointer { + return cultMeshStatePointer(pointerId, resolvePointer, watchPointer, options); + } + + public static mutableStatePointer( + pointerId: string, + resolvePointer: (() => Promise) | CultMeshStatePointerResolver, + watchPointer: + | ((callback: (value: T) => void) => CultMeshUnsubscribe) + | CultMeshStatePointerWatcher, + replacePointer: + | ((value: T) => Promise) + | CultMeshMutableStatePointerReplacer, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, + ): CultMeshMutableStatePointer { + return cultMeshMutableStatePointer( + pointerId, + resolvePointer, + watchPointer, + replacePointer, + options, + ); + } + + public static bindStatePointer( + verse: CultMeshVerseContext | CultMeshVerse, + pointer: CultMeshStatePointer, + ): CultMeshBoundStatePointer { + return cultMeshBindStatePointer(verse, pointer); + } + + public static bindMutableStatePointer( + verse: CultMeshVerseContext | CultMeshVerse, + pointer: CultMeshMutableStatePointer, + ): CultMeshBoundMutableStatePointer { + return cultMeshBindMutableStatePointer(verse, pointer); + } + + public static projectionSource( + sourceId: string, + options: { schemaId?: string; description?: string } = {}, + ): CultMeshProjectionSource { + return cultMeshProjectionSource(sourceId, options); + } + + public static stateRefResolver( + resolverId: string, + resolveRef: + | ((stateRef: string) => string | undefined) + | ((stateRef: string, context: CultMeshQueryContext) => string | undefined), + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, + ): CultMeshStateRefResolver { + return cultMeshStateRefResolver(resolverId, resolveRef, options); + } + + public static describeStateRefResolver( + resolver: CultMeshStateRefResolver, + ): CultMeshStateRefResolverDiagnostic { + return cultMeshDescribeStateRefResolver(resolver); + } + + public static stateBinding( + targetProp: string, + pointer: + | { + readonly pointerId: string; + readonly routeHint?: CultMeshRouteHint; + readonly sources?: readonly CultMeshProjectionSource[]; + } + | string, + options: { + sourceId?: string; + schemaId?: string; + routeHint?: CultMeshRouteHint; + bindingName?: string; + documentId?: string; + fieldPath?: string; + valueKind?: CultMeshStateBindingDescriptor["valueKind"]; + accessMode?: CultMeshStateBindingDescriptor["accessMode"]; + authority?: string; + writeCommand?: string; + } = {}, + ): CultMeshStateBindingDescriptor { + return cultMeshStateBinding(targetProp, pointer, options); + } + + public static stateBindingRecord( + binding?: Partial, + ): CultMeshStateBindingRecord; + public static stateBindingRecord( + targetProp?: string, + pointerId?: string, + sourceId?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, + bindingName?: string, + documentId?: string, + fieldPath?: string, + valueKind?: string, + accessMode?: string, + authority?: string, + writeCommand?: string, + ): CultMeshStateBindingRecord; + public static stateBindingRecord( + bindingOrTargetProp?: Partial | string, + pointerId?: string, + sourceId?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, + bindingName?: string, + documentId?: string, + fieldPath?: string, + valueKind?: string, + accessMode?: string, + authority?: string, + writeCommand?: string, + ): CultMeshStateBindingRecord { + return typeof bindingOrTargetProp === "object" || bindingOrTargetProp === undefined + ? cultMeshStateBindingRecord(bindingOrTargetProp) + : cultMeshStateBindingRecord( + bindingOrTargetProp, + pointerId, + sourceId, + schemaId, + routeKind, + routeDescription, + bindingName, + documentId, + fieldPath, + valueKind, + accessMode, + authority, + writeCommand, + ); + } + + public static stateBindingFromRecord( + record: Partial, + options: { + fallbackRouteHint?: CultMeshRouteHint; + fallbackTargetProp?: string; + } = {}, + ): CultMeshStateBindingDescriptor { + return cultMeshStateBindingFromRecord(record, options); + } + + public static operationBinding( + operation: + | { + readonly operationId: string; + } + | string, + options: { + label?: string; + schemaId?: string; + routeHint?: CultMeshRouteHint; + } = {}, + ): CultMeshOperationBindingDescriptor { + return cultMeshOperationBinding(operation, options); + } + + public static operationBindingRecord( + binding?: Partial, + ): CultMeshOperationBindingRecord; + public static operationBindingRecord( + operationId?: string, + label?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, + ): CultMeshOperationBindingRecord; + public static operationBindingRecord( + bindingOrOperationId?: Partial | string, + label?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, + ): CultMeshOperationBindingRecord { + return typeof bindingOrOperationId === "object" || bindingOrOperationId === undefined + ? cultMeshOperationBindingRecord(bindingOrOperationId) + : cultMeshOperationBindingRecord( + bindingOrOperationId, + label, + schemaId, + routeKind, + routeDescription, + ); + } + + public static operationBindingFromRecord( + record: Partial, + options: { + fallbackRouteHint?: CultMeshRouteHint; + fallbackOperationId?: string; + } = {}, + ): CultMeshOperationBindingDescriptor { + return cultMeshOperationBindingFromRecord(record, options); + } + + public static operationInvocation( + operation: + | { + readonly operationId: string; + readonly schemaId?: string; + readonly routeHint?: CultMeshRouteHint; + } + | string, + options: { + schemaId?: string; + routeHint?: CultMeshRouteHint; + idempotencyKey?: string; + } = {}, + ): CultMeshOperationInvocationDescriptor { + return cultMeshOperationInvocation(operation, options); + } + + public static operationInvocationRecord( + invocation?: CultMeshOperationInvocationDescriptor, + options: { + fallbackOperationId?: string; + fallbackSchemaId?: string; + fallbackRouteHint?: CultMeshRouteHint; + fallbackIdempotencyKey?: string; + } = {}, + ): CultMeshOperationInvocationRecord { + return cultMeshOperationInvocationRecord(invocation, options); + } + + public static operationInvocationFromRecord( + record: Partial, + options: { + fallbackOperationId?: string; + fallbackSchemaId?: string; + fallbackRouteHint?: CultMeshRouteHint; + fallbackIdempotencyKey?: string; + } = {}, + ): CultMeshOperationInvocationDescriptor { + return cultMeshOperationInvocationFromRecord(record, options); + } + + public static operationPayload( + fields: Readonly> = {}, + ): CultMeshOperationPayload { + return cultMeshOperationPayload(fields); + } + + public static projectionRecipe( + projectionId: string, + sources: readonly CultMeshProjectionSource[], + projectProjection: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + routeHint?: CultMeshRouteHint; + watchProjection?: ( + parameters: TParameters, + context: CultMeshQueryContext, + callback: (value: TResult) => void, + ) => CultMeshUnsubscribe; + } = {}, + ): CultMeshProjectionRecipe { + return cultMeshProjectionRecipe( + projectionId, + sources, + projectProjection, + options, + ); + } + + public static describeProjectionRecipe( + recipe: { + readonly projectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + }, + ): CultMeshProjectionRecipeDiagnostic { + return cultMeshDescribeProjectionRecipe(recipe); + } + + public static describeSurface( + surface: + | { + readonly queryId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly operationId: string; + } + | { + readonly projectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly feedId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly pointerId: string; + } + | { + readonly documentId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly collectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | CultMeshNativeSliceViewDescriptor, + ): CultMeshSurfaceDiagnostic { + return cultMeshDescribeSurface(surface); + } + + public static surfaceDiagnostic( + kind: CultMeshSurfaceKind, + surfaceId: string, + options: { + routeHint?: CultMeshRouteHint; + sources?: readonly CultMeshProjectionSource[]; + } = {}, + ): CultMeshSurfaceDiagnostic { + return cultMeshSurfaceDiagnostic(kind, surfaceId, options); + } + + public static describeSurfaceCatalog( + catalogId: string, + surfaces: readonly CultMeshSurfaceDiagnostic[], + ): CultMeshSurfaceCatalogDiagnostic { + return cultMeshDescribeSurfaceCatalog(catalogId, surfaces); + } + + public static findSurface( + catalog: CultMeshSurfaceCatalogDiagnostic, + surfaceId: string, + ): CultMeshSurfaceDiagnostic | undefined { + return cultMeshFindSurface(catalog, surfaceId); + } + + public static surfacesByKind( + catalog: CultMeshSurfaceCatalogDiagnostic, + kind: CultMeshSurfaceKind, + ): CultMeshSurfaceDiagnostic[] { + return cultMeshSurfacesByKind(catalog, kind); + } + + public static surfaceCatalogIndex( + catalog: CultMeshSurfaceCatalogDiagnostic, + ): CultMeshSurfaceCatalogIndexDiagnostic { + return cultMeshSurfaceCatalogIndex(catalog); + } + + public static nativeSliceColumn( + name: string, + valueType: string, + elementSizeBytes: number, + ): CultMeshNativeSliceColumn { + return cultMeshNativeSliceColumn(name, valueType, elementSizeBytes); + } + + public static nativeSliceView( + viewId: string, + schemaId: string, + rowCount: number, + columns: readonly CultMeshNativeSliceColumn[], + options: { route?: CultMeshRouteHint; nativeHandle?: string } = {}, + ): CultMeshNativeSliceViewDescriptor { + return cultMeshNativeSliceView(viewId, schemaId, rowCount, columns, options); + } + + public static denseRowStrideBytes(view: CultMeshNativeSliceViewDescriptor): number { + return cultMeshDenseRowStrideBytes(view); + } + + public static findNativeSliceColumn( + view: CultMeshNativeSliceViewDescriptor, + name: string, + ): CultMeshNativeSliceColumn | undefined { + return cultMeshFindNativeSliceColumn(view, name); + } + + public static describeNativeSliceView( + view: CultMeshNativeSliceViewDescriptor, + ): CultMeshNativeSliceViewDiagnostic { + return cultMeshDescribeNativeSliceView(view); + } + public static async createNode( cachePath: string, options: CultMeshNodeOptions = {}, @@ -555,7 +5677,7 @@ export class CultMesh { const documents = new CultNetDocumentRegistry(options.bindings); for (const definition of options.documents ?? []) { - cache.registerDocumentType(definition); + await cache.registerDocumentType(definition); if (!documents.get(definition.type)) { documents.register(defineCultNetDocumentBinding({ definition })); } @@ -628,9 +5750,14 @@ export class CultMesh { const bind = { host, port }; const socket = options.socket ?? createSocket(host.includes(":") ? "udp6" : "udp4"); const sessions = new Map; }>(); + let sessionSequence = 0; const resendPollMs = Math.max(10, options.resendPollMs ?? 25); const sessionTimeoutMs = Math.max(1_000, options.sessionTimeoutMs ?? 30_000); const wireContract = options.wireContract ?? "cultnet.schema.v0"; @@ -653,14 +5780,28 @@ export class CultMesh { let record = sessions.get(key); if (packet.packetType === "connect") { + const connectPayload = Uint8Array.from(packet.payload ?? []); + if (record && byteArraysEqual(record.connectPayload, connectPayload)) { + socket.send( + encodeRudpPacket(record.session.acceptConnect(packet, nowMs)), + remote.port, + remote.address, + ); + return; + } + if (record) closeSession(record); record = { + sessionId: `${runtimeId}:${++sessionSequence}`, remote: { address: remote.address, family: remote.family, port: remote.port }, + connectPayload, + closed: false, session: new CultNetRudpSession({ connectionId, initialSequence: options.initialSequence, resendDelayMs: options.resendDelayMs, maxPendingReliablePackets: options.maxPendingReliablePackets, }), + work: Promise.resolve(), }; sessions.set(key, record); socket.send(encodeRudpPacket(record.session.acceptConnect(packet, nowMs)), remote.port, remote.address); @@ -675,20 +5816,28 @@ export class CultMesh { if (result.reply) { socket.send(encodeRudpPacket(result.reply), record.remote.port, record.remote.address); } + for (const ready of result.readyToSend ?? []) { + socket.send(encodeRudpPacket(ready), record.remote.port, record.remote.address); + } for (const frame of result.delivered) { if (frame.channelId !== "schema") { continue; } - handleDocumentServerFrame(record, frame.payload).catch((error) => { - reportError(error); - }); + record.work = record.work + .then(() => handleDocumentServerFrame(record, frame.payload)) + .catch(reportError); } if (result.disconnected) { sessions.delete(key); + closeSession(record); return; } - if (packet.packetType === "accept" || result.delivered.length > 0) { - socket.send(encodeRudpPacket(record.session.createAck()), record.remote.port, record.remote.address); + if (packet.reliable) { + socket.send( + encodeRudpPacket(record.session.createAckFor(packet.sequence)), + record.remote.port, + record.remote.address, + ); } } catch (error) { reportError(error); @@ -697,9 +5846,10 @@ export class CultMesh { socket.on("error", reportError); async function handleDocumentServerFrame( - record: { remote: { address: string; family: string; port: number }; session: CultNetRudpSession }, + record: { sessionId: string; remote: { address: string; family: string; port: number }; session: CultNetRudpSession; connectPayload: Uint8Array; closed: boolean }, payload: Uint8Array, ): Promise { + if (record.closed) return; const message = parseCultNetMessage(decode(payload), wireContract); switch (message.schemaVersion) { case "cultnet.snapshot_request.v0": { @@ -726,9 +5876,30 @@ export class CultMesh { return; case "cultnet.document_put_raw.v0": if (options.onDocumentPutRaw) { - await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote)); + const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote)); + if (receipt) { + sendSchemaMessage(record, options.documents.createRawDocumentPutMessage( + receipt.binding, receipt.messageId ?? message.messageId, receipt.recordKey, receipt.value, + { + sourceRuntimeId: receipt.sourceRuntimeId ?? runtimeId, + sourceAgentId: receipt.sourceAgentId, + sourceRole: receipt.sourceRole ?? "provider", + tags: receipt.tags ?? ["receipt"], + }, + )); + } } return; + case "cultnet.operation_request.v0": + if (!options.onOperationRequest) { + sendSchemaMessage(record, { + schemaVersion: "cultnet.error.v0", + error: "CultMesh RUDP document server has no operation handler.", + }); + return; + } + sendSchemaMessage(record, await options.onOperationRequest(message, publicSession(record))); + return; default: sendSchemaMessage(record, { schemaVersion: "cultnet.error.v0", @@ -737,10 +5908,52 @@ export class CultMesh { } } + function publicSession(record: { + sessionId: string; + remote: { address: string; family: string; port: number }; + session: CultNetRudpSession; + connectPayload: Uint8Array; + closed: boolean; + }): CultMeshRudpServerSession { + return { + sessionId: record.sessionId, + remote: { ...record.remote }, + connectPayload: Uint8Array.from(record.connectPayload), + send: message => { + if (record.closed) throw new Error(`CultMesh RUDP session ${record.sessionId} is closed.`); + sendSchemaMessage(record, message); + }, + }; + } + + function notifySessionClosed(record: { + sessionId: string; + remote: { address: string; family: string; port: number }; + session: CultNetRudpSession; + connectPayload: Uint8Array; + closed: boolean; + }): void { + if (!options.onSessionClosed) return; + void Promise.resolve(options.onSessionClosed(publicSession(record))).catch(reportError); + } + + function closeSession(record: { + sessionId: string; + remote: { address: string; family: string; port: number }; + session: CultNetRudpSession; + connectPayload: Uint8Array; + closed: boolean; + }): void { + if (record.closed) return; + record.closed = true; + notifySessionClosed(record); + } + function sendSchemaMessage( - record: { remote: { address: string; port: number }; session: CultNetRudpSession }, + record: { sessionId: string; remote: { address: string; port: number }; session: CultNetRudpSession; closed: boolean }, message: CultNetMessage, ): void { + if (record.closed) throw new Error(`CultMesh RUDP session ${record.sessionId} is closed.`); const payload = encode(encodeCultNetMessageForWire(message, wireContract)); for (const packet of record.session.sendMany("schema", payload, { reliable: true, @@ -770,6 +5983,7 @@ export class CultMesh { for (const [key, record] of sessions) { if (record.session.checkTimeout(nowMs, sessionTimeoutMs)) { sessions.delete(key); + closeSession(record); continue; } for (const packet of record.session.dueResends(nowMs)) { @@ -784,12 +5998,44 @@ export class CultMesh { clearInterval(resendTimer); resendTimer = undefined; } + for (const record of sessions.values()) closeSession(record); sessions.clear(); socket.close(); }, }; } + public static resolveRudpEndpoint( + endpoint: string, + environment: Record = process.env, + ): CultMeshRudpEndpoint { + requireNonEmpty(endpoint, "endpoint"); + const text = endpoint.trim(); + if (text.toLowerCase().startsWith("rudp://")) { + return CultMesh.parseRudpEndpoint(text); + } + if (!text.toLowerCase().startsWith("cultmesh://")) { + return CultMesh.parseRudpEndpoint(`rudp://${text}`); + } + + const uri = new URL(text); + const authority = uri.hostname.trim(); + if (!authority) throw new Error("CultMesh URI must include an authority."); + const slug = authority + .replace(/[^a-z0-9]+/gi, "_") + .replace(/^_+|_+$/g, "") + .toUpperCase(); + for (const key of [`CULTMESH_URI_${slug}_RUDP`, `${slug}_CULTMESH_RUDP_ENDPOINT`]) { + const resolved = environment[key]?.trim(); + if (resolved) { + return CultMesh.parseRudpEndpoint( + resolved.toLowerCase().startsWith("rudp://") ? resolved : `rudp://${resolved}`, + ); + } + } + throw new Error(`CultMesh URI ${text} did not resolve to a RUDP endpoint.`); + } + public static parseRudpEndpoint(endpoint: string): CultMeshRudpEndpoint { requireNonEmpty(endpoint, "endpoint"); const parsed = new URL(endpoint); @@ -835,12 +6081,42 @@ export class CultMesh { tags: options.tags, }, )); - await new Promise((resolve) => setTimeout(resolve, Math.max(0, options.flushTimeoutMs ?? 150))); + await peer.flush(options.flushTimeoutMs ?? 30_000); } finally { peer.close(); } } + public static async publishRudpDocumentAndWaitForReceipt( + runtimeId: string, connectionId: number, endpoint: string | CultMeshRudpEndpoint, + binding: CultNetDocumentBinding, recordKey: string, value: CultCacheDocumentValue, + receiptBinding: CultNetDocumentBinding, options: CultMeshRudpDocumentReceiptOptions = {}, + ): Promise> { + requireNonEmpty(runtimeId, "runtimeId"); + requireNonEmpty(recordKey, "recordKey"); + const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`; + const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); + try { + const receipt = new Promise>((resolve, reject) => { + const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for RUDP application receipt for ${recordKey}.`)); }, options.receiptTimeoutMs ?? 5_000); + const onMessage = (message: CultNetMessage): void => { + if (message.schemaVersion !== "cultnet.document_put_raw.v0" || message.messageId !== messageId || message.document.schemaId !== receiptBinding.definition.schemaId) return; + try { + const decoded = decode(new Uint8Array(message.document.payload)); + cleanup(); + resolve(receiptBinding.definition.schema.parse(decoded) as CultCacheDocumentValue); + } catch (error) { cleanup(); reject(error); } + }; + const cleanup = (): void => { clearTimeout(timeout); peer.off("message", onMessage); }; + peer.on("message", onMessage); + }); + peer.send(new CultNetDocumentRegistry([binding]).createRawDocumentPutMessage(binding, messageId, recordKey, value, { + sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, sourceAgentId: options.sourceAgentId, sourceRole: options.sourceRole, tags: options.tags, + })); + return await receipt; + } finally { peer.close(); } + } + public static async createRudpServer( runtimeId: string, connectionId: number, @@ -994,6 +6270,136 @@ export class CultMesh { } } +function cultMeshSchemaFromDefinition( + definition: AnyCultCacheDocumentDefinition, +): CultMeshDocumentSchemaDescriptor { + return { + type: definition.type, + schemaId: definition.schemaId ?? definition.type, + schemaName: definition.schemaName, + schemaVersion: definition.schemaVersion, + }; +} + +function normalizeCultMeshDocumentSchema( + schema: CultMeshDocumentSchemaDescriptor, +): CultMeshDocumentSchemaDescriptor { + const schemaId = schema.schemaId?.trim() || undefined; + const inferredSchemaName = schema.schemaName?.trim() || inferCultMeshSchemaName(schemaId); + const normalized = { + type: schema.type?.trim() || undefined, + schemaId, + schemaName: inferredSchemaName, + schemaVersion: schema.schemaVersion?.trim() || (inferredSchemaName ? schemaId : undefined), + }; + + if (!normalized.type && !normalized.schemaId && !normalized.schemaName) { + throw new Error("Document schema must include type, schemaId, or schemaName."); + } + + return normalized; +} + +function cultMeshSchemaNameVersionKey( + schema: CultMeshDocumentSchemaDescriptor, +): string | undefined { + return schema.schemaName + ? `${schema.schemaName}@${schema.schemaVersion ?? ""}` + : undefined; +} + +function cultMeshSchemasAreCompatible( + left: CultMeshDocumentSchemaDescriptor, + right: CultMeshDocumentSchemaDescriptor, +): boolean { + if (left.schemaId && right.schemaId && left.schemaId === right.schemaId) { + return true; + } + + if ( + left.schemaName && + right.schemaName && + left.schemaName === right.schemaName && + (left.schemaVersion ?? "") === (right.schemaVersion ?? "") + ) { + return true; + } + + return Boolean(left.type && right.type && left.type === right.type); +} + +function inferCultMeshSchemaName(schemaId: string | undefined): string | undefined { + if (!schemaId) { + return undefined; + } + const marker = schemaId.lastIndexOf(".v"); + if (marker <= 0 || marker + 2 >= schemaId.length) { + return undefined; + } + const version = schemaId.slice(marker + 2); + return /^\d+$/.test(version) ? schemaId.slice(0, marker) : undefined; +} + +function resolveCultMeshStoreDocumentRecord( + records: readonly CultCacheEnvelope[], + schema: CultMeshDocumentSchemaDescriptor, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, +): CultCacheEnvelope | undefined { + const schemaId = schema.schemaId ?? schema.type; + return ( + records.find(candidate => schemaId !== undefined && candidate.schemaId === schemaId) ?? + records.find(candidate => + cultMeshEnvelopeMatchesSchema(candidate, schema) && + (typeof schemaOrDefinition === "string" || + cultMeshEnvelopeParsesAsDefinition(candidate, schemaOrDefinition))) ?? + (typeof schemaOrDefinition === "string" + ? undefined + : records.find(candidate => cultMeshEnvelopeParsesAsDefinition(candidate, schemaOrDefinition))) + ); +} + +function cultMeshEnvelopeMatchesSchema( + envelope: CultCacheEnvelope, + schema: CultMeshDocumentSchemaDescriptor, +): boolean { + const entry = envelope.catalogEntry; + if (!entry) { + return false; + } + if ( + schema.schemaId && + (entry.schemaId === schema.schemaId || + envelope.schemaId === schema.schemaId || + entry.compatibleSchemaIds?.includes(schema.schemaId)) + ) { + return true; + } + return Boolean( + schema.schemaName && + entry.schemaName === schema.schemaName && + (schema.schemaVersion ?? "") === (entry.schemaVersion ?? ""), + ); +} + +function cultMeshEnvelopeParsesAsDefinition( + envelope: CultCacheEnvelope, + definition: AnyCultCacheDocumentDefinition, +): boolean { + try { + definition.schema.parse(decode(envelope.payload)); + return true; + } catch { + return false; + } +} + +function cultMeshSchemaLabel(schema: CultMeshDocumentSchemaDescriptor): string { + return schema.schemaId ?? + cultMeshSchemaNameVersionKey(schema) ?? + schema.type ?? + ""; +} + function requireNonEmpty(value: string, name: string): void { if (!value || value.trim().length === 0) { throw new Error(`${name} must be non-empty.`); @@ -1027,6 +6433,125 @@ function normalizeRudpDocumentPut( }; } +async function requestCultNetRawSnapshotDocument( + peerOrProvider: CultNetPeer | (() => CultNetPeer | Promise), + schemaId: string, + recordKey: string, + options: { + timeoutMs?: number; + messageIdPrefix?: string; + accepts?: (document: CultNetRawDocumentRecord) => boolean; + } = {}, +): Promise { + const peer = await resolveCultNetPeer(peerOrProvider); + const messageId = `${options.messageIdPrefix ?? "cultmesh-snapshot"}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + const response = await requestCultNetRawSnapshot(peer, [recordKey], { + messageId, + timeoutMs: options.timeoutMs, + }); + const candidates = response.documents.filter(candidate => candidate.recordKey === recordKey); + const document = + candidates.find(candidate => candidate.schemaId === schemaId) ?? + (options.accepts ? candidates.find(options.accepts) : undefined) ?? + candidates[0]; + if (!document) { + throw new Error(`CultMesh peer snapshot did not return ${schemaId} at ${recordKey}.`); + } + return document; +} + +async function requestCultNetRawSnapshot( + peer: CultNetPeer, + recordKeys: readonly string[], + options: { + messageId: string; + timeoutMs?: number; + }, +): Promise { + return new Promise((resolve, reject) => { + const cleanup = (): void => { + clearTimeout(timer); + peer.off("message", onMessage); + peer.off("invalidMessage", onInvalidMessage); + peer.off("error", onError); + peer.off("close", onClose); + }; + const rejectWith = (error: Error): void => { + cleanup(); + reject(error); + }; + const onMessage = (message: CultNetMessage): void => { + if (isCultNetErrorMessage(message)) { + cleanup(); + reject(new Error(message.error)); + return; + } + if (message.schemaVersion !== "cultnet.snapshot_response_raw.v0" || + message.messageId !== options.messageId) { + return; + } + + cleanup(); + resolve(message); + }; + const onInvalidMessage = (error: Error): void => rejectWith(error); + const onError = (error: Error): void => rejectWith(error); + const onClose = (): void => rejectWith(new Error("CultNet peer closed before snapshot response.")); + const timer = setTimeout( + () => rejectWith(new Error(`Timed out waiting for CultNet snapshot ${options.messageId}.`)), + options.timeoutMs ?? 4_000, + ); + + peer.on("message", onMessage); + peer.on("invalidMessage", onInvalidMessage); + peer.on("error", onError); + peer.on("close", onClose); + peer.sendSnapshotRequest({ + schemaVersion: "cultnet.snapshot_request.v0", + messageId: options.messageId, + recordKeys: [...recordKeys], + }); + }); +} + +async function resolveCultNetPeer( + peerOrProvider: CultNetPeer | (() => CultNetPeer | Promise), +): Promise { + return typeof peerOrProvider === "function" + ? await peerOrProvider() + : peerOrProvider; +} + +function decodeCultNetRawDocumentPayload(document: CultNetRawDocumentRecord): unknown { + if (document.payloadEncoding !== "messagepack") { + throw new Error(`Unsupported CultNet raw document payload encoding ${document.payloadEncoding}.`); + } + return decode(toUint8Array(document.payload)); +} + +function parseCultMeshDocumentValue( + schemaOrDefinition: string | TDefinition, + value: unknown, +): unknown | CultCacheDocumentValue { + return typeof schemaOrDefinition === "string" + ? value + : schemaOrDefinition.schema.parse(value); +} + +function isCultNetErrorMessage(message: CultNetMessage): message is CultNetErrorMessage { + return message.schemaVersion === "cultnet.error.v0"; +} + +function toUint8Array(value: unknown): Uint8Array { + if (value instanceof Uint8Array) { + return value; + } + if (Array.isArray(value)) { + return Uint8Array.from(value); + } + throw new Error("CultNet raw document payload was not binary."); +} + async function bindRudpSocket(options: CultMeshRudpSocketOptions): Promise { const socket = createSocket("udp4"); const host = options.bindHost ?? "127.0.0.1"; @@ -1107,3 +6632,39 @@ function copyBudgetFor( return "opaque-runtime"; } } + +function nonBlankOr(value?: string, fallback = ""): string { + return value && value.trim() ? value : fallback; +} + +function byteArraysEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function parseCultMeshLocalityKind( + value: string | undefined, + fallback: CultMeshLocalityKind, +): CultMeshLocalityKind { + switch ((value ?? "").trim().toLowerCase()) { + case "automatic": + return "automatic"; + case "inprocess": + case "in-process": + return "in-process"; + case "sharedmemory": + case "shared-memory": + return "shared-memory"; + case "ipc": + return "ipc"; + case "network": + return "network"; + case "wasm": + return "wasm"; + default: + return fallback; + } +} diff --git a/packages/cultmesh-ts/src/provider-rudp-transport.ts b/packages/cultmesh-ts/src/provider-rudp-transport.ts new file mode 100644 index 00000000..0557bf48 --- /dev/null +++ b/packages/cultmesh-ts/src/provider-rudp-transport.ts @@ -0,0 +1,395 @@ +import { randomUUID } from "node:crypto"; +import { createSocket, type Socket } from "node:dgram"; +import { encode } from "@msgpack/msgpack"; +import { + CultNetPeer, + CultNetRudpSocketTransportConnection, + type CultNetMessage, + type CultNetOperationRequestMessage, + type CultNetOperationResponseMessage, + type CultNetRawDocumentRecord, +} from "cultnet-ts"; + +import type { + CultMeshProviderCommand, + CultMeshProviderCommandListener, + CultMeshProviderCommandReceipt, + CultMeshProviderConnection, + CultMeshProviderIdentity, + CultMeshProviderLease, + CultMeshProviderPublication, + CultMeshProviderRegistration, + CultMeshProviderTransport, + CultMeshProviderUnsubscribe, + CultMeshProviderWithdrawal, +} from "./provider-session"; +import { + CULTMESH_PROVIDER_SESSION_SERVICE_ID, + assertMutationAcceptance, + assertProviderLease, + cultMeshProviderSessionOperations, + cultMeshProviderSessionSchemas, + decodeProviderCommandDocument, + encodeProviderConnectEvidence, + decodeProviderSessionPayload, + encodeProviderSessionPayload, + type CultMeshProviderCommandReceiptWire, + type CultMeshProviderLeaseWire, + type CultMeshProviderMutationAcceptanceWire, +} from "./provider-session-wire"; + +export interface CultMeshProviderRudpTransportOptions { + readonly endpoint: string; + readonly runtimeId: string; + readonly connectionId: number; + readonly bindHost?: string; + readonly connectTimeoutMs?: number; + readonly operationTimeoutMs?: number; + readonly maxFragmentBytes?: number; + readonly sessionToken?: string; + readonly socketFactory?: () => Socket; +} + +/** + * Private-development provider transport. Public deployment additionally needs + * an authenticated CultNet session whose claims authorize the provider identity. + */ +export class CultMeshProviderRudpTransport implements CultMeshProviderTransport { + readonly #options: CultMeshProviderRudpTransportOptions; + + public constructor(options: CultMeshProviderRudpTransportOptions) { + if (!options.endpoint) throw new Error("CultMesh provider RUDP transport requires endpoint."); + if (!options.runtimeId) throw new Error("CultMesh provider RUDP transport requires runtimeId."); + if (!Number.isInteger(options.connectionId)) throw new Error("CultMesh provider RUDP transport requires integer connectionId."); + this.#options = options; + } + + public async connect( + identity: CultMeshProviderIdentity, + signal: AbortSignal, + ): Promise { + if (signal.aborted) throw abortError(); + const endpoint = parseEndpoint(this.#options.endpoint); + const socket = this.#options.socketFactory?.() ?? createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); + await bindSocket(socket, this.#options.bindHost ?? (endpoint.host.includes(":") ? "::" : "0.0.0.0"), signal); + const transport = new CultNetRudpSocketTransportConnection({ + mode: "client", + runtimeId: this.#options.runtimeId, + transportId: `${this.#options.runtimeId}.cultmesh-provider-session`, + socket, + remoteHost: endpoint.host, + remotePort: endpoint.port, + connectionId: this.#options.connectionId, + maxFragmentBytes: this.#options.maxFragmentBytes ?? 2_048, + }); + const peer = new CultNetPeer(transport, { wireContract: "cultnet.schema.v0" }); + transport.connect(Buffer.from(encodeProviderConnectEvidence({ + clientSessionId: randomUUID(), + sessionToken: this.#options.sessionToken ?? null, + }))); + try { + await waitUntilConnected(transport, signal, this.#options.connectTimeoutMs ?? 2_000); + return new RudpProviderConnection(identity, peer, transport, this.#options.operationTimeoutMs ?? 5_000); + } catch (error) { + transport.close(); + throw error; + } + } +} + +class RudpProviderConnection implements CultMeshProviderConnection { + readonly #identity: CultMeshProviderIdentity; + readonly #peer: CultNetPeer; + readonly #transport: CultNetRudpSocketTransportConnection; + readonly #timeoutMs: number; + readonly #listeners = new Set(); + readonly #pending = new Map void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; + }>(); + readonly #publications = new Map(); + readonly #bufferedCommands = new Map(); + #closed = false; + #requestedLeaseDurationMs = 30_000; + + public constructor( + identity: CultMeshProviderIdentity, + peer: CultNetPeer, + transport: CultNetRudpSocketTransportConnection, + timeoutMs: number, + ) { + this.#identity = identity; + this.#peer = peer; + this.#transport = transport; + this.#timeoutMs = timeoutMs; + peer.on("message", message => this.#onMessage(message)); + peer.on("invalidMessage", error => this.#failPending(error)); + peer.on("close", () => this.#failPending(new Error("CultMesh provider RUDP connection closed."))); + peer.on("error", error => this.#failPending(error)); + } + + public async register( + registration: CultMeshProviderRegistration, + signal: AbortSignal, + ): Promise { + this.#requestedLeaseDurationMs = registration.requestedLeaseDurationMs; + const response = await this.#operation( + cultMeshProviderSessionOperations.register, + cultMeshProviderSessionSchemas.registration, + cultMeshProviderSessionSchemas.lease, + { ...registration.identity, requestedLeaseDurationMs: registration.requestedLeaseDurationMs }, + signal, + ); + const wire = decodeProviderSessionPayload(response.payload); + assertProviderLease(wire); + assertIdentity(this.#identity, wire); + return { leaseId: wire.leaseId, expiresAt: new Date(wire.expiresAtUtc) }; + } + + public async renew(lease: CultMeshProviderLease): Promise { + const response = await this.#operation( + cultMeshProviderSessionOperations.renew, + cultMeshProviderSessionSchemas.leaseRenewal, + cultMeshProviderSessionSchemas.lease, + { leaseId: lease.leaseId, requestedLeaseDurationMs: this.#requestedLeaseDurationMs }, + ); + const wire = decodeProviderSessionPayload(response.payload); + assertProviderLease(wire); + assertIdentity(this.#identity, wire); + return { leaseId: wire.leaseId, expiresAt: new Date(wire.expiresAtUtc) }; + } + + public async publish( + publication: CultMeshProviderPublication, + lease: CultMeshProviderLease, + ): Promise { + const document: CultNetRawDocumentRecord = { + schemaId: publication.schemaId, + recordKey: publication.recordKey, + storedAt: new Date().toISOString(), + payloadEncoding: "messagepack", + payload: encode(publication.value), + sourceRuntimeId: this.#identity.serviceInstanceId, + sourceAgentId: this.#identity.providerId, + sourceRole: "provider", + }; + await this.#accepted(cultMeshProviderSessionOperations.publicationPut, cultMeshProviderSessionSchemas.publicationPut, { + leaseId: lease.leaseId, + publicationId: publication.publicationId, + document, + }); + this.#publications.set(publication.publicationId, publication); + } + + public async withdrawPublication(publicationId: string, lease: CultMeshProviderLease): Promise { + const publication = this.#publications.get(publicationId); + if (!publication) throw new Error(`CultMesh provider publication ${publicationId} is not owned by this connection.`); + await this.#accepted(cultMeshProviderSessionOperations.publicationDelete, cultMeshProviderSessionSchemas.publicationDelete, { + leaseId: lease.leaseId, + publicationId, + schemaId: publication.schemaId, + recordKey: publication.recordKey, + }); + this.#publications.delete(publicationId); + } + + public watchCommands(listener: CultMeshProviderCommandListener): CultMeshProviderUnsubscribe { + this.#listeners.add(listener); + for (const [commandId, command] of this.#bufferedCommands) { + this.#bufferedCommands.delete(commandId); + void Promise.resolve(listener(command)).catch(() => undefined); + } + return () => { + this.#listeners.delete(listener); + }; + } + + public async publishReceipt( + receipt: CultMeshProviderCommandReceipt, + lease: CultMeshProviderLease, + ): Promise { + const wire: CultMeshProviderCommandReceiptWire = { + receiptId: receipt.receiptId, + commandId: receipt.commandId, + commandKind: receipt.commandKind, + providerId: receipt.providerId, + serviceInstanceId: receipt.serviceInstanceId, + state: receipt.state, + completedAtUtc: receipt.completedAt.toISOString(), + result: receipt.result, + error: receipt.error, + }; + await this.#accepted(cultMeshProviderSessionOperations.receiptPut, cultMeshProviderSessionSchemas.receiptPut, { + leaseId: lease.leaseId, + receipt: wire, + }); + } + + public async withdraw(withdrawal: CultMeshProviderWithdrawal): Promise { + await this.#accepted(cultMeshProviderSessionOperations.withdraw, cultMeshProviderSessionSchemas.withdrawal, { + leaseId: withdrawal.leaseId, + }); + this.#publications.clear(); + } + + public close(): void { + if (this.#closed) return; + this.#closed = true; + this.#failPending(new Error("CultMesh provider RUDP connection closed.")); + this.#listeners.clear(); + this.#transport.close(); + } + + async #accepted(operation: string, payloadSchema: string, payload: unknown): Promise { + const response = await this.#operation(operation, payloadSchema, cultMeshProviderSessionSchemas.mutationAcceptance, payload); + const acceptance = decodeProviderSessionPayload(response.payload); + assertMutationAcceptance(acceptance); + return acceptance; + } + + async #operation( + operation: string, + payloadSchema: string, + responsePayloadSchema: string, + payload: unknown, + signal?: AbortSignal, + ): Promise { + if (this.#closed) throw new Error("CultMesh provider RUDP connection is closed."); + if (signal?.aborted) throw abortError(); + const messageId = randomUUID(); + const request: CultNetOperationRequestMessage = { + schemaVersion: "cultnet.operation_request.v0", + messageId, + serviceId: CULTMESH_PROVIDER_SESSION_SERVICE_ID, + operation, + payloadSchema, + payloadEncoding: "messagepack-base64", + payload: encodeProviderSessionPayload(payload), + sourceRuntimeId: this.#identity.serviceInstanceId, + }; + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#pending.delete(messageId); + reject(new Error(`CultMesh provider operation ${operation} timed out.`)); + }, this.#timeoutMs); + const abort = (): void => { + clearTimeout(timer); + this.#pending.delete(messageId); + reject(abortError()); + }; + signal?.addEventListener("abort", abort, { once: true }); + this.#pending.set(messageId, { + operation, + payloadSchema: responsePayloadSchema, + timer, + resolve: response => { + signal?.removeEventListener("abort", abort); + if (response.serviceId !== CULTMESH_PROVIDER_SESSION_SERVICE_ID || response.operation !== operation) { + reject(new Error(`CultMesh provider response correlation does not match ${operation}.`)); + return; + } + if (response.payloadEncoding !== "messagepack-base64") { + reject(new Error(`CultMesh provider response for ${operation} has incompatible payload encoding.`)); + return; + } + if (response.status !== "ok") { + reject(new Error(`CultMesh provider operation ${operation} was ${response.status}: ${(response.diagnostics ?? []).join("; ")}`)); + return; + } + if (response.payloadSchema !== responsePayloadSchema) { + reject(new Error(`CultMesh provider response for ${operation} has incompatible payload schema.`)); + return; + } + resolve(response); + }, + reject: error => { + signal?.removeEventListener("abort", abort); + reject(error); + }, + }); + this.#peer.send(request); + }); + } + + #onMessage(message: CultNetMessage): void { + if (message.schemaVersion === "cultnet.operation_response.v0") { + const pending = this.#pending.get(message.messageId); + if (!pending) return; + this.#pending.delete(message.messageId); + clearTimeout(pending.timer); + pending.resolve(message); + return; + } + if (message.schemaVersion !== "cultnet.document_put_raw.v0") return; + if (message.document.schemaId !== cultMeshProviderSessionSchemas.command) return; + const wire = decodeProviderCommandDocument(message.document); + if (wire.providerId !== this.#identity.providerId || wire.serviceInstanceId !== this.#identity.serviceInstanceId) return; + const command: CultMeshProviderCommand = { ...wire }; + if (this.#listeners.size === 0) { + this.#bufferedCommands.set(command.commandId, command); + return; + } + for (const listener of [...this.#listeners]) void Promise.resolve(listener(command)).catch(() => undefined); + } + + #failPending(error: Error): void { + for (const [messageId, pending] of this.#pending) { + this.#pending.delete(messageId); + clearTimeout(pending.timer); + pending.reject(error); + } + } +} + +function assertIdentity(identity: CultMeshProviderIdentity, lease: CultMeshProviderLeaseWire): void { + for (const key of ["providerId", "serviceInstanceId", "endpointId", "verseId"] as const) { + if (identity[key] !== lease[key]) throw new Error(`CultMesh provider lease ${key} does not match the registered identity.`); + } +} + +function parseEndpoint(endpoint: string): { host: string; port: number } { + const url = new URL(endpoint); + const port = Number(url.port); + if (url.protocol !== "rudp:" || !url.hostname || !Number.isInteger(port) || port <= 0 || port > 65_535) { + throw new Error(`Invalid CultMesh provider RUDP endpoint ${endpoint}.`); + } + return { host: url.hostname, port }; +} + +function bindSocket(socket: Socket, host: string, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = (): void => { + socket.close(); + reject(abortError()); + }; + signal.addEventListener("abort", abort, { once: true }); + socket.once("error", reject); + socket.bind(0, host, () => { + signal.removeEventListener("abort", abort); + socket.removeListener("error", reject); + resolve(); + }); + }); +} + +async function waitUntilConnected( + transport: CultNetRudpSocketTransportConnection, + signal: AbortSignal, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (!transport.connected) { + if (signal.aborted) throw abortError(); + if (Date.now() >= deadline) throw new Error("CultMesh provider RUDP connection timed out."); + await new Promise(resolve => setTimeout(resolve, 5)); + } +} + +function abortError(): Error { + const error = new Error("CultMesh provider RUDP operation aborted."); + error.name = "AbortError"; + return error; +} diff --git a/packages/cultmesh-ts/src/provider-session-broker.ts b/packages/cultmesh-ts/src/provider-session-broker.ts new file mode 100644 index 00000000..eb9e18b5 --- /dev/null +++ b/packages/cultmesh-ts/src/provider-session-broker.ts @@ -0,0 +1,528 @@ +import { encode } from "@msgpack/msgpack"; +import type { + CultNetDocumentPutRawMessage, + CultNetOperationRequestMessage, + CultNetOperationResponseMessage, + CultNetRawDocumentRecord, +} from "cultnet-ts"; + +import type { CultMeshRudpServerSession } from "./index"; +import { + CULTMESH_PROVIDER_SESSION_SERVICE_ID, + cultMeshProviderSessionOperations, + cultMeshProviderSessionSchemas, + decodeProviderSessionPayload, + encodeProviderSessionPayload, + type CultMeshProviderCommandReceiptWire, + type CultMeshProviderCommandWire, + type CultMeshProviderLeaseRenewalWire, + type CultMeshProviderLeaseWire, + type CultMeshProviderOperationStatus, + type CultMeshProviderPublicationDeleteWire, + type CultMeshProviderPublicationPutWire, + type CultMeshProviderReceiptPutWire, + type CultMeshProviderRegistrationWire, + type CultMeshProviderWithdrawalWire, +} from "./provider-session-wire"; + +export interface CultMeshProviderBrokerClock { + now(): Date; +} + +export interface CultMeshProviderSessionBrokerOptions { + readonly runtimeId: string; + readonly clock?: CultMeshProviderBrokerClock; + readonly createLeaseId?: () => string; + readonly maximumLeaseDurationMs?: number; + readonly expiryPollMs?: number; + readonly onError?: (error: Error) => void; + readonly authorizeRegistration: ( + identity: CultMeshProviderRegistrationWire, + session: CultMeshRudpServerSession, + ) => boolean | Promise; + readonly acceptPublication: ( + identity: CultMeshProviderRegistrationWire, + publicationId: string, + document: CultNetRawDocumentRecord, + ) => void | Promise; + readonly deletePublications: ( + identity: CultMeshProviderRegistrationWire, + publications: readonly { + publicationId: string; + document: Pick; + }[], + ) => void | Promise; + readonly acceptReceipt: ( + identity: CultMeshProviderRegistrationWire, + receipt: CultMeshProviderCommandReceiptWire, + ) => void | Promise; +} + +interface LeaseRecord { + identity: CultMeshProviderRegistrationWire; + lease: CultMeshProviderLeaseWire; + session?: CultMeshRudpServerSession; + publications: Map; +} + +export class CultMeshProviderSessionBroker { + readonly #options: CultMeshProviderSessionBrokerOptions; + readonly #clock: CultMeshProviderBrokerClock; + readonly #leases = new Map(); + readonly #identityLeases = new Map(); + readonly #sessionLeases = new Map>(); + readonly #commands = new Map(); + readonly #receipts = new Map(); + readonly #publicationOwners = new Map(); + readonly #closedSessions = new Set(); + readonly #inFlightBySession = new Map(); + readonly #expiryTimer: NodeJS.Timeout; + #work: Promise = Promise.resolve(); + #leaseSequence = 0; + + public constructor(options: CultMeshProviderSessionBrokerOptions) { + if (!options.runtimeId) throw new Error("CultMesh provider-session broker requires runtimeId."); + if (!options.authorizeRegistration) throw new Error("CultMesh provider-session broker requires an explicit registration authority."); + this.#options = options; + this.#clock = options.clock ?? { now: () => new Date() }; + this.#expiryTimer = setInterval(() => { + void this.expireLeases().catch(error => { + const normalized = error instanceof Error ? error : new Error(String(error)); + if (this.#options.onError) this.#options.onError(normalized); + else console.error(`CultMesh provider-session expiry failed: ${normalized.message}`); + }); + }, Math.max(10, options.expiryPollMs ?? 1_000)); + this.#expiryTimer.unref?.(); + } + + public close(): void { + clearInterval(this.#expiryTimer); + } + + public async handle( + request: CultNetOperationRequestMessage, + session: CultMeshRudpServerSession, + ): Promise { + this.#inFlightBySession.set( + session.sessionId, + (this.#inFlightBySession.get(session.sessionId) ?? 0) + 1, + ); + const work = this.#work.then(() => this.#handle(request, session)); + this.#work = work.then(() => undefined, () => undefined); + try { + return await work; + } finally { + const remaining = (this.#inFlightBySession.get(session.sessionId) ?? 1) - 1; + if (remaining > 0) { + this.#inFlightBySession.set(session.sessionId, remaining); + } else { + this.#inFlightBySession.delete(session.sessionId); + this.#closedSessions.delete(session.sessionId); + } + } + } + + async #handle( + request: CultNetOperationRequestMessage, + session: CultMeshRudpServerSession, + ): Promise { + if (this.#closedSessions.has(session.sessionId)) { + return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ + "The physical provider session is closed or replaced.", + ]); + } + if (request.serviceId !== CULTMESH_PROVIDER_SESSION_SERVICE_ID) { + return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ + `Unsupported service ${request.serviceId}.`, + ]); + } + if (request.payloadEncoding !== "messagepack-base64") { + return this.#response(request, "invalid", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ + "Provider-session operation payloads must use messagepack-base64.", + ]); + } + try { + switch (request.operation) { + case cultMeshProviderSessionOperations.register: + return await this.#register(request, session); + case cultMeshProviderSessionOperations.renew: + return await this.#renew(request, session); + case cultMeshProviderSessionOperations.publicationPut: + return await this.#publicationPut(request, session); + case cultMeshProviderSessionOperations.publicationDelete: + return await this.#publicationDelete(request, session); + case cultMeshProviderSessionOperations.receiptPut: + return await this.#receiptPut(request, session); + case cultMeshProviderSessionOperations.withdraw: + return await this.#withdraw(request, session); + default: + return this.#response(request, "invalid", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ + `Unsupported provider-session operation ${request.operation}.`, + ]); + } + } catch (error) { + if (error instanceof BrokerInfrastructureError) throw error.cause; + return this.#response(request, error instanceof BrokerStatusError ? error.status : "invalid", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ + error instanceof Error ? error.message : String(error), + ]); + } + } + + public sessionClosed(session: CultMeshRudpServerSession): void { + if ((this.#inFlightBySession.get(session.sessionId) ?? 0) > 0) { + this.#closedSessions.add(session.sessionId); + } + for (const leaseId of this.#sessionLeases.get(session.sessionId) ?? []) { + const record = this.#leases.get(leaseId); + if (record?.session?.sessionId === session.sessionId) record.session = undefined; + } + this.#sessionLeases.delete(session.sessionId); + } + + public enqueueCommand(command: CultMeshProviderCommandWire): void { + requireText(command.commandId, "commandId"); + requireText(command.providerId, "providerId"); + requireText(command.serviceInstanceId, "serviceInstanceId"); + const completed = this.#receipts.get(command.commandId); + if (completed) { + if (completed.commandKind !== command.commandKind || completed.providerId !== command.providerId || completed.serviceInstanceId !== command.serviceInstanceId) { + throw new Error(`Command id ${command.commandId} is already completed for another command transaction.`); + } + return; + } + const existing = this.#commands.get(command.commandId); + if (existing && !wireEquivalent(existing, command)) { + throw new Error(`Command id ${command.commandId} is already retained for another command transaction.`); + } + this.#commands.set(command.commandId, existing ?? command); + for (const record of this.#leases.values()) { + if (!this.#isActive(record)) continue; + if (record.identity.providerId !== command.providerId || record.identity.serviceInstanceId !== command.serviceInstanceId) continue; + this.#sendCommand(record, command); + } + } + + public get activeLeaseCount(): number { + return [...this.#leases.values()].filter(record => this.#isActive(record)).length; + } + + public async expireLeases(): Promise { + const work = this.#work.then(() => this.#expireLeases()); + this.#work = work.then(() => undefined, () => undefined); + await work; + } + + async #expireLeases(): Promise { + const now = this.#clock.now().getTime(); + for (const [leaseId, record] of [...this.#leases]) { + if (Date.parse(record.lease.expiresAtUtc) > now) continue; + await this.#options.deletePublications(record.identity, publicationEntries(record)); + this.#releasePublicationOwners(record); + this.#removeLease(leaseId, record); + } + } + + async #register(request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession): Promise { + expectSchema(request, cultMeshProviderSessionSchemas.registration); + const identity = decodeProviderSessionPayload(request.payload); + validateRegistration(identity); + if (!await infrastructure(() => this.#options.authorizeRegistration(identity, session))) { + return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ + "The CultNet session does not authorize this provider identity.", + ]); + } + if (this.#closedSessions.has(session.sessionId)) { + return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ + "The physical provider session closed while registration authority was being checked.", + ]); + } + const identityKey = identityTuple(identity); + const previousLeaseId = this.#identityLeases.get(identityKey); + let publications = new Map(); + if (previousLeaseId) { + const previous = this.#leases.get(previousLeaseId); + if (previous) { + publications = previous.publications; + this.#removeLease(previousLeaseId, previous); + } + } + const lease = this.#createLease(identity, identity.requestedLeaseDurationMs); + const record: LeaseRecord = { identity, lease, session, publications }; + this.#leases.set(lease.leaseId, record); + this.#identityLeases.set(identityKey, lease.leaseId); + this.#attachSession(session, lease.leaseId); + this.#replayCommands(record); + return this.#response(request, "ok", cultMeshProviderSessionSchemas.lease, lease); + } + + async #renew(request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession): Promise { + expectSchema(request, cultMeshProviderSessionSchemas.leaseRenewal); + const renewal = decodeProviderSessionPayload(request.payload); + const current = this.#requireLease(renewal.leaseId, session); + const lease = this.#createLease(current.identity, renewal.requestedLeaseDurationMs); + const replacement: LeaseRecord = { ...current, lease, session }; + this.#removeLease(renewal.leaseId, current); + this.#leases.set(lease.leaseId, replacement); + this.#identityLeases.set(identityTuple(current.identity), lease.leaseId); + this.#attachSession(session, lease.leaseId); + return this.#response(request, "ok", cultMeshProviderSessionSchemas.lease, lease); + } + + async #publicationPut(request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession): Promise { + expectSchema(request, cultMeshProviderSessionSchemas.publicationPut); + const mutation = decodeProviderSessionPayload(request.payload); + const record = this.#requireLease(mutation.leaseId, session); + requireText(mutation.publicationId, "publicationId"); + validateDocument(mutation.document); + const previous = record.publications.get(mutation.publicationId); + if (previous && (previous.schemaId !== mutation.document.schemaId || previous.recordKey !== mutation.document.recordKey)) { + return this.#response(request, "conflict", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ + `Publication ${mutation.publicationId} already owns ${previous.schemaId}:${previous.recordKey}. Delete it before changing its tuple.`, + ]); + } + const tuple = documentTuple(mutation.document); + const owner = this.#publicationOwners.get(tuple); + const ownerIdentity = identityTuple(record.identity); + if (owner && (owner.identityKey !== ownerIdentity || owner.publicationId !== mutation.publicationId)) { + return this.#response(request, "conflict", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ + `Publication tuple ${mutation.document.schemaId}:${mutation.document.recordKey} is already owned.`, + ]); + } + const normalizedDocument: CultNetRawDocumentRecord = { + ...mutation.document, + sourceRuntimeId: record.identity.serviceInstanceId, + sourceAgentId: record.identity.providerId, + sourceRole: "provider", + }; + await infrastructure(() => this.#options.acceptPublication(record.identity, mutation.publicationId, normalizedDocument)); + record.publications.set(mutation.publicationId, normalizedDocument); + this.#publicationOwners.set(tuple, { identityKey: ownerIdentity, publicationId: mutation.publicationId }); + return this.#accepted(request, record.lease.leaseId, { publicationId: mutation.publicationId }); + } + + async #publicationDelete(request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession): Promise { + expectSchema(request, cultMeshProviderSessionSchemas.publicationDelete); + const mutation = decodeProviderSessionPayload(request.payload); + const record = this.#requireLease(mutation.leaseId, session); + const owned = record.publications.get(mutation.publicationId); + if (!owned || owned.schemaId !== mutation.schemaId || owned.recordKey !== mutation.recordKey) { + return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ + `Publication ${mutation.publicationId} is not owned by this lease with the supplied schema and key.`, + ]); + } + await infrastructure(() => this.#options.deletePublications(record.identity, [{ publicationId: mutation.publicationId, document: owned }])); + record.publications.delete(mutation.publicationId); + this.#publicationOwners.delete(documentTuple(owned)); + return this.#accepted(request, record.lease.leaseId, { publicationId: mutation.publicationId }); + } + + async #receiptPut(request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession): Promise { + expectSchema(request, cultMeshProviderSessionSchemas.receiptPut); + const mutation = decodeProviderSessionPayload(request.payload); + const record = this.#requireLease(mutation.leaseId, session); + const receipt = mutation.receipt; + validateReceipt(receipt); + if (receipt.providerId !== record.identity.providerId || receipt.serviceInstanceId !== record.identity.serviceInstanceId) { + return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, ["Receipt identity does not match its lease."]); + } + const previousReceipt = this.#receipts.get(receipt.commandId); + if (previousReceipt) { + if (!wireEquivalent(previousReceipt, receipt)) { + return this.#response(request, "conflict", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ + `Command ${receipt.commandId} already has a different accepted receipt.`, + ]); + } + return this.#accepted(request, record.lease.leaseId, { commandId: receipt.commandId, receiptId: receipt.receiptId }); + } + const command = this.#commands.get(receipt.commandId); + if (!command || command.commandKind !== receipt.commandKind || command.providerId !== receipt.providerId || command.serviceInstanceId !== receipt.serviceInstanceId) { + return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ + `Receipt ${receipt.receiptId} does not match a retained command for this provider generation.`, + ]); + } + await infrastructure(() => this.#options.acceptReceipt(record.identity, receipt)); + this.#receipts.set(receipt.commandId, receipt); + this.#commands.delete(receipt.commandId); + return this.#accepted(request, record.lease.leaseId, { commandId: receipt.commandId, receiptId: receipt.receiptId }); + } + + async #withdraw(request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession): Promise { + expectSchema(request, cultMeshProviderSessionSchemas.withdrawal); + const mutation = decodeProviderSessionPayload(request.payload); + const record = this.#requireLease(mutation.leaseId, session); + await infrastructure(() => this.#options.deletePublications(record.identity, publicationEntries(record))); + this.#releasePublicationOwners(record); + this.#removeLease(mutation.leaseId, record); + return this.#accepted(request, mutation.leaseId); + } + + #requireLease(leaseId: string, session: CultMeshRudpServerSession): LeaseRecord { + requireText(leaseId, "leaseId"); + const record = this.#leases.get(leaseId); + if (!record || !this.#isActive(record)) throw new BrokerStatusError("expired", `Provider lease ${leaseId} is expired or fenced.`); + if (record.session?.sessionId !== session.sessionId) throw new BrokerStatusError("denied", `Provider lease ${leaseId} belongs to another physical session.`); + return record; + } + + #isActive(record: LeaseRecord): boolean { + return Date.parse(record.lease.expiresAtUtc) > this.#clock.now().getTime(); + } + + #createLease(identity: CultMeshProviderRegistrationWire, requestedMs: number): CultMeshProviderLeaseWire { + if (!Number.isFinite(requestedMs) || requestedMs <= 0) throw new Error("requestedLeaseDurationMs must be positive."); + const durationMs = Math.min(requestedMs, this.#options.maximumLeaseDurationMs ?? 120_000); + const now = this.#clock.now(); + return { + providerId: identity.providerId, + serviceInstanceId: identity.serviceInstanceId, + endpointId: identity.endpointId, + verseId: identity.verseId, + leaseId: this.#options.createLeaseId?.() ?? `${this.#options.runtimeId}:lease:${++this.#leaseSequence}`, + validFromUtc: now.toISOString(), + expiresAtUtc: new Date(now.getTime() + durationMs).toISOString(), + }; + } + + #attachSession(session: CultMeshRudpServerSession, leaseId: string): void { + let leases = this.#sessionLeases.get(session.sessionId); + if (!leases) this.#sessionLeases.set(session.sessionId, leases = new Set()); + leases.add(leaseId); + } + + #removeLease(leaseId: string, record: LeaseRecord): void { + this.#leases.delete(leaseId); + if (this.#identityLeases.get(identityTuple(record.identity)) === leaseId) this.#identityLeases.delete(identityTuple(record.identity)); + this.#sessionLeases.get(record.session?.sessionId ?? "")?.delete(leaseId); + } + + #releasePublicationOwners(record: LeaseRecord): void { + const ownerIdentity = identityTuple(record.identity); + for (const [publicationId, document] of record.publications) { + const tuple = documentTuple(document); + const owner = this.#publicationOwners.get(tuple); + if (owner?.identityKey === ownerIdentity && owner.publicationId === publicationId) this.#publicationOwners.delete(tuple); + } + } + + #replayCommands(record: LeaseRecord): void { + for (const command of this.#commands.values()) { + if (command.providerId === record.identity.providerId && command.serviceInstanceId === record.identity.serviceInstanceId) this.#sendCommand(record, command); + } + } + + #sendCommand(record: LeaseRecord, command: CultMeshProviderCommandWire): void { + if (!record.session) return; + const message: CultNetDocumentPutRawMessage = { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: `${this.#options.runtimeId}:command:${command.commandId}`, + document: { + schemaId: cultMeshProviderSessionSchemas.command, + recordKey: `${command.providerId}/${command.serviceInstanceId}/${command.commandId}`, + storedAt: this.#clock.now().toISOString(), + payloadEncoding: "messagepack", + payload: encode(command), + sourceRuntimeId: this.#options.runtimeId, + sourceRole: "provider-session-broker", + }, + }; + record.session.send(message); + } + + #accepted(request: CultNetOperationRequestMessage, leaseId: string, extra: Record = {}): CultNetOperationResponseMessage { + return this.#response(request, "ok", cultMeshProviderSessionSchemas.mutationAcceptance, { + acceptedAtUtc: this.#clock.now().toISOString(), + leaseId, + ...extra, + }); + } + + #response( + request: CultNetOperationRequestMessage, + status: CultMeshProviderOperationStatus, + payloadSchema: string, + payload: unknown, + diagnostics?: string[], + ): CultNetOperationResponseMessage { + const response: CultNetOperationResponseMessage = { + schemaVersion: "cultnet.operation_response.v0", + messageId: request.messageId, + serviceId: request.serviceId, + operation: request.operation, + status, + payloadSchema, + payloadEncoding: "messagepack-base64", + payload: encodeProviderSessionPayload(payload), + sourceRuntimeId: this.#options.runtimeId, + }; + if (diagnostics) response.diagnostics = diagnostics; + return response; + } +} + +class BrokerStatusError extends Error { + public constructor(public readonly status: CultMeshProviderOperationStatus, message: string) { + super(message); + } +} + +class BrokerInfrastructureError extends Error { + public constructor(public readonly cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause)); + } +} + +async function infrastructure(action: () => T | Promise): Promise { + try { + return await action(); + } catch (error) { + throw new BrokerInfrastructureError(error); + } +} + +function expectSchema(request: CultNetOperationRequestMessage, schema: string): void { + if (request.payloadSchema !== schema) throw new Error(`Operation ${request.operation} requires payload schema ${schema}.`); +} + +function validateRegistration(value: CultMeshProviderRegistrationWire): void { + for (const field of ["providerId", "serviceInstanceId", "endpointId", "verseId"] as const) requireText(value[field], field); + if (!Number.isFinite(value.requestedLeaseDurationMs) || value.requestedLeaseDurationMs <= 0) throw new Error("requestedLeaseDurationMs must be positive."); +} + +function validateDocument(document: CultNetRawDocumentRecord): void { + requireText(document?.schemaId, "document.schemaId"); + requireText(document?.recordKey, "document.recordKey"); + if (document.payloadEncoding !== "messagepack" || !(document.payload instanceof Uint8Array)) throw new Error("Provider publications must carry a raw MessagePack document."); +} + +function validateReceipt(receipt: CultMeshProviderCommandReceiptWire): void { + requireText(receipt?.receiptId, "receipt.receiptId"); + requireText(receipt?.commandId, "receipt.commandId"); + requireText(receipt?.commandKind, "receipt.commandKind"); + requireText(receipt?.providerId, "receipt.providerId"); + requireText(receipt?.serviceInstanceId, "receipt.serviceInstanceId"); + if (receipt.state !== "applied" && receipt.state !== "rejected" && receipt.state !== "failed") throw new Error("CultMesh provider-session receipt.state is invalid."); + if (!Number.isFinite(Date.parse(receipt.completedAtUtc))) throw new Error("CultMesh provider-session receipt.completedAtUtc must be RFC3339."); +} + +function identityTuple(identity: CultMeshProviderRegistrationWire): string { + return `${identity.verseId}\u0000${identity.providerId}\u0000${identity.serviceInstanceId}\u0000${identity.endpointId}`; +} + +function documentTuple(document: Pick): string { + return `${document.schemaId}\u0000${document.recordKey}`; +} + +function wireEquivalent(left: unknown, right: unknown): boolean { + return Buffer.from(encode(left)).equals(Buffer.from(encode(right))); +} + +function publicationEntries(record: LeaseRecord): readonly { + publicationId: string; + document: Pick; +}[] { + return [...record.publications].map(([publicationId, document]) => ({ publicationId, document })); +} + +function requireText(value: unknown, name: string): asserts value is string { + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`CultMesh provider-session ${name} must be non-empty.`); +} diff --git a/packages/cultmesh-ts/src/provider-session-wire.ts b/packages/cultmesh-ts/src/provider-session-wire.ts new file mode 100644 index 00000000..e89a3148 --- /dev/null +++ b/packages/cultmesh-ts/src/provider-session-wire.ts @@ -0,0 +1,207 @@ +import { decode, encode } from "@msgpack/msgpack"; +import type { CultNetRawDocumentRecord } from "cultnet-ts"; + +export const CULTMESH_PROVIDER_SESSION_SERVICE_ID = "gamecult.mesh.provider_session"; + +export const cultMeshProviderSessionOperations = { + register: "provider.register", + renew: "provider.renew", + publicationPut: "provider.publication.put", + publicationDelete: "provider.publication.delete", + receiptPut: "provider.receipt.put", + withdraw: "provider.withdraw", +} as const; + +export const cultMeshProviderSessionSchemas = { + registration: "gamecult.mesh.provider_registration.v1", + lease: "gamecult.mesh.provider_lease.v1", + leaseRenewal: "gamecult.mesh.provider_lease_renewal.v1", + publicationPut: "gamecult.mesh.provider_publication_put.v1", + publicationDelete: "gamecult.mesh.provider_publication_delete.v1", + command: "gamecult.mesh.provider_command.v1", + receiptPut: "gamecult.mesh.provider_receipt_put.v1", + withdrawal: "gamecult.mesh.provider_withdrawal.v1", + mutationAcceptance: "gamecult.mesh.provider_mutation_acceptance.v1", +} as const; + +export type CultMeshProviderOperationStatus = + | "ok" + | "conflict" + | "expired" + | "denied" + | "invalid"; + +/** + * Transport evidence carried by a provider's RUDP Connect packet. + * + * `clientSessionId` distinguishes an actual reconnect from retransmission of + * the same Connect packet. The token remains authorization evidence only; it + * is not a transport-session identifier. + */ +export interface CultMeshProviderConnectEvidenceWire { + clientSessionId: string; + sessionToken: string | null; +} + +export interface CultMeshProviderRegistrationWire { + providerId: string; + serviceInstanceId: string; + endpointId: string; + verseId: string; + requestedLeaseDurationMs: number; + authorityLeaseId?: string; +} + +export interface CultMeshProviderLeaseWire { + providerId: string; + serviceInstanceId: string; + endpointId: string; + verseId: string; + leaseId: string; + validFromUtc: string; + expiresAtUtc: string; +} + +export interface CultMeshProviderLeaseRenewalWire { + leaseId: string; + requestedLeaseDurationMs: number; +} + +export interface CultMeshProviderPublicationPutWire { + leaseId: string; + publicationId: string; + document: CultNetRawDocumentRecord; +} + +export interface CultMeshProviderPublicationDeleteWire { + leaseId: string; + publicationId: string; + schemaId: string; + recordKey: string; +} + +export interface CultMeshProviderCommandWire { + commandId: string; + commandKind: string; + providerId: string; + serviceInstanceId: string; + payload: unknown; +} + +export type CultMeshProviderReceiptStateWire = "applied" | "rejected" | "failed"; + +export interface CultMeshProviderCommandReceiptWire { + receiptId: string; + commandId: string; + commandKind: string; + providerId: string; + serviceInstanceId: string; + state: CultMeshProviderReceiptStateWire; + completedAtUtc: string; + result?: unknown; + error?: string; +} + +export interface CultMeshProviderReceiptPutWire { + leaseId: string; + receipt: CultMeshProviderCommandReceiptWire; +} + +export interface CultMeshProviderWithdrawalWire { + leaseId: string; +} + +export interface CultMeshProviderMutationAcceptanceWire { + acceptedAtUtc: string; + leaseId?: string; + publicationId?: string; + commandId?: string; + receiptId?: string; +} + +export function encodeProviderSessionPayload(value: unknown): string { + return Buffer.from(encode(value)).toString("base64"); +} + +export function decodeProviderSessionPayload(payload: string): T { + if (typeof payload !== "string" || payload.length === 0) { + throw new Error("CultMesh provider-session payload must be non-empty MessagePack base64."); + } + return decode(Buffer.from(payload, "base64")) as T; +} + +export function encodeProviderConnectEvidence( + evidence: CultMeshProviderConnectEvidenceWire, +): Uint8Array { + assertProviderConnectEvidence(evidence); + return encode(evidence); +} + +export function decodeProviderConnectEvidence( + payload: Uint8Array, +): CultMeshProviderConnectEvidenceWire { + const evidence = decode(payload) as unknown; + assertProviderConnectEvidence(evidence); + return evidence; +} + +export function assertProviderConnectEvidence( + value: unknown, +): asserts value is CultMeshProviderConnectEvidenceWire { + const evidence = requireObject(value, "provider Connect evidence"); + requireText(evidence.clientSessionId, "clientSessionId"); + if (evidence.sessionToken !== null) { + requireText(evidence.sessionToken, "sessionToken"); + } +} + +export function decodeProviderCommandDocument(document: CultNetRawDocumentRecord): CultMeshProviderCommandWire { + if (document.schemaId !== cultMeshProviderSessionSchemas.command) { + throw new Error(`Expected ${cultMeshProviderSessionSchemas.command}, received ${document.schemaId}.`); + } + if (document.payloadEncoding !== "messagepack") { + throw new Error(`Provider command ${document.recordKey} must use MessagePack.`); + } + const command = decode(document.payload) as Partial; + requireText(command.commandId, "commandId"); + requireText(command.commandKind, "commandKind"); + requireText(command.providerId, "providerId"); + requireText(command.serviceInstanceId, "serviceInstanceId"); + return command as CultMeshProviderCommandWire; +} + +export function assertProviderLease(value: unknown): asserts value is CultMeshProviderLeaseWire { + const lease = requireObject(value, "provider lease"); + requireText(lease.providerId, "providerId"); + requireText(lease.serviceInstanceId, "serviceInstanceId"); + requireText(lease.endpointId, "endpointId"); + requireText(lease.verseId, "verseId"); + requireText(lease.leaseId, "leaseId"); + requireTimestamp(lease.validFromUtc, "validFromUtc"); + requireTimestamp(lease.expiresAtUtc, "expiresAtUtc"); +} + +export function assertMutationAcceptance(value: unknown): asserts value is CultMeshProviderMutationAcceptanceWire { + const acceptance = requireObject(value, "provider mutation acceptance"); + requireTimestamp(acceptance.acceptedAtUtc, "acceptedAtUtc"); +} + +function requireObject(value: unknown, name: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`CultMesh ${name} must be a MessagePack map.`); + } + return value as Record; +} + +function requireText(value: unknown, name: string): asserts value is string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`CultMesh provider-session ${name} must be non-empty.`); + } +} + +function requireTimestamp(value: unknown, name: string): asserts value is string { + requireText(value, name); + if (!Number.isFinite(Date.parse(value))) { + throw new Error(`CultMesh provider-session ${name} must be an RFC3339 timestamp.`); + } +} diff --git a/packages/cultmesh-ts/src/provider-session.ts b/packages/cultmesh-ts/src/provider-session.ts new file mode 100644 index 00000000..f63d222c --- /dev/null +++ b/packages/cultmesh-ts/src/provider-session.ts @@ -0,0 +1,884 @@ +export interface CultMeshProviderIdentity { + readonly providerId: string; + readonly serviceInstanceId: string; + readonly endpointId: string; + readonly verseId: string; +} + +export interface CultMeshProviderLease { + readonly leaseId: string; + readonly expiresAt: Date; +} + +export interface CultMeshProviderPublication { + readonly publicationId: string; + readonly documentType: string; + readonly schemaId: string; + readonly recordKey: string; + readonly value: TValue; +} + +export interface CultMeshProviderCommand { + readonly commandId: string; + readonly commandKind: string; + readonly providerId: string; + readonly serviceInstanceId: string; + readonly payload: TPayload; +} + +export type CultMeshProviderReceiptState = "applied" | "rejected" | "failed"; + +export interface CultMeshProviderCommandReceipt { + readonly receiptId: string; + readonly commandId: string; + readonly commandKind: string; + readonly providerId: string; + readonly serviceInstanceId: string; + readonly state: CultMeshProviderReceiptState; + readonly completedAt: Date; + readonly result?: TResult; + readonly error?: string; +} + +export interface CultMeshProviderCommandOutcome { + readonly state: CultMeshProviderReceiptState; + readonly result?: TResult; + readonly error?: string; +} + +export interface CultMeshProviderCommandContext { + readonly identity: CultMeshProviderIdentity; +} + +export type CultMeshProviderCommandHandler = ( + command: CultMeshProviderCommand, + context: CultMeshProviderCommandContext, +) => Promise; + +export interface CultMeshProviderReceiptStore { + get(commandId: string): Promise; + put(receipt: CultMeshProviderCommandReceipt): Promise; + listPending(): Promise; + markPublished(receiptId: string): Promise; +} + +export class CultMeshMemoryProviderReceiptStore implements CultMeshProviderReceiptStore { + readonly #receipts = new Map(); + readonly #pendingReceiptIds = new Set(); + + public async get(commandId: string): Promise { + return this.#receipts.get(commandId); + } + + public async put(receipt: CultMeshProviderCommandReceipt): Promise { + this.#receipts.set(receipt.commandId, receipt); + this.#pendingReceiptIds.add(receipt.receiptId); + } + + public async listPending(): Promise { + return [...this.#receipts.values()] + .filter(receipt => this.#pendingReceiptIds.has(receipt.receiptId)) + .sort((left, right) => left.receiptId.localeCompare(right.receiptId)); + } + + public async markPublished(receiptId: string): Promise { + this.#pendingReceiptIds.delete(receiptId); + } +} + +export interface CultMeshProviderRegistration { + readonly identity: CultMeshProviderIdentity; + readonly requestedLeaseDurationMs: number; +} + +export interface CultMeshProviderWithdrawal { + readonly identity: CultMeshProviderIdentity; + readonly leaseId: string; + readonly publicationIds: readonly string[]; +} + +export type CultMeshProviderCommandListener = ( + command: CultMeshProviderCommand, +) => void | Promise; + +export type CultMeshProviderUnsubscribe = () => void | Promise; + +export interface CultMeshProviderConnection { + register( + registration: CultMeshProviderRegistration, + signal: AbortSignal, + ): Promise; + renew(lease: CultMeshProviderLease): Promise; + publish( + publication: CultMeshProviderPublication, + lease: CultMeshProviderLease, + ): Promise; + withdrawPublication(publicationId: string, lease: CultMeshProviderLease): Promise; + watchCommands(listener: CultMeshProviderCommandListener): CultMeshProviderUnsubscribe; + publishReceipt( + receipt: CultMeshProviderCommandReceipt, + lease: CultMeshProviderLease, + ): Promise; + withdraw(withdrawal: CultMeshProviderWithdrawal): Promise; + close(): void | Promise; +} + +export interface CultMeshProviderTransport { + connect( + identity: CultMeshProviderIdentity, + signal: AbortSignal, + ): Promise; +} + +export interface CultMeshProviderScheduledTask { + cancel(): void; +} + +export interface CultMeshProviderScheduler { + now(): Date; + schedule(delayMs: number, action: () => void | Promise): CultMeshProviderScheduledTask; +} + +export class CultMeshSystemProviderScheduler implements CultMeshProviderScheduler { + public now(): Date { + return new Date(); + } + + public schedule(delayMs: number, action: () => void | Promise): CultMeshProviderScheduledTask { + const timer = setTimeout(() => { + void Promise.resolve(action()).catch(() => undefined); + }, Math.max(0, delayMs)); + return { cancel: () => clearTimeout(timer) }; + } +} + +export type CultMeshProviderSessionStatus = + | "stopped" + | "connecting" + | "active" + | "degraded" + | "reconnecting" + | "withdrawing"; + +export interface CultMeshProviderSessionState { + readonly status: CultMeshProviderSessionStatus; + readonly identity: CultMeshProviderIdentity; + readonly reconnectAttempt: number; + readonly leaseId?: string; + readonly leaseExpiresAt?: Date; + readonly lastError?: string; +} + +export interface CultMeshProviderSessionOptions { + readonly identity: CultMeshProviderIdentity; + readonly transport: CultMeshProviderTransport; + readonly publications?: Iterable; + readonly commandHandlers?: Readonly>; + readonly receiptStore: CultMeshProviderReceiptStore; + readonly scheduler?: CultMeshProviderScheduler; + readonly onObserverError?: (error: unknown) => void; + readonly leaseDurationMs?: number; + readonly renewalLeadMs?: number; + readonly reconnectBaseDelayMs?: number; + readonly reconnectMaxDelayMs?: number; +} + +export class CultMeshProviderSession { + readonly #identity: CultMeshProviderIdentity; + readonly #transport: CultMeshProviderTransport; + readonly #receiptStore: CultMeshProviderReceiptStore; + readonly #scheduler: CultMeshProviderScheduler; + readonly #onObserverError: (error: unknown) => void; + readonly #leaseDurationMs: number; + readonly #renewalLeadMs: number; + readonly #reconnectBaseDelayMs: number; + readonly #reconnectMaxDelayMs: number; + readonly #publications = new Map(); + readonly #handlers = new Map(); + readonly #subscribers = new Set<(state: CultMeshProviderSessionState) => void>(); + readonly #inFlightCommands = new Map; + }>(); + #state: CultMeshProviderSessionState; + #connection?: CultMeshProviderConnection; + #lease?: CultMeshProviderLease; + #unsubscribeCommands?: CultMeshProviderUnsubscribe; + #scheduled?: CultMeshProviderScheduledTask; + #abort?: AbortController; + #running = false; + #generation = 0; + #reconnectAttempt = 0; + #publicationWork: Promise = Promise.resolve(); + #startWork?: Promise; + #stopWork?: Promise; + #lossWork?: { generation: number; work: Promise }; + + public constructor(options: CultMeshProviderSessionOptions) { + this.#identity = validateIdentity(options.identity); + this.#transport = options.transport; + if (!options.receiptStore) { + throw new Error( + "CultMesh provider sessions require an explicit receiptStore so command receipts survive the provider lifecycle.", + ); + } + this.#receiptStore = options.receiptStore; + this.#scheduler = options.scheduler ?? new CultMeshSystemProviderScheduler(); + this.#onObserverError = options.onObserverError ?? (() => undefined); + this.#leaseDurationMs = positive(options.leaseDurationMs ?? 30_000, "leaseDurationMs"); + this.#renewalLeadMs = nonNegative(options.renewalLeadMs ?? 10_000, "renewalLeadMs"); + if (this.#renewalLeadMs >= this.#leaseDurationMs) { + throw new Error("CultMesh provider renewalLeadMs must be less than leaseDurationMs."); + } + this.#reconnectBaseDelayMs = positive( + options.reconnectBaseDelayMs ?? 250, + "reconnectBaseDelayMs", + ); + this.#reconnectMaxDelayMs = positive( + options.reconnectMaxDelayMs ?? 10_000, + "reconnectMaxDelayMs", + ); + if (this.#reconnectMaxDelayMs < this.#reconnectBaseDelayMs) { + throw new Error("CultMesh provider reconnectMaxDelayMs must not be less than reconnectBaseDelayMs."); + } + for (const publication of options.publications ?? []) { + this.#publications.set(validatePublication(publication).publicationId, publication); + } + for (const [kind, handler] of Object.entries(options.commandHandlers ?? {})) { + this.setCommandHandler(kind, handler); + } + this.#state = { + status: "stopped", + identity: this.#identity, + reconnectAttempt: 0, + }; + } + + public get identity(): CultMeshProviderIdentity { + return this.#identity; + } + + public get state(): CultMeshProviderSessionState { + return this.#state; + } + + public get publications(): readonly CultMeshProviderPublication[] { + return [...this.#publications.values()].sort((left, right) => + left.publicationId.localeCompare(right.publicationId), + ); + } + + public watchState(callback: (state: CultMeshProviderSessionState) => void): () => void { + this.#subscribers.add(callback); + try { + callback(this.#state); + } catch (error) { + this.#reportObserverError(error); + } + return () => this.#subscribers.delete(callback); + } + + public setCommandHandler(kind: string, handler: CultMeshProviderCommandHandler): void { + this.#handlers.set(required(kind, "command kind"), handler); + } + + public removeCommandHandler(kind: string): boolean { + return this.#handlers.delete(required(kind, "command kind")); + } + + public upsertPublication(publication: CultMeshProviderPublication): Promise { + const valid = validatePublication(publication); + this.#publications.set(valid.publicationId, valid); + if (this.#connection && this.#lease) { + return this.#enqueuePublication(() => this.#deliverPublication(valid, this.#generation)); + } + return Promise.resolve(); + } + + public async removePublication(publicationId: string): Promise { + const id = required(publicationId, "publicationId"); + const removed = this.#publications.delete(id); + if (removed && this.#connection && this.#lease) { + await this.#enqueuePublication(() => this.#withdrawPublication(id, this.#generation)); + } + return removed; + } + + public start(): Promise { + if (this.#stopWork) { + return Promise.reject(new Error("CultMesh provider cannot start while shutdown is in progress.")); + } + if (this.#startWork) return this.#startWork; + if (this.#running) return Promise.resolve(); + let tracked!: Promise; + tracked = Promise.resolve().then(() => this.#start()).finally(() => { + if (this.#startWork === tracked) this.#startWork = undefined; + }); + this.#startWork = tracked; + return tracked; + } + + async #start(): Promise { + this.#running = true; + this.#reconnectAttempt = 0; + const generation = ++this.#generation; + this.#abort = new AbortController(); + await this.#connect(generation, false); + } + + public stop(): Promise { + if (this.#stopWork) return this.#stopWork; + if (!this.#running && !this.#startWork && this.#state.status === "stopped") { + return Promise.resolve(); + } + let tracked!: Promise; + tracked = Promise.resolve().then(() => this.#stop()).finally(() => { + if (this.#stopWork === tracked) this.#stopWork = undefined; + }); + this.#stopWork = tracked; + return tracked; + } + + async #stop(): Promise { + this.#running = false; + const generation = ++this.#generation; + this.#cancelScheduled(); + this.#abort?.abort(); + this.#setState("withdrawing"); + const connection = this.#connection; + const lease = this.#lease; + const starting = this.#startWork; + const losing = this.#lossWork?.work; + this.#connection = undefined; + this.#lease = undefined; + const errors: unknown[] = []; + try { + await this.#unsubscribe(); + } catch (error) { + errors.push(error); + } + try { + await this.#publicationWork; + } catch (error) { + errors.push(error); + } + for (const lifecycleWork of [starting, losing]) { + if (!lifecycleWork) continue; + try { + await lifecycleWork; + } catch (error) { + errors.push(error); + } + } + this.#cancelScheduled(); + const commandWork = [...this.#inFlightCommands.values()].map(value => value.work); + const commandResults = await Promise.allSettled(commandWork); + for (const result of commandResults) { + if (result.status === "rejected") errors.push(result.reason); + } + if (connection && lease) { + try { + await connection.withdraw({ + identity: this.#identity, + leaseId: lease.leaseId, + publicationIds: this.publications.map(value => value.publicationId), + }); + } catch (error) { + errors.push(error); + } + } + if (connection) { + try { + await connection.close(); + } catch (error) { + errors.push(error); + } + } + if (generation === this.#generation) this.#setState("stopped"); + if (errors.length > 0) { + throw new AggregateError(errors, "CultMesh provider shutdown completed with cleanup failures."); + } + } + + async #connect(generation: number, reconnecting: boolean): Promise { + if (!this.#isCurrent(generation)) return; + this.#scheduled = undefined; + this.#setState(reconnecting ? "reconnecting" : "connecting"); + try { + const connection = await this.#transport.connect(this.#identity, this.#abort!.signal); + if (!this.#isCurrent(generation)) { + await connection.close(); + return; + } + this.#connection = connection; + const lease = validateLease( + await connection.register({ + identity: this.#identity, + requestedLeaseDurationMs: this.#leaseDurationMs, + }, this.#abort!.signal), + this.#scheduler.now(), + ); + if (!this.#isCurrent(generation)) { + await connection.close(); + return; + } + this.#lease = lease; + const initialPublications = this.publications; + await this.#enqueuePublication(async () => { + for (const publication of initialPublications) { + if (!this.#isCurrent(generation)) return; + requireUnexpiredLease(lease, this.#scheduler.now()); + await connection.publish(publication, lease); + } + }); + if (!this.#isCurrent(generation)) return; + const readyLease = await this.#renewIfNeeded(connection, lease, generation); + if (!readyLease || !this.#isCurrent(generation)) return; + this.#lease = readyLease; + this.#reconnectAttempt = 0; + const receiptsReady = await this.#drainPendingReceipts(connection, readyLease, generation); + if (!this.#isCurrent(generation)) return; + if (receiptsReady) { + this.#enableCommands(connection, generation); + this.#setState("active", undefined, readyLease); + } + this.#scheduleRenewal(generation, readyLease); + } catch (error) { + await this.#loseConnection(generation, error); + } + } + + #scheduleRenewal(generation: number, lease: CultMeshProviderLease): void { + if (!this.#isCurrent(generation)) return; + this.#scheduled?.cancel(); + const delay = Math.max( + 1, + lease.expiresAt.getTime() - this.#scheduler.now().getTime() - this.#renewalLeadMs, + ); + this.#scheduled = this.#scheduler.schedule(delay, () => this.#renew(generation)); + } + + async #renew(generation: number): Promise { + if (!this.#isCurrent(generation) || !this.#connection || !this.#lease) return; + this.#scheduled = undefined; + try { + let lease = validateLease( + await this.#connection.renew(this.#lease), + this.#scheduler.now(), + ); + if (!this.#isCurrent(generation)) return; + this.#lease = lease; + const receiptsReady = await this.#drainPendingReceipts(this.#connection, lease, generation); + if (!this.#isCurrent(generation)) return; + if (receiptsReady) { + lease = await this.#renewIfNeeded(this.#connection, lease, generation) ?? lease; + if (!this.#isCurrent(generation)) return; + this.#lease = lease; + this.#enableCommands(this.#connection, generation); + this.#setState("active", undefined, lease); + } + this.#scheduleRenewal(generation, lease); + } catch (error) { + await this.#loseConnection(generation, error); + } + } + + async #deliverPublication( + publication: CultMeshProviderPublication, + generation: number, + ): Promise { + const connection = this.#connection; + const lease = this.#lease; + if (!connection || !lease || !this.#isCurrent(generation)) return; + try { + requireUnexpiredLease(lease, this.#scheduler.now()); + await connection.publish(publication, lease); + } catch (error) { + await this.#loseConnection(generation, error); + } + } + + async #withdrawPublication(publicationId: string, generation: number): Promise { + const connection = this.#connection; + const lease = this.#lease; + if (!connection || !lease || !this.#isCurrent(generation)) return; + try { + requireUnexpiredLease(lease, this.#scheduler.now()); + await connection.withdrawPublication(publicationId, lease); + } catch (error) { + await this.#loseConnection(generation, error); + } + } + + async #receiveCommand(command: CultMeshProviderCommand, generation: number): Promise { + if (!this.#isCurrent(generation) || + command.providerId !== this.#identity.providerId || + command.serviceInstanceId !== this.#identity.serviceInstanceId) { + return; + } + try { + required(command.commandId, "commandId"); + required(command.commandKind, "commandKind"); + } catch (error) { + this.#setState("degraded", errorText(error), this.#lease); + return; + } + const inFlight = this.#inFlightCommands.get(command.commandId); + if (inFlight) { + if (!sameCommandIdentity(inFlight.command, command)) { + this.#setState( + "degraded", + `CultMesh command id '${command.commandId}' was reused with different identity while in flight.`, + this.#lease, + ); + return; + } + await inFlight.work; + return; + } + const work = this.#processCommand(command, generation); + const entry = { command, work }; + this.#inFlightCommands.set(command.commandId, entry); + try { + await work; + } finally { + if (this.#inFlightCommands.get(command.commandId) === entry) { + this.#inFlightCommands.delete(command.commandId); + } + } + } + + async #processCommand(command: CultMeshProviderCommand, generation: number): Promise { + let receipt: CultMeshProviderCommandReceipt; + try { + receipt = await this.#resolveCommand(command); + } catch (error) { + if (this.#isCurrent(generation)) { + await this.#degradeReceiptStore( + `Command receipt persistence failed: ${errorText(error)}`, + generation, + ); + } + return; + } + const connection = this.#connection; + const lease = this.#lease; + if (!connection || !lease || !this.#isCurrent(generation)) return; + try { + await connection.publishReceipt(receipt, lease); + } catch (error) { + await this.#loseConnection(generation, error); + return; + } + try { + await this.#receiptStore.markPublished(receipt.receiptId); + } catch (error) { + if (this.#isCurrent(generation)) { + await this.#degradeReceiptStore( + `Command receipt publication could not be committed: ${errorText(error)}`, + generation, + ); + } + } + } + + async #resolveCommand(command: CultMeshProviderCommand): Promise { + const prior = await this.#receiptStore.get(command.commandId); + if (prior) { + validateStoredReceipt(prior, this.#identity); + if (prior.commandKind !== command.commandKind) { + throw new Error(`CultMesh command id '${command.commandId}' was reused with different identity.`); + } + return prior; + } + const handler = this.#handlers.get(command.commandKind); + let outcome: CultMeshProviderCommandOutcome; + if (!handler) { + outcome = { state: "rejected", error: `No handler for '${command.commandKind}'.` }; + } else { + try { + outcome = await handler(command, { identity: this.#identity }); + validateOutcomeState(outcome?.state); + } catch (error) { + outcome = { state: "failed", error: errorText(error) }; + } + } + const receipt: CultMeshProviderCommandReceipt = { + receiptId: receiptId(this.#identity, command.commandId), + commandId: command.commandId, + commandKind: command.commandKind, + providerId: this.#identity.providerId, + serviceInstanceId: this.#identity.serviceInstanceId, + state: validateOutcomeState(outcome.state), + completedAt: this.#scheduler.now(), + result: outcome.result, + error: outcome.error, + }; + await this.#receiptStore.put(receipt); + return receipt; + } + + async #loseConnection(generation: number, error: unknown): Promise { + if (!this.#isCurrent(generation)) return; + const existing = this.#lossWork; + if (existing?.generation === generation) { + await existing.work; + return; + } + let tracked!: Promise; + tracked = this.#performConnectionLoss(generation, error).finally(() => { + if (this.#lossWork?.work === tracked) this.#lossWork = undefined; + }); + this.#lossWork = { generation, work: tracked }; + await tracked; + } + + async #performConnectionLoss(generation: number, error: unknown): Promise { + this.#cancelScheduled(); + const connection = this.#connection; + this.#connection = undefined; + this.#lease = undefined; + this.#reconnectAttempt++; + const failures = [errorText(error)]; + this.#setState("reconnecting", failures[0]); + try { + await this.#unsubscribe(); + } catch (cleanupError) { + failures.push(`unsubscribe failed: ${errorText(cleanupError)}`); + } + if (connection) { + try { + await connection.close(); + } catch (cleanupError) { + failures.push(`close failed: ${errorText(cleanupError)}`); + } + } + if (!this.#isCurrent(generation)) return; + this.#setState("reconnecting", failures.join("; ")); + const exponent = Math.max(0, this.#reconnectAttempt - 1); + const delay = Math.min( + this.#reconnectMaxDelayMs, + this.#reconnectBaseDelayMs * 2 ** exponent, + ); + this.#scheduled = this.#scheduler.schedule(delay, () => this.#connect(generation, true)); + } + + async #drainPendingReceipts( + connection: CultMeshProviderConnection, + lease: CultMeshProviderLease, + generation: number, + ): Promise { + let pending: readonly CultMeshProviderCommandReceipt[]; + try { + pending = await this.#receiptStore.listPending(); + } catch (error) { + if (this.#isCurrent(generation)) { + await this.#degradeReceiptStore( + `Pending command receipts could not be read: ${errorText(error)}`, + generation, + ); + } + return false; + } + for (const receipt of [...pending].sort((left, right) => left.receiptId.localeCompare(right.receiptId))) { + try { + validateStoredReceipt(receipt, this.#identity); + } catch (error) { + if (this.#isCurrent(generation)) { + await this.#degradeReceiptStore( + `Pending command receipt is invalid: ${errorText(error)}`, + generation, + ); + } + return false; + } + if (!this.#isCurrent(generation)) return false; + requireUnexpiredLease(lease, this.#scheduler.now()); + await connection.publishReceipt(receipt, lease); + try { + await this.#receiptStore.markPublished(receipt.receiptId); + } catch (error) { + if (this.#isCurrent(generation)) { + await this.#degradeReceiptStore( + `Command receipt publication could not be committed: ${errorText(error)}`, + generation, + ); + } + return false; + } + } + return true; + } + + async #renewIfNeeded( + connection: CultMeshProviderConnection, + lease: CultMeshProviderLease, + generation: number, + ): Promise { + if (lease.expiresAt.getTime() - this.#scheduler.now().getTime() > this.#renewalLeadMs) { + return lease; + } + const renewed = validateLease(await connection.renew(lease), this.#scheduler.now()); + return this.#isCurrent(generation) ? renewed : undefined; + } + + #enableCommands(connection: CultMeshProviderConnection, generation: number): void { + if (this.#unsubscribeCommands || !this.#isCurrent(generation)) return; + this.#unsubscribeCommands = connection.watchCommands(command => this.#receiveCommand(command, generation)); + } + + async #degradeReceiptStore(message: string, generation: number): Promise { + try { + await this.#unsubscribe(); + } catch (error) { + this.#reportObserverError(error); + } + if (this.#isCurrent(generation)) this.#setState("degraded", message, this.#lease); + } + + async #unsubscribe(): Promise { + const unsubscribe = this.#unsubscribeCommands; + this.#unsubscribeCommands = undefined; + if (unsubscribe) await unsubscribe(); + } + + #cancelScheduled(): void { + const scheduled = this.#scheduled; + this.#scheduled = undefined; + scheduled?.cancel(); + } + + #enqueuePublication(action: () => Promise): Promise { + const work = this.#publicationWork.then(action, action); + this.#publicationWork = work.catch(() => undefined); + return work; + } + + #isCurrent(generation: number): boolean { + return this.#running && generation === this.#generation; + } + + #setState( + status: CultMeshProviderSessionStatus, + lastError?: string, + lease?: CultMeshProviderLease, + ): void { + this.#state = { + status, + identity: this.#identity, + reconnectAttempt: this.#reconnectAttempt, + leaseId: lease?.leaseId, + leaseExpiresAt: lease?.expiresAt, + lastError, + }; + for (const subscriber of [...this.#subscribers]) { + try { + subscriber(this.#state); + } catch (error) { + this.#reportObserverError(error); + } + } + } + + #reportObserverError(error: unknown): void { + try { + this.#onObserverError(error); + } catch { + // Diagnostics cannot become a provider lifecycle authority. + } + } +} + +function validateIdentity(identity: CultMeshProviderIdentity): CultMeshProviderIdentity { + required(identity.providerId, "providerId"); + required(identity.serviceInstanceId, "serviceInstanceId"); + required(identity.endpointId, "endpointId"); + required(identity.verseId, "verseId"); + return identity; +} + +function validatePublication(publication: CultMeshProviderPublication): CultMeshProviderPublication { + required(publication.publicationId, "publicationId"); + required(publication.documentType, "documentType"); + required(publication.schemaId, "schemaId"); + required(publication.recordKey, "recordKey"); + return publication; +} + +function validateLease(lease: CultMeshProviderLease, now: Date): CultMeshProviderLease { + required(lease.leaseId, "leaseId"); + if (!(lease.expiresAt instanceof Date) || !Number.isFinite(lease.expiresAt.getTime())) { + throw new Error("CultMesh provider lease requires a valid expiresAt date."); + } + if (lease.expiresAt <= now) { + throw new Error("CultMesh provider lease must expire in the future."); + } + return lease; +} + +function requireUnexpiredLease(lease: CultMeshProviderLease, now: Date): void { + if (lease.expiresAt <= now) { + throw new Error(`CultMesh provider lease '${lease.leaseId}' expired during replay.`); + } +} + +function validateOutcomeState(state: CultMeshProviderReceiptState): CultMeshProviderReceiptState { + if (state !== "applied" && state !== "rejected" && state !== "failed") { + throw new Error(`CultMesh provider command handler returned invalid receipt state '${String(state)}'.`); + } + return state; +} + +function sameCommandIdentity( + left: CultMeshProviderCommand, + right: CultMeshProviderCommand, +): boolean { + return left.providerId === right.providerId && + left.serviceInstanceId === right.serviceInstanceId && + left.commandKind === right.commandKind; +} + +function validateStoredReceipt( + receipt: CultMeshProviderCommandReceipt, + identity: CultMeshProviderIdentity, +): void { + required(receipt.commandId, "stored receipt commandId"); + required(receipt.commandKind, "stored receipt commandKind"); + required(receipt.receiptId, "stored receipt receiptId"); + validateOutcomeState(receipt.state); + if (receipt.providerId !== identity.providerId || + receipt.serviceInstanceId !== identity.serviceInstanceId || + receipt.receiptId !== receiptId(identity, receipt.commandId)) { + throw new Error(`CultMesh stored receipt '${receipt.receiptId}' does not belong to this provider session.`); + } +} + +function receiptId(identity: CultMeshProviderIdentity, commandId: string): string { + const parts = [identity.providerId, identity.serviceInstanceId, commandId]; + return `receipt:${parts.map(value => `${value.length}:${value}`).join("")}`; +} + +function required(value: string, name: string): string { + if (!value || value.trim().length === 0) { + throw new Error(`CultMesh provider ${name} must be non-empty.`); + } + return value; +} + +function positive(value: number, name: string): number { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`CultMesh provider ${name} must be positive.`); + } + return value; +} + +function nonNegative(value: number, name: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`CultMesh provider ${name} must be non-negative.`); + } + return value; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index c14d3a47..16d51e1e 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -5,7 +5,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { encode } from "@msgpack/msgpack"; import { z } from "zod"; -import { defineDocumentType } from "cultcache-ts"; +import { defineDocumentType } from "@gamecult/cultcache-ts"; import { CultNetDocumentRegistry, CultNetPeer, @@ -24,6 +24,41 @@ const noteDocument = defineDocumentType({ name: "noteId", }); +const noteAliasDocument = defineDocumentType({ + type: "cultmesh.note.ui", + schemaId: "cultmesh.note.v0", + schema: z.object({ + noteId: z.string(), + body: z.string(), + }), + name: "noteId", +}); + +const noteReceiptDocument = defineDocumentType({ + type: "cultmesh.note_receipt", + schemaId: "cultmesh.note_receipt.v0", + schema: z.object({ commandId: z.string(), state: z.literal("applied"), body: z.string() }), + name: "commandId", +}); + +const foreignNoteDocument = defineDocumentType({ + type: "runtime.generated.cultmesh.note", + schemaId: "runtime.generated.cultmesh.note.ui.42", + schema: z.object({ + noteId: z.string(), + body: z.string(), + }), + name: "noteId", +}); + +const incompatibleNoteDocument = defineDocumentType({ + type: "runtime.generated.incompatible-note", + schemaId: "runtime.generated.incompatible-note.v1", + schema: z.object({ + nope: z.string(), + }), +}); + async function waitFor(predicate: () => boolean, description: string): Promise { const startedAt = Date.now(); while (!predicate()) { @@ -58,6 +93,1110 @@ test("CultMesh TS opens a durable local node and registers document bindings", a assert.ok(reopened.documents.getBySchemaId("cultmesh.note.v0")); }); +test("CultMesh TS document handles hide local cache plumbing behind typed reactive reads", async () => { + const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-doc-")), "node.ccmp"); + const node = await CultMesh.startNode(filePath, { + documents: [noteDocument], + }); + await node.put(noteDocument, "note:1", { + noteId: "note:1", + body: "initial", + }); + + const document = node.document(noteDocument, "note:1", { pollMs: 5 }); + const verse = CultMesh.verse("local", "browser-client", { + routeHint: CultMesh.routeHint("shared-memory", "local cache"), + }); + const bound = verse.bindDocument(document); + const observed: string[] = []; + const unsubscribe = bound.watch(note => observed.push(note.body)); + + assert.equal(document.documentId, "cultmesh.note:note:1"); + assert.equal(document.canReplace, true); + assert.equal(document.canSubmitPrediction, false); + assert.equal((await bound.latest()).body, "initial"); + + await bound.replace({ + noteId: "note:1", + body: "updated", + }); + await waitFor(() => observed.includes("updated"), "document handle update"); + unsubscribe(); + + const alias = bound.asSchemaAlias(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }); + assert.equal((await alias.latest()).body, "updated"); + + const catalog = CultMesh.documents(document); + assert.equal(catalog.canReplace(noteAliasDocument), true); + await catalog.authoritativeWriter(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + context: "browser-client", + }).write({ + noteId: "note:1", + body: "catalog-updated", + }); + assert.equal( + (await catalog.latest(noteAliasDocument, "browser-client", { + parse: value => noteAliasDocument.schema.parse(value), + })).body, + "catalog-updated", + ); + const incremented = await bound.authoritativeWriter().update(value => ({ + ...value, + body: `${value.body}:updated-through-bound-set`, + })); + assert.equal(incremented.body, "catalog-updated:updated-through-bound-set"); + assert.equal((await document.latest()).body, "catalog-updated:updated-through-bound-set"); + assert.throws( + () => document.asSchemaAlias({ schemaId: "cultmesh.other.v0" }), + /not compatible/, + ); +}); + +test("CultMesh TS document handles submit predictions through configured authority hooks", async () => { + let current = { + noteId: "note:prediction", + body: "initial", + }; + const predictions: string[] = []; + const contexts: string[] = []; + const document = CultMesh.document( + "cultmesh.note:prediction", + noteDocument, + async () => current, + { + routeHint: CultMesh.routeHint("network", "CultNet prediction"), + submitPrediction: async (context, value) => { + contexts.push(`${context.runtimeId}:${context.routeHint.kind}`); + predictions.push(value.body); + current = value; + }, + }, + ); + const verse = CultMesh.verse("starbridge", "pilot-a"); + const bound = document.bind(verse); + const catalog = CultMesh.documents(document); + + assert.equal(document.canReplace, false); + assert.equal(document.canSubmitPrediction, true); + assert.equal(bound.canSubmitPrediction, true); + assert.equal(catalog.canSubmitPrediction(noteAliasDocument), true); + + await catalog.predictionWriter(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + context: "pilot-a", + }).write({ + noteId: "note:prediction", + body: "predicted", + }); + + assert.deepEqual(contexts, ["pilot-a:network"]); + assert.deepEqual(predictions, ["predicted"]); + assert.equal((await bound.latest()).body, "predicted"); + + const alias = bound.asSchemaAlias(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }); + assert.equal(alias.canSubmitPrediction, true); + await alias.predictionWriter().write({ + noteId: "note:prediction", + body: "alias-predicted", + }); + + assert.deepEqual(predictions, ["predicted", "alias-predicted"]); + assert.equal((await document.latest("pilot-a")).body, "alias-predicted"); + const updated = await document.predictionWriter("pilot-a").update(value => ({ + ...value, + body: "updated-as-prediction", + })); + assert.equal(updated.body, "updated-as-prediction"); + assert.deepEqual(predictions, ["predicted", "alias-predicted", "updated-as-prediction"]); + + assert.throws( + () => document.replace({ + noteId: "note:prediction", + body: "not-authoritative", + }), + /does not support replacement/, + ); +}); + +test("CultMesh TS documents with both capabilities require an immutable writer choice", async () => { + let current = { noteId: "note:dual", body: "initial" }; + const writes: string[] = []; + const document = CultMesh.document( + "cultmesh.note:dual", + noteDocument, + async () => current, + { + replaceDocument: async (_context, value) => { + writes.push(`authoritative:${value.body}`); + current = value; + }, + submitPrediction: async (_context, value) => { + writes.push(`prediction:${value.body}`); + current = value; + }, + }, + ); + + assert.equal((document as unknown as { set?: unknown }).set, undefined); + await document.authoritativeWriter().write({ noteId: "note:dual", body: "canonical" }); + const predictionWriter = document.predictionWriter(); + await predictionWriter.write({ noteId: "note:dual", body: "predicted" }); + + assert.equal(predictionWriter.kind, "prediction"); + assert.deepEqual(writes, ["authoritative:canonical", "prediction:predicted"]); +}); + +test("CultMesh TS store document handles choose compatible foreign schema payloads", async () => { + const store = { + async pullAll() { + return [ + { + key: "note:foreign", + type: "runtime.generated.incompatible-note", + schemaId: "runtime.generated.incompatible-note.v1", + storedAt: "2026-06-27T00:00:00Z", + payload: encode({ nope: "not the requested shape" }), + catalogEntry: { + schemaId: "runtime.generated.incompatible-note.v1", + schemaName: "runtime.generated.incompatible-note", + schemaVersion: "runtime.generated.incompatible-note.v1", + contentHash: "runtime.generated.incompatible-note.v1", + canonicalSchemaJson: "{}", + compatibleSchemaIds: ["runtime.generated.incompatible-note.v1"], + }, + }, + { + key: "note:foreign", + type: "runtime.generated.cultmesh.note", + schemaId: "runtime.generated.cultmesh.note.ui.42", + storedAt: "2026-06-27T00:00:01Z", + payload: encode({ + noteId: "note:foreign", + body: "foreign schema id, local store shape", + }), + catalogEntry: { + schemaId: "runtime.generated.cultmesh.note.ui.42", + schemaName: "cultmesh.note", + schemaVersion: "cultmesh.note.v1", + contentHash: "runtime.generated.cultmesh.note.ui.42", + canonicalSchemaJson: "{}", + compatibleSchemaIds: ["runtime.generated.cultmesh.note.ui.42"], + }, + }, + ]; + }, + async push() {}, + async delete() {}, + }; + + const document = CultMesh.documentFromStore(store, noteAliasDocument, { + documentId: "note:foreign.store", + }); + + assert.deepEqual(await document.latest(), { + noteId: "note:foreign", + body: "foreign schema id, local store shape", + }); +}); + +test("CultMesh TS reactive documents submit predictions from explicit updates", async () => { + let current = { + noteId: "note:reactive", + body: "initial", + }; + const predictions: string[] = []; + const document = CultMesh.document( + "cultmesh.note:reactive", + noteDocument, + async () => current, + { + routeHint: CultMesh.routeHint("network", "CultNet prediction"), + submitPrediction: async (_context, value) => { + predictions.push(value.body); + current = value; + }, + }, + ); + + const reactive = document.predictionWriter().reactive({ + watch: false, + }); + await reactive.ready; + reactive.update(value => { + value.body = "explicit-update-prediction"; + }); + reactive.update(value => { + value.body = "explicit-update-prediction-final"; + }); + await waitFor( + () => predictions.includes("explicit-update-prediction-final"), + "reactive explicit update prediction", + ); + + assert.equal(current.body, "explicit-update-prediction-final"); + assert.deepEqual(predictions, ["explicit-update-prediction-final"]); + reactive.dispose(); + assert.throws(() => reactive.update(value => { + value.body = "after-dispose"; + }), /disposed/); + assert.deepEqual(predictions, ["explicit-update-prediction-final"]); +}); + +test("CultMesh TS reactive updates publish nested edits while snapshot mutation stays local", async () => { + let current = { + shipId: "ship:nested", + loadout: { + weapon: { + key: "weapon:laser", + tuning: { damage: 10 }, + }, + }, + }; + const predictions: typeof current[] = []; + const document = CultMesh.document( + "cultmesh.ship:nested", + { schemaId: "cultmesh.ship.nested.v0" }, + async () => current, + { + submitPrediction: async (_context, value) => { + current = structuredClone(value); + predictions.push(structuredClone(value)); + }, + }, + ); + const reactive = document.predictionWriter().reactive({ watch: false }); + await reactive.ready; + + reactive.update(draft => { + draft.loadout.weapon.tuning.damage = 12; + }); + await reactive.flush(); + + assert.equal(current.loadout.weapon.tuning.damage, 12); + assert.equal(predictions.length, 1); + + const borrowed = reactive.snapshot; + borrowed.loadout.weapon.tuning.damage = 99; + await new Promise(resolve => setTimeout(resolve, 20)); + + assert.equal(reactive.snapshot.loadout.weapon.tuning.damage, 12); + assert.equal(current.loadout.weapon.tuning.damage, 12); + assert.equal(predictions.length, 1); + reactive.dispose(); +}); + +test("CultMesh TS node opens same-schema aliases with one call", async () => { + const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-node-reactive-")), "node.ccmp"); + const node = await CultMesh.startNode(filePath, { + documents: [noteDocument], + }); + await node.put(noteDocument, "note:node-reactive", { + noteId: "note:node-reactive", + body: "initial", + }); + + const aliasHandle = node.document(noteAliasDocument, "note:node-reactive", { + pollMs: 5, + }); + assert.equal(aliasHandle.documentId, "cultmesh.note.ui:note:node-reactive"); + assert.equal((await aliasHandle.latest()).body, "initial"); + await aliasHandle.authoritativeWriter().write({ + noteId: "note:node-reactive", + body: "edited-through-alias-handle", + }); + assert.equal( + node.getRequired(noteAliasDocument, "note:node-reactive").body, + "edited-through-alias-handle", + ); + + await node.put(noteAliasDocument, "note:node-reactive", { + noteId: "note:node-reactive", + body: "edited-through-alias-put", + }); + assert.equal( + node.getRequired(noteDocument, "note:node-reactive").body, + "edited-through-alias-put", + ); + assert.deepEqual( + (await node.collection(noteAliasDocument).latest()).map(note => note.body), + ["edited-through-alias-put"], + ); + + const reactive = aliasHandle.authoritativeWriter( + CultMesh.queryContext("browser-client"), + ).reactive({ + watch: false, + }); + await reactive.ready; + reactive.update(value => { + value.body = "edited-through-node-helper"; + }); + await waitFor( + () => node.getRequired(noteDocument, "note:node-reactive").body === "edited-through-node-helper", + "node-level reactive alias write", + ); + + assert.equal(reactive.snapshot.noteId, "note:node-reactive"); + assert.equal( + node.document(noteDocument, "note:node-reactive").asSchemaAlias(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }).canReplace, + true, + ); + assert.equal( + (await node.document(noteAliasDocument, "note:node-reactive").observe({ + watch: false, + }).ready).body, + "edited-through-node-helper", + ); + reactive.dispose(); +}); + +test("CultMesh TS reactive documents store reconciliation deltas after misprediction", async () => { + let current = { + noteId: "note:reconcile", + body: "initial", + revision: 1, + }; + let watcher: ((value: typeof current) => void) | undefined; + const predictions: typeof current[] = []; + const document = CultMesh.document( + "cultmesh.note:reconcile", + { schemaId: "cultmesh.note.reconcile.v0" }, + async () => current, + { + routeHint: CultMesh.routeHint("network", "CultNet prediction"), + submitPrediction: async (_context, value) => { + predictions.push(value); + current = value; + }, + watchDocument: (_context, callback) => { + watcher = callback; + return () => { + watcher = undefined; + }; + }, + }, + ); + + const reactive = document.predictionWriter().reactive(); + await reactive.ready; + reactive.update(value => { + value.body = "predicted"; + value.revision = 7; + }); + await waitFor(() => predictions.length === 1, "reactive reconciliation prediction"); + + watcher?.({ + noteId: "note:reconcile", + body: "authoritative", + revision: 5, + }); + + assert.equal(reactive.snapshot.body, "authoritative"); + assert.equal(reactive.snapshot.revision, 5); + assert.equal(reactive.reconciliation?.version, 1); + assert.deepEqual(reactive.reconciliation?.canonical, { + noteId: "note:reconcile", + body: "authoritative", + revision: 5, + }); + assert.deepEqual(reactive.reconciliation?.predicted, { + noteId: "note:reconcile", + body: "predicted", + revision: 7, + }); + assert.deepEqual(reactive.reconciliation?.delta, { + body: "predicted", + revision: 2, + }); + + reactive.clearReconciliation(); + assert.equal(reactive.reconciliation, undefined); + reactive.dispose(); +}); + +test("CultMesh TS reactive documents coalesce same-frame explicit updates", async () => { + let current = { + noteId: "note:reactive-frame", + body: "initial", + }; + const predictions: string[] = []; + const previousRequestAnimationFrame = globalThis.requestAnimationFrame; + const frameCallbacks: FrameRequestCallback[] = []; + globalThis.requestAnimationFrame = callback => { + frameCallbacks.push(callback); + return frameCallbacks.length; + }; + const document = CultMesh.document( + "cultmesh.note:reactive-frame", + noteDocument, + async () => current, + { + submitPrediction: async (_context, value) => { + predictions.push(value.body); + current = value; + }, + }, + ); + + const reactive = document.predictionWriter().reactive({ + watch: false, + }); + try { + await reactive.ready; + reactive.update(value => { value.body = "frame-1"; }); + reactive.update(value => { value.body = "frame-2"; }); + reactive.update(value => { value.body = "frame-3"; }); + + assert.equal(frameCallbacks.length, 1); + frameCallbacks[0](performance.now()); + await waitFor(() => predictions.length === 1, "same-frame prediction"); + + assert.deepEqual(predictions, ["frame-3"]); + assert.equal(current.body, "frame-3"); + } finally { + reactive.dispose(); + if (previousRequestAnimationFrame) { + globalThis.requestAnimationFrame = previousRequestAnimationFrame; + } else { + delete (globalThis as unknown as { + requestAnimationFrame?: (callback: FrameRequestCallback) => number; + }).requestAnimationFrame; + } + } +}); + +test("CultMesh TS headless reactive documents coalesce on one microtask", async () => { + const previousRequestAnimationFrame = globalThis.requestAnimationFrame; + delete (globalThis as unknown as { + requestAnimationFrame?: (callback: FrameRequestCallback) => number; + }).requestAnimationFrame; + let current = { noteId: "note:reactive-headless", body: "initial" }; + const predictions: string[] = []; + const document = CultMesh.document( + "cultmesh.note:reactive-headless", + noteDocument, + async () => current, + { + submitPrediction: async (_context, value) => { + predictions.push(value.body); + current = value; + }, + }, + ); + const reactive = document.predictionWriter().reactive({ watch: false }); + try { + await reactive.ready; + reactive.update(value => { value.body = "microtask-1"; }); + reactive.update(value => { value.body = "microtask-2"; }); + reactive.update(value => { value.body = "microtask-3"; }); + assert.equal(predictions.length, 0, "the current turn remains coalesced"); + + await waitFor(() => predictions.length === 1, "headless microtask prediction"); + assert.deepEqual(predictions, ["microtask-3"]); + } finally { + reactive.dispose(); + if (previousRequestAnimationFrame) { + globalThis.requestAnimationFrame = previousRequestAnimationFrame; + } + } +}); + +test("CultMesh TS reactive scheduling scales with changed documents only", async () => { + const previousRequestAnimationFrame = globalThis.requestAnimationFrame; + const previousCancelAnimationFrame = globalThis.cancelAnimationFrame; + const frameCallbacks = new Map(); + let nextFrame = 1; + globalThis.requestAnimationFrame = callback => { + const frame = nextFrame++; + frameCallbacks.set(frame, callback); + return frame; + }; + globalThis.cancelAnimationFrame = frame => { + frameCallbacks.delete(frame); + }; + + try { + for (const documentCount of [1, 100, 1000]) { + const reactiveDocuments = []; + for (let index = 0; index < documentCount; index++) { + let current = { + noteId: `note:scale:${index}`, + body: "initial", + }; + const document = CultMesh.document( + `cultmesh.note:scale:${index}`, + noteDocument, + async () => current, + { + submitPrediction: async (_context, value) => { + current = value; + }, + }, + ); + reactiveDocuments.push(document.predictionWriter().reactive({ watch: false })); + } + + await Promise.all(reactiveDocuments.map(document => document.ready)); + assert.equal(frameCallbacks.size, 0, `${documentCount} idle documents scheduled no work`); + + const changedDocumentCount = Math.max(1, Math.floor(documentCount / 100)); + for (let index = 0; index < changedDocumentCount; index++) { + reactiveDocuments[index].update(value => { + value.body = `changed-${index}`; + }); + } + + assert.equal( + frameCallbacks.size, + changedDocumentCount, + `${documentCount} documents scheduled only the ${changedDocumentCount} changed documents`, + ); + assert.equal( + reactiveDocuments.filter(document => document.dirty).length, + changedDocumentCount, + ); + + for (const reactive of reactiveDocuments) reactive.dispose(); + assert.equal(frameCallbacks.size, 0, "dispose cancels every scheduled frame flush"); + } + } finally { + if (previousRequestAnimationFrame) { + globalThis.requestAnimationFrame = previousRequestAnimationFrame; + } else { + delete (globalThis as unknown as { + requestAnimationFrame?: (callback: FrameRequestCallback) => number; + }).requestAnimationFrame; + } + if (previousCancelAnimationFrame) { + globalThis.cancelAnimationFrame = previousCancelAnimationFrame; + } else { + delete (globalThis as unknown as { + cancelAnimationFrame?: (handle: number) => void; + }).cancelAnimationFrame; + } + } +}); + +test("CultMesh TS reactive documents queue edits made while a prediction is in flight", async () => { + let current = { + noteId: "note:reactive-in-flight", + body: "initial", + }; + let releaseFirstPrediction!: () => void; + let resolveFirstPredictionStarted!: () => void; + const firstPredictionStarted = new Promise(resolve => { + resolveFirstPredictionStarted = resolve; + }); + const predictions: typeof current[] = []; + const document = CultMesh.document( + "cultmesh.note:reactive-in-flight", + noteDocument, + async () => current, + { + submitPrediction: async (_context, value) => { + predictions.push(value); + if (predictions.length === 1) { + resolveFirstPredictionStarted(); + await new Promise(release => { + releaseFirstPrediction = release; + }); + } + current = value; + }, + }, + ); + + const reactive = document.predictionWriter().reactive({ + watch: false, + debounceMs: 0, + }); + await reactive.ready; + reactive.update(value => { value.body = "first"; }); + await firstPredictionStarted; + reactive.update(value => { value.body = "second"; }); + releaseFirstPrediction(); + await waitFor(() => predictions.length === 2, "in-flight queued prediction"); + + assert.deepEqual(predictions.map(value => value.body), ["first", "second"]); + assert.equal(current.body, "second"); + reactive.dispose(); +}); + +test("CultMesh TS observed documents never publish borrowed snapshot mutation and keep watching", async () => { + let current = { + noteId: "note:reactive-readonly", + body: "initial", + }; + let watcher: ((value: typeof current) => void) | undefined; + const writes: typeof current[] = []; + const document = CultMesh.document( + "cultmesh.note:reactive-readonly", + noteDocument, + async () => current, + { + watchDocument: (_context, callback) => { + watcher = callback; + return () => { + watcher = undefined; + }; + }, + submitPrediction: async (_context, value) => { + writes.push(value); + }, + }, + ); + + const observed = document.observe(); + await observed.ready; + observed.current.body = "borrowed-local-edit"; + await new Promise(resolve => setTimeout(resolve, 20)); + assert.deepEqual(writes, []); + + current = { + noteId: "note:reactive-readonly", + body: "canonical-after-error", + }; + watcher?.(current); + + assert.equal(observed.current.body, "canonical-after-error"); + observed.dispose(); +}); + +test("CultMesh TS document handles read schema publications from single-file stores", async () => { + const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-")), "publication.ccmp"); + const node = await CultMesh.startNode(filePath, { + documents: [noteDocument], + }); + await node.put(noteDocument, "note:publication", { + noteId: "note:publication", + body: "published", + }); + await node.flush(); + + const document = CultMesh.documentFromPublication({ + kind: "single-file", + path: filePath, + }, noteDocument, "note:publication", { + documentId: "daemon:cultmesh.note.latest", + sourceId: "daemon:cultmesh.note.latest.v0", + pollMs: 50, + }); + const observed: string[] = []; + const unsubscribe = document.watch(value => { + observed.push(value.body); + }); + + try { + assert.equal((await document.latest()).body, "published"); + assert.equal(document.schema.type, "cultmesh.note"); + + const republisher = await CultMesh.startNode(filePath, { + documents: [noteDocument], + }); + await republisher.put(noteDocument, "note:publication", { + noteId: "note:publication", + body: "republished", + }); + await waitFor(() => observed.includes("republished"), "single-file document watch update"); + } finally { + unsubscribe(); + } + + assert.equal(document.documentId, "daemon:cultmesh.note.latest"); + assert.equal(document.routeHint.kind, "shared-memory"); + assert.deepEqual(document.sources.map(source => source.schemaId), [ + "cultmesh.note.v0", + ]); +}); + +test("CultMesh TS projects live provider publications as typed document handles", async () => { + let current: unknown = { + noteId: "note:live-provider", + body: "initial publication", + }; + let watcher: ((value: unknown) => void) | undefined; + const calls: string[] = []; + + const document = CultMesh.documentFromPublication( + { + kind: "live-publication", + endpoint: "provider-session:aetheria", + source: { + latest: async (schemaId, recordKey) => { + calls.push(`latest:${schemaId}:${recordKey}`); + return current; + }, + watch: (schemaId, recordKey, callback) => { + calls.push(`watch:${schemaId}:${recordKey}`); + watcher = callback; + return () => { + calls.push("unsubscribed"); + watcher = undefined; + }; + }, + }, + }, + noteDocument, + "note:live-provider", + { documentId: "aetheria.note.live" }, + ); + + assert.deepEqual(await document.latest(), current); + assert.equal(document.routeHint.kind, "network"); + assert.equal(document.routeHint.description, "provider-session:aetheria"); + + const observed: string[] = []; + const unsubscribe = document.watch(value => observed.push(value.body)); + current = { + noteId: "note:live-provider", + body: "ordered live upsert", + }; + watcher?.(current); + unsubscribe(); + + assert.deepEqual(observed, ["ordered live upsert"]); + assert.deepEqual(calls, [ + "latest:cultmesh.note.v0:note:live-provider", + "watch:cultmesh.note.v0:note:live-provider", + "unsubscribed", + ]); + assert.equal(watcher, undefined); +}); + +test("CultMesh TS validates live provider publications through typed document schemas", async () => { + const document = CultMesh.documentFromPublication( + { + kind: "live-publication", + source: { + latest: async () => ({ noteId: 42, body: "invalid" }), + watch: () => () => undefined, + }, + }, + noteDocument, + "note:invalid-live-provider", + ); + + await assert.rejects(document.latest(), /noteId/); +}); + +test("CultMesh TS binds publication document catalogs from source resolvers", async () => { + const root = await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-catalog-")); + const firstPath = join(root, "first.ccmp"); + const secondPath = join(root, "second.ccmp"); + const first = await CultMesh.startNode(firstPath, { + documents: [noteDocument], + }); + const second = await CultMesh.startNode(secondPath, { + documents: [noteDocument], + }); + await first.put(noteDocument, "note:first", { + noteId: "note:first", + body: "first source", + }); + await second.put(noteDocument, "note:second", { + noteId: "note:second", + body: "second source", + }); + await first.flush(); + await second.flush(); + + const bindings = [ + CultMesh.publicationDocument(noteDocument, "note:first", { + documentId: "daemon:first", + sourceId: "daemon:first.latest", + }), + CultMesh.publicationDocument(noteDocument, "note:second", { + documentId: "daemon:second", + sourceId: "daemon:second.latest", + }), + ]; + const paths = new Map([ + ["note:first", firstPath], + ["note:second", secondPath], + ]); + const catalog = CultMesh.documentsFromPublication( + binding => ({ + kind: "single-file", + path: paths.get(binding.recordKey) ?? firstPath, + }), + bindings, + { + routeHint: CultMesh.routeHint("shared-memory", "publication catalog"), + pollMs: 50, + }, + ); + + assert.deepEqual( + catalog.documents.map(document => document.documentId), + ["daemon:first", "daemon:second"], + ); + assert.equal( + (await catalog.document(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }).latest()).body, + "second source", + ); + assert.equal(catalog.document(noteDocument).routeHint.description, "publication catalog"); +}); + +test("CultMesh TS syncs configured publications into local node aliases", async () => { + const sourcePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-source-")), "source.ccmp"); + const targetPath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-target-")), "target.ccmp"); + const source = await CultMesh.startNode(sourcePath, { + documents: [noteDocument], + }); + await source.put(noteDocument, "note:published", { + noteId: "note:published", + body: "publication source hydrates local alias", + }); + await source.flush(); + const target = await CultMesh.startNode(targetPath, { + documents: [noteDocument], + }); + + const synced = await target.syncDocumentFromPublication( + { + kind: "single-file", + path: sourcePath, + }, + noteAliasDocument, + "note:published", + ); + const facadeSynced = await CultMesh.syncDocumentFromPublication( + target, + { + kind: "single-file", + path: sourcePath, + }, + noteAliasDocument, + "note:published", + ); + + assert.deepEqual(synced, { + noteId: "note:published", + body: "publication source hydrates local alias", + }); + assert.deepEqual(facadeSynced, synced); + assert.equal(target.getRequired(noteDocument, "note:published").body, "publication source hydrates local alias"); + assert.equal(target.getRequired(noteAliasDocument, "note:published").body, "publication source hydrates local alias"); + assert.equal((await target.document(noteAliasDocument, "note:published").observe({ watch: false }).ready).body, "publication source hydrates local alias"); +}); + +test("CultMesh TS syncs configured publication catalogs into local node aliases", async () => { + const firstPath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-catalog-first-")), "first.ccmp"); + const secondPath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-catalog-second-")), "second.ccmp"); + const targetPath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-catalog-target-")), "target.ccmp"); + const first = await CultMesh.startNode(firstPath, { + documents: [noteDocument], + }); + const second = await CultMesh.startNode(secondPath, { + documents: [noteDocument], + }); + await first.put(noteDocument, "note:first-sync", { + noteId: "note:first-sync", + body: "first synced publication", + }); + await second.put(noteDocument, "note:second-sync", { + noteId: "note:second-sync", + body: "second synced publication", + }); + await first.flush(); + await second.flush(); + const target = await CultMesh.startNode(targetPath, { + documents: [noteDocument], + }); + const bindings = [ + CultMesh.publicationDocument(noteDocument, "note:first-sync", { + documentId: "local:first", + }), + CultMesh.publicationDocument(noteAliasDocument, "note:second-sync", { + documentId: "local:second", + source: { + kind: "single-file", + path: secondPath, + }, + }), + ]; + + const catalog = await target.syncDocumentsFromPublication( + { + kind: "single-file", + path: firstPath, + }, + bindings, + { + routeHint: CultMesh.routeHint("shared-memory", "publication sync catalog"), + pollMs: 5, + }, + ); + const facadeCatalog = await CultMesh.syncDocumentsFromPublication( + target, + { + kind: "single-file", + path: firstPath, + }, + bindings, + ); + + assert.deepEqual( + catalog.documents.map(document => document.documentId), + ["local:first", "local:second"], + ); + assert.equal( + await catalog.document(noteDocument, { + parse: value => noteDocument.schema.parse(value), + }).latest().then(value => value.body), + "first synced publication", + ); + assert.equal( + await catalog.document(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }).latest().then(value => value.body), + "second synced publication", + ); + assert.equal(catalog.document(noteAliasDocument).routeHint.description, "publication sync catalog"); + assert.equal(target.getRequired(noteDocument, "note:second-sync").body, "second synced publication"); + assert.equal(target.getRequired(noteAliasDocument, "note:second-sync").body, "second synced publication"); + assert.equal( + await facadeCatalog.document(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }).latest().then(value => value.body), + "second synced publication", + ); +}); + +test("CultMesh TS document catalogs resolve semantic schema versions passed as schema ids", async () => { + const current = { + noteId: "note:semantic", + body: "semantic alias", + }; + const document = CultMesh.document( + "daemon:semantic-note", + { + schemaId: "sha256:runtime-generated-note", + schemaName: "cultmesh.note", + schemaVersion: "cultmesh.note.v1", + }, + async () => current, + { + routeHint: CultMesh.routeHint("shared-memory", "semantic alias catalog"), + }, + ); + const catalog = CultMesh.documents(document); + + const alias = catalog.document({ + schemaId: "cultmesh.note.v1", + }); + + assert.equal(alias.documentId, "daemon:semantic-note"); + assert.equal(alias.routeHint.description, "semantic alias catalog"); + assert.deepEqual(await alias.latest(), current); +}); + +test("CultMesh TS collection handles expose typed snapshots and reset watches", async () => { + const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-coll-")), "node.ccmp"); + const node = await CultMesh.startNode(filePath, { + documents: [noteDocument], + }); + await node.put(noteDocument, "note:a", { + noteId: "note:a", + body: "alpha", + }); + + const collection = node.collection(noteDocument, { pollMs: 5 }); + const bound = CultMesh.bindCollection( + CultMesh.verse("local", "rts-client").withRoute("shared-memory", "local cache"), + collection, + ); + const changes: string[] = []; + const unsubscribe = bound.watchChanges(change => changes.push(change.kind)); + + assert.deepEqual((await bound.latest()).map(note => note.body), ["alpha"]); + + await node.put(noteDocument, "note:b", { + noteId: "note:b", + body: "bravo", + }); + await waitFor(() => changes.length >= 2, "collection reset after update"); + unsubscribe(); + + assert.deepEqual( + (await collection.asSchemaAlias(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }).latest()).map(note => note.body).sort(), + ["alpha", "bravo"], + ); + const catalog = CultMesh.collections(collection); + assert.deepEqual( + (await catalog.latest(noteAliasDocument, "local", { + parse: value => noteAliasDocument.schema.parse(value), + })).map(note => note.body).sort(), + ["alpha", "bravo"], + ); + assert.equal( + catalog.collection({ schemaId: "cultmesh.note.v0" }).collectionId, + collection.collectionId, + ); + assert.equal(catalog.tryCollection({ schemaId: "cultmesh.missing.v1" }), undefined); + assert.ok(changes.every(kind => kind === "reset")); +}); + +test("CultMesh TS collection catalogs resolve same-schema alias watches", async () => { + const current = [{ + noteId: "note:catalog-watch", + body: "initial", + }]; + let watcher: ((change: { kind: "updated"; value: typeof current[number] }) => void) | undefined; + const collection = CultMesh.collection( + "daemon:catalog-watch", + { + schemaId: "sha256:runtime-generated-note", + schemaName: "cultmesh.note", + schemaVersion: "cultmesh.note.v1", + }, + async () => current, + { + routeHint: CultMesh.routeHint("shared-memory", "collection catalog"), + watchCollection: (_context, callback) => { + watcher = callback as typeof watcher; + return () => { + watcher = undefined; + }; + }, + }, + ); + const catalog = CultMesh.collections(collection); + const observed: string[] = []; + const semanticAlias = { schemaId: "cultmesh.note.v1" }; + const unsubscribe = catalog.watchChanges>(semanticAlias, change => { + if (change.value) { + observed.push(change.value.body); + } + }, { + parse: value => noteAliasDocument.schema.parse(value), + }); + + watcher?.({ + kind: "updated", + value: { + noteId: "note:catalog-watch", + body: "through-catalog-alias", + }, + }); + unsubscribe(); + + assert.deepEqual(observed, ["through-catalog-alias"]); + assert.equal(watcher, undefined); + assert.equal(catalog.collection(semanticAlias).routeHint.description, "collection catalog"); +}); + test("CultMesh TS local authority leases do not trust peer cards by contact alone", () => { const peers = CultMesh.createPeerCatalog(); const leases = CultMesh.createAuthorityLeaseCatalog(); @@ -145,6 +1284,1192 @@ test("CultMesh TS local Verse catalog exposes sorted views and watch ergonomics" ); }); +test("CultMesh TS exposes shared geometry query primitives", () => { + const viewport = CultMesh.rect(CultMesh.vec2(10, -5), CultMesh.vec2(-2, 7)); + assert.deepEqual(viewport, { + min: { x: -2, y: -5 }, + max: { x: 10, y: 7 }, + }); + + assert.deepEqual(CultMesh.viewportRequest(viewport, [3, 1]), { + minX: -2, + minY: -5, + maxX: 10, + maxY: 7, + controlledEntityIndices: [3, 1], + }); + + assert.deepEqual(CultMesh.rectFromBounds(4, 3, 2, 1), { + min: { x: 2, y: 1 }, + max: { x: 4, y: 3 }, + }); +}); + +test("CultMesh TS exposes typed operation primitives", async () => { + const operation = CultMesh.operation< + { actor: string; direction: { x: number; y: number } }, + { receipt: ReturnType; actor: string } + >("game.test.move", async (request, context) => { + assert.equal(context.runtimeId, "rts-client"); + assert.deepEqual(context.claims, [ + { role: "simulation-authority", shardId: "zone:0", leaseId: "lease:zone:0" }, + ]); + assert.deepEqual(context.routeHint, { + kind: "network", + description: "remote Verse node", + }); + assert.equal(context.idempotencyKey, "move-1"); + return { + receipt: CultMesh.operationReceipt("game.test.move", true, { + route: context.routeHint, + }), + actor: request.actor, + }; + }); + + const response = await operation.invoke( + { actor: "pawn:1", direction: CultMesh.vec2(1, 0) }, + CultMesh.operationContext("rts-client", { + claims: [ + CultMesh.authorityClaim("simulation-authority", { + shardId: "zone:0", + leaseId: "lease:zone:0", + }), + ], + routeHint: CultMesh.routeHint("network", "remote Verse node"), + idempotencyKey: "move-1", + }), + ); + + assert.equal(operation.operationId, "game.test.move"); + assert.equal(response.actor, "pawn:1"); + assert.deepEqual(CultMesh.describeOperationHandle(operation), { + operationId: "game.test.move", + }); + assert.deepEqual(response.receipt, { + operationId: "game.test.move", + accepted: true, + route: { + kind: "network", + description: "remote Verse node", + }, + diagnostic: undefined, + }); +}); + +test("CultMesh TS flattens and parses cross-runtime route records", () => { + const record = CultMesh.routeRecord( + CultMesh.routeHint("shared-memory", "co-located slab"), + ); + const restored = CultMesh.routeFromRecord(record); + const fromCsharp = CultMesh.routeFromRecord( + { + kind: "InProcess", + description: "", + }, + CultMesh.routeHint("network", "fallback route"), + ); + const fallback = CultMesh.routeFromRecord( + { + kind: "not-real", + description: "", + }, + CultMesh.routeHint("ipc", "fallback route"), + ); + + assert.deepEqual(record, { + kind: "shared-memory", + description: "co-located slab", + }); + assert.deepEqual(restored, { + kind: "shared-memory", + description: "co-located slab", + }); + assert.deepEqual(fromCsharp, { + kind: "in-process", + description: "fallback route", + }); + assert.deepEqual(fallback, { + kind: "ipc", + description: "fallback route", + }); +}); + +test("CultMesh TS exposes typed query primitives", async () => { + const watchedCounts: number[] = []; + const visibleObjects = CultMesh.query< + { viewport: ReturnType }, + { queryId: string; count: number } + >("game.test.visible_objects", async (parameters, context) => { + assert.equal(context.runtimeId, "browser-client"); + assert.deepEqual(context.routeHint, { + kind: "shared-memory", + description: "co-located daemon slab", + }); + assert.equal(parameters.viewport.minX, -10); + return { + queryId: "game.test.visible_objects", + count: 2, + }; + }, { + watchQuery: (parameters, context, callback) => { + assert.equal(context.runtimeId, "browser-client"); + assert.equal(parameters.viewport.maxX, 10); + callback({ queryId: "game.test.visible_objects", count: 3 }); + return () => watchedCounts.push(-1); + }, + }); + + const result = await visibleObjects.execute( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-10, -5, 10, 5)) }, + CultMesh.queryContext("browser-client", { + routeHint: CultMesh.routeHint("shared-memory", "co-located daemon slab"), + }), + ); + + assert.deepEqual(result, { + queryId: "game.test.visible_objects", + count: 2, + }); + + const diagnostic = CultMesh.describeQuerySurface(visibleObjects); + assert.equal(diagnostic.queryId, "game.test.visible_objects"); + assert.equal(diagnostic.routeHint.kind, "automatic"); + assert.deepEqual(diagnostic.sources, []); + assert.notEqual(diagnostic.sources, visibleObjects.sources); + + const unsubscribe = visibleObjects.watch( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-10, -5, 10, 5)) }, + "browser-client", + (value) => watchedCounts.push(value.count), + ); + unsubscribe(); + + assert.deepEqual(watchedCounts, [3, -1]); +}); + +test("CultMesh TS polling watcher turns snapshots into a query watch", async () => { + let value = 0; + const observed: number[] = []; + const query = CultMesh.query( + "game.test.counter", + async () => value, + { + watchQuery: CultMesh.pollingQueryWatcher(async () => value, { + intervalMs: 5, + equals: (left, right) => left === right, + }), + }, + ); + + const unsubscribe = query.watch(undefined, "browser-client", next => observed.push(next)); + await delay(15); + value = 1; + await delay(20); + value = 1; + await delay(12); + value = 2; + await delay(20); + unsubscribe(); + value = 3; + await delay(12); + + assert.deepEqual(observed, [0, 1, 2]); +}); + +test("CultMesh TS polling watcher can suppress the initial baseline", async () => { + let value = "baseline"; + const observed: string[] = []; + const watch = CultMesh.pollingQueryWatcher(async () => value, { + intervalMs: 5, + emitInitial: false, + }); + + const unsubscribe = watch(undefined, CultMesh.queryContext("browser-client"), next => observed.push(next)); + await delay(15); + value = "changed"; + await delay(15); + unsubscribe(); + + assert.deepEqual(observed, ["changed"]); +}); + +test("CultMesh TS exposes live feed primitives for reactive client views", async () => { + let frameId = 10; + const observed: number[] = []; + const feed = CultMesh.liveFeed< + { viewport: ReturnType }, + { frameId: number; route: string } + >( + "aetheria.rts.viewport.feed", + async (_parameters, context) => ({ + frameId, + route: context.routeHint.kind, + }), + { + sources: [ + CultMesh.projectionSource("daemon:aetheria.frame.latest.v1"), + CultMesh.projectionSource("daemon:aetheria.health.latest.v1"), + ], + routeHint: CultMesh.routeHint("shared-memory", "co-located RTS cache"), + watchFeed: CultMesh.pollingQueryWatcher(async (_parameters, context) => ({ + frameId, + route: context.routeHint.kind, + }), { + intervalMs: 5, + equals: (left, right) => left.frameId === right.frameId && left.route === right.route, + }), + }, + ); + + assert.equal(feed.feedId, "aetheria.rts.viewport.feed"); + assert.equal(feed.routeHint.kind, "shared-memory"); + assert.deepEqual(feed.sources.map(source => source.sourceId), [ + "daemon:aetheria.frame.latest.v1", + "daemon:aetheria.health.latest.v1", + ]); + + const diagnostic = CultMesh.describeLiveFeed(feed); + assert.equal(diagnostic.feedId, "aetheria.rts.viewport.feed"); + assert.equal(diagnostic.routeHint.description, "co-located RTS cache"); + assert.deepEqual(diagnostic.sources.map(source => source.sourceId), [ + "daemon:aetheria.frame.latest.v1", + "daemon:aetheria.health.latest.v1", + ]); + assert.notEqual(diagnostic.sources, feed.sources); + + const snapshot = await feed.snapshot( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-1, -1, 1, 1)) }, + "browser-client", + ); + + assert.deepEqual(snapshot, { + frameId: 10, + route: "shared-memory", + }); + + const unsubscribe = feed.watch( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-1, -1, 1, 1)) }, + "browser-client", + value => observed.push(value.frameId), + ); + await delay(15); + frameId = 11; + await delay(15); + unsubscribe(); + + assert.deepEqual(observed, [10, 11]); +}); + +test("CultMesh TS exposes projection recipes as shared query affordances", async () => { + const watchedRoutes: string[] = []; + const recipe = CultMesh.projectionRecipe< + { viewport: ReturnType }, + { projectionId: string; sourceCount: number; route: string } + >( + "aetheria.zone.objects.visible", + [ + CultMesh.projectionSource("daemon:aetheria.frame.latest.v1", { + schemaId: "gamecult.aetheria.daemon_frame.v1", + description: "latest daemon frame", + }), + CultMesh.projectionSource("daemon:aetheria.authority.policy.v1", { + schemaId: "gamecult.aetheria.authority_policy.v1", + }), + ], + async (_parameters, context) => ({ + projectionId: "aetheria.zone.objects.visible", + sourceCount: 2, + route: context.routeHint.kind, + }), + { + routeHint: CultMesh.routeHint("shared-memory", "co-located frame slab"), + watchProjection: (_parameters, context, callback) => { + callback({ + projectionId: "aetheria.zone.objects.visible", + sourceCount: 2, + route: context.routeHint.kind, + }); + return () => watchedRoutes.push("unsubscribed"); + }, + }, + ); + + assert.equal(recipe.projectionId, "aetheria.zone.objects.visible"); + assert.equal(recipe.routeHint.kind, "shared-memory"); + assert.deepEqual(recipe.sources.map((source) => source.sourceId), [ + "daemon:aetheria.frame.latest.v1", + "daemon:aetheria.authority.policy.v1", + ]); + + const recipeDiagnostic = CultMesh.describeProjectionRecipe(recipe); + assert.equal(recipeDiagnostic.projectionId, "aetheria.zone.objects.visible"); + assert.equal(recipeDiagnostic.routeHint.description, "co-located frame slab"); + assert.deepEqual(recipeDiagnostic.sources.map((source) => source.schemaId), [ + "gamecult.aetheria.daemon_frame.v1", + "gamecult.aetheria.authority_policy.v1", + ]); + assert.notEqual(recipeDiagnostic.sources, recipe.sources); + + const projected = await recipe.project( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-10, -5, 10, 5)) }, + CultMesh.queryContextFor("browser-starfire") + .route("wasm", "browser-local projection") + .build(), + ); + + assert.deepEqual(projected, { + projectionId: "aetheria.zone.objects.visible", + sourceCount: 2, + route: "wasm", + }); + + const query = recipe.asQuerySurface(); + assert.equal(query.routeHint.kind, "shared-memory"); + assert.deepEqual(query.sources.map((source) => source.schemaId), [ + "gamecult.aetheria.daemon_frame.v1", + "gamecult.aetheria.authority_policy.v1", + ]); + + const queried = await query.execute( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-1, -1, 1, 1)) }, + "unity-raven", + ); + + assert.equal(query.queryId, recipe.projectionId); + assert.equal(queried.route, "shared-memory"); + + const queryDiagnostic = CultMesh.describeQuerySurface(query); + assert.equal(queryDiagnostic.queryId, "aetheria.zone.objects.visible"); + assert.equal(queryDiagnostic.routeHint.kind, "shared-memory"); + assert.deepEqual(queryDiagnostic.sources.map((source) => source.sourceId), [ + "daemon:aetheria.frame.latest.v1", + "daemon:aetheria.authority.policy.v1", + ]); + assert.notEqual(queryDiagnostic.sources, query.sources); + + const unsubscribe = query.watch( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-1, -1, 1, 1)) }, + "unity-raven", + (value) => watchedRoutes.push(value.route), + ); + unsubscribe(); + + assert.deepEqual(watchedRoutes, ["shared-memory", "unsubscribed"]); +}); + +test("CultMesh TS describes typed surface catalogs for tools and generated bindings", () => { + const source = CultMesh.projectionSource("daemon:aetheria.frame.latest.v1", { + schemaId: "gamecult.aetheria.daemon_frame.v1", + }); + const routeHint = CultMesh.routeHint("shared-memory", "co-located frame slab"); + const query = CultMesh.query( + "aetheria.zone.objects.visible", + async () => "objects", + { + sources: [source], + routeHint, + }, + ); + const feed = CultMesh.liveFeed( + "aetheria.rts.viewport.feed", + async () => "frame", + { + sources: [source], + routeHint, + }, + ); + const operation = CultMesh.operation( + "aetheria.pilot.set_move_vector", + async () => "accepted", + ); + const pointer = CultMesh.statePointer( + "aetheria.selection.current", + async () => "entity:ship:1", + undefined, + { + sources: [source], + routeHint, + }, + ); + const document = CultMesh.document( + "daemon:aetheria.frame.latest.v1", + { schemaId: "gamecult.aetheria.daemon_frame.v1" }, + async () => ({ frameId: 1 }), + { + sources: [source], + routeHint, + }, + ); + const collection = CultMesh.collection( + "daemon:aetheria.contacts.v1", + { schemaId: "gamecult.aetheria.contact.v1" }, + async () => [], + { + sources: [source], + routeHint, + }, + ); + const nativeView = CultMesh.nativeSliceView( + "aetheria.zone.render", + "gamecult.aetheria.render_body.v1", + 128, + [CultMesh.nativeSliceColumn("position", "CultVec2", 8)], + { route: routeHint }, + ); + + const querySurface = CultMesh.describeSurface(query); + const feedSurface = CultMesh.describeSurface(feed); + const operationSurface = CultMesh.describeSurface(operation); + const pointerSurface = CultMesh.describeSurface(pointer); + const documentSurface = CultMesh.describeSurface(document); + const collectionSurface = CultMesh.describeSurface(collection); + const nativeViewSurface = CultMesh.describeSurface(nativeView); + const catalog = CultMesh.describeSurfaceCatalog("gamecult.aetheria.rts.surfaces.v1", [ + querySurface, + feedSurface, + operationSurface, + documentSurface, + collectionSurface, + pointerSurface, + nativeViewSurface, + ]); + + assert.equal(querySurface.kind, "query"); + assert.equal(feedSurface.kind, "live-feed"); + assert.equal(documentSurface.kind, "document"); + assert.equal(collectionSurface.kind, "collection"); + assert.equal(pointerSurface.kind, "state-pointer"); + assert.equal(pointerSurface.routeHint.kind, "shared-memory"); + assert.deepEqual(pointerSurface.sources.map(next => next.schemaId), [ + "gamecult.aetheria.daemon_frame.v1", + ]); + assert.equal(nativeViewSurface.kind, "native-slice-view"); + assert.equal(catalog.catalogId, "gamecult.aetheria.rts.surfaces.v1"); + assert.deepEqual(catalog.surfaces.map(surface => surface.surfaceId), [ + "aetheria.zone.objects.visible", + "aetheria.rts.viewport.feed", + "aetheria.pilot.set_move_vector", + "daemon:aetheria.frame.latest.v1", + "daemon:aetheria.contacts.v1", + "aetheria.selection.current", + "aetheria.zone.render", + ]); + assert.equal(catalog.surfaces[0].routeHint.kind, "shared-memory"); + assert.deepEqual(catalog.surfaces[0].sources.map(next => next.schemaId), [ + "gamecult.aetheria.daemon_frame.v1", + ]); + assert.notEqual(catalog.surfaces[0], querySurface); + assert.notEqual(catalog.surfaces[0].sources, querySurface.sources); + assert.equal(catalog.surfaces[6].routeHint.kind, "shared-memory"); + + assert.equal( + CultMesh.findSurface(catalog, "aetheria.selection.current")?.kind, + "state-pointer", + ); + assert.equal( + CultMesh.findSurface(catalog, "daemon:aetheria.frame.latest.v1")?.kind, + "document", + ); + assert.equal(CultMesh.findSurface(catalog, "missing"), undefined); + + const operations = CultMesh.surfacesByKind(catalog, "operation"); + assert.deepEqual(operations.map(surface => surface.surfaceId), [ + "aetheria.pilot.set_move_vector", + ]); + assert.notEqual(operations, catalog.surfaces); + assert.notEqual(operations[0], operationSurface); + + const index = CultMesh.surfaceCatalogIndex(catalog); + assert.equal(index.catalogId, "gamecult.aetheria.rts.surfaces.v1"); + assert.deepEqual(index.queries.map(surface => surface.surfaceId), [ + "aetheria.zone.objects.visible", + ]); + assert.deepEqual(index.liveFeeds.map(surface => surface.surfaceId), [ + "aetheria.rts.viewport.feed", + ]); + assert.deepEqual(index.operations.map(surface => surface.surfaceId), [ + "aetheria.pilot.set_move_vector", + ]); + assert.deepEqual(index.documents.map(surface => surface.surfaceId), [ + "daemon:aetheria.frame.latest.v1", + ]); + assert.deepEqual(index.collections.map(surface => surface.surfaceId), [ + "daemon:aetheria.contacts.v1", + ]); + assert.deepEqual(index.statePointers.map(surface => surface.surfaceId), [ + "aetheria.selection.current", + ]); + assert.deepEqual(index.nativeSliceViews.map(surface => surface.surfaceId), [ + "aetheria.zone.render", + ]); + assert.deepEqual(index.projectionRecipes, []); + assert.notEqual(index.operations, operations); + assert.notEqual(index.operations[0], operations[0]); +}); + +test("CultMesh TS exposes fluent context builders for typed handles", async () => { + const operationContext = CultMesh.operationContextFor("unity-client") + .claim("pilot-authority", { shardId: "zone:raven" }) + .route("shared-memory", "co-located Verse") + .idempotency("move:raven:1") + .build(); + + assert.deepEqual(operationContext, { + runtimeId: "unity-client", + claims: [{ role: "pilot-authority", shardId: "zone:raven", leaseId: undefined }], + routeHint: { kind: "shared-memory", description: "co-located Verse" }, + idempotencyKey: "move:raven:1", + }); + + const queryContext = CultMesh.queryContextFor("browser-client") + .route("wasm", "browser-local projection") + .build(); + + assert.deepEqual(queryContext, { + runtimeId: "browser-client", + routeHint: { kind: "wasm", description: "browser-local projection" }, + }); +}); + +test("CultMesh TS Verse context lets generated domain sugar use shared typed contexts", async () => { + const verse = await CultMesh.connectVerse("starbridge", "browser-starfire", { + routeHint: CultMesh.routeHint("network", "remote Verse peer"), + claims: [ + CultMesh.authorityClaim("commander-control", { + shardId: "zone:frontier", + leaseId: "lease:starfire", + }), + ], + }); + const queryVerse = verse.withRoute("shared-memory", "local projection slab"); + const commandVerse = verse.withRoute("network", "remote command route"); + + const aetheria = queryVerse.use((queryContext) => { + const moveOperation = commandVerse.bindOperation(CultMesh.operation< + { entityId: number; direction: { x: number; y: number } }, + ReturnType + >("aetheria.entity.pilot.move", async (request, operationContext) => { + assert.equal(operationContext.runtimeId, "browser-starfire"); + assert.equal(operationContext.claims[0]?.role, "commander-control"); + assert.equal(operationContext.idempotencyKey, "move:starfire:1"); + return CultMesh.operationReceipt("aetheria.entity.pilot.move", request.entityId === 7, { + route: operationContext.routeHint, + }); + })); + const objectsVisible = CultMesh.bindQuery(queryContext, CultMesh.query< + { viewport: ReturnType }, + { runtimeId: string; zoneId: string; route: string; minX: number } + >("aetheria.zone.objects.visible", async (parameters, context) => ({ + runtimeId: context.runtimeId, + zoneId: "zone:frontier", + route: context.routeHint.kind, + minX: parameters.viewport.minX, + }))); + + return { + entity: (entityId: number) => ({ + pilot: { + move: (direction: { x: number; y: number }, idempotencyKey: string) => + moveOperation.invoke({ entityId, direction }, { idempotencyKey }), + }, + }), + zone: (zoneId: string) => ({ + objects: { + visibleWithin: async (viewport: ReturnType) => ({ + ...await objectsVisible.execute({ viewport }), + zoneId, + }), + }, + }), + }; + }); + + const receipt = await aetheria + .entity(7) + .pilot + .move(CultMesh.vec2(1, 0), "move:starfire:1"); + const viewport = await aetheria + .zone("zone:frontier") + .objects + .visibleWithin(CultMesh.viewportRequest(CultMesh.rectFromBounds(-16, -8, 16, 8))); + + assert.equal(verse.verseId, "starbridge"); + assert.equal(verse.runtimeId, "browser-starfire"); + assert.deepEqual(verse.operationContext({ idempotencyKey: "move:starfire:2" }), { + runtimeId: "browser-starfire", + claims: [{ role: "commander-control", shardId: "zone:frontier", leaseId: "lease:starfire" }], + routeHint: { kind: "network", description: "remote Verse peer" }, + idempotencyKey: "move:starfire:2", + }); + assert.deepEqual(queryVerse.queryContext(), { + runtimeId: "browser-starfire", + routeHint: { kind: "shared-memory", description: "local projection slab" }, + }); + assert.equal(commandVerse.bindOperation(CultMesh.operation("noop", async (_request, context) => + CultMesh.operationReceipt("noop", true, { route: context.routeHint }))).operationId, "noop"); + assert.deepEqual(receipt, { + operationId: "aetheria.entity.pilot.move", + accepted: true, + route: { + kind: "network", + description: "remote command route", + }, + diagnostic: undefined, + }); + assert.deepEqual(viewport, { + runtimeId: "browser-starfire", + zoneId: "zone:frontier", + route: "shared-memory", + minX: -16, + }); +}); + +test("CultMesh TS exposes typed state pointers for UI and tools", async () => { + const updates: string[] = []; + let value = "initial"; + const pointer = CultMesh.statePointer( + "aetheria.selection.current", + async () => value, + (callback: (value: string) => void) => { + callback(value); + return () => updates.push("unsubscribed"); + }, + { + sources: [ + CultMesh.projectionSource("daemon:aetheria.selection.current.v1", { + schemaId: "gamecult.aetheria.selection.v1", + }), + ], + routeHint: CultMesh.routeHint("shared-memory", "co-located selection cache"), + }, + ); + + assert.equal(pointer.pointerId, "aetheria.selection.current"); + assert.equal(await pointer.resolve(), "initial"); + assert.deepEqual(CultMesh.describeStatePointer(pointer), { + pointerId: "aetheria.selection.current", + routeHint: { + kind: "shared-memory", + description: "co-located selection cache", + }, + sources: [ + { + sourceId: "daemon:aetheria.selection.current.v1", + schemaId: "gamecult.aetheria.selection.v1", + description: undefined, + }, + ], + }); + + value = "selected:ship:1"; + const unsubscribe = pointer.watch((next) => updates.push(next)); + unsubscribe(); + + assert.deepEqual(updates, ["selected:ship:1", "unsubscribed"]); + + const contextualUpdates: string[] = []; + const contextualPointer = CultMesh.statePointer( + "aetheria.daemon.frame.latest", + async (context) => `${context.runtimeId}:${context.routeHint.kind}`, + (context, callback) => { + callback(`${context.runtimeId}:frame:12:${context.routeHint.kind}`); + return () => contextualUpdates.push("contextual-unsubscribed"); + }, + { + sources: [ + CultMesh.projectionSource("daemon:aetheria.frame.latest.v1", { + schemaId: "gamecult.aetheria.daemon_frame.v1", + }), + ], + routeHint: CultMesh.routeHint("shared-memory", "co-located daemon frame"), + }, + ); + const verse = CultMesh.verse("aetheria.local", "bifrost-tool") + .withRoute("ipc", "tool bridge"); + const bound = CultMesh.bindStatePointer(verse, contextualPointer); + + assert.equal(await bound.resolve(), "bifrost-tool:ipc"); + const contextualUnsubscribe = bound.watch((next) => contextualUpdates.push(next)); + contextualUnsubscribe(); + + assert.equal(bound.pointerId, "aetheria.daemon.frame.latest"); + assert.deepEqual(bound.sources.map(source => source.schemaId), [ + "gamecult.aetheria.daemon_frame.v1", + ]); + assert.deepEqual(contextualUpdates, [ + "bifrost-tool:frame:12:ipc", + "contextual-unsubscribed", + ]); +}); + +test("CultMesh TS mutable state pointers read watch and replace through a Verse", async () => { + let stored = "frame:0"; + let watcher: ((value: string) => void) | undefined; + const contexts: string[] = []; + const pointer = CultMesh.mutableStatePointer( + "aetheria.daemon.frame.latest", + async (context) => { + contexts.push(`read:${context.runtimeId}:${context.routeHint.kind}`); + return stored; + }, + (context, callback) => { + contexts.push(`watch:${context.runtimeId}:${context.routeHint.kind}`); + watcher = (value) => callback(`${context.runtimeId}:${value}:${context.routeHint.kind}`); + return () => { + watcher = undefined; + }; + }, + async (context, value) => { + contexts.push(`replace:${context.runtimeId}:${context.routeHint.kind}`); + stored = value; + watcher?.(value); + }, + { + routeHint: CultMesh.routeHint("shared-memory", "co-located daemon frame"), + sources: [ + CultMesh.projectionSource("daemon:aetheria.frame.latest.v1", { + schemaId: "gamecult.aetheria.daemon_frame.v1", + }), + ], + }, + ); + + const verse = CultMesh.verse("aetheria.local", "unity-raven", { + routeHint: CultMesh.routeHint("ipc", "tool bridge"), + }); + const bound = verse.bindMutableStatePointer(pointer); + + let observed = ""; + const unsubscribe = bound.watch((value) => { + observed = value; + }); + assert.equal(await bound.read(), "frame:0"); + await bound.replace("frame:1"); + unsubscribe(); + + assert.equal(stored, "frame:1"); + assert.equal(observed, "unity-raven:frame:1:ipc"); + assert.deepEqual(contexts, [ + "watch:unity-raven:ipc", + "read:unity-raven:ipc", + "replace:unity-raven:ipc", + ]); + assert.deepEqual(CultMesh.describeStatePointer(pointer), { + pointerId: "aetheria.daemon.frame.latest", + routeHint: { + kind: "shared-memory", + description: "co-located daemon frame", + }, + sources: [ + { + sourceId: "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1", + description: undefined, + }, + ], + }); + assert.deepEqual(CultMesh.stateBinding("frame", pointer), { + targetProp: "frame", + pointerId: "aetheria.daemon.frame.latest", + sourceId: "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1", + routeHint: { + kind: "shared-memory", + description: "co-located daemon frame", + }, + }); +}); + +test("CultMesh TS describes UI state bindings from typed state pointers", async () => { + const pointer = CultMesh.statePointer( + "aetheria.selection.current", + async () => "entity:station:0", + undefined, + { + sources: [ + CultMesh.projectionSource("daemon:aetheria.selection.current.v1", { + schemaId: "gamecult.aetheria.selection.v1", + }), + ], + routeHint: CultMesh.routeHint("shared-memory", "co-located selection cache"), + }, + ); + + const binding = CultMesh.stateBinding("value", pointer); + const explicitBinding = CultMesh.stateBinding( + "label", + "aetheria.selection.label", + { + sourceId: "daemon:aetheria.selection.label.v1", + schemaId: "gamecult.aetheria.selection_label.v1", + routeHint: CultMesh.routeHint("ipc", "tool bridge"), + }, + ); + + assert.deepEqual(binding, { + targetProp: "value", + pointerId: "aetheria.selection.current", + sourceId: "daemon:aetheria.selection.current.v1", + schemaId: "gamecult.aetheria.selection.v1", + routeHint: { + kind: "shared-memory", + description: "co-located selection cache", + }, + }); + assert.deepEqual(explicitBinding, { + targetProp: "label", + pointerId: "aetheria.selection.label", + sourceId: "daemon:aetheria.selection.label.v1", + schemaId: "gamecult.aetheria.selection_label.v1", + routeHint: { + kind: "ipc", + description: "tool bridge", + }, + }); +}); + +test("CultMesh TS flattens and rehydrates UI state binding records", () => { + const binding = CultMesh.stateBinding( + "status", + "aetheria.current.status", + { + sourceId: "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1", + routeHint: CultMesh.routeHint("shared-memory", "co-located frame slab"), + }, + ); + + assert.deepEqual(CultMesh.stateBindingRecord(binding), { + targetProp: "status", + pointerId: "aetheria.current.status", + sourceId: "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1", + routeKind: "shared-memory", + routeDescription: "co-located frame slab", + bindingName: "", + documentId: "", + fieldPath: "", + valueKind: "", + accessMode: "", + authority: "", + writeCommand: "", + }); + + assert.deepEqual(CultMesh.stateBindingFromRecord({ + targetProp: "value", + pointerId: "aetheria.selection.current", + sourceId: "daemon:aetheria.selection.current.v1", + schemaId: "gamecult.aetheria.selection.v1", + routeKind: "InProcess", + routeDescription: "embedded tool host", + }), { + targetProp: "value", + pointerId: "aetheria.selection.current", + sourceId: "daemon:aetheria.selection.current.v1", + schemaId: "gamecult.aetheria.selection.v1", + routeHint: { + kind: "in-process", + description: "embedded tool host", + }, + }); +}); + +test("CultMesh TS preserves editable binding identity and authority through transport records", () => { + const binding = CultMesh.stateBinding("value", "ghostlight.session-zero.composer", { + sourceId: "session-zero:draft-7", + schemaId: "session_zero.v1", + routeHint: CultMesh.routeHint("network", "Ghostlight provider"), + bindingName: "composer.message", + documentId: "session-zero:draft-7", + fieldPath: "channels.private.messages.pending", + valueKind: "string", + accessMode: "local-draft", + authority: "SessionZeroKernel", + writeCommand: "edit.value", + }); + const record = CultMesh.stateBindingRecord(binding); + assert.equal(record.bindingName, "composer.message"); + assert.equal(record.fieldPath, "channels.private.messages.pending"); + assert.equal(record.accessMode, "local-draft"); + assert.deepEqual(CultMesh.stateBindingFromRecord(record), binding); +}); + +test("CultMesh TS describes UI command bindings from typed operations", async () => { + const operation = CultMesh.operation< + { actor: string }, + ReturnType + >("aetheria.entity.pilot.move", async (_request, context) => + CultMesh.operationReceipt("aetheria.entity.pilot.move", true, { + route: context.routeHint, + })); + + const binding = CultMesh.operationBinding(operation, { + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: CultMesh.routeHint("network", "remote Verse peer"), + }); + const explicitBinding = CultMesh.operationBinding("aetheria.surface.refresh", { + label: "Refresh", + }); + + assert.deepEqual(binding, { + operationId: "aetheria.entity.pilot.move", + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: { + kind: "network", + description: "remote Verse peer", + }, + }); + assert.deepEqual(explicitBinding, { + operationId: "aetheria.surface.refresh", + label: "Refresh", + schemaId: "", + routeHint: { + kind: "automatic", + }, + }); +}); + +test("CultMesh TS flattens and rehydrates UI operation binding records", () => { + const binding = CultMesh.operationBinding("aetheria.entity.pilot.move", { + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: CultMesh.routeHint("network", "remote Verse peer"), + }); + + assert.deepEqual(CultMesh.operationBindingRecord(binding), { + operationId: "aetheria.entity.pilot.move", + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeKind: "network", + routeDescription: "remote Verse peer", + }); + + assert.deepEqual(CultMesh.operationBindingFromRecord({ + operationId: "aetheria.surface.refresh", + label: "Refresh", + schemaId: "gamecult.aetheria.refresh.v1", + routeKind: "SharedMemory", + routeDescription: "co-located command boundary", + }), { + operationId: "aetheria.surface.refresh", + label: "Refresh", + schemaId: "gamecult.aetheria.refresh.v1", + routeHint: { + kind: "shared-memory", + description: "co-located command boundary", + }, + }); +}); + +test("CultMesh TS carries UI command invocations as typed operation descriptors", () => { + const binding = CultMesh.operationBinding("aetheria.entity.pilot.move", { + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: CultMesh.routeHint("ipc", "local Verse node"), + }); + + const invocation = CultMesh.operationInvocation(binding, { + idempotencyKey: "move:42", + }); + const explicitInvocation = CultMesh.operationInvocation( + "aetheria.surface.refresh", + { + routeHint: CultMesh.routeHint("network", "remote Verse peer"), + }, + ); + + assert.deepEqual(invocation, { + operationId: "aetheria.entity.pilot.move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: { + kind: "ipc", + description: "local Verse node", + }, + idempotencyKey: "move:42", + }); + assert.deepEqual(explicitInvocation, { + operationId: "aetheria.surface.refresh", + schemaId: "", + routeHint: { + kind: "network", + description: "remote Verse peer", + }, + idempotencyKey: undefined, + }); +}); + +test("CultMesh TS reads common operation payload scalar fields", () => { + const payload = CultMesh.operationPayload({ + value: "42.5", + tierIndex: 3, + enabled: "on", + name: "Starfire", + }); + const updated = payload.with("enabled", false); + + assert.equal(payload.getString("name"), "Starfire"); + assert.equal(payload.getString("missing", "fallback"), "fallback"); + assert.equal(payload.getInt("tierIndex", -1), 3); + assert.equal(payload.getInt("missing", -1), -1); + assert.equal(payload.getDouble("value", -1), 42.5); + assert.equal(payload.getBoolean("enabled"), true); + assert.equal(payload.getBoolean("missing", true), true); + assert.equal(updated.getBoolean("enabled", true), false); + assert.equal(updated.getString("name"), "Starfire"); + assert.deepEqual(payload.fields, { + value: "42.5", + tierIndex: "3", + enabled: "on", + name: "Starfire", + }); + assert.deepEqual(payload.toRecord(), payload.fields); +}); + +test("CultMesh TS flattens and rehydrates operation invocation records", () => { + const invocation = CultMesh.operationInvocation("aetheria.entity.pilot.move", { + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: CultMesh.routeHint("ipc", "local Verse node"), + idempotencyKey: "move:42", + }); + + const record = CultMesh.operationInvocationRecord(invocation); + const restored = CultMesh.operationInvocationFromRecord(record, { + fallbackOperationId: "fallback.operation", + fallbackSchemaId: "fallback.schema", + fallbackRouteHint: CultMesh.routeHint("network", "fallback route"), + }); + const csharpRecord = CultMesh.operationInvocationFromRecord( + { + operationId: "", + schemaId: "", + routeKind: "InProcess", + routeDescription: "", + idempotencyKey: "", + }, + { + fallbackOperationId: "fallback.operation", + fallbackSchemaId: "fallback.schema", + fallbackRouteHint: CultMesh.routeHint("network", "fallback route"), + fallbackIdempotencyKey: "fallback-key", + }, + ); + + assert.deepEqual(record, { + operationId: "aetheria.entity.pilot.move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeKind: "ipc", + routeDescription: "local Verse node", + idempotencyKey: "move:42", + }); + assert.deepEqual(restored, invocation); + assert.deepEqual(csharpRecord, { + operationId: "fallback.operation", + schemaId: "fallback.schema", + routeHint: { + kind: "in-process", + description: "fallback route", + }, + idempotencyKey: "fallback-key", + }); +}); + +test("CultMesh TS composes named state ref resolvers for surfaces", () => { + const daemon = CultMesh.stateRefResolver( + "aetheria.daemon.refs", + (stateRef: string, context) => + stateRef === "aetheria.daemon/frame/id" + ? `${context.runtimeId}:${context.routeHint.kind}:42` + : "", + { + sources: [ + CultMesh.projectionSource("daemon:aetheria.frame.latest.v1", { + schemaId: "gamecult.aetheria.daemon_frame.v1", + }), + ], + routeHint: CultMesh.routeHint("in-process", "embedded renderer"), + }, + ); + const itemStats = CultMesh.stateRefResolver( + "aetheria.item_stats.refs", + (stateRef: string) => + stateRef === "aetheria.item_stats/laser/damage" ? "12.5" : "", + ); + const resolver = daemon.or(itemStats); + + assert.equal( + resolver.resolve( + "aetheria.daemon/frame/id", + CultMesh.queryContext("unity-raven", { + routeHint: CultMesh.routeHint("network", "remote peer"), + }), + ), + "unity-raven:network:42", + ); + assert.equal(resolver.resolve("aetheria.item_stats/laser/damage"), "12.5"); + assert.deepEqual(resolver.tryResolve("missing"), { + resolved: false, + value: "", + }); + assert.equal(resolver.asFunction()("aetheria.item_stats/laser/damage"), "12.5"); + + assert.deepEqual(CultMesh.describeStateRefResolver(resolver), { + resolverId: "aetheria.daemon.refs|aetheria.item_stats.refs", + routeHint: { + kind: "in-process", + description: "embedded renderer", + }, + sources: [ + { + sourceId: "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1", + description: undefined, + }, + ], + }); +}); + +test("CultMesh TS exposes native slice descriptors", () => { + const view = CultMesh.nativeSliceView( + "aetheria.zone.render", + "gamecult.aetheria.zone_render.v1", + 12, + [ + CultMesh.nativeSliceColumn("position", "CultVec3", 12), + CultMesh.nativeSliceColumn("rotationRadians", "float32", 4), + CultMesh.nativeSliceColumn("renderGroupId", "uint32", 4), + ], + { + route: CultMesh.routeHint("shared-memory", "CultCache slab"), + nativeHandle: "aetheria-zone-render-001", + }, + ); + + assert.equal(CultMesh.denseRowStrideBytes(view), 20); + assert.deepEqual(CultMesh.findNativeSliceColumn(view, "position"), { + name: "position", + valueType: "CultVec3", + elementSizeBytes: 12, + }); + assert.equal(CultMesh.findNativeSliceColumn(view, "missing"), undefined); + + const diagnostic = CultMesh.describeNativeSliceView(view); + assert.equal(diagnostic.viewId, "aetheria.zone.render"); + assert.equal(diagnostic.schemaId, "gamecult.aetheria.zone_render.v1"); + assert.equal(diagnostic.rowCount, 12); + assert.equal(diagnostic.route.kind, "shared-memory"); + assert.equal(diagnostic.nativeHandle, "aetheria-zone-render-001"); + assert.equal(diagnostic.denseRowStrideBytes, 20); + assert.deepEqual(diagnostic.columns.map(column => column.name), [ + "position", + "rotationRadians", + "renderGroupId", + ]); + assert.notEqual(diagnostic.columns, view.columns); +}); + test("CultMesh TS branded facade exposes schema and shard catalogs", () => { const schemaCatalog = CultMesh.createSchemaCatalog(); const schemaUpdates: string[] = []; @@ -537,6 +2862,284 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async () } }); +test("CultMesh TS resolves a CultMesh URI through its authority bootstrap", () => { + assert.deepEqual( + CultMesh.resolveRudpEndpoint("cultmesh://odin/rendezvous/provider-catalog", { + CULTMESH_URI_ODIN_RUDP: "127.0.0.1:17871", + }), + { host: "127.0.0.1", port: 17871, uri: "rudp://127.0.0.1:17871" }, + ); + assert.throws( + () => CultMesh.resolveRudpEndpoint("cultmesh://missing/rendezvous/provider-catalog", {}), + /did not resolve/, + ); +}); + +test("CultMesh TS returns a correlated provider receipt after a RUDP document mutation", async () => { + const connectionId = 0x10203050; + const commandBinding = defineCultNetDocumentBinding({ definition: noteDocument }); + const receiptBinding = defineCultNetDocumentBinding({ definition: noteReceiptDocument }); + const server = CultMesh.createRudpDocumentServer("cultmesh-ts-receipt-server", connectionId, { + documents: new CultNetDocumentRegistry([commandBinding, receiptBinding]), bindHost: "127.0.0.1", bindPort: 0, + resendDelayMs: 25, resendPollMs: 5, + onDocumentPutRaw: async (document) => ({ + binding: receiptBinding, recordKey: document.recordKey, + value: { commandId: document.recordKey, state: "applied", body: (document.payload as { body: string }).body }, + }), + }); + try { + await server.start(); + const receipt = await CultMesh.publishRudpDocumentAndWaitForReceipt( + "cultmesh-ts-receipt-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, + commandBinding, "command:note", { noteId: "command:note", body: "provider applied this" }, receiptBinding, + { messageId: "command:note", receiptTimeoutMs: 1_000, resendPollMs: 5 }, + ); + assert.deepEqual(receipt, { commandId: "command:note", state: "applied", body: "provider applied this" }); + } finally { server.close(); } +}); + +test("CultMesh TS reads remote RUDP snapshots through document handles", async () => { + const connectionId = 0x10203048; + const node = await CultMesh.startNode( + join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-snapshot-")), "node.ccmp"), + { + documents: [noteDocument], + }, + ); + await node.put(noteDocument, "note:remote", { + noteId: "note:remote", + body: "remote handles feel local", + }); + + const server = CultMesh.createRudpDocumentServer( + "cultmesh-ts-rudp-snapshot-server", + connectionId, + { + documents: new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: noteDocument }), + ]), + getCache: () => node.cache, + bindHost: "127.0.0.1", + bindPort: 0, + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + }, + ); + + let peer: CultNetPeer | undefined; + try { + await server.start(); + peer = await CultMesh.createRudpPeer( + "cultmesh-ts-rudp-snapshot-client", + connectionId, + `rudp://127.0.0.1:${server.bind.port}`, + { + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + connectTimeoutMs: 1_000, + }, + ); + + const document = CultMesh.documentFromPublication( + { + kind: "peer-snapshot", + peer, + endpoint: `rudp://127.0.0.1:${server.bind.port}`, + }, + noteDocument, + "note:remote", + { + documentId: "note:remote", + timeoutMs: 1_000, + }, + ); + + const latest = await document.latest(); + assert.equal(latest.body, "remote handles feel local"); + assert.deepEqual(latest, { + noteId: "note:remote", + body: "remote handles feel local", + }); + assert.equal(document.schema.type, "cultmesh.note"); + + const alias = CultMesh.documentFromPeerSnapshot( + peer, + "cultmesh.note.alias.v1", + "note:remote", + { + documentId: "note:remote.alias", + timeoutMs: 1_000, + }, + ); + + assert.deepEqual(await alias.latest(), { + noteId: "note:remote", + body: "remote handles feel local", + }); + } finally { + peer?.close(); + server.close(); + } +}); + +test("CultMesh TS syncs remote RUDP snapshots into a local node with same-schema aliases", async () => { + const connectionId = 0x1020304a; + const source = await CultMesh.startNode( + join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-sync-source-")), "node.ccmp"), + { + documents: [noteDocument], + }, + ); + await source.put(noteDocument, "note:sync-remote", { + noteId: "note:sync-remote", + body: "synced once, read as local", + }); + const target = await CultMesh.startNode( + join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-sync-target-")), "node.ccmp"), + { + documents: [noteDocument], + }, + ); + + const server = CultMesh.createRudpDocumentServer( + "cultmesh-ts-rudp-sync-server", + connectionId, + { + documents: new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: noteDocument }), + ]), + getCache: () => source.cache, + bindHost: "127.0.0.1", + bindPort: 0, + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + }, + ); + + let peer: CultNetPeer | undefined; + try { + await server.start(); + peer = await CultMesh.createRudpPeer( + "cultmesh-ts-rudp-sync-client", + connectionId, + `rudp://127.0.0.1:${server.bind.port}`, + { + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + connectTimeoutMs: 1_000, + }, + ); + + const synced = await target.syncDocumentFromPeerSnapshot( + peer, + noteAliasDocument, + "note:sync-remote", + { + timeoutMs: 1_000, + }, + ); + const facadeSynced = await CultMesh.syncDocumentFromPeerSnapshot( + target, + peer, + noteAliasDocument, + "note:sync-remote", + { + timeoutMs: 1_000, + }, + ); + + assert.deepEqual(synced, { + noteId: "note:sync-remote", + body: "synced once, read as local", + }); + assert.deepEqual(facadeSynced, synced); + assert.equal(target.getRequired(noteDocument, "note:sync-remote").body, "synced once, read as local"); + assert.equal(target.getRequired(noteAliasDocument, "note:sync-remote").body, "synced once, read as local"); + assert.equal((await target.document(noteAliasDocument, "note:sync-remote").latest()).body, "synced once, read as local"); + } finally { + peer?.close(); + server.close(); + } +}); + +test("CultMesh TS peer snapshot handles choose compatible foreign schema payloads", async () => { + const connectionId = 0x10203049; + const node = await CultMesh.startNode( + join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-foreign-snapshot-")), "node.ccmp"), + { + documents: [incompatibleNoteDocument, foreignNoteDocument], + }, + ); + await node.put(incompatibleNoteDocument, "note:foreign", { + nope: "not the requested shape", + }); + await node.put(foreignNoteDocument, "note:foreign", { + noteId: "note:foreign", + body: "foreign schema id, local shape", + }); + + const server = CultMesh.createRudpDocumentServer( + "cultmesh-ts-rudp-foreign-snapshot-server", + connectionId, + { + documents: new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: incompatibleNoteDocument }), + defineCultNetDocumentBinding({ definition: foreignNoteDocument }), + ]), + getCache: () => node.cache, + bindHost: "127.0.0.1", + bindPort: 0, + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + }, + ); + + let peer: CultNetPeer | undefined; + try { + await server.start(); + peer = await CultMesh.createRudpPeer( + "cultmesh-ts-rudp-foreign-snapshot-client", + connectionId, + `rudp://127.0.0.1:${server.bind.port}`, + { + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + connectTimeoutMs: 1_000, + }, + ); + + const alias = CultMesh.documentFromPeerSnapshot( + peer, + noteAliasDocument, + "note:foreign", + { + documentId: "note:foreign.alias", + timeoutMs: 1_000, + }, + ); + + assert.deepEqual(await alias.latest(), { + noteId: "note:foreign", + body: "foreign schema id, local shape", + }); + } finally { + peer?.close(); + server.close(); + } +}); + test("CultMesh TS negotiates streaming frame body transports explicitly", () => { const streams = CultMesh.createStreamCatalog(); const streamUpdates: string[] = []; @@ -615,3 +3218,7 @@ test("CultMesh TS negotiates streaming frame body transports explicitly", () => assert.deepEqual(streamUpdates, ["mimir:kiyo-pro"]); assert.deepEqual(frameUpdates, [42n]); }); + +function delay(milliseconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} diff --git a/packages/cultmesh-ts/test/provider-rudp-transport.test.ts b/packages/cultmesh-ts/test/provider-rudp-transport.test.ts new file mode 100644 index 00000000..5b44c84b --- /dev/null +++ b/packages/cultmesh-ts/test/provider-rudp-transport.test.ts @@ -0,0 +1,334 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createSocket } from "node:dgram"; +import { decode } from "@msgpack/msgpack"; +import { + CultNetDocumentRegistry, + CultNetRudpSession, + decodeRudpPacket, + encodeRudpPacket, + type CultNetOperationRequestMessage, +} from "cultnet-ts"; + +import { + CultMesh, + CultMeshProviderRudpTransport, + CultMeshProviderSessionBroker, + CULTMESH_PROVIDER_SESSION_SERVICE_ID, + cultMeshProviderSessionOperations, + cultMeshProviderSessionSchemas, + decodeProviderConnectEvidence, + encodeProviderConnectEvidence, + encodeProviderSessionPayload, + type CultMeshProviderCommand, + type CultMeshProviderIdentity, + type CultMeshProviderPublication, +} from "../src"; + +const identity: CultMeshProviderIdentity = { + providerId: "voidbot.swarm", + serviceInstanceId: "voidbot-worker-7", + endpointId: "voidbot-worker-7.rudp", + verseId: "voidbot.local", +}; + +test("RUDP provider transport requires broker acceptance for the full lifecycle", async () => { + const accepted = new Map(); + const acceptedProvenance = new Map(); + const deleted: string[] = []; + const receipts: string[] = []; + let leaseSequence = 0; + const broker = new CultMeshProviderSessionBroker({ + runtimeId: "odin-test", + createLeaseId: () => `lease-${++leaseSequence}`, + authorizeRegistration: (_identity, session) => + session.connectPayload !== undefined + && decodeProviderConnectEvidence(session.connectPayload).sessionToken === "signed-provider-token", + acceptPublication: (_identity, publicationId, document) => { + accepted.set(publicationId, decode(document.payload)); + acceptedProvenance.set(publicationId, { + runtime: document.sourceRuntimeId, + agent: document.sourceAgentId, + }); + }, + deletePublications: (_identity, publications) => { + for (const { publicationId } of publications) { + deleted.push(publicationId); + accepted.delete(publicationId); + } + }, + acceptReceipt: (_identity, receipt) => { + receipts.push(receipt.receiptId); + }, + }); + const server = CultMesh.createRudpDocumentServer("odin-test", 0x43554c54, { + bindPort: 0, + documents: new CultNetDocumentRegistry(), + onOperationRequest: (request, session) => broker.handle(request, session), + onSessionClosed: session => broker.sessionClosed(session), + }); + await server.start(); + + const unauthorizedConnection = await new CultMeshProviderRudpTransport({ + endpoint: `rudp://127.0.0.1:${server.bind.port}`, + runtimeId: "unauthorized-provider", + connectionId: 0x43554c54, + sessionToken: "not-authority", + }).connect(identity, new AbortController().signal); + try { + await assert.rejects( + unauthorizedConnection.register( + { identity, requestedLeaseDurationMs: 10_000 }, + new AbortController().signal, + ), + /denied|not authorized/i, + ); + assert.equal( + broker.activeLeaseCount, + 0, + "a successful RUDP handshake does not grant provider authority", + ); + } finally { + unauthorizedConnection.close(); + } + + const providerSocket = createSocket("udp4"); + const rawSend = providerSocket.send.bind(providerSocket); + let originalConnectWire: Buffer | undefined; + providerSocket.send = ((message: Uint8Array, ...args: unknown[]) => { + if (decodeRudpPacket(message).packetType === "connect" && !originalConnectWire) { + originalConnectWire = Buffer.from(message); + } + return (rawSend as (...parameters: unknown[]) => boolean)(message, ...args); + }) as typeof providerSocket.send; + const transport = new CultMeshProviderRudpTransport({ + endpoint: `rudp://127.0.0.1:${server.bind.port}`, + runtimeId: identity.serviceInstanceId, + connectionId: 0x43554c54, + sessionToken: "signed-provider-token", + socketFactory: () => providerSocket, + }); + const connection = await transport.connect(identity, new AbortController().signal); + const secondIdentity: CultMeshProviderIdentity = { + providerId: "aetheria", + serviceInstanceId: "aetheria-daemon-2", + endpointId: "aetheria-daemon-2.rudp", + verseId: "aetheria.local", + }; + const secondConnection = await new CultMeshProviderRudpTransport({ + endpoint: `rudp://127.0.0.1:${server.bind.port}`, + runtimeId: secondIdentity.serviceInstanceId, + connectionId: 0x43554c54, + sessionToken: "signed-provider-token", + }).connect(secondIdentity, new AbortController().signal); + try { + broker.enqueueCommand({ + commandId: "refresh-1", + commandKind: "voidbot.refresh", + providerId: identity.providerId, + serviceInstanceId: identity.serviceInstanceId, + payload: { force: true }, + }); + const lease = await connection.register({ identity, requestedLeaseDurationMs: 10_000 }, new AbortController().signal); + assert.equal(lease.leaseId, "lease-1"); + assert.ok(originalConnectWire); + await new Promise((resolve, reject) => { + rawSend(originalConnectWire!, server.bind.port, "127.0.0.1", error => error ? reject(error) : resolve()); + }); + await new Promise(resolve => setTimeout(resolve, 25)); + assert.equal(broker.activeLeaseCount, 1, "a duplicate Connect cannot revoke the live provider lease"); + const secondLease = await secondConnection.register({ + identity: secondIdentity, + requestedLeaseDurationMs: 10_000, + }, new AbortController().signal); + assert.equal(secondLease.leaseId, "lease-2"); + assert.equal(broker.activeLeaseCount, 2, "the broker demultiplexes simultaneous physical provider sessions"); + + const publication: CultMeshProviderPublication = { + publicationId: "voidbot.surface", + documentType: "gamecult.eve.surface_state", + schemaId: "gamecult.eve.surface_state.v1", + recordKey: "voidbot.swarm", + value: { generation: 3 }, + }; + await connection.publish(publication, lease); + assert.deepEqual(accepted.get(publication.publicationId), publication.value); + assert.deepEqual(acceptedProvenance.get(publication.publicationId), { + runtime: identity.serviceInstanceId, + agent: identity.providerId, + }, "the broker derives provenance from the fenced identity"); + const secondPublication: CultMeshProviderPublication = { + publicationId: "aetheria.surface", + documentType: "gamecult.eve.surface_state", + schemaId: "gamecult.eve.surface_state.v1", + recordKey: "aetheria.pilot", + value: { generation: 8 }, + }; + await secondConnection.publish(secondPublication, secondLease); + await assert.rejects(secondConnection.publish({ + ...secondPublication, + publicationId: "aetheria.stolen-tuple", + recordKey: publication.recordKey, + }, secondLease), /already owned/); + await assert.rejects( + secondConnection.publish({ ...secondPublication, value: { generation: 9 } }, lease), + /belongs to another physical session/, + ); + + const commandArrived = deferred(); + connection.watchCommands(command => commandArrived.resolve(command)); + const command = await commandArrived.promise; + assert.equal(command.commandId, "refresh-1"); + await connection.publishReceipt({ + receiptId: "receipt-refresh-1", + commandId: command.commandId, + commandKind: command.commandKind, + providerId: identity.providerId, + serviceInstanceId: identity.serviceInstanceId, + state: "applied", + completedAt: new Date(), + result: { refreshed: true }, + }, lease); + assert.deepEqual(receipts, ["receipt-refresh-1"]); + + const renewed = await connection.renew(lease); + assert.equal(renewed.leaseId, "lease-3"); + await assert.rejects(connection.withdrawPublication(publication.publicationId, lease), /expired|fenced/); + await connection.withdraw({ identity, leaseId: renewed.leaseId, publicationIds: [publication.publicationId] }); + await secondConnection.withdraw({ + identity: secondIdentity, + leaseId: secondLease.leaseId, + publicationIds: [secondPublication.publicationId], + }); + assert.deepEqual(deleted, [publication.publicationId, secondPublication.publicationId]); + assert.equal(accepted.size, 0); + assert.equal(broker.activeLeaseCount, 0); + } finally { + connection.close(); + secondConnection.close(); + server.close(); + broker.close(); + } +}); + +test("closed physical sessions cannot finish delayed registration", async () => { + const authorization = deferred(); + const broker = new CultMeshProviderSessionBroker({ + runtimeId: "odin-test", + authorizeRegistration: () => authorization.promise, + acceptPublication: () => undefined, + deletePublications: () => undefined, + acceptReceipt: () => undefined, + }); + const session = { + sessionId: "closed-generation", + remote: { address: "127.0.0.1", family: "IPv4", port: 40000 }, + connectPayload: new Uint8Array(), + send: () => undefined, + }; + const request: CultNetOperationRequestMessage = { + schemaVersion: "cultnet.operation_request.v0", + messageId: "late-register", + serviceId: CULTMESH_PROVIDER_SESSION_SERVICE_ID, + operation: cultMeshProviderSessionOperations.register, + payloadSchema: cultMeshProviderSessionSchemas.registration, + payloadEncoding: "messagepack-base64", + payload: encodeProviderSessionPayload({ ...identity, requestedLeaseDurationMs: 10_000 }), + }; + try { + const pending = broker.handle(request, session); + await new Promise(resolve => setTimeout(resolve, 0)); + broker.sessionClosed(session); + authorization.resolve(true); + const response = await pending; + assert.equal(response.status, "denied"); + assert.equal(broker.activeLeaseCount, 0); + } finally { + broker.close(); + } +}); + +test("a replaced RUDP generation drops operations queued behind an active handler", async () => { + const connectionId = 0x43554c54; + const handlerGate = deferred(); + let handlerCalls = 0; + let closedSessions = 0; + const server = CultMesh.createRudpDocumentServer("odin-queue-test", connectionId, { + bindPort: 0, + documents: new CultNetDocumentRegistry(), + onOperationRequest: async request => { + handlerCalls += 1; + if (handlerCalls === 1) await handlerGate.promise; + return { + schemaVersion: "cultnet.operation_response.v0", + messageId: request.messageId, + serviceId: request.serviceId, + operation: request.operation, + status: "denied", + payloadSchema: cultMeshProviderSessionSchemas.mutationAcceptance, + payloadEncoding: "messagepack-base64", + payload: encodeProviderSessionPayload({}), + }; + }, + onSessionClosed: () => { closedSessions += 1; }, + onError: () => undefined, + }); + await server.start(); + + const socket = createSocket("udp4"); + const rawSend = socket.send.bind(socket); + const connection = await new CultMeshProviderRudpTransport({ + endpoint: `rudp://127.0.0.1:${server.bind.port}`, + runtimeId: identity.serviceInstanceId, + connectionId, + sessionToken: "signed-provider-token", + operationTimeoutMs: 100, + socketFactory: () => socket, + }).connect(identity, new AbortController().signal); + try { + const first = connection.register( + { identity, requestedLeaseDurationMs: 10_000 }, + new AbortController().signal, + ); + const second = connection.register( + { identity, requestedLeaseDurationMs: 10_000 }, + new AbortController().signal, + ); + await waitFor(() => handlerCalls === 1, "first queued provider operation"); + + const replacement = new CultNetRudpSession({ connectionId }); + const replacementWire = encodeRudpPacket(replacement.createConnect( + Date.now(), + Buffer.from(encodeProviderConnectEvidence({ + clientSessionId: "replacement-generation", + sessionToken: "signed-provider-token", + })), + )); + await new Promise((resolve, reject) => { + rawSend(replacementWire, server.bind.port, "127.0.0.1", error => error ? reject(error) : resolve()); + }); + await waitFor(() => closedSessions === 1, "old RUDP generation closure"); + handlerGate.resolve(); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.equal(handlerCalls, 1, "transport-queued work from the closed generation must be dropped"); + + connection.close(); + await Promise.allSettled([first, second]); + } finally { + connection.close(); + server.close(); + } +}); + +function deferred(): { promise: Promise; resolve(value: T): void } { + let resolve!: (value: T) => void; + return { promise: new Promise(done => resolve = done), resolve }; +} + +async function waitFor(predicate: () => boolean, description: string): Promise { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > 1_000) throw new Error(`Timed out waiting for ${description}.`); + await new Promise(resolve => setTimeout(resolve, 5)); + } +} diff --git a/packages/cultmesh-ts/test/provider-session-wire.test.ts b/packages/cultmesh-ts/test/provider-session-wire.test.ts new file mode 100644 index 00000000..ac607163 --- /dev/null +++ b/packages/cultmesh-ts/test/provider-session-wire.test.ts @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + decodeProviderConnectEvidence, + encodeProviderConnectEvidence, + encodeProviderSessionPayload, +} from "../src/provider-session-wire"; + +const REGISTRATION_GOLDEN = "hqpwcm92aWRlcklkqGFldGhlcmlhsXNlcnZpY2VJbnN0YW5jZUlkq2FldGhlcmlhLTQyqmVuZHBvaW50SWSvYWV0aGVyaWEtcHVibGljp3ZlcnNlSWSmcHVibGljuHJlcXVlc3RlZExlYXNlRHVyYXRpb25Nc811MLBhdXRob3JpdHlMZWFzZUlkq2F1dGhvcml0eS03"; +const CONNECT_EVIDENCE_GOLDEN = "gq9jbGllbnRTZXNzaW9uSWSyYWV0aGVyaWEtY2xpZW50LTQyrHNlc3Npb25Ub2tlbrJvZGluLXNlc3Npb24tdG9rZW4="; +const TOKENLESS_CONNECT_EVIDENCE_GOLDEN = "gq9jbGllbnRTZXNzaW9uSWSyYWV0aGVyaWEtY2xpZW50LTQyrHNlc3Npb25Ub2tlbsA="; + +test("provider registration matches the shared C# MessagePack golden payload", () => { + assert.equal(encodeProviderSessionPayload({ + providerId: "aetheria", + serviceInstanceId: "aetheria-42", + endpointId: "aetheria-public", + verseId: "public", + requestedLeaseDurationMs: 30_000, + authorityLeaseId: "authority-7", + }), REGISTRATION_GOLDEN); +}); + +test("provider Connect evidence keeps transport generation separate from authority", () => { + const evidence = { + clientSessionId: "aetheria-client-42", + sessionToken: "odin-session-token", + }; + const encoded = encodeProviderConnectEvidence(evidence); + assert.equal(Buffer.from(encoded).toString("base64"), CONNECT_EVIDENCE_GOLDEN); + assert.deepEqual( + decodeProviderConnectEvidence(encoded), + evidence, + ); + assert.equal( + Buffer.from(encodeProviderConnectEvidence({ + clientSessionId: "aetheria-client-42", + sessionToken: null, + })).toString("base64"), + TOKENLESS_CONNECT_EVIDENCE_GOLDEN, + ); +}); diff --git a/packages/cultmesh-ts/test/provider-session.test.ts b/packages/cultmesh-ts/test/provider-session.test.ts new file mode 100644 index 00000000..8e1c9d50 --- /dev/null +++ b/packages/cultmesh-ts/test/provider-session.test.ts @@ -0,0 +1,791 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + CultMeshMemoryProviderReceiptStore, + CultMeshProviderSession, + type CultMeshProviderCommand, + type CultMeshProviderCommandListener, + type CultMeshProviderCommandReceipt, + type CultMeshProviderConnection, + type CultMeshProviderIdentity, + type CultMeshProviderLease, + type CultMeshProviderPublication, + type CultMeshProviderRegistration, + type CultMeshProviderScheduledTask, + type CultMeshProviderScheduler, + type CultMeshProviderTransport, + type CultMeshProviderWithdrawal, + type CultMeshProviderUnsubscribe, +} from "../src/provider-session"; + +const identity: CultMeshProviderIdentity = { + providerId: "voidbot.swarm", + serviceInstanceId: "voidbot-worker-7", + endpointId: "odin:voidbot-worker-7", + verseId: "voidbot.local", +}; + +const surface: CultMeshProviderPublication = { + publicationId: "voidbot.swarm.surface", + documentType: "gamecult.eve.surface_state", + schemaId: "gamecult.eve.surface_state.v1", + recordKey: "voidbot.swarm", + value: { version: 1 }, +}; + +const advertisement: CultMeshProviderPublication = { + publicationId: "voidbot.swarm.advertisement", + documentType: "gamecult.eve.provider_advertisement", + schemaId: "gamecult.eve.provider_advertisement.v1", + recordKey: "voidbot.swarm", + value: { providerId: "voidbot.swarm" }, +}; + +test("provider session owns registration, renewal, desired publication, and withdrawal", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + const states: string[] = []; + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: new CultMeshMemoryProviderReceiptStore(), + publications: [surface, advertisement], + leaseDurationMs: 1_000, + renewalLeadMs: 200, + reconnectBaseDelayMs: 100, + reconnectMaxDelayMs: 1_000, + }); + session.watchState(state => states.push(state.status)); + + await session.start(); + + const connection = transport.connections[0]!; + assert.equal(session.state.status, "active"); + assert.deepEqual(connection.registrations[0]?.identity, identity); + assert.deepEqual( + connection.publications.map(value => value.publicationId), + [advertisement.publicationId, surface.publicationId], + "initial desired state is republished in stable order", + ); + assert.notEqual(identity.providerId, identity.serviceInstanceId); + assert.notEqual(identity.serviceInstanceId, identity.endpointId); + + await scheduler.advance(800); + assert.equal(connection.renewals.length, 1); + assert.equal(session.state.status, "active"); + + const updatedSurface = { ...surface, value: { version: 2 } }; + const finalSurface = { ...surface, value: { version: 3 } }; + const publishBarrier = new Deferred(); + connection.publishBarrier = publishBarrier; + const updating = session.upsertPublication(updatedSurface); + const finalizing = session.upsertPublication(finalSurface); + await settle(); + assert.deepEqual(connection.publications.at(-1), surface, "the second update waits behind the first delivery"); + publishBarrier.resolve(); + await Promise.all([updating, finalizing]); + assert.deepEqual( + connection.publications.slice(-2), + [updatedSurface, finalSurface], + "desired publication updates preserve caller order on the live connection", + ); + + assert.equal(await session.removePublication(surface.publicationId), true); + assert.deepEqual(connection.withdrawnPublications, [surface.publicationId]); + + await session.stop(); + assert.equal(connection.closed, true); + assert.deepEqual(connection.withdrawals[0]?.identity, identity); + assert.deepEqual(connection.withdrawals[0]?.publicationIds, [advertisement.publicationId]); + assert.equal(session.state.status, "stopped"); + assert.ok(states.includes("connecting")); + assert.ok(states.includes("withdrawing")); +}); + +test("stopping during registration prevents a stale connection from publishing", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + transport.registerBarrier = new Deferred(); + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: new CultMeshMemoryProviderReceiptStore(), + publications: [advertisement], + leaseDurationMs: 1_000, + renewalLeadMs: 200, + }); + + const starting = session.start(); + await settle(); + const connection = transport.connections[0]!; + const stopping = session.stop(); + transport.registerBarrier.resolve(); + await Promise.all([starting, stopping]); + + assert.equal(session.state.status, "stopped"); + assert.equal(connection.closed, true); + assert.deepEqual(connection.publications, []); + assert.deepEqual(connection.withdrawals, []); +}); + +test("provider session reconnects and republishes desired state after transient failure", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + transport.failConnectCount = 2; + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: new CultMeshMemoryProviderReceiptStore(), + publications: [advertisement], + leaseDurationMs: 1_000, + renewalLeadMs: 200, + reconnectBaseDelayMs: 100, + reconnectMaxDelayMs: 400, + }); + + await session.start(); + assert.equal(session.state.status, "reconnecting"); + assert.equal(session.state.reconnectAttempt, 1); + + await scheduler.advance(100); + assert.equal(session.state.status, "reconnecting"); + assert.equal(session.state.reconnectAttempt, 2); + await scheduler.advance(200); + assert.equal(session.state.status, "active"); + assert.equal(transport.connections.length, 1); + assert.deepEqual( + transport.connections[0]?.publications.map(value => value.publicationId), + [advertisement.publicationId], + ); + + transport.connections[0]!.failNextPublish = new Error("partition"); + await session.upsertPublication(surface); + assert.equal(session.state.status, "reconnecting"); + assert.match(session.state.lastError ?? "", /partition/); + assert.equal(transport.connections[0]?.closed, true); + + await scheduler.advance(100); + assert.equal(session.state.status, "active"); + assert.deepEqual( + transport.connections[1]?.publications.map(value => value.publicationId), + [advertisement.publicationId, surface.publicationId], + ); + await session.stop(); +}); + +test("receipt persistence failure degrades the provider without inventing a transport outage", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: { + get: async () => { throw new Error("receipt store offline"); }, + put: async () => undefined, + listPending: async () => [], + markPublished: async () => undefined, + }, + leaseDurationMs: 1_000, + renewalLeadMs: 200, + commandHandlers: { + "swarm.set_heat": async () => ({ state: "applied" }), + }, + }); + await session.start(); + const connection = transport.connections[0]!; + await connection.emit({ + commandId: "heat-store-failure", + commandKind: "swarm.set_heat", + providerId: identity.providerId, + serviceInstanceId: identity.serviceInstanceId, + payload: 0.5, + }); + + assert.equal(session.state.status, "degraded"); + assert.match(session.state.lastError ?? "", /receipt store offline/); + assert.equal(connection.closed, false); + assert.equal(transport.connections.length, 1); + await session.stop(); +}); + +test("provider session dispatches commands once and republishes typed receipts", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + let applyCount = 0; + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: new CultMeshMemoryProviderReceiptStore(), + leaseDurationMs: 1_000, + renewalLeadMs: 200, + commandHandlers: { + "swarm.set_heat": async command => { + applyCount++; + await settle(); + return { state: "applied", result: { heat: command.payload } }; + }, + }, + }); + await session.start(); + const connection = transport.connections[0]!; + const command: CultMeshProviderCommand = { + commandId: "heat-1", + commandKind: "swarm.set_heat", + providerId: identity.providerId, + serviceInstanceId: identity.serviceInstanceId, + payload: 0.75, + }; + + await Promise.all([connection.emit(command), connection.emit(command)]); + await connection.emit(command); + + assert.equal(applyCount, 1); + assert.equal(connection.receipts.length, 2, "concurrent duplicates share one delivery; later retries republish"); + assert.ok(connection.receipts.every(receipt => + receipt.receiptId === "receipt:13:voidbot.swarm16:voidbot-worker-76:heat-1")); + assert.ok(connection.receipts.every(receipt => receipt.state === "applied")); + + await connection.emit({ ...command, commandId: "unknown-1", commandKind: "swarm.unknown" }); + assert.equal(connection.receipts.at(-1)?.state, "rejected"); + + await connection.emit({ ...command, commandId: "wrong-instance", serviceInstanceId: "somebody-else" }); + assert.equal(connection.receipts.length, 3, "commands for another service instance are ignored"); + await session.stop(); +}); + +test("persisted receipts survive a transport failure and leave the outbox only after replay", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + const receipts = new CultMeshMemoryProviderReceiptStore(); + let applyCount = 0; + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: receipts, + leaseDurationMs: 1_000, + renewalLeadMs: 200, + reconnectBaseDelayMs: 100, + commandHandlers: { + "swarm.set_heat": async () => { + applyCount++; + return { state: "applied" }; + }, + }, + }); + await session.start(); + transport.connections[0]!.failNextReceipt = new Error("receipt route lost"); + await transport.connections[0]!.emit({ + commandId: "durable-1", + commandKind: "swarm.set_heat", + providerId: identity.providerId, + serviceInstanceId: identity.serviceInstanceId, + payload: 0.25, + }); + + assert.equal(session.state.status, "reconnecting"); + assert.equal((await receipts.listPending()).length, 1); + await scheduler.advance(100); + assert.equal(session.state.status, "active"); + assert.equal(transport.connections[1]!.receipts.length, 1); + assert.equal((await receipts.listPending()).length, 0); + assert.equal(applyCount, 1); + + transport.connections[1]!.failNextPublish = new Error("state route lost"); + await session.upsertPublication(surface); + await scheduler.advance(100); + assert.equal(transport.connections[2]!.receipts.length, 0, "marked receipts are not replayed again"); + await session.stop(); +}); + +test("initial replay and live publication changes share one ordered lane", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + transport.publishBarrier = new Deferred(); + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: new CultMeshMemoryProviderReceiptStore(), + publications: [surface], + leaseDurationMs: 1_000, + renewalLeadMs: 200, + }); + + const starting = session.start(); + await settle(); + const connection = transport.connections[0]!; + const updated = { ...surface, value: { version: 2 } }; + const updating = session.upsertPublication(updated); + const removing = session.removePublication(surface.publicationId); + transport.publishBarrier.resolve(); + await Promise.all([starting, updating, removing]); + + assert.deepEqual(connection.publications, [surface, updated]); + assert.deepEqual(connection.withdrawnPublications, [surface.publicationId]); + assert.deepEqual(session.publications, []); + await session.stop(); +}); + +test("state observers cannot become lifecycle authorities", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + const observerErrors: unknown[] = []; + const observed: string[] = []; + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: new CultMeshMemoryProviderReceiptStore(), + leaseDurationMs: 1_000, + renewalLeadMs: 200, + onObserverError: error => observerErrors.push(error), + }); + session.watchState(() => { throw new Error("display broken"); }); + session.watchState(state => observed.push(state.status)); + + await session.start(); + await session.stop(); + + assert.equal(session.state.status, "stopped"); + assert.ok(observerErrors.length >= 3); + assert.deepEqual(observed, ["stopped", "connecting", "active", "withdrawing", "stopped"]); +}); + +test("shutdown drains command persistence and always closes after cleanup failures", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + const handlerBarrier = new Deferred(); + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: new CultMeshMemoryProviderReceiptStore(), + leaseDurationMs: 1_000, + renewalLeadMs: 200, + commandHandlers: { + "swarm.set_heat": async () => { + await handlerBarrier.promise; + return { state: "applied" }; + }, + }, + }); + await session.start(); + const connection = transport.connections[0]!; + const command = connection.emit({ + commandId: "stop-drain-1", + commandKind: "swarm.set_heat", + providerId: identity.providerId, + serviceInstanceId: identity.serviceInstanceId, + payload: 0.5, + }); + connection.unsubscribeError = new Error("unsubscribe failed"); + connection.withdrawError = new Error("withdraw failed"); + connection.closeError = new Error("close failed"); + let stopped = false; + const stopping = session.stop().finally(() => { stopped = true; }); + await settle(); + assert.equal(stopped, false, "shutdown waits for the authoritative command transaction"); + handlerBarrier.resolve(); + await command; + await assert.rejects(stopping, AggregateError); + + assert.equal(connection.closeAttempts, 1); + assert.equal(session.state.status, "stopped"); +}); + +test("concurrent reuse of a command id with another kind cannot join the first transaction", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + const barrier = new Deferred(); + let heatCount = 0; + let otherCount = 0; + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: new CultMeshMemoryProviderReceiptStore(), + leaseDurationMs: 1_000, + renewalLeadMs: 200, + commandHandlers: { + "swarm.set_heat": async () => { + heatCount++; + await barrier.promise; + return { state: "applied" }; + }, + "swarm.other": async () => { + otherCount++; + return { state: "applied" }; + }, + }, + }); + await session.start(); + const connection = transport.connections[0]!; + const first = connection.emit({ + commandId: "collision-1", + commandKind: "swarm.set_heat", + providerId: identity.providerId, + serviceInstanceId: identity.serviceInstanceId, + payload: 0.5, + }); + await settle(); + await connection.emit({ + commandId: "collision-1", + commandKind: "swarm.other", + providerId: identity.providerId, + serviceInstanceId: identity.serviceInstanceId, + payload: null, + }); + assert.equal(session.state.status, "degraded"); + barrier.resolve(); + await first; + assert.equal(heatCount, 1); + assert.equal(otherCount, 0); + await session.stop(); +}); + +test("registration failure closes the connection before retry", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + transport.registerError = new Error("registration rejected"); + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: new CultMeshMemoryProviderReceiptStore(), + leaseDurationMs: 1_000, + renewalLeadMs: 200, + reconnectBaseDelayMs: 100, + }); + await session.start(); + assert.equal(transport.connections[0]!.closed, true); + assert.equal(session.state.status, "reconnecting"); + await session.stop(); +}); + +test("an expired replay lease never carries the next pending receipt", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + const receipts = new CultMeshMemoryProviderReceiptStore(); + const pending = (commandId: string): CultMeshProviderCommandReceipt => ({ + receiptId: `receipt:13:voidbot.swarm16:voidbot-worker-7${commandId.length}:${commandId}`, + commandId, + commandKind: "swarm.set_heat", + providerId: identity.providerId, + serviceInstanceId: identity.serviceInstanceId, + state: "applied", + completedAt: scheduler.now(), + }); + await receipts.put(pending("one")); + await receipts.put(pending("two")); + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: receipts, + leaseDurationMs: 1_000, + renewalLeadMs: 200, + reconnectBaseDelayMs: 100, + }); + transport.onFirstConnection = connection => { + connection.afterPublishReceipt = async () => scheduler.advance(1_001); + }; + + await session.start(); + assert.equal(session.state.status, "reconnecting"); + assert.deepEqual( + transport.connections[0]!.receipts.map(value => value.commandId), + ["one"], + "the expired lease is rejected before the next receipt", + ); + assert.deepEqual((await receipts.listPending()).map(value => value.commandId), ["two"]); + await scheduler.advance(100); + assert.equal(session.state.status, "active"); + assert.deepEqual(transport.connections[1]!.receipts.map(value => value.commandId), ["two"]); + assert.equal((await receipts.listPending()).length, 0); + await session.stop(); +}); + +test("overlapping shutdown callers share cleanup and restart waits for its boundary", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: new CultMeshMemoryProviderReceiptStore(), + leaseDurationMs: 1_000, + renewalLeadMs: 200, + }); + await session.start(); + const connection = transport.connections[0]!; + connection.withdrawBarrier = new Deferred(); + + const firstStop = session.stop(); + const secondStop = session.stop(); + assert.equal(firstStop, secondStop, "concurrent callers own one shutdown operation"); + await assert.rejects(session.start(), /shutdown is in progress/); + await settle(); + assert.equal(connection.closed, false); + connection.withdrawBarrier.resolve(); + await firstStop; + assert.equal(session.state.status, "stopped"); + + await session.start(); + assert.equal(session.state.status, "active"); + assert.equal(transport.connections.length, 2); + await session.stop(); +}); + +test("loss cleanup failures cannot suppress reconnect scheduling", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: new CultMeshMemoryProviderReceiptStore(), + leaseDurationMs: 1_000, + renewalLeadMs: 200, + reconnectBaseDelayMs: 100, + }); + await session.start(); + const connection = transport.connections[0]!; + connection.unsubscribeError = new Error("watch cleanup failed"); + connection.closeError = new Error("route close failed"); + connection.failNextPublish = new Error("route lost"); + + await session.upsertPublication(surface); + assert.equal(session.state.status, "reconnecting"); + assert.match(session.state.lastError ?? "", /watch cleanup failed/); + assert.match(session.state.lastError ?? "", /route close failed/); + await scheduler.advance(100); + assert.equal(session.state.status, "active"); + await session.stop(); +}); + +test("stop during receipt replay cannot publish active state or leave a renewal timer", async () => { + const scheduler = new ManualScheduler(); + const transport = new FakeTransport(scheduler); + const listBarrier = new Deferred(); + const states: string[] = []; + const session = new CultMeshProviderSession({ + identity, + transport, + scheduler, + receiptStore: { + get: async () => undefined, + put: async () => undefined, + listPending: async () => { + await listBarrier.promise; + return []; + }, + markPublished: async () => undefined, + }, + leaseDurationMs: 1_000, + renewalLeadMs: 200, + }); + session.watchState(state => states.push(state.status)); + const starting = session.start(); + await settle(); + const stopping = session.stop(); + await settle(); + const withdrawingIndex = states.lastIndexOf("withdrawing"); + listBarrier.resolve(); + await Promise.all([starting, stopping]); + + assert.equal(session.state.status, "stopped"); + assert.equal(states.slice(withdrawingIndex + 1).includes("active"), false); + assert.equal(scheduler.pendingTaskCount, 0); +}); + +class ManualScheduler implements CultMeshProviderScheduler { + #nowMs = Date.parse("2026-07-14T00:00:00.000Z"); + readonly #tasks: Array<{ + dueAt: number; + cancelled: boolean; + action: () => void | Promise; + }> = []; + + public now(): Date { + return new Date(this.#nowMs); + } + + public get pendingTaskCount(): number { + return this.#tasks.filter(value => !value.cancelled).length; + } + + public schedule(delayMs: number, action: () => void | Promise): CultMeshProviderScheduledTask { + const task = { dueAt: this.#nowMs + delayMs, cancelled: false, action }; + this.#tasks.push(task); + return { cancel: () => { task.cancelled = true; } }; + } + + public async advance(delayMs: number): Promise { + this.#nowMs += delayMs; + while (true) { + const task = this.#tasks + .filter(value => !value.cancelled && value.dueAt <= this.#nowMs) + .sort((left, right) => left.dueAt - right.dueAt)[0]; + if (!task) return; + task.cancelled = true; + await task.action(); + } + } +} + +class FakeTransport implements CultMeshProviderTransport { + public failConnectCount = 0; + public registerBarrier?: Deferred; + public registerError?: Error; + public publishBarrier?: Deferred; + public onFirstConnection?: (connection: FakeConnection) => void; + public readonly connections: FakeConnection[] = []; + + public constructor(private readonly scheduler: CultMeshProviderScheduler) {} + + public async connect( + connectedIdentity: CultMeshProviderIdentity, + signal: AbortSignal, + ): Promise { + assert.deepEqual(connectedIdentity, identity); + assert.equal(signal.aborted, false); + if (this.failConnectCount-- > 0) throw new Error("bootstrap unavailable"); + const connection = new FakeConnection(this.scheduler); + connection.registerBarrier = this.registerBarrier; + connection.registerError = this.registerError; + connection.publishBarrier = this.publishBarrier; + this.connections.push(connection); + if (this.connections.length === 1) this.onFirstConnection?.(connection); + return connection; + } +} + +class FakeConnection implements CultMeshProviderConnection { + public readonly registrations: CultMeshProviderRegistration[] = []; + public readonly renewals: CultMeshProviderLease[] = []; + public readonly publications: CultMeshProviderPublication[] = []; + public readonly withdrawnPublications: string[] = []; + public readonly receipts: CultMeshProviderCommandReceipt[] = []; + public readonly withdrawals: CultMeshProviderWithdrawal[] = []; + public failNextPublish?: Error; + public failNextReceipt?: Error; + public registerError?: Error; + public registerBarrier?: Deferred; + public publishBarrier?: Deferred; + public unsubscribeError?: Error; + public withdrawError?: Error; + public closeError?: Error; + public closeAttempts = 0; + public afterPublishReceipt?: () => void | Promise; + public withdrawBarrier?: Deferred; + public closed = false; + #listener?: CultMeshProviderCommandListener; + #leaseSequence = 0; + + public constructor(private readonly scheduler: CultMeshProviderScheduler) {} + + public async register( + registration: CultMeshProviderRegistration, + signal: AbortSignal, + ): Promise { + this.registrations.push(registration); + if (this.registerBarrier) { + await Promise.race([ + this.registerBarrier.promise, + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("registration aborted")), { once: true }); + }), + ]); + } + if (this.registerError) throw this.registerError; + return this.lease(registration.requestedLeaseDurationMs); + } + + public async renew(lease: CultMeshProviderLease): Promise { + this.renewals.push(lease); + return this.lease(1_000); + } + + public async publish(publication: CultMeshProviderPublication): Promise { + if (this.failNextPublish) { + const error = this.failNextPublish; + this.failNextPublish = undefined; + throw error; + } + const barrier = this.publishBarrier; + this.publishBarrier = undefined; + await barrier?.promise; + this.publications.push(publication); + } + + public async withdrawPublication(publicationId: string): Promise { + this.withdrawnPublications.push(publicationId); + } + + public watchCommands(listener: CultMeshProviderCommandListener): CultMeshProviderUnsubscribe { + this.#listener = listener; + return () => { + this.#listener = undefined; + if (this.unsubscribeError) throw this.unsubscribeError; + }; + } + + public async publishReceipt(receipt: CultMeshProviderCommandReceipt): Promise { + if (this.failNextReceipt) { + const error = this.failNextReceipt; + this.failNextReceipt = undefined; + throw error; + } + this.receipts.push(receipt); + const afterPublish = this.afterPublishReceipt; + this.afterPublishReceipt = undefined; + await afterPublish?.(); + } + + public async withdraw(withdrawal: CultMeshProviderWithdrawal): Promise { + this.withdrawals.push(withdrawal); + await this.withdrawBarrier?.promise; + if (this.withdrawError) throw this.withdrawError; + } + + public close(): void { + this.closeAttempts++; + this.closed = true; + if (this.closeError) throw this.closeError; + } + + public async emit(command: CultMeshProviderCommand): Promise { + await this.#listener?.(command); + } + + private lease(durationMs: number): CultMeshProviderLease { + this.#leaseSequence++; + return { + leaseId: `lease-${this.#leaseSequence}`, + expiresAt: new Date(this.scheduler.now().getTime() + durationMs), + }; + } +} + +async function settle(): Promise { + await new Promise(resolve => setImmediate(resolve)); +} + +class Deferred { + public readonly promise: Promise; + #resolve!: (value: T | PromiseLike) => void; + + public constructor() { + this.promise = new Promise(resolve => { this.#resolve = resolve; }); + } + + public resolve(value?: T): void { + this.#resolve(value as T); + } +} diff --git a/packages/cultnet-py/README.md b/packages/cultnet-py/README.md new file mode 100644 index 00000000..cca39ba7 --- /dev/null +++ b/packages/cultnet-py/README.md @@ -0,0 +1,156 @@ +# cultnet-py + +`cultnet-py` is the Python CultNet runtime: schema-v0 MessagePack wire helpers, +4-byte big-endian framing, the raw client, database subscriptions, shard +catch-up and replication, simulation observations and consensus, witness +artifact bundles, the RUDP codec/session/socket transport, and the CultMesh +wire contracts that both a node and a bare peer have to agree on. + +It depends on `cultcache-py` and `msgpack`; AES-GCM helpers on `CultNetSecret` +light up with the optional `crypto` extra. `cultmesh-py` builds on it. The +layering mirrors `cultnet-rs` and `cultnet-ts`. + +## Example + +```python +from datetime import UTC, datetime + +from cultnet_py import ( + CultNetSecret, + CultNetServerSecurityOptions, + CultNetRawClient, + CultNetSimulationObservation, + CultNetWitnessArtifactBundle, + apply_raw_snapshot, + apply_shard_log_response, + compute_simulation_claim_hash, + database_subscribe, + decode_frame, + encode_frame, + hello, + parse_message, + shard_catalog_request, + simulation_observation, + witness_artifact_bundle, +) + +payload = hello(runtime_id="python-runtime").to_bytes() +message = parse_message(decode_frame(encode_frame(payload))) + +client = CultNetRawClient("127.0.0.1", 3075) +catalog = client.fetch_schema_catalog(kinds=["wire_message"]) +snapshot = client.fetch_snapshot_response(schema_ids=["cultnet.interop-note"]) +shard_catalog = client.fetch_shard_catalog(schema_ids=["cultnet.interop-note"]) +# With a local CultCache and the matching registered document definitions: +# applied = apply_raw_snapshot(cache, [note_doc], snapshot) + +subscription = database_subscribe(subscription_id="ui", schema_ids=["cultnet.interop-note"]) +shard_request = shard_catalog_request(message_id="shards", schema_ids=["cultnet.interop-note"]) +shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=0) +# log_changes = apply_shard_log_response(cache, [note_doc], shard_log) +with client.subscribe_database(subscription_id="ui", schema_ids=["cultnet.interop-note"]) as live: + initial_snapshot = live.read_next_snapshot_response() + +claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit") +observation = simulation_observation( + message_id="obs-1", + witness_runtime_id="python-runtime", + shard_id="interop", + shard_epoch=1, + frame=42, + subject_id="player-1", + claim_kind="hit", + claim_hash=claim_hash, +) +typed_observation = CultNetSimulationObservation.from_wire(observation.to_wire()) +witness = witness_artifact_bundle( + bundle_id="bundle-1", + witness_kind="interop-proof", + captured_at="2026-06-13T00:00:00Z", + subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"}, + contracts=[{"role": "payload", "schemaId": "cultnet.interop-note"}], + artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}], + provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-runtime"}, +) +typed_witness = CultNetWitnessArtifactBundle.from_wire(witness) +witness_payload = typed_witness.to_payload() + +security = CultNetServerSecurityOptions.development() +token = CultNetSecret.create_session_token( + "318fb4b6-ff5e-4c4f-b911-d81807de53a8", + datetime(2035, 1, 1, tzinfo=UTC), + security, + session_version=1, +) +session = CultNetSecret.try_validate_session_token(token, security) +``` + +The raw-client request surface can ride RUDP when the peer advertises a +`rudp://` endpoint and shares the connection id for that lane; +`create_rudp_schema_transport(...)` builds that transport, and +`cultmesh_py.CultMesh.create_client(endpoint="rudp://...")` selects it from an +endpoint string. + +## CultMesh wire contracts + +`cultnet_py.cultmesh_contracts` carries the Verse catalog, peer exchange, +authority lease, stream negotiation, and committed simulation fact contracts. +They live here rather than in `cultmesh-py` for the same reason +`CultMeshPeerCard` lives in `cultnet-rs`: the interop peer has to speak them +without owning a node. `cultmesh_py` re-exports every one of them, so callers +that already import from there keep working. + +## RUDP + +`CultNetRudpSession` is the shared reliable/unreliable/sequenced/ordered +session over the RUDP packet codec, with fragmentation above +`max_fragment_bytes`, a bounded reliable window, reliable expiry, and a +bounded fragment reassembly map that evicts the oldest stranded set and counts +it in `fragment_sets_evicted` rather than refusing new payloads. The +socket-backed transport, reconnect policy/controller, and reconnect loop follow +the Rust and TypeScript entrypoint pattern. + +## Interop peer + +The peer can serve, dial, and probe the same raw-state interop lane used by the +TypeScript, Rust, and C# test peers, and answers Verse catalog and peer exchange +requests without a CultMesh node: + +```powershell +python -m cultnet_py.interop_peer serve --runtime-id python-peer --runtime-kind python --display-name "Python Peer" --agent-id python-agent --advertise-host 127.0.0.1 --tcp-port 3075 --discovery-port 4075 --discovery-group 239.77.44.11 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json +python -m cultnet_py.interop_peer dial --runtime-id python-client --runtime-kind python --display-name "Python Client" --agent-id python-client --target-host 127.0.0.1 --target-port 3075 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json +python -m cultnet_py.interop_peer probe --runtime-id python-prober --discovery-port 4075 --discovery-group 239.77.44.11 +``` + +`packages/cultnet-ts/test/interop/cultnet-interop.test.ts` drives it inside the +live TS/Rust/C#/Python peer ring. + +## Performance baseline + +The package ships a lightweight benchmark for Python-owned hot paths. In a +CultLib source checkout, it can also run the C# reference benchmark beside it: + +```powershell +python -m cultnet_py.benchmark --records 1000 --json +python -m cultnet_py.compare_csharp --records 1000 --json +``` + +It reports slot-indexed `DatabaseEntry` encode/decode throughput, framed +CultNet MessagePack parse throughput, raw snapshot application into a +registered cache, and public `CultCache` upsert/get throughput. The C# compare +command also runs `packages/cultnet-py/tools/GameCult.Caching.Benchmark` and +reports median Python-to-C# ratios for the shared public cache operations. It +uses three samples by default; pass `--samples` when you need a different +evidence shape. Treat the numbers as a local baseline, not a claim that Python +matches the C# reference in every workload. + +## Tests + +```powershell +$env:PYTHONPATH="$PWD\packages\cultcache-py\src;$PWD\packages\cultnet-py\src" +python -m unittest discover -s packages\cultnet-py\tests +``` + +The suite runs without `cultmesh_py` on the path. See +[docs/python-runtime-parity.md](../../docs/python-runtime-parity.md) for the +evidence map across the three Python packages. diff --git a/packages/cultnet-py/pyproject.toml b/packages/cultnet-py/pyproject.toml new file mode 100644 index 00000000..41c4e8f6 --- /dev/null +++ b/packages/cultnet-py/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "cultnet-py" +version = "0.1.0" +description = "Python CultNet transport, wire contracts, and replication for GameCult typed state." +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [{ name = "GameCult" }] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = ["cultcache-py>=0.2.0", "msgpack>=1.0"] +[project.optional-dependencies] +crypto = ["cryptography>=42"] + +[project.scripts] +cultnet-py-interop = "cultnet_py.interop_peer:main" +cultnet-py-benchmark = "cultnet_py.benchmark:main" +cultnet-py-compare-csharp = "cultnet_py.compare_csharp:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +cultnet_py = ["py.typed"] diff --git a/packages/cultcache-py/src/cultnet_py/__init__.py b/packages/cultnet-py/src/cultnet_py/__init__.py similarity index 78% rename from packages/cultcache-py/src/cultnet_py/__init__.py rename to packages/cultnet-py/src/cultnet_py/__init__.py index 5a842dbe..b1734f25 100644 --- a/packages/cultcache-py/src/cultnet_py/__init__.py +++ b/packages/cultnet-py/src/cultnet_py/__init__.py @@ -7,6 +7,34 @@ from .client import ( create_rudp_schema_transport, create_tcp_framed_schema_transport, ) +from .cultmesh_contracts import ( + PEER_EXCHANGE_REQUEST, + PEER_EXCHANGE_RESPONSE, + SIMULATION_FACT_DOCUMENT_TYPE, + SIMULATION_FACT_SCHEMA_VERSION, + VERSE_CATALOG_REQUEST, + VERSE_CATALOG_RESPONSE, + AuthorityLeaseVerifier, + CultMeshAuthorityLease, + CultMeshAuthorityLeaseCatalog, + CultMeshHmacAuthorityLeaseVerifier, + CultMeshPeerCard, + CultMeshPeerCatalog, + CultMeshSimulationFact, + CultMeshStreamCatalog, + CultMeshStreamConsumerProfile, + CultMeshStreamDescriptor, + CultMeshStreamFrameHandle, + CultMeshStreamNegotiation, + CultMeshVerseCatalog, + CultMeshVerseCompatibility, + CultMeshVerseDescriptor, + peer_exchange_request, + peer_from_wire, + simulation_fact_document, + verse_catalog_request, + verse_from_wire, +) from .framing import decode_frame, encode_frame, read_frame, write_frame from .messages import ( CultNetMessage, @@ -37,6 +65,7 @@ from .replication import ( apply_raw_document_record, apply_raw_snapshot, apply_shard_log_response, + resolve_document_and_schema_id_for_raw_record, schema_document_map, ) from .schema_catalog import ( @@ -107,6 +136,32 @@ from .simulation import ( ) __all__ = [ + "PEER_EXCHANGE_REQUEST", + "PEER_EXCHANGE_RESPONSE", + "SIMULATION_FACT_DOCUMENT_TYPE", + "SIMULATION_FACT_SCHEMA_VERSION", + "VERSE_CATALOG_REQUEST", + "VERSE_CATALOG_RESPONSE", + "AuthorityLeaseVerifier", + "CultMeshAuthorityLease", + "CultMeshAuthorityLeaseCatalog", + "CultMeshHmacAuthorityLeaseVerifier", + "CultMeshPeerCard", + "CultMeshPeerCatalog", + "CultMeshSimulationFact", + "CultMeshStreamCatalog", + "CultMeshStreamConsumerProfile", + "CultMeshStreamDescriptor", + "CultMeshStreamFrameHandle", + "CultMeshStreamNegotiation", + "CultMeshVerseCatalog", + "CultMeshVerseCompatibility", + "CultMeshVerseDescriptor", + "peer_exchange_request", + "peer_from_wire", + "simulation_fact_document", + "verse_catalog_request", + "verse_from_wire", "CultNetMessage", "CultNetWitnessArtifactBundle", "CultNetDatabaseSubscription", @@ -168,6 +223,7 @@ __all__ = [ "apply_raw_document_record", "apply_raw_snapshot", "apply_shard_log_response", + "resolve_document_and_schema_id_for_raw_record", "compute_simulation_claim_hash", "compute_reconnect_delay_ms", "create_reconnect_policy", diff --git a/packages/cultcache-py/src/cultcache_py/benchmark.py b/packages/cultnet-py/src/cultnet_py/benchmark.py similarity index 98% rename from packages/cultcache-py/src/cultcache_py/benchmark.py rename to packages/cultnet-py/src/cultnet_py/benchmark.py index a444acd9..83834635 100644 --- a/packages/cultcache-py/src/cultcache_py/benchmark.py +++ b/packages/cultnet-py/src/cultnet_py/benchmark.py @@ -19,7 +19,7 @@ class BenchItem: def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="cultcache-py-benchmark") + parser = argparse.ArgumentParser(prog="cultnet-py-benchmark") parser.add_argument("--records", type=int, default=5000) parser.add_argument("--json", action="store_true", dest="as_json") args = parser.parse_args(argv) diff --git a/packages/cultcache-py/src/cultnet_py/client.py b/packages/cultnet-py/src/cultnet_py/client.py similarity index 100% rename from packages/cultcache-py/src/cultnet_py/client.py rename to packages/cultnet-py/src/cultnet_py/client.py diff --git a/packages/cultcache-py/src/cultcache_py/compare_csharp.py b/packages/cultnet-py/src/cultnet_py/compare_csharp.py similarity index 99% rename from packages/cultcache-py/src/cultcache_py/compare_csharp.py rename to packages/cultnet-py/src/cultnet_py/compare_csharp.py index 8a9e4c5b..a2654a33 100644 --- a/packages/cultcache-py/src/cultcache_py/compare_csharp.py +++ b/packages/cultnet-py/src/cultnet_py/compare_csharp.py @@ -16,7 +16,7 @@ DEFAULT_PARITY_THRESHOLD = 0.90 def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="cultcache-py-compare-csharp") + parser = argparse.ArgumentParser(prog="cultnet-py-compare-csharp") parser.add_argument("--records", type=int, default=5000) parser.add_argument("--samples", type=int, default=DEFAULT_SAMPLE_COUNT) parser.add_argument("--parity-threshold", type=float, default=DEFAULT_PARITY_THRESHOLD) diff --git a/packages/cultcache-py/src/cultmesh_py/wire.py b/packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py similarity index 84% rename from packages/cultcache-py/src/cultmesh_py/wire.py rename to packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py index 9a666a1e..91d0805b 100644 --- a/packages/cultcache-py/src/cultmesh_py/wire.py +++ b/packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py @@ -1,14 +1,18 @@ from __future__ import annotations import base64 +import hashlib import hmac from dataclasses import dataclass, field, replace -from datetime import datetime +from datetime import UTC, datetime from hashlib import sha256 from typing import Any, Callable, Mapping import msgpack +from cultcache_py import define_database_entry_type +from cultcache_py.documents import DocumentDefinition + VERSE_CATALOG_REQUEST = "cultmesh.verse_catalog_request.v0" VERSE_CATALOG_RESPONSE = "cultmesh.verse_catalog_response.v0" @@ -613,3 +617,104 @@ def copy_budget_for(transport: str) -> str: if transport == "inline-bytes": return "opaque-runtime" return "opaque-runtime" + + +SIMULATION_FACT_DOCUMENT_TYPE = "gamecult.mesh.simulation_fact" +SIMULATION_FACT_SCHEMA_VERSION = "gamecult.mesh.simulation_fact.v1" + + +@dataclass(frozen=True) +class CultMeshSimulationFact: + fact_id: str + shard_id: str + shard_epoch: int + frame: int + subject_id: str + claim_kind: str + claim_hash: str + claim_summary: str | None + witness_count: int + support_weight: float + total_weight: float + confidence: float + committed_at: str + + @staticmethod + def compute_fact_id(candidate: dict[str, Any]) -> str: + canonical = "\x1f".join([ + _candidate_string(candidate, "shardId", "shard_id"), + str(_candidate_int(candidate, "shardEpoch", "shard_epoch")), + str(_candidate_int(candidate, "frame", "frame")), + _candidate_string(candidate, "subjectId", "subject_id"), + _candidate_string(candidate, "claimKind", "claim_kind"), + ]) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + @staticmethod + def create_record_key(candidate: dict[str, Any]) -> str: + return f"simulation:{CultMeshSimulationFact.compute_fact_id(candidate)}" + + @staticmethod + def from_candidate(candidate: dict[str, Any], *, committed_at: str | None = None) -> "CultMeshSimulationFact": + return CultMeshSimulationFact( + fact_id=CultMeshSimulationFact.compute_fact_id(candidate), + shard_id=_candidate_string(candidate, "shardId", "shard_id"), + shard_epoch=_candidate_int(candidate, "shardEpoch", "shard_epoch"), + frame=_candidate_int(candidate, "frame", "frame"), + subject_id=_candidate_string(candidate, "subjectId", "subject_id"), + claim_kind=_candidate_string(candidate, "claimKind", "claim_kind"), + claim_hash=_candidate_string(candidate, "claimHash", "claim_hash"), + claim_summary=_candidate_optional_string(candidate, "claimSummary", "claim_summary"), + witness_count=_candidate_int(candidate, "witnessCount", "witness_count"), + support_weight=_candidate_float(candidate, "supportWeight", "support_weight"), + total_weight=_candidate_float(candidate, "totalWeight", "total_weight"), + confidence=_candidate_float(candidate, "confidence", "confidence"), + committed_at=committed_at or datetime.now(UTC).isoformat(), + ) + + +simulation_fact_document: DocumentDefinition[CultMeshSimulationFact] = define_database_entry_type( + SIMULATION_FACT_DOCUMENT_TYPE, + [ + ("fact_id", 0), + ("shard_id", 1), + ("shard_epoch", 2), + ("frame", 3), + ("subject_id", 4), + ("claim_kind", 5), + ("claim_hash", 6), + ("claim_summary", 7, None), + ("witness_count", 8), + ("support_weight", 9), + ("total_weight", 10), + ("confidence", 11), + ("committed_at", 12), + ], + cls=CultMeshSimulationFact, + name="fact_id", + indexes={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"}, + schema_name=SIMULATION_FACT_DOCUMENT_TYPE, + schema_version=SIMULATION_FACT_SCHEMA_VERSION, +) + + +def _candidate_string(candidate: dict[str, Any], camel: str, snake: str) -> str: + value = candidate.get(camel, candidate.get(snake, "")) + if value is None: + return "" + return str(value) + + +def _candidate_optional_string(candidate: dict[str, Any], camel: str, snake: str) -> str | None: + value = candidate.get(camel, candidate.get(snake)) + if value is None: + return None + return str(value) + + +def _candidate_int(candidate: dict[str, Any], camel: str, snake: str) -> int: + return int(candidate.get(camel, candidate.get(snake, 0))) + + +def _candidate_float(candidate: dict[str, Any], camel: str, snake: str) -> float: + return float(candidate.get(camel, candidate.get(snake, 0.0))) diff --git a/packages/cultcache-py/src/cultnet_py/framing.py b/packages/cultnet-py/src/cultnet_py/framing.py similarity index 100% rename from packages/cultcache-py/src/cultnet_py/framing.py rename to packages/cultnet-py/src/cultnet_py/framing.py diff --git a/packages/cultcache-py/src/cultnet_py/interop_peer.py b/packages/cultnet-py/src/cultnet_py/interop_peer.py similarity index 99% rename from packages/cultcache-py/src/cultnet_py/interop_peer.py rename to packages/cultnet-py/src/cultnet_py/interop_peer.py index d26f1b70..ca3c692d 100644 --- a/packages/cultcache-py/src/cultnet_py/interop_peer.py +++ b/packages/cultnet-py/src/cultnet_py/interop_peer.py @@ -24,7 +24,7 @@ from cultcache_py import ( define_document_type, ) from cultcache_py.documents import DocumentDefinition -from cultmesh_py import ( +from .cultmesh_contracts import ( PEER_EXCHANGE_REQUEST, SIMULATION_FACT_DOCUMENT_TYPE, VERSE_CATALOG_REQUEST, @@ -306,6 +306,8 @@ def rudp_loop( result = peer.session.receive(packet, now_ms()) if result.reply is not None: send_rudp_packet(sock, remote, result.reply) + for ready in result.ready_to_send: + send_rudp_packet(sock, remote, ready) if result.disconnected: peers.pop(remote, None) continue @@ -317,8 +319,8 @@ def rudp_loop( continue for response in handle_server_message(state, message, peer.subscriptions): send_rudp_schema_frame(sock, remote, peer.session, response) - if packet.packet_type == CultNetRudpPacketType.DATA or result.delivered: - send_rudp_packet(sock, remote, peer.session.create_ack()) + if packet.reliable or packet.packet_type == CultNetRudpPacketType.DATA or result.delivered: + send_rudp_packet(sock, remote, peer.session.create_ack_for_received(packet.sequence)) except Exception as error: sys.stderr.write(json.dumps({ "event": "rudpMessageError", diff --git a/packages/cultcache-py/src/cultnet_py/messages.py b/packages/cultnet-py/src/cultnet_py/messages.py similarity index 100% rename from packages/cultcache-py/src/cultnet_py/messages.py rename to packages/cultnet-py/src/cultnet_py/messages.py diff --git a/packages/cultcache-py/src/cultnet_py/prediction.py b/packages/cultnet-py/src/cultnet_py/prediction.py similarity index 100% rename from packages/cultcache-py/src/cultnet_py/prediction.py rename to packages/cultnet-py/src/cultnet_py/prediction.py diff --git a/packages/cultcache-py/src/cultnet_py/py.typed b/packages/cultnet-py/src/cultnet_py/py.typed similarity index 100% rename from packages/cultcache-py/src/cultnet_py/py.typed rename to packages/cultnet-py/src/cultnet_py/py.typed diff --git a/packages/cultcache-py/src/cultnet_py/replication.py b/packages/cultnet-py/src/cultnet_py/replication.py similarity index 62% rename from packages/cultcache-py/src/cultnet_py/replication.py rename to packages/cultnet-py/src/cultnet_py/replication.py index 7290a434..6450d4f9 100644 --- a/packages/cultcache-py/src/cultnet_py/replication.py +++ b/packages/cultnet-py/src/cultnet_py/replication.py @@ -23,6 +23,8 @@ def schema_document_map(documents: Iterable[DocumentDefinition[Any]]) -> dict[st for document in documents: catalog_entry = document.catalog_entry() mapped[catalog_entry.schema_id] = document + mapped[catalog_entry.schema_name] = document + mapped[catalog_entry.schema_version] = document for compatible_schema_id in catalog_entry.compatible_schema_ids: mapped[compatible_schema_id] = document return mapped @@ -35,11 +37,15 @@ def apply_raw_document_record( ) -> CultNetAppliedRecord: wire = record.to_wire() if isinstance(record, CultNetRawDocumentRecord) else record schema_id = str(wire["schemaId"]) - document = documents_by_schema_id[schema_id] - envelope = raw_record_to_envelope(document, schema_id, wire) + document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( + documents_by_schema_id, + schema_id, + wire, + ) + envelope = raw_record_to_envelope(document, resolved_schema_id, wire) value = cache.put_envelope(document, envelope) return CultNetAppliedRecord( - schema_id=schema_id, + schema_id=resolved_schema_id, record_key=envelope.key, change_kind="added", value=value, @@ -76,11 +82,15 @@ def apply_raw_snapshot( if not isinstance(record, dict): continue schema_id = str(record["schemaId"]) - document = documents_by_schema_id[schema_id] - envelope = raw_record_to_envelope(document, schema_id, record) + document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( + documents_by_schema_id, + schema_id, + record, + ) + envelope = raw_record_to_envelope(document, resolved_schema_id, record) batch = batches.setdefault(document.type, (document, [])) - batch[1].append((schema_id, envelope)) - order.append((document, schema_id, envelope)) + batch[1].append((resolved_schema_id, envelope)) + order.append((document, resolved_schema_id, envelope)) values_by_key: dict[tuple[str, str], Any] = {} for document, entries in batches.values(): @@ -120,7 +130,7 @@ def apply_shard_log_response( if change_kind in ("added", "updated") and isinstance(entry.get("put"), dict): put = entry["put"] schema_id = str(put["document"]["schemaId"]) - if schema_id not in documents_by_schema_id: + if not can_resolve_document_for_raw_record(documents_by_schema_id, schema_id, put["document"]): continue result = apply_raw_document_record(cache, documents_by_schema_id, put["document"]) applied.append(CultNetAppliedRecord(result.schema_id, result.record_key, str(change_kind), result.value)) @@ -131,6 +141,67 @@ def apply_shard_log_response( if schema_id not in documents_by_schema_id: continue document = documents_by_schema_id[schema_id] + resolved_schema_id = document.catalog_entry().schema_id cache.delete(document, record_key) - applied.append(CultNetAppliedRecord(schema_id, record_key, "removed", None)) + applied.append(CultNetAppliedRecord(resolved_schema_id, record_key, "removed", None)) return applied + + +def can_resolve_document_for_raw_record( + documents_by_schema_id: dict[str, DocumentDefinition[Any]], + schema_id: str, + record: dict[str, Any], +) -> bool: + try: + resolve_document_for_raw_record(documents_by_schema_id, schema_id, record) + return True + except KeyError: + return False + + +def resolve_document_for_raw_record( + documents_by_schema_id: dict[str, DocumentDefinition[Any]], + schema_id: str, + record: dict[str, Any], +) -> DocumentDefinition[Any]: + return resolve_document_and_schema_id_for_raw_record(documents_by_schema_id, schema_id, record)[0] + + +def resolve_document_and_schema_id_for_raw_record( + documents_by_schema_id: dict[str, DocumentDefinition[Any]], + schema_id: str, + record: dict[str, Any], +) -> tuple[DocumentDefinition[Any], str]: + document = documents_by_schema_id.get(schema_id) + if document is not None: + return document, schema_id + schema_version = _infer_schema_version_from_payload(bytes(record["payload"])) + schema_name = _infer_schema_name(schema_version) if schema_version is not None else None + if schema_name is not None and schema_name in documents_by_schema_id: + document = documents_by_schema_id[schema_name] + return document, document.catalog_entry().schema_id + raise KeyError(schema_id) + + +def _infer_schema_version_from_payload(payload: bytes) -> str | None: + try: + import msgpack # type: ignore + decoded = msgpack.unpackb(payload, raw=False) + except Exception: + return None + if isinstance(decoded, list) and decoded and isinstance(decoded[0], str): + return decoded[0] + if isinstance(decoded, dict): + value = decoded.get("schemaVersion", decoded.get("schema_version")) + return value if isinstance(value, str) else None + return None + + +def _infer_schema_name(schema_version: str) -> str | None: + marker = schema_version.rfind(".v") + if marker <= 0: + return None + version = schema_version[marker + 2:] + if not version or not version.isdigit(): + return None + return schema_version[:marker] diff --git a/packages/cultcache-py/src/cultnet_py/schema_catalog.py b/packages/cultnet-py/src/cultnet_py/schema_catalog.py similarity index 100% rename from packages/cultcache-py/src/cultnet_py/schema_catalog.py rename to packages/cultnet-py/src/cultnet_py/schema_catalog.py diff --git a/packages/cultcache-py/src/cultnet_py/security.py b/packages/cultnet-py/src/cultnet_py/security.py similarity index 100% rename from packages/cultcache-py/src/cultnet_py/security.py rename to packages/cultnet-py/src/cultnet_py/security.py diff --git a/packages/cultcache-py/src/cultnet_py/shard_catalog.py b/packages/cultnet-py/src/cultnet_py/shard_catalog.py similarity index 84% rename from packages/cultcache-py/src/cultnet_py/shard_catalog.py rename to packages/cultnet-py/src/cultnet_py/shard_catalog.py index 951ad5b7..4335f617 100644 --- a/packages/cultcache-py/src/cultnet_py/shard_catalog.py +++ b/packages/cultnet-py/src/cultnet_py/shard_catalog.py @@ -60,7 +60,7 @@ class CultNetShardDescriptor: return wire def serves(self, *, schema_id: str | None = None, record_key: str | None = None) -> bool: - if schema_id is not None and self.schema_ids and schema_id not in self.schema_ids: + if schema_id is not None and self.schema_ids and not _schema_ids_match_any(self.schema_ids, (schema_id,)): return False if record_key is not None and self.key_prefix and not record_key.startswith(self.key_prefix): return False @@ -122,13 +122,38 @@ def _shard_matches( requested_schema_ids: set[str], requested_record_keys: list[str], ) -> bool: - if requested_schema_ids and not any(schema_id in shard.schema_ids for schema_id in requested_schema_ids): + if requested_schema_ids and not _schema_ids_match_any(shard.schema_ids, requested_schema_ids): return False if requested_record_keys and not any(shard.serves(record_key=record_key) for record_key in requested_record_keys): return False return True +def _schema_ids_match_any( + advertised_schema_ids: tuple[str, ...], + requested_schema_ids: set[str] | tuple[str, ...], +) -> bool: + return any( + _schema_ids_match(advertised, requested) + for advertised in advertised_schema_ids + for requested in requested_schema_ids + ) + + +def _schema_ids_match(left: str, right: str) -> bool: + if left == right: + return True + return (_infer_schema_name(left) or left) == (_infer_schema_name(right) or right) + + +def _infer_schema_name(schema_id: str) -> str | None: + marker = schema_id.rfind(".v") + if marker <= 0 or marker + 2 >= len(schema_id): + return None + version = schema_id[marker + 2:] + return schema_id[:marker] if version.isdigit() else None + + def _string_tuple(value: Any) -> tuple[str, ...]: return tuple(str(item) for item in value or []) diff --git a/packages/cultcache-py/src/cultnet_py/shard_log.py b/packages/cultnet-py/src/cultnet_py/shard_log.py similarity index 100% rename from packages/cultcache-py/src/cultnet_py/shard_log.py rename to packages/cultnet-py/src/cultnet_py/shard_log.py diff --git a/packages/cultcache-py/src/cultnet_py/shard_replication.py b/packages/cultnet-py/src/cultnet_py/shard_replication.py similarity index 100% rename from packages/cultcache-py/src/cultnet_py/shard_replication.py rename to packages/cultnet-py/src/cultnet_py/shard_replication.py diff --git a/packages/cultcache-py/src/cultnet_py/simulation.py b/packages/cultnet-py/src/cultnet_py/simulation.py similarity index 100% rename from packages/cultcache-py/src/cultnet_py/simulation.py rename to packages/cultnet-py/src/cultnet_py/simulation.py diff --git a/packages/cultcache-py/src/cultnet_py/snapshot.py b/packages/cultnet-py/src/cultnet_py/snapshot.py similarity index 100% rename from packages/cultcache-py/src/cultnet_py/snapshot.py rename to packages/cultnet-py/src/cultnet_py/snapshot.py diff --git a/packages/cultcache-py/src/cultnet_py/subscription.py b/packages/cultnet-py/src/cultnet_py/subscription.py similarity index 100% rename from packages/cultcache-py/src/cultnet_py/subscription.py rename to packages/cultnet-py/src/cultnet_py/subscription.py diff --git a/packages/cultcache-py/src/cultnet_py/transport.py b/packages/cultnet-py/src/cultnet_py/transport.py similarity index 82% rename from packages/cultcache-py/src/cultnet_py/transport.py rename to packages/cultnet-py/src/cultnet_py/transport.py index 25a4c891..73bd1e62 100644 --- a/packages/cultcache-py/src/cultnet_py/transport.py +++ b/packages/cultnet-py/src/cultnet_py/transport.py @@ -280,6 +280,7 @@ class CultNetRudpDeliveredFrame: @dataclass(frozen=True) class CultNetRudpReceiveResult: delivered: tuple[CultNetRudpDeliveredFrame, ...] = field(default_factory=tuple) + ready_to_send: tuple[CultNetRudpPacket, ...] = field(default_factory=tuple) reply: CultNetRudpPacket | None = None pong: bool = False pong_payload: bytes = b"" @@ -395,6 +396,9 @@ def create_rudp_transport_profile( class CultNetRudpSession: + RELIABLE_SEND_WINDOW_PACKETS = 32 + RECEIVED_SEQUENCE_WINDOW = 4_096 + def __init__(self, options: CultNetRudpSessionOptions) -> None: self.connection_id = _uint32(options.connection_id, "connection_id") self.resend_delay_ms = options.resend_delay_ms @@ -402,20 +406,33 @@ class CultNetRudpSession: raise ValueError("RUDP max_pending_reliable_packets must be greater than zero") self.max_pending_reliable_packets = options.max_pending_reliable_packets self._next_sequence = _uint32(options.initial_sequence, "initial_sequence") + self._next_sequenced_by_channel: dict[str, int] = {} self._next_fragment_id = 1 self._connected = False self._last_received_at_ms: int | None = None self._highest_received_sequence: int | None = None self._received_sequences: set[int] = set() + self._latest_sequenced_by_channel: dict[str, int] = {} self._pending_reliable: dict[int, _PendingReliablePacket] = {} + self._queued_reliable: deque[CultNetRudpPacket] = deque() self._ordered_next_sequence_by_channel: dict[str, int] = {} self._ordered_buffers: dict[str, dict[int, tuple[CultNetRudpDeliveredFrame, int]]] = {} self._fragment_buffers: dict[tuple[str, int], dict[str, Any]] = {} + # Matches cultnet-rs max_pending_fragment_sets. A set that loses one + # fragment is never completed; without a bound it is kept for the life + # of the session and the map grows under any loss on a fragmenting channel. + self._max_pending_fragment_sets = 64 + self._fragment_sets_evicted = 0 @property def connected(self) -> bool: return self._connected + @property + def fragment_sets_evicted(self) -> int: + """Incomplete fragment sets dropped to admit newer ones; one lost payload each.""" + return self._fragment_sets_evicted + @property def last_received_at_ms(self) -> int | None: return self._last_received_at_ms @@ -424,6 +441,14 @@ class CultNetRudpSession: def pending_reliable_sequences(self) -> tuple[int, ...]: return tuple(sorted(self._pending_reliable)) + @property + def queued_reliable_packet_count(self) -> int: + return len(self._queued_reliable) + + @property + def outstanding_reliable_packet_count(self) -> int: + return len(self._pending_reliable) + len(self._queued_reliable) + def create_connect(self, now_ms: int = 0, payload: bytes = b"") -> CultNetRudpPacket: self._ensure_reliable_capacity(1) packet = self._create_packet( @@ -465,7 +490,10 @@ class CultNetRudpSession: payload: bytes, options: CultNetRudpSendOptions | None = None, ) -> CultNetRudpPacket: - return self.send_many(channel_id, payload, options)[0] + resolved = options or CultNetRudpSendOptions() + if resolved.reliable and len(self._pending_reliable) >= self.RELIABLE_SEND_WINDOW_PACKETS: + raise ValueError("RUDP reliable send window is full; receive acknowledgements before sending") + return self.send_many(channel_id, payload, resolved)[0] def send_many( self, @@ -479,6 +507,8 @@ class CultNetRudpSession: raise ValueError("Cannot send RUDP data before the session is connected") options = options or CultNetRudpSendOptions() + if options.ordered and not options.reliable: + raise ValueError("RUDP ordered delivery requires reliability") if max_fragment_bytes is not None and max_fragment_bytes <= 0: raise ValueError("RUDP max_fragment_bytes must be greater than zero") @@ -492,9 +522,7 @@ class CultNetRudpSession: ordered=options.ordered, sequenced=options.sequenced, ) - if packet.reliable: - self._track_reliable(packet, options.now_ms) - return (packet,) + return self._admit_reliable_packets((packet,), options.now_ms) if packet.reliable else (packet,) fragment_count = (len(payload) + max_fragment_bytes - 1) // max_fragment_bytes if fragment_count > 0xFFFF: @@ -516,14 +544,13 @@ class CultNetRudpSession: fragment_index=index, fragment_count=fragment_count, ) - if packet.reliable: - self._track_reliable(packet, options.now_ms) packets.append(packet) - return tuple(packets) + return self._admit_reliable_packets(tuple(packets), options.now_ms) if options.reliable else tuple(packets) def receive(self, packet: CultNetRudpPacket, now_ms: int = 0) -> CultNetRudpReceiveResult: self._require_connection(packet) self._apply_acknowledgements(packet) + ready_to_send = self._promote_queued_reliable(now_ms) self._last_received_at_ms = now_ms expected_sequence_if_uninitialized = ( packet.sequence @@ -534,51 +561,93 @@ class CultNetRudpSession: if packet.packet_type == CultNetRudpPacketType.ACCEPT: self._remember_received(packet.sequence) self._connected = True - return CultNetRudpReceiveResult() + return CultNetRudpReceiveResult(ready_to_send=ready_to_send) if packet.packet_type == CultNetRudpPacketType.PING: - self._remember_received(packet.sequence) return CultNetRudpReceiveResult( + ready_to_send=ready_to_send, reply=self._create_packet(CultNetRudpPacketType.PONG, "control", packet.payload) ) if packet.packet_type in (CultNetRudpPacketType.ACK, CultNetRudpPacketType.PONG): - self._remember_received(packet.sequence) return CultNetRudpReceiveResult( + ready_to_send=ready_to_send, pong=packet.packet_type == CultNetRudpPacketType.PONG, pong_payload=bytes(packet.payload) if packet.packet_type == CultNetRudpPacketType.PONG else b"", ) if packet.packet_type == CultNetRudpPacketType.DISCONNECT: - self._remember_received(packet.sequence) self._connected = False return CultNetRudpReceiveResult( + ready_to_send=ready_to_send, disconnected=True, disconnect_reason=bytes(packet.payload), ) if packet.packet_type != CultNetRudpPacketType.DATA: - return CultNetRudpReceiveResult() + return CultNetRudpReceiveResult(ready_to_send=ready_to_send) - duplicate = packet.sequence in self._received_sequences - self._remember_received(packet.sequence) + duplicate = packet.reliable and ( + packet.sequence in self._received_sequences or ( + self._highest_received_sequence is not None + and packet.sequence < self._highest_received_sequence + and self._highest_received_sequence - packet.sequence >= self.RECEIVED_SEQUENCE_WINDOW + ) + ) + if packet.reliable: + self._remember_received(packet.sequence) if duplicate: - return CultNetRudpReceiveResult() + return CultNetRudpReceiveResult(ready_to_send=ready_to_send) reassembled = self._reassemble(packet) if reassembled is None: - return CultNetRudpReceiveResult() + return CultNetRudpReceiveResult(ready_to_send=ready_to_send) frame, ordered, next_sequence = reassembled + if not ordered and packet.sequenced: + newest_sequence = next_sequence - 1 + latest_sequence = self._latest_sequenced_by_channel.get(frame.channel_id) + if latest_sequence is not None and newest_sequence <= latest_sequence: + return CultNetRudpReceiveResult(ready_to_send=ready_to_send) + self._latest_sequenced_by_channel[frame.channel_id] = newest_sequence + return CultNetRudpReceiveResult(delivered=(frame,), ready_to_send=ready_to_send) if not ordered: - return CultNetRudpReceiveResult(delivered=(frame,)) + return CultNetRudpReceiveResult(delivered=(frame,), ready_to_send=ready_to_send) return CultNetRudpReceiveResult( delivered=tuple( self._deliver_ordered(frame, next_sequence, expected_sequence_if_uninitialized) - ) + ), + ready_to_send=ready_to_send, ) def create_ack(self) -> CultNetRudpPacket: - return self._create_packet(CultNetRudpPacketType.ACK, "control", b"") + ack, ack_mask = self._ack_state() + return CultNetRudpPacket( + packet_type=CultNetRudpPacketType.ACK, + connection_id=self.connection_id, + sequence=0, + ack=ack, + ack_mask=ack_mask, + channel_id="control", + payload=b"", + ) + + def create_ack_for(self, sequence: int) -> CultNetRudpPacket: + return CultNetRudpPacket( + packet_type=CultNetRudpPacketType.ACK, + connection_id=self.connection_id, + sequence=0, + ack=_uint32(sequence, "ack sequence"), + ack_mask=0, + channel_id="control", + payload=b"", + ) + + def create_ack_for_received(self, sequence: int) -> CultNetRudpPacket: + received_sequence = _uint32(sequence, "received sequence") + ack, _ = self._ack_state() + if ack >= received_sequence and ack - received_sequence <= 32: + return self.create_ack() + return self.create_ack_for(received_sequence) def create_ping(self, payload: bytes = b"") -> CultNetRudpPacket: return self._create_packet(CultNetRudpPacketType.PING, "control", payload) @@ -616,8 +685,17 @@ class CultNetRudpSession: fragment_index: int = 0, fragment_count: int = 0, ) -> CultNetRudpPacket: - sequence = self._next_sequence - self._next_sequence = _uint32(self._next_sequence + 1, "sequence") + if reliable: + sequence = self._next_sequence + self._next_sequence = _uint32(self._next_sequence + 1, "reliable sequence") + elif sequenced: + sequence = self._next_sequenced_by_channel.get(channel_id, 1) + self._next_sequenced_by_channel[channel_id] = _uint32( + sequence + 1, + "sequenced channel sequence", + ) + else: + sequence = 0 ack, ack_mask = self._ack_state() return CultNetRudpPacket( packet_type=packet_type, @@ -655,10 +733,31 @@ class CultNetRudpSession: last_sent_at_ms=now_ms, ) + def _admit_reliable_packets( + self, + packets: tuple[CultNetRudpPacket, ...], + now_ms: int, + ) -> tuple[CultNetRudpPacket, ...]: + available = max(0, self.RELIABLE_SEND_WINDOW_PACKETS - len(self._pending_reliable)) + ready = packets[:available] + for packet in ready: + self._track_reliable(packet, now_ms) + self._queued_reliable.extend(packets[available:]) + return ready + + def _promote_queued_reliable(self, now_ms: int) -> tuple[CultNetRudpPacket, ...]: + available = max(0, self.RELIABLE_SEND_WINDOW_PACKETS - len(self._pending_reliable)) + ready: list[CultNetRudpPacket] = [] + while len(ready) < available and self._queued_reliable: + packet = self._queued_reliable.popleft() + self._track_reliable(packet, now_ms) + ready.append(packet) + return tuple(ready) + def _ensure_reliable_capacity(self, packet_count: int) -> None: if packet_count == 0 or self.max_pending_reliable_packets is None: return - if len(self._pending_reliable) + packet_count > self.max_pending_reliable_packets: + if self.outstanding_reliable_packet_count + packet_count > self.max_pending_reliable_packets: raise ValueError("RUDP reliable send queue is full") def _apply_acknowledgements(self, packet: CultNetRudpPacket) -> None: @@ -671,6 +770,9 @@ class CultNetRudpSession: self._received_sequences.add(sequence) if self._highest_received_sequence is None or sequence > self._highest_received_sequence: self._highest_received_sequence = sequence + if len(self._received_sequences) > self.RECEIVED_SEQUENCE_WINDOW: + keep_from = max(0, (self._highest_received_sequence or sequence) - self.RECEIVED_SEQUENCE_WINDOW + 1) + self._received_sequences = {received for received in self._received_sequences if received >= keep_from} def _ack_state(self) -> tuple[int, int]: ack = self._highest_received_sequence or 0 @@ -693,6 +795,11 @@ class CultNetRudpSession: raise ValueError("RUDP fragment index must be lower than fragment count") key = (packet.channel_id, packet.fragment_id) + if key not in self._fragment_buffers and len(self._fragment_buffers) >= self._max_pending_fragment_sets: + # Evict the oldest stranded set rather than refusing the payload; dicts + # iterate in insertion order, so the first key is the oldest. + del self._fragment_buffers[next(iter(self._fragment_buffers))] + self._fragment_sets_evicted += 1 buffer = self._fragment_buffers.setdefault( key, { @@ -838,6 +945,10 @@ class CultNetRudpSocketTransportConnection: frames_sent=self._frames_sent, ) + @property + def outstanding_reliable_packet_count(self) -> int: + return self.session.outstanding_reliable_packet_count + def connect(self, payload: bytes = b"") -> None: if self.mode != CultNetRudpSocketMode.CLIENT: raise ValueError("Only a client RUDP socket transport can initiate connect") @@ -879,6 +990,8 @@ class CultNetRudpSocketTransportConnection: result = self.session.receive(packet, _now_ms()) if result.reply is not None: self._send_packet(result.reply) + for ready in result.ready_to_send: + self._send_packet(ready) if result.pong: self.pong_payloads.append(result.pong_payload) if result.disconnected: @@ -892,8 +1005,8 @@ class CultNetRudpSocketTransportConnection: self._frames_received += 1 frame = self._delivered_frames.popleft() if self._delivered_frames else None - if packet.packet_type == CultNetRudpPacketType.ACCEPT or frame is not None: - self._send_packet(self.session.create_ack()) + if packet.reliable or packet.packet_type == CultNetRudpPacketType.ACCEPT or frame is not None: + self._send_packet(self.session.create_ack_for_received(packet.sequence)) return frame def receive(self, timeout_seconds: float | None = None) -> CultNetTransportFrame: @@ -906,6 +1019,29 @@ class CultNetRudpSocketTransportConnection: if deadline is not None and time.monotonic() >= deadline: raise TimeoutError("Timed out waiting for RUDP schema frame") + def flush_reliable(self, timeout_seconds: float = 30.0) -> None: + deadline = time.monotonic() + max(0.0, timeout_seconds) + original_timeout = self.socket.gettimeout() + poll_timeout = min(original_timeout, 0.01) if original_timeout is not None else 0.01 + preserved = deque(self._delivered_frames) + self._delivered_frames.clear() + self.socket.settimeout(poll_timeout) + try: + while self.session.outstanding_reliable_packet_count > 0: + if time.monotonic() >= deadline: + raise TimeoutError( + "RUDP reliable flush timed out with " + f"{self.session.outstanding_reliable_packet_count} packets outstanding" + ) + frame = self.receive_once() + if frame is not None: + preserved.append(frame) + self.poll_resends() + finally: + preserved.extend(self._delivered_frames) + self._delivered_frames = preserved + self.socket.settimeout(original_timeout) + def disconnect(self, reason: bytes = b"") -> None: self._send_packet(self.session.create_disconnect(reason)) diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py new file mode 100644 index 00000000..171a34b9 --- /dev/null +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -0,0 +1,2046 @@ +from __future__ import annotations + +import json +import socket +import threading +import time +import unittest +import hashlib +import hmac +import io +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Callable +from uuid import uuid4 +from cultcache_py import CultCache, define_database_entry_type +from cultnet_py.benchmark import run_benchmark +from cultnet_py.compare_csharp import ( + DEFAULT_PARITY_THRESHOLD, + DEFAULT_SAMPLE_COUNT, + _compare_common_metrics, + _median_result, + _parity_status, +) +from cultnet_py.interop_peer import append_shard_log_put, build_state, raw_snapshot_response +from cultnet_py import ( + compute_simulation_claim_hash, + CultNetDatabaseChange, + CultNetRawClient, + CultNetRawDocumentRecord, + CultNetRawSnapshotResponse, + CultNetSchemaCatalog, + CultNetSchemaDescriptor, + CultNetShardCatalog, + CultNetShardDescriptor, + CultNetShardLogEntry, + CultNetShardLogResponse, + CultNetPeerError, + CultNetClientSecurityOptions, + CultNetSecret, + CultNetServerSecurityOptions, + CultNetSimulationConsensusOptions, + CultNetSimulationConsensusCandidate, + CultNetSimulationObservation, + CultNetRudpPacket, + CultNetRudpPacketType, + CultNetRudpSendOptions, + CultNetRudpSession, + CultNetRudpSessionOptions, + CultNetRudpSocketMode, + CultNetRudpReconnectLoop, + CultNetRudpSocketTransportConnection, + CultNetRudpSocketTransportOptions, + TcpFramedTransportConnection, + CultNetWitnessArtifactBundle, + apply_raw_document_record, + apply_raw_snapshot, + apply_shard_log_response, + create_rudp_transport_profile, + create_tcp_framed_transport_profile, + create_reconnect_policy, + CultNetReconnectController, + database_subscribe, + database_unsubscribe, + decode_frame, + decode_rudp_packet, + decode_witness_artifact_bundle_payload, + document_delete, + document_put_raw, + encode_frame, + encode_rudp_packet, + encode_witness_artifact_bundle_payload, + hello, + login, + login_success, + parse_message, + register, + schema_catalog_request, + schema_document_map, + shard_catalog_request, + shard_log_request, + simulation_observation, + snapshot_request, + verify_session, + wire_message_schema_catalog, + wire_message_schema_descriptors, + witness_artifact_bundle, + compute_reconnect_delay_ms, +) + + +def bind_udp_socket() -> socket.socket: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + sock.settimeout(0.02) + return sock + + +def pump_rudp_handshake( + client: CultNetRudpSocketTransportConnection, + server: CultNetRudpSocketTransportConnection, +) -> None: + for _ in range(20): + server.receive_once() + client.receive_once() + server.receive_once() + if client.connected and server.connected: + return + time.sleep(0.005) + raise AssertionError("RUDP socket handshake did not complete") + + +def receive_rudp_frame(transport: CultNetRudpSocketTransportConnection): + for _ in range(20): + frame = transport.receive_once() + if frame is not None: + return frame + time.sleep(0.005) + raise AssertionError("RUDP socket frame was not delivered") + + +@dataclass +class Item: + name: str + category: str + value: int + + +class CultNetTests(unittest.TestCase): + def test_benchmark_reports_core_hot_path_metrics(self) -> None: + result = run_benchmark(8) + metric_names = {metric["name"] for metric in result["metrics"]} + self.assertEqual(result["records"], 8) + self.assertEqual(result["wireMessageCount"], 8) + self.assertEqual(metric_names, { + "database_entry_encode", + "database_entry_decode", + "cultnet_frame_parse", + "raw_snapshot_apply", + "cache_upsert", + "cache_get", + }) + self.assertTrue(all(metric["opsPerSecond"] > 0 for metric in result["metrics"])) + + def test_compare_csharp_median_result_summarizes_samples(self) -> None: + self.assertEqual(DEFAULT_SAMPLE_COUNT, 3) + self.assertEqual(DEFAULT_PARITY_THRESHOLD, 0.90) + samples = [ + { + "runtime": "python", + "records": 3, + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 30.0, "opsPerSecond": 100.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 60.0, "opsPerSecond": 50.0}, + ], + }, + { + "runtime": "python", + "records": 3, + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 10.0, "opsPerSecond": 300.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 20.0, "opsPerSecond": 150.0}, + ], + }, + { + "runtime": "python", + "records": 3, + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 20.0, "opsPerSecond": 200.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 40.0, "opsPerSecond": 75.0}, + ], + }, + ] + + summarized = _median_result(samples) + + self.assertEqual(summarized["sampleCount"], 3) + self.assertEqual(summarized["metrics"][0]["name"], "cache_get") + self.assertEqual(summarized["metrics"][0]["elapsedMs"], 20.0) + self.assertEqual(summarized["metrics"][0]["opsPerSecond"], 200.0) + self.assertEqual(summarized["metrics"][1]["opsPerSecond"], 75.0) + + def test_compare_csharp_marks_threshold_status_per_metric(self) -> None: + python_result = { + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 2.0, "opsPerSecond": 50.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 95.0}, + ], + } + csharp_result = { + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, + ], + } + + comparison = _compare_common_metrics(python_result, csharp_result, parity_threshold=0.90) + by_name = {item["name"]: item for item in comparison} + + self.assertFalse(by_name["cache_get"]["meetsParityThreshold"]) + self.assertTrue(by_name["cache_upsert"]["meetsParityThreshold"]) + self.assertEqual(_parity_status(comparison, "ok"), "below-threshold") + self.assertEqual(_parity_status([by_name["cache_upsert"]], "ok"), "meets-threshold") + self.assertEqual(_parity_status(comparison, "failed"), "unknown") + + def test_compare_csharp_marks_meets_threshold_when_all_common_metrics_pass(self) -> None: + comparison = _compare_common_metrics( + { + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 95.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 120.0}, + ], + }, + { + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, + ], + }, + parity_threshold=0.90, + ) + + self.assertEqual(_parity_status(comparison, "ok"), "meets-threshold") + + def test_cultnet_security_options_match_cultlib_defaults_and_environment_rules(self) -> None: + client = CultNetClientSecurityOptions.development() + server = CultNetServerSecurityOptions.development() + + self.assertEqual(client.connection_key, "gamecult-dev-connection-key") + self.assertEqual(server.connection_key, "gamecult-dev-connection-key") + self.assertTrue(server.is_development) + self.assertEqual(server.to_client_options().connection_key, client.connection_key) + self.assertEqual(client.encryption_key(), hashlib.sha256(b"gamecult-dev-connection-key").digest()) + self.assertEqual(server.session_signing_key(), hashlib.sha256(b"gamecult-dev-session-signing-secret").digest()) + self.assertEqual( + CultNetServerSecurityOptions.from_environment({}, allow_development_defaults=True), + server, + ) + with self.assertRaisesRegex(ValueError, "not configured"): + CultNetServerSecurityOptions.from_environment({}) + with self.assertRaisesRegex(ValueError, "GAMECULT_SESSION_SIGNING_SECRET"): + CultNetServerSecurityOptions.from_environment({"GAMECULT_CONNECTION_KEY": "key"}) + configured = CultNetServerSecurityOptions.from_environment({ + "GAMECULT_CONNECTION_KEY": "prod-key", + "GAMECULT_SESSION_SIGNING_SECRET": "prod-secret", + }) + self.assertEqual(configured.connection_key, "prod-key") + self.assertFalse(configured.is_development) + + def test_cultnet_secret_helpers_validate_csharp_versioned_session_tokens(self) -> None: + security = CultNetServerSecurityOptions("connection-key", "session-secret") + user_id = uuid4() + expires = datetime(2035, 1, 2, 3, 4, 5, tzinfo=UTC) + + token = CultNetSecret.create_session_token(user_id, expires, security, session_version=7) + payload_b64, signature_b64 = token.split(".") + self.assertEqual( + CultNetSecret.from_base64url(payload_b64).decode("utf-8"), + f"{user_id.hex}|{int(expires.timestamp())}|7", + ) + self.assertEqual( + CultNetSecret.from_base64url(signature_b64), + hmac.new( + hashlib.sha256(b"session-secret").digest(), + CultNetSecret.from_base64url(payload_b64), + hashlib.sha256, + ).digest(), + ) + + validated = CultNetSecret.try_validate_session_token( + token, + security, + now_utc=datetime(2035, 1, 2, 3, 4, 4, tzinfo=UTC), + ) + self.assertIsNotNone(validated) + assert validated is not None + self.assertEqual(validated.user_id, user_id) + self.assertEqual(validated.expires_at_utc, expires) + self.assertEqual(validated.session_version, 7) + + tampered_payload = CultNetSecret.to_base64url(CultNetSecret.from_base64url(payload_b64) + b"tamper") + tampered = f"{tampered_payload}.{signature_b64}" + self.assertIsNone(CultNetSecret.try_validate_session_token(tampered, security)) + self.assertIsNone(CultNetSecret.try_validate_session_token(token, security, now_utc=expires)) + + def test_cultnet_secret_helpers_validate_legacy_two_field_session_tokens(self) -> None: + security = CultNetServerSecurityOptions.development() + user_id = uuid4() + expires_at_seconds = int(datetime(2036, 1, 1, tzinfo=UTC).timestamp()) + payload = f"{user_id.hex}|{expires_at_seconds}".encode("utf-8") + signature = hmac.new(security.session_signing_key(), payload, hashlib.sha256).digest() + token = f"{CultNetSecret.to_base64url(payload)}.{CultNetSecret.to_base64url(signature)}" + + validated = CultNetSecret.try_validate_session_token( + token, + security, + now_utc=datetime(2035, 12, 31, tzinfo=UTC), + ) + + self.assertIsNotNone(validated) + assert validated is not None + self.assertEqual(validated.user_id, user_id) + self.assertEqual(validated.session_version, 0) + + def test_cultnet_secret_encryption_helpers_are_optional_without_crypto_dependency(self) -> None: + nonce = b"123456789012" + security = CultNetServerSecurityOptions.development() + try: + encrypted = CultNetSecret.encrypt_string("hello", nonce, security.to_client_options()) + except ImportError as exc: + self.assertIn("cryptography", str(exc)) + return + self.assertEqual(CultNetSecret.decrypt_string(encrypted, nonce, security), "hello") + + def test_cultnet_schema_message_frame_round_trip(self) -> None: + message = hello( + runtime_id="python-test", + supported_schema_versions=["cultnet.hello.v0"], + transport_profiles=[ + { + "schemaVersion": "cultnet.transport_profile.v0", + "runtimeId": "python-test", + "transports": [ + { + "transportId": "test", + "protocol": "tcp_framed", + "channels": [{"channelId": "schema", "delivery": "reliable", "ordering": "ordered"}], + } + ], + } + ], + ) + parsed = parse_message(decode_frame(encode_frame(message.to_bytes()))) + self.assertEqual(parsed.schema_version, "cultnet.hello.v0") + self.assertEqual(parsed.body["runtimeId"], "python-test") + self.assertEqual(parsed.body["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed") + + def test_cultnet_tcp_framed_transport_carries_schema_payloads_with_stats(self) -> None: + payload = b"cultnet-payload" + output = io.BytesIO() + sender = TcpFramedTransportConnection( + output, + profile=create_tcp_framed_transport_profile("sender", transport_id="test-tcp"), + ) + sender.send("schema", payload) + self.assertEqual(sender.stats.frames_sent, 1) + self.assertEqual(sender.stats.bytes_sent, len(payload) + 4) + with self.assertRaisesRegex(ValueError, "only supports the schema channel"): + sender.send("unreliable", b"") + + receiver = TcpFramedTransportConnection( + io.BytesIO(output.getvalue()), + profile=create_tcp_framed_transport_profile("receiver", transport_id="test-tcp"), + ) + frame = receiver.receive() + self.assertEqual(frame.channel_id, "schema") + self.assertEqual(frame.payload, payload) + self.assertEqual(receiver.stats.frames_received, 1) + self.assertEqual(receiver.stats.bytes_received, len(payload) + 4) + self.assertEqual(receiver.profile["transports"][0]["protocol"], "tcp_framed") + + def test_cultnet_raw_client_uses_schema_transport_factory(self) -> None: + import msgpack # type: ignore + + sent_messages: list[dict[str, object]] = [] + test_case = self + + class FakeSchemaTransport: + profile = create_tcp_framed_transport_profile("fake-client") + + def __enter__(self) -> "FakeSchemaTransport": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + self.close() + + def send(self, channel_id: str, payload: bytes) -> None: + test_case.assertEqual(channel_id, "schema") + sent_messages.append(msgpack.unpackb(payload, raw=False)) + + def receive(self) -> object: + response = { + "schemaVersion": "cultnet.schema_catalog_response.v0", + "messageId": "transport-factory", + "schemas": [], + } + return type("Frame", (), {"channel_id": "schema", "payload": msgpack.packb(response, use_bin_type=True)})() + + def close(self) -> None: + pass + + client = CultNetRawClient( + "unused.example.test", + 1, + create_transport=FakeSchemaTransport, + ) + + response = client.fetch_schema_catalog(message_id="transport-factory") + + self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0") + self.assertEqual(sent_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0") + self.assertEqual(sent_messages[0]["messageId"], "transport-factory") + + def test_cultnet_rudp_packet_codec_uses_deterministic_reliable_ordered_fixture(self) -> None: + encoded = encode_rudp_packet( + CultNetRudpPacket( + packet_type=CultNetRudpPacketType.DATA, + connection_id=0x01020304, + sequence=0x0000002A, + ack=0x00000029, + ack_mask=0x80000001, + channel_id="schema", + reliable=True, + ordered=True, + fragment_id=7, + fragment_index=1, + fragment_count=3, + payload=b"hello", + ) + ) + + self.assertEqual( + encoded.hex(), + "434e523000030b2a010203040000002a0000002980000001000700010003000000050600736368656d6168656c6c6f", + ) + + decoded = decode_rudp_packet(encoded) + self.assertEqual(decoded.packet_type, CultNetRudpPacketType.DATA) + self.assertEqual(decoded.connection_id, 0x01020304) + self.assertEqual(decoded.sequence, 0x0000002A) + self.assertEqual(decoded.ack, 0x00000029) + self.assertEqual(decoded.ack_mask, 0x80000001) + self.assertEqual(decoded.channel_id, "schema") + self.assertTrue(decoded.reliable) + self.assertTrue(decoded.ordered) + self.assertFalse(decoded.sequenced) + self.assertEqual(decoded.fragment_id, 7) + self.assertEqual(decoded.fragment_index, 1) + self.assertEqual(decoded.fragment_count, 3) + self.assertEqual(decoded.payload, b"hello") + + def test_cultnet_rudp_transport_profile_advertises_state_and_realtime_channels(self) -> None: + profile = create_rudp_transport_profile( + "python-rudp", + transport_id="public-rudp", + host="127.0.0.1", + port=7777, + max_payload_bytes=1200, + max_fragment_bytes=1000, + ) + + self.assertEqual(profile["transports"][0]["protocol"], "rudp") + self.assertEqual(profile["transports"][0]["reconnectPolicy"]["schemaVersion"], "cultnet.reconnect_policy.v0") + self.assertEqual(profile["transports"][0]["reconnectPolicy"]["baseDelayMs"], 1_000) + self.assertEqual( + [ + (channel["channelId"], channel["delivery"], channel["ordering"]) + for channel in profile["transports"][0]["channels"] + ], + [ + ("schema", "reliable", "ordered"), + ("latest", "unreliable", "sequenced"), + ("realtime", "unreliable", "unordered"), + ], + ) + + def test_cultnet_reconnect_policy_exposes_portable_delay_contract(self) -> None: + policy = create_reconnect_policy(policy_id="rudp-default", max_attempts=8) + + self.assertEqual(policy.schema_version, "cultnet.reconnect_policy.v0") + self.assertEqual(policy.policy_id, "rudp-default") + self.assertEqual(policy.max_attempts, 8) + self.assertEqual(policy.to_wire()["policyId"], "rudp-default") + self.assertEqual(policy.to_wire()["maxAttempts"], 8) + self.assertEqual(compute_reconnect_delay_ms(policy, 1), 1_000) + self.assertEqual(compute_reconnect_delay_ms(policy, 3, 17), 4_017) + self.assertEqual(compute_reconnect_delay_ms(policy, 9, 999), 30_250) + self.assertEqual(compute_reconnect_delay_ms(policy, 0, -5), 1_000) + + def test_cultnet_reconnect_controller_schedules_attempts_and_reset(self) -> None: + controller = CultNetReconnectController(create_reconnect_policy(max_attempts=2)) + + first = controller.record_failure(10_000) + self.assertEqual(first.attempt, 1) + self.assertTrue(first.should_retry) + self.assertEqual(first.delay_ms, 1_000) + self.assertEqual(first.next_attempt_at_ms, 11_000) + self.assertFalse(first.exhausted) + self.assertFalse(controller.can_attempt(10_999)) + self.assertTrue(controller.can_attempt(11_000)) + + second = controller.record_failure(11_000, 17) + self.assertEqual(second.attempt, 2) + self.assertEqual(second.delay_ms, 2_017) + self.assertEqual(second.next_attempt_at_ms, 13_017) + self.assertTrue(second.should_retry) + + exhausted = controller.record_failure(13_017) + self.assertEqual(exhausted.attempt, 2) + self.assertFalse(exhausted.should_retry) + self.assertEqual(exhausted.delay_ms, 0) + self.assertIsNone(exhausted.next_attempt_at_ms) + self.assertTrue(exhausted.exhausted) + self.assertFalse(controller.can_attempt(99_000)) + + controller.reset() + self.assertEqual(controller.attempt, 0) + self.assertIsNone(controller.next_attempt_at_ms) + self.assertFalse(controller.exhausted) + self.assertTrue(controller.can_attempt(99_000)) + + def test_cultnet_rudp_reconnect_loop_consumes_shared_controller(self) -> None: + class FakeRudpReconnectTransport: + def __init__(self) -> None: + self.connect_calls = 0 + self.close_calls = 0 + self.last_payload: bytes | None = None + + def connect(self, payload: bytes = b"") -> None: + self.connect_calls += 1 + self.last_payload = bytes(payload) + + def close(self) -> None: + self.close_calls += 1 + + now_ms = 10_000 + captured_callback: list[Callable[[], None]] = [] + transports: list[FakeRudpReconnectTransport] = [] + + def create_transport() -> FakeRudpReconnectTransport: + transport = FakeRudpReconnectTransport() + transports.append(transport) + return transport + + def scheduler(delay_ms: int, callback: Callable[[], None]) -> Callable[[], None]: + self.assertEqual(delay_ms, 1_017) + captured_callback[:] = [callback] + + def cancel() -> None: + captured_callback.clear() + + return cancel + + loop = CultNetRudpReconnectLoop( + create_transport, + reconnect_policy=create_reconnect_policy(max_attempts=2), + connect_payload=b"join", + now_ms=lambda: now_ms, + jitter_ms=lambda: 17, + scheduler=scheduler, + ) + + first = loop.start() + self.assertEqual(first.connect_calls, 1) + self.assertEqual(first.last_payload, b"join") + self.assertIs(loop.transport, first) + + decision = loop.handle_closed() + self.assertIsNotNone(decision) + self.assertEqual(decision.attempt, 1) + self.assertTrue(decision.should_retry) + self.assertEqual(decision.delay_ms, 1_017) + self.assertEqual(decision.next_attempt_at_ms, 11_017) + self.assertEqual(loop.reconnect_controller.attempt, 1) + self.assertEqual(loop.reconnect_controller.next_attempt_at_ms, 11_017) + self.assertEqual(len(captured_callback), 1) + + now_ms = 11_017 + captured_callback[0]() + self.assertEqual(len(transports), 2) + self.assertEqual(transports[1].connect_calls, 1) + self.assertIs(loop.transport, transports[1]) + + loop.mark_connected() + self.assertEqual(loop.reconnect_controller.attempt, 0) + + loop.stop() + self.assertEqual(transports[1].close_calls, 1) + self.assertIsNone(loop.transport) + + def test_cultnet_rudp_session_handshake_acks_reliable_connect_and_accept_packets(self) -> None: + client = CultNetRudpSession( + CultNetRudpSessionOptions(connection_id=0x0A0B0C0D, initial_sequence=1, resend_delay_ms=50) + ) + server = CultNetRudpSession( + CultNetRudpSessionOptions(connection_id=0x0A0B0C0D, initial_sequence=100, resend_delay_ms=50) + ) + + connect = client.create_connect(0, b"join") + self.assertEqual(connect.packet_type, CultNetRudpPacketType.CONNECT) + self.assertEqual(connect.sequence, 1) + self.assertEqual(client.pending_reliable_sequences, (1,)) + + accept = server.accept_connect(connect, 10, b"ok") + self.assertEqual(accept.packet_type, CultNetRudpPacketType.ACCEPT) + self.assertEqual(accept.ack, 1) + self.assertTrue(server.connected) + self.assertEqual(server.pending_reliable_sequences, (100,)) + + client.receive(accept, 20) + self.assertTrue(client.connected) + self.assertEqual(client.pending_reliable_sequences, ()) + + ack = client.create_ack() + self.assertEqual(ack.ack, 100) + server.receive(ack, 30) + self.assertEqual(server.pending_reliable_sequences, ()) + + def test_cultnet_rudp_session_computes_ack_masks_and_clears_pending_reliable_packets(self) -> None: + sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=7, initial_sequence=10, resend_delay_ms=100)) + receiver = CultNetRudpSession( + CultNetRudpSessionOptions(connection_id=7, initial_sequence=200, resend_delay_ms=100) + ) + sender.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 7, 1, 0, 0, "control") + ) + receiver.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 7, 2, 0, 0, "control") + ) + + first = sender.send( + "schema", + b"first", + CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0), + ) + second = sender.send( + "schema", + b"second", + CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0), + ) + third = sender.send( + "schema", + b"third", + CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0), + ) + self.assertEqual(sender.pending_reliable_sequences, (10, 11, 12)) + + receiver.receive(first) + receiver.receive(third) + ack_with_gap = receiver.create_ack() + self.assertEqual(ack_with_gap.ack, 12) + self.assertEqual(ack_with_gap.ack_mask, 0b10 | (1 << 9)) + sender.receive(ack_with_gap) + self.assertEqual(sender.pending_reliable_sequences, (11,)) + + receiver.receive(second) + full_ack = receiver.create_ack() + self.assertEqual(full_ack.ack, 12) + self.assertEqual(full_ack.ack_mask, 0b11 | (1 << 9)) + sender.receive(full_ack) + self.assertEqual(sender.pending_reliable_sequences, ()) + + def test_cultnet_rudp_session_schedules_reliable_resends_until_acked(self) -> None: + session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=99, initial_sequence=1, resend_delay_ms=100)) + session.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 99, 50, 0, 0, "control") + ) + sent = session.send( + "schema", + b"payload", + CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=10), + ) + + self.assertEqual(session.due_resends(90), ()) + self.assertEqual(tuple(packet.sequence for packet in session.due_resends(110)), (sent.sequence,)) + self.assertEqual(session.due_resends(150), ()) + + session.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACK, 99, 51, sent.sequence, 0, "control") + ) + self.assertEqual(session.due_resends(250), ()) + + def test_cultnet_rudp_session_pings_and_detects_receive_timeout(self) -> None: + client = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=101, initial_sequence=1)) + server = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=101, initial_sequence=100)) + connect = client.create_connect(0, b"join") + accept = server.accept_connect(connect, 10) + client.receive(accept, 20) + + ping = client.create_ping(b"pulse") + ping_result = server.receive(ping, 30) + self.assertIsNotNone(ping_result.reply) + self.assertEqual(ping_result.reply.packet_type, CultNetRudpPacketType.PONG) + self.assertEqual(ping_result.reply.payload, b"pulse") + + pong_result = client.receive(ping_result.reply, 40) + self.assertTrue(pong_result.pong) + self.assertEqual(pong_result.pong_payload, b"pulse") + self.assertFalse(client.check_timeout(90, 50)) + self.assertTrue(client.check_timeout(91, 50)) + self.assertFalse(client.connected) + + def test_cultnet_rudp_session_bounds_pending_reliable_packets_before_enqueue(self) -> None: + session = CultNetRudpSession( + CultNetRudpSessionOptions(connection_id=102, initial_sequence=1, max_pending_reliable_packets=2) + ) + session.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 102, 50, 0, 0, "control")) + session.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True)) + session.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True)) + with self.assertRaisesRegex(ValueError, "reliable send queue is full"): + session.send("schema", b"third", CultNetRudpSendOptions(reliable=True, ordered=True)) + self.assertEqual(session.pending_reliable_sequences, (1, 2)) + + fragmented = CultNetRudpSession( + CultNetRudpSessionOptions(connection_id=103, initial_sequence=1, max_pending_reliable_packets=3) + ) + fragmented.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 103, 50, 0, 0, "control")) + with self.assertRaisesRegex(ValueError, "reliable send queue is full"): + fragmented.send_many( + "schema", + b"fragment-me", + CultNetRudpSendOptions(reliable=True, ordered=True), + max_fragment_bytes=3, + ) + self.assertEqual(fragmented.pending_reliable_sequences, ()) + + def test_cultnet_rudp_session_suppresses_duplicates_and_delivers_reliable_ordered_payloads(self) -> None: + sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=123, initial_sequence=1)) + receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=123, initial_sequence=100)) + sender.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 123, 90, 0, 0, "control") + ) + receiver.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 123, 91, 0, 0, "control") + ) + + first = sender.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True)) + second = sender.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True)) + third = sender.send("schema", b"third", CultNetRudpSendOptions(reliable=True, ordered=True)) + + self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(first).delivered], ["first"]) + self.assertEqual(receiver.receive(third).delivered, ()) + self.assertEqual(receiver.receive(first).delivered, ()) + self.assertEqual( + [frame.payload.decode("utf-8") for frame in receiver.receive(second).delivered], + ["second", "third"], + ) + + def test_cultnet_rudp_lossy_packets_cannot_create_reliable_ordered_gaps(self) -> None: + sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=198, initial_sequence=1)) + receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=198, initial_sequence=100)) + sender.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 198, 90, 0, 0, "control")) + receiver.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 198, 91, 0, 0, "control")) + + realtime = sender.send("realtime", b"discarded realtime", CultNetRudpSendOptions()) + latest = sender.send("latest", b"discarded latest state", CultNetRudpSendOptions(sequenced=True)) + schema = sender.send("schema", b"committed response", CultNetRudpSendOptions(reliable=True, ordered=True)) + + self.assertEqual(realtime.sequence, 0) + self.assertEqual(latest.sequence, 1) + self.assertEqual( + [frame.payload.decode("utf-8") for frame in receiver.receive(schema).delivered], + ["committed response"], + ) + + def test_cultnet_rudp_unreliable_sequenced_delivery_is_scoped_to_its_channel(self) -> None: + sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=197, initial_sequence=50)) + receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=197, initial_sequence=100)) + sender.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 197, 90, 0, 0, "control")) + receiver.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 197, 91, 0, 0, "control")) + + older = sender.send("latest", b"older", CultNetRudpSendOptions(sequenced=True)) + newer = sender.send("latest", b"newer", CultNetRudpSendOptions(sequenced=True)) + + self.assertEqual(older.sequence, 1) + self.assertEqual(newer.sequence, 2) + self.assertEqual( + [frame.payload.decode("utf-8") for frame in receiver.receive(newer).delivered], + ["newer"], + ) + self.assertEqual(receiver.receive(older).delivered, ()) + + def test_cultnet_rudp_rejects_unreliable_ordered_delivery(self) -> None: + session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=196, initial_sequence=1)) + session.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 196, 50, 0, 0, "control")) + + with self.assertRaises(ValueError) as raised: + session.send( + "schema", + b"cannot order what will not retransmit", + CultNetRudpSendOptions(ordered=True), + ) + self.assertIn("ordered delivery requires reliability", str(raised.exception)) + + def test_cultnet_rudp_session_skips_control_packets_while_ordering_schema_payloads(self) -> None: + sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=124, initial_sequence=1)) + receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=124, initial_sequence=100)) + sender.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 124, 90, 0, 0, "control") + ) + receiver.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 124, 91, 0, 0, "control") + ) + + first = sender.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True)) + control = sender.create_ack() + second = sender.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True)) + + self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(first).delivered], ["first"]) + self.assertEqual(receiver.receive(control).delivered, ()) + self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(second).delivered], ["second"]) + + def test_cultnet_rudp_session_fragments_and_reassembles_reliable_ordered_payloads(self) -> None: + sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=456, initial_sequence=1)) + receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=456, initial_sequence=100)) + sender.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 456, 90, 0, 0, "control") + ) + receiver.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 456, 91, 0, 0, "control") + ) + + packets = sender.send_many( + "schema", + b"fragment-me-please", + CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=10), + max_fragment_bytes=5, + ) + self.assertEqual(len(packets), 4) + self.assertEqual(tuple(packet.fragment_count for packet in packets), (4, 4, 4, 4)) + self.assertEqual(tuple(packet.fragment_index for packet in packets), (0, 1, 2, 3)) + self.assertTrue(all(packet.fragment_id == packets[0].fragment_id for packet in packets)) + + self.assertEqual(receiver.receive(packets[0]).delivered, ()) + self.assertEqual(receiver.receive(packets[1]).delivered, ()) + self.assertEqual(receiver.receive(packets[2]).delivered, ()) + delivered = receiver.receive(packets[3]).delivered + self.assertEqual(len(delivered), 1) + self.assertEqual(delivered[0].payload, b"fragment-me-please") + self.assertEqual(delivered[0].sequence, packets[0].sequence) + + def test_cultnet_rudp_session_advances_large_fragment_sets_through_bounded_window(self) -> None: + connection_id = 457 + sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=connection_id, initial_sequence=1)) + receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=connection_id, initial_sequence=100)) + sender.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, connection_id, 0, 0, 0, "control")) + receiver.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, connection_id, 0, 0, 0, "control")) + + fragment_count = CultNetRudpSession.RELIABLE_SEND_WINDOW_PACKETS + 17 + payload = bytes(index % 251 for index in range(fragment_count * 8)) + wire = list(sender.send_many( + "schema", + payload, + CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=1), + max_fragment_bytes=8, + )) + self.assertEqual(len(wire), CultNetRudpSession.RELIABLE_SEND_WINDOW_PACKETS) + oldest_sequence = wire[0].sequence + self.assertEqual(len(sender.pending_reliable_sequences), len(wire)) + self.assertEqual(sender.queued_reliable_packet_count, 17) + + delivered = [] + while wire: + admitted = len(wire) + for _ in range(admitted): + packet = wire.pop(0) + delivered.extend(receiver.receive(packet, 2).delivered) + acknowledged = sender.receive(receiver.create_ack(), 3) + wire.extend(acknowledged.ready_to_send) + + self.assertEqual(sender.outstanding_reliable_packet_count, 0) + self.assertEqual(len(delivered), 1) + self.assertEqual(delivered[0].payload, payload) + old_ack = receiver.create_ack_for_received(oldest_sequence) + self.assertEqual(old_ack.ack, oldest_sequence) + self.assertEqual(old_ack.ack_mask, 0) + + def test_cultnet_rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames(self) -> None: + server_socket = bind_udp_socket() + client_socket = bind_udp_socket() + connection_id = 0x10203040 + server = CultNetRudpSocketTransportConnection( + CultNetRudpSocketTransportOptions( + runtime_id="python-rudp-server", + socket=server_socket, + mode=CultNetRudpSocketMode.SERVER, + connection_id=connection_id, + initial_sequence=100, + resend_delay_ms=25, + ) + ) + client = CultNetRudpSocketTransportConnection( + CultNetRudpSocketTransportOptions( + runtime_id="python-rudp-client", + socket=client_socket, + mode=CultNetRudpSocketMode.CLIENT, + remote_addr=server_socket.getsockname(), + connection_id=connection_id, + initial_sequence=1, + resend_delay_ms=25, + ) + ) + + try: + client.connect(b"join") + pump_rudp_handshake(client, server) + self.assertTrue(client.connected) + self.assertTrue(server.connected) + + client.send("schema", b"client-state") + server_frame = receive_rudp_frame(server) + self.assertEqual(server_frame.channel_id, "schema") + self.assertEqual(server_frame.payload, b"client-state") + + server.send("schema", b"server-state") + client_frame = receive_rudp_frame(client) + self.assertEqual(client_frame.channel_id, "schema") + self.assertEqual(client_frame.payload, b"server-state") + self.assertEqual(server.profile["transports"][0]["protocol"], "rudp") + self.assertEqual(client.stats.frames_sent, 1) + self.assertEqual(server.stats.frames_received, 1) + finally: + client.close() + server.close() + + def test_cultnet_rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames(self) -> None: + server_socket = bind_udp_socket() + client_socket = bind_udp_socket() + connection_id = 0x10203041 + server = CultNetRudpSocketTransportConnection( + CultNetRudpSocketTransportOptions( + runtime_id="python-rudp-fragment-server", + socket=server_socket, + mode=CultNetRudpSocketMode.SERVER, + connection_id=connection_id, + initial_sequence=100, + resend_delay_ms=25, + max_fragment_bytes=8, + ) + ) + client = CultNetRudpSocketTransportConnection( + CultNetRudpSocketTransportOptions( + runtime_id="python-rudp-fragment-client", + socket=client_socket, + mode=CultNetRudpSocketMode.CLIENT, + remote_addr=server_socket.getsockname(), + connection_id=connection_id, + initial_sequence=1, + resend_delay_ms=25, + max_fragment_bytes=8, + ) + ) + + try: + payload = b"this-schema-frame-is-larger-than-one-rudp-fragment" + client.connect(b"join") + pump_rudp_handshake(client, server) + client.send("schema", payload) + server_frame = receive_rudp_frame(server) + self.assertEqual(server_frame.channel_id, "schema") + self.assertEqual(server_frame.payload, payload) + self.assertEqual(client.stats.frames_sent, 1) + self.assertEqual(server.stats.frames_received, 1) + finally: + client.close() + server.close() + + def test_cultnet_database_subscription_helpers_match_schema_v0_shape(self) -> None: + subscribe = database_subscribe( + message_id="sub-message", + subscription_id="sub-1", + schema_ids=["schema-a"], + record_keys=["record-a"], + include_snapshot=False, + ).to_wire() + self.assertEqual(subscribe["schemaVersion"], "cultnet.database_subscribe.v0") + self.assertEqual(subscribe["messageId"], "sub-message") + self.assertEqual(subscribe["subscriptionId"], "sub-1") + self.assertEqual(subscribe["schemaIds"], ["schema-a"]) + self.assertEqual(subscribe["recordKeys"], ["record-a"]) + self.assertFalse(subscribe["includeSnapshot"]) + + unsubscribe = database_unsubscribe(message_id="unsub-message", subscription_id="sub-1").to_wire() + self.assertEqual(unsubscribe["schemaVersion"], "cultnet.database_unsubscribe.v0") + self.assertEqual(unsubscribe["messageId"], "unsub-message") + self.assertEqual(unsubscribe["subscriptionId"], "sub-1") + + def test_cultnet_database_change_parses_put_and_delete_shapes(self) -> None: + put_change = CultNetDatabaseChange.from_wire({ + "schemaVersion": "cultnet.database_change_raw.v0", + "messageId": "change-put", + "subscriptionId": "sub-1", + "changeKind": "added", + "document": { + "schemaId": "schema-note", + "recordKey": "note:1", + "payload": b"payload", + }, + }) + self.assertEqual(put_change.schema_id, "schema-note") + self.assertEqual(put_change.record_key, "note:1") + self.assertIsInstance(put_change.raw_document, CultNetRawDocumentRecord) + self.assertEqual(put_change.raw_document.payload, b"payload") + self.assertEqual(put_change.to_wire()["document"]["recordKey"], "note:1") + + delete_change = CultNetDatabaseChange.from_wire({ + "schemaVersion": "cultnet.database_change_raw.v0", + "messageId": "change-delete", + "subscriptionId": "sub-1", + "changeKind": "removed", + "schemaId": "schema-note", + "recordKey": "note:1", + }) + self.assertIsNone(delete_change.document) + self.assertIsNone(delete_change.raw_document) + self.assertEqual(delete_change.schema_id, "schema-note") + self.assertEqual(delete_change.to_wire()["recordKey"], "note:1") + + def test_cultnet_raw_put_helper_carries_message_id(self) -> None: + put = document_put_raw( + message_id="put-1", + key="record-a", + schema_id="schema-a", + stored_at="2026-06-13T00:00:00Z", + payload=b"payload", + source_runtime_id="python-test", + shard_id="interop", + shard_epoch=1, + ).to_wire() + self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0") + self.assertEqual(put["messageId"], "put-1") + self.assertEqual(put["document"]["recordKey"], "record-a") + self.assertEqual(put["document"]["sourceRuntimeId"], "python-test") + self.assertEqual(put["shardId"], "interop") + self.assertEqual(put["shardEpoch"], 1) + + def test_cultnet_catalog_and_snapshot_helpers_accept_filters(self) -> None: + catalog = schema_catalog_request( + message_id="catalog-1", + include_schema_json=True, + schema_ids=["schema-a"], + kinds=["wire_message"], + ).to_wire() + self.assertEqual(catalog["schemaVersion"], "cultnet.schema_catalog_request.v0") + self.assertEqual(catalog["messageId"], "catalog-1") + self.assertTrue(catalog["includeSchemaJson"]) + self.assertEqual(catalog["schemaIds"], ["schema-a"]) + self.assertEqual(catalog["kinds"], ["wire_message"]) + + snapshot = snapshot_request( + message_id="snapshot-1", + schema_ids=["schema-a"], + record_keys=["record-a"], + shard_id="interop", + shard_epoch=1, + ).to_wire() + self.assertEqual(snapshot["schemaVersion"], "cultnet.snapshot_request.v0") + self.assertEqual(snapshot["messageId"], "snapshot-1") + self.assertEqual(snapshot["schemaIds"], ["schema-a"]) + self.assertEqual(snapshot["recordKeys"], ["record-a"]) + self.assertEqual(snapshot["shardId"], "interop") + self.assertEqual(snapshot["shardEpoch"], 1) + + def test_cultnet_raw_snapshot_response_filters_and_roundtrips_records(self) -> None: + response = CultNetRawSnapshotResponse.from_wire({ + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "snapshot-1", + "shardId": "notes", + "shardEpoch": 3, + "shardLogSequence": 8, + "documents": [ + CultNetRawDocumentRecord( + schema_id="schema-note", + record_key="note:1", + stored_at="2026-06-14T00:00:00Z", + payload=b"note", + source_runtime_id="python-test", + tags=("snapshot",), + ).to_wire(), + { + "schemaId": "schema-fact", + "recordKey": "fact:1", + "payloadEncoding": "messagepack", + "payload": b"fact", + }, + ], + }) + self.assertEqual(response.shard_id, "notes") + self.assertEqual(response.shard_epoch, 3) + self.assertEqual(response.shard_log_sequence, 8) + self.assertEqual(response.documents[0].source_runtime_id, "python-test") + self.assertEqual(response.filter(schema_ids=["schema-note"])[0].record_key, "note:1") + self.assertEqual(response.filter(record_keys=["fact:1"])[0].schema_id, "schema-fact") + self.assertEqual(response.to_wire()["documents"][0]["tags"], ["snapshot"]) + + def test_cultnet_raw_client_fetches_schema_snapshot_and_shard_reads(self) -> None: + import msgpack # type: ignore + from cultnet_py import read_frame, write_frame + + received_versions: list[str] = [] + ready = threading.Event() + server_error: list[BaseException] = [] + port_holder: list[int] = [] + + def serve_requests() -> None: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + port_holder.append(server.getsockname()[1]) + server.listen(4) + ready.set() + for _ in range(4): + connection, _ = server.accept() + with connection: + stream = connection.makefile("rwb") + request = msgpack.unpackb(read_frame(stream), raw=False) + received_versions.append(request["schemaVersion"]) + if request["schemaVersion"] == "cultnet.schema_catalog_request.v0": + self.assertEqual(request["kinds"], ["wire_message"]) + response = {"schemaVersion": "cultnet.schema_catalog_response.v0", "messageId": request["messageId"], "schemas": []} + elif request["schemaVersion"] == "cultnet.snapshot_request.v0": + self.assertEqual(request["shardId"], "interop") + response = {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": request["messageId"], "documents": []} + elif request["schemaVersion"] == "cultnet.shard_catalog_request.v0": + response = { + "schemaVersion": "cultnet.shard_catalog_response.v0", + "messageId": request["messageId"], + "shards": [{ + "shardId": "interop", + "ownerRuntimeId": "socket-test", + "epoch": 1, + "schemaIds": request["schemaIds"], + "keyPrefix": "record-", + "primaryEndpoints": ["cultnet://127.0.0.1:1"], + }], + } + elif request["schemaVersion"] == "cultnet.shard_log_request.v0": + self.assertEqual(request["afterSequence"], 7) + response = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": request["messageId"], + "shardId": request["shardId"], + "shardEpoch": request["shardEpoch"], + "entries": [], + "resyncRequired": False, + } + else: + raise AssertionError(f"unexpected request {request['schemaVersion']}") + write_frame(stream, msgpack.packb(response, use_bin_type=True)) + stream.flush() + except BaseException as error: + server_error.append(error) + ready.set() + + thread = threading.Thread(target=serve_requests, daemon=True) + thread.start() + self.assertTrue(ready.wait(2.0)) + self.assertFalse(server_error) + + client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) + self.assertEqual(client.fetch_schema_catalog(kinds=["wire_message"])["schemaVersion"], "cultnet.schema_catalog_response.v0") + typed_snapshot = client.fetch_snapshot_response(shard_id="interop", shard_epoch=1) + self.assertEqual(typed_snapshot.shard_id, None) + self.assertEqual(typed_snapshot.documents, ()) + shard_catalog = CultNetShardCatalog() + synced_shards = client.sync_shard_catalog(shard_catalog, schema_ids=["schema-a"]) + self.assertEqual(synced_shards[0].shard_id, "interop") + self.assertEqual(shard_catalog.get("interop"), synced_shards[0]) + shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=7) + self.assertEqual(shard_log.shard_id, "interop") + self.assertFalse(shard_log.resync_required) + self.assertEqual(shard_log.last_sequence, 0) + + thread.join(2.0) + self.assertFalse(server_error) + self.assertEqual(received_versions, [ + "cultnet.schema_catalog_request.v0", + "cultnet.snapshot_request.v0", + "cultnet.shard_catalog_request.v0", + "cultnet.shard_log_request.v0", + ]) + + def test_cultnet_raw_client_raises_typed_peer_error_response(self) -> None: + import msgpack # type: ignore + from cultnet_py import read_frame, write_frame + + ready = threading.Event() + port_holder: list[int] = [] + + def serve_error() -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + port_holder.append(server.getsockname()[1]) + server.listen(1) + ready.set() + connection, _ = server.accept() + with connection: + stream = connection.makefile("rwb") + read_frame(stream) + write_frame(stream, msgpack.packb({ + "schemaVersion": "cultnet.error.v0", + "messageId": "error-1", + "error": "Snapshot document limit exceeded", + }, use_bin_type=True)) + stream.flush() + + thread = threading.Thread(target=serve_error, daemon=True) + thread.start() + self.assertTrue(ready.wait(2.0)) + + client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) + with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded") as raised: + client.fetch_snapshot() + + self.assertEqual(raised.exception.peer_error, "Snapshot document limit exceeded") + self.assertEqual(raised.exception.response["schemaVersion"], "cultnet.error.v0") + thread.join(2.0) + + def test_cultnet_database_subscription_reads_snapshot_and_change(self) -> None: + import msgpack # type: ignore + from cultnet_py import read_frame, write_frame + + document = define_database_entry_type( + "sub.item", + [ + ("name", 0), + ("category", 1), + ("value", 2, 0), + ], + cls=Item, + ) + schema_id = document.catalog_entry().schema_id + ready = threading.Event() + server_error: list[BaseException] = [] + port_holder: list[int] = [] + received_versions: list[str] = [] + + def serve_subscription() -> None: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + port_holder.append(server.getsockname()[1]) + server.listen(1) + ready.set() + connection, _ = server.accept() + with connection: + stream = connection.makefile("rwb") + subscribe = msgpack.unpackb(read_frame(stream), raw=False) + received_versions.append(subscribe["schemaVersion"]) + self.assertEqual(subscribe["subscriptionId"], "sub-1") + snapshot = { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": subscribe["messageId"], + "documents": [], + } + write_frame(stream, msgpack.packb(snapshot, use_bin_type=True)) + stream.flush() + + put = msgpack.unpackb(read_frame(stream), raw=False) + received_versions.append(put["schemaVersion"]) + change = { + "schemaVersion": "cultnet.database_change_raw.v0", + "messageId": "change-1", + "subscriptionId": "sub-1", + "changeKind": "added", + "document": put["document"], + } + write_frame(stream, msgpack.packb(change, use_bin_type=True)) + stream.flush() + + unsubscribe = msgpack.unpackb(read_frame(stream), raw=False) + received_versions.append(unsubscribe["schemaVersion"]) + except BaseException as error: + server_error.append(error) + ready.set() + + thread = threading.Thread(target=serve_subscription, daemon=True) + thread.start() + self.assertTrue(ready.wait(2.0)) + self.assertFalse(server_error) + + client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) + put = document_put_raw( + message_id="put-sub", + key="item:sub", + schema_id=schema_id, + stored_at="2026-06-14T00:00:00Z", + payload=document.encode_payload(Item("orb", "gear", 8)), + ) + with client.subscribe_database(subscription_id="sub-1", schema_ids=[schema_id]) as subscription: + snapshot = subscription.read_next_snapshot_response() + subscription.send(put) + change = subscription.read_next_change() + + thread.join(2.0) + self.assertFalse(server_error) + self.assertIsInstance(snapshot, CultNetRawSnapshotResponse) + self.assertEqual(snapshot.message_id, "cultnet-python-subscribe") + self.assertEqual(snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0") + self.assertEqual(change.change_kind, "added") + self.assertEqual(change.record_key, "item:sub") + self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord) + self.assertEqual(change.raw_document.record_key, "item:sub") + self.assertEqual(received_versions, [ + "cultnet.database_subscribe.v0", + "cultnet.document_put_raw.v0", + "cultnet.database_unsubscribe.v0", + ]) + + def test_cultnet_database_subscription_raises_typed_peer_error(self) -> None: + import msgpack # type: ignore + from cultnet_py import read_frame, write_frame + + ready = threading.Event() + port_holder: list[int] = [] + + def serve_subscription_error() -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + port_holder.append(server.getsockname()[1]) + server.listen(1) + ready.set() + connection, _ = server.accept() + with connection: + stream = connection.makefile("rwb") + read_frame(stream) + write_frame(stream, msgpack.packb({ + "schemaVersion": "cultnet.error.v0", + "messageId": "subscription-error", + "error": "subscription rejected", + }, use_bin_type=True)) + stream.flush() + + thread = threading.Thread(target=serve_subscription_error, daemon=True) + thread.start() + self.assertTrue(ready.wait(2.0)) + + client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) + with client.subscribe_database(subscription_id="sub-error") as subscription: + with self.assertRaisesRegex(CultNetPeerError, "subscription rejected") as raised: + subscription.read_next() + self.assertEqual(raised.exception.response["messageId"], "subscription-error") + thread.join(2.0) + + def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None: + document = define_database_entry_type( + "replica.item", + [ + ("name", 0), + ("category", 1), + ("value", 2, 0), + ], + cls=Item, + ) + cache = CultCache() + cache.register_document_type(document) + schema_id = document.catalog_entry().schema_id + + snapshot = { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "snapshot-1", + "documents": [ + { + "schemaId": schema_id, + "recordKey": "item:1", + "storedAt": "2026-06-13T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(Item("sword", "gear", 3)), + } + ], + } + applied_snapshot = apply_raw_snapshot(cache, [document], snapshot) + self.assertEqual(applied_snapshot[0].record_key, "item:1") + self.assertEqual(cache.get_required(document, "item:1").value, 3) + typed_snapshot = CultNetRawSnapshotResponse.from_wire(snapshot) + direct_applied = apply_raw_document_record(cache, schema_document_map([document]), typed_snapshot.documents[0]) + self.assertEqual(direct_applied.schema_id, schema_id) + self.assertEqual(direct_applied.record_key, "item:1") + typed_snapshot_applied = apply_raw_snapshot(cache, [document], typed_snapshot) + self.assertEqual(typed_snapshot_applied[0].record_key, "item:1") + + shard_log = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "log-1", + "shardId": "interop", + "shardEpoch": 1, + "resyncRequired": False, + "entries": [ + { + "sequence": 0, + "changeKind": "updated", + "put": { + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": "put-unknown", + "document": { + "schemaId": "replica.unknown.v1", + "recordKey": "unknown:1", + "storedAt": "2026-06-13T00:00:01Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(Item("ignored", "unknown", 0)), + }, + "shardId": "interop", + "shardEpoch": 1, + }, + }, + { + "sequence": 1, + "changeKind": "updated", + "put": { + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": "put-1", + "document": { + "schemaId": schema_id, + "recordKey": "item:1", + "storedAt": "2026-06-13T00:00:01Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(Item("sword", "gear", 12)), + }, + "shardId": "interop", + "shardEpoch": 1, + }, + }, + { + "sequence": 2, + "changeKind": "removed", + "delete": { + "schemaVersion": "cultnet.document_delete.v0", + "messageId": "delete-1", + "schemaId": schema_id, + "recordKey": "item:1", + "shardId": "interop", + "shardEpoch": 1, + }, + }, + ], + } + applied_log = apply_shard_log_response(cache, [document], shard_log) + self.assertEqual([change.change_kind for change in applied_log], ["updated", "removed"]) + self.assertIsNone(cache.get(document, "item:1")) + + cache.put(document, "item:1", Item("sword", "gear", 3)) + typed_shard_log = CultNetShardLogResponse.from_wire({ + **shard_log, + "entries": shard_log["entries"][1:], + }) + typed_applied_log = apply_shard_log_response(cache, [document], typed_shard_log) + self.assertEqual([change.change_kind for change in typed_applied_log], ["updated", "removed"]) + self.assertIsNone(cache.get(document, "item:1")) + + def test_cultnet_replication_helpers_recover_schema_stamped_raw_records(self) -> None: + document = define_database_entry_type( + "replica.runtime-policy", + [ + ("schema_version", 0), + ("name", 1), + ("value", 2), + ], + schema_id="replica.runtime-policy.current", + schema_name="replica.runtime_policy", + schema_version="replica.runtime_policy.v1", + ) + cache = CultCache() + cache.register_document_type(document) + stale_schema_id = "sha256:stale-replica-runtime-policy" + payload = document.encode_payload({ + "schema_version": "replica.runtime_policy.v1", + "name": "policy", + "value": "still synced", + }) + record = { + "schemaId": stale_schema_id, + "recordKey": "policy:1", + "storedAt": "2026-06-25T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": payload, + } + snapshot = { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "snapshot-stale", + "documents": [record], + } + + applied_snapshot = apply_raw_snapshot(cache, [document], snapshot) + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(applied_snapshot[0].schema_id, local_schema_id) + self.assertEqual(cache.get_required(document, "policy:1")["value"], "still synced") + self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) + direct_applied = apply_raw_document_record(cache, schema_document_map([document]), record) + self.assertEqual(direct_applied.schema_id, local_schema_id) + self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) + + shard_log = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "log-stale", + "shardId": "interop", + "shardEpoch": 1, + "resyncRequired": False, + "entries": [ + { + "sequence": 0, + "changeKind": "updated", + "put": { + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": "put-stale", + "document": { + **record, + "payload": document.encode_payload({ + "schema_version": "replica.runtime_policy.v1", + "name": "policy", + "value": "updated through log", + }), + }, + "shardId": "interop", + "shardEpoch": 1, + }, + } + ], + } + + applied_log = apply_shard_log_response(cache, [document], shard_log) + self.assertEqual(applied_log[0].schema_id, local_schema_id) + self.assertEqual(cache.get_required(document, "policy:1")["value"], "updated through log") + self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) + + delete_log = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "log-delete-alias", + "shardId": "interop", + "shardEpoch": 1, + "resyncRequired": False, + "entries": [ + { + "sequence": 1, + "changeKind": "removed", + "delete": { + "schemaVersion": "cultnet.document_delete.v0", + "messageId": "delete-version-alias", + "schemaId": "replica.runtime_policy.v1", + "recordKey": "policy:1", + "shardId": "interop", + "shardEpoch": 1, + }, + } + ], + } + applied_delete = apply_shard_log_response(cache, [document], delete_log) + self.assertEqual(applied_delete[0].schema_id, local_schema_id) + self.assertEqual(applied_delete[0].change_kind, "removed") + self.assertIsNone(cache.get(document, "policy:1")) + + def test_cultnet_document_delete_helper_matches_schema_v0_shape(self) -> None: + message = document_delete( + message_id="delete-1", + schema_id="schema-a", + record_key="record-a", + shard_id="interop", + shard_epoch=1, + ).to_wire() + self.assertEqual(message["schemaVersion"], "cultnet.document_delete.v0") + self.assertEqual(message["messageId"], "delete-1") + self.assertEqual(message["schemaId"], "schema-a") + self.assertEqual(message["recordKey"], "record-a") + self.assertEqual(message["shardId"], "interop") + self.assertEqual(message["shardEpoch"], 1) + + def test_cultnet_auth_session_helpers_match_schema_v0_shape(self) -> None: + self.assertEqual( + login(nonce="nonce", auth="encrypted-auth", password="encrypted-password").to_wire(), + { + "schemaVersion": "cultnet.login.v0", + "nonce": "nonce", + "auth": "encrypted-auth", + "password": "encrypted-password", + }, + ) + self.assertEqual( + register( + nonce="nonce", + email="encrypted-email", + password="encrypted-password", + name="encrypted-name", + ).to_wire(), + { + "schemaVersion": "cultnet.register.v0", + "nonce": "nonce", + "email": "encrypted-email", + "password": "encrypted-password", + "name": "encrypted-name", + }, + ) + self.assertEqual( + verify_session(nonce="nonce", session="encrypted-session").to_wire(), + { + "schemaVersion": "cultnet.verify.v0", + "nonce": "nonce", + "session": "encrypted-session", + }, + ) + self.assertEqual( + login_success(nonce="nonce", session="encrypted-session").to_wire(), + { + "schemaVersion": "cultnet.login_success.v0", + "nonce": "nonce", + "session": "encrypted-session", + }, + ) + + def test_cultnet_wire_catalog_describes_python_handled_messages(self) -> None: + descriptors = wire_message_schema_descriptors(include_schema_json=True) + by_version = {descriptor["schemaVersion"]: descriptor for descriptor in descriptors} + for schema_version in [ + "cultnet.error.v0", + "cultnet.login.v0", + "cultnet.register.v0", + "cultnet.verify.v0", + "cultnet.login_success.v0", + "cultnet.transport_profile.v0", + "cultnet.document_delete.v0", + "cultnet.database_change_raw.v0", + "cultnet.shard_log_response.v0", + "cultnet.simulation_consensus_candidate.v0", + "cultmesh.peer_exchange_response.v0", + ]: + self.assertIn(schema_version, by_version) + expected_kind = "shared_contract" if schema_version == "cultnet.transport_profile.v0" else "wire_message" + self.assertEqual(by_version[schema_version]["kind"], expected_kind) + self.assertIn("cultnet.schema.v0", by_version[schema_version]["wireContracts"]) + self.assertIn(schema_version, by_version[schema_version]["schemaJson"]) + self.assertEqual(len(by_version[schema_version]["contentHash"]), 64) + + delete_schema = json.loads(by_version["cultnet.document_delete.v0"]["schemaJson"]) + self.assertEqual( + delete_schema["required"], + ["schemaVersion", "messageId", "schemaId", "recordKey"], + ) + self.assertEqual(delete_schema["properties"]["recordKey"]["type"], "string") + + error_schema = json.loads(by_version["cultnet.error.v0"]["schemaJson"]) + self.assertEqual(error_schema["required"], ["schemaVersion", "error"]) + self.assertEqual(error_schema["properties"]["error"]["type"], "string") + self.assertIn("details", error_schema["properties"]) + + login_schema = json.loads(by_version["cultnet.login.v0"]["schemaJson"]) + self.assertEqual(login_schema["required"], ["schemaVersion", "nonce", "auth", "password"]) + self.assertEqual(login_schema["properties"]["auth"]["type"], "string") + + register_schema = json.loads(by_version["cultnet.register.v0"]["schemaJson"]) + self.assertEqual(register_schema["required"], ["schemaVersion", "nonce", "email", "password", "name"]) + self.assertEqual(register_schema["properties"]["email"]["type"], "string") + + verify_schema = json.loads(by_version["cultnet.verify.v0"]["schemaJson"]) + self.assertEqual(verify_schema["required"], ["schemaVersion", "nonce", "session"]) + self.assertEqual(verify_schema["properties"]["session"]["type"], "string") + + success_schema = json.loads(by_version["cultnet.login_success.v0"]["schemaJson"]) + self.assertEqual(success_schema["required"], ["schemaVersion", "nonce", "session"]) + + transport_profile_schema = json.loads(by_version["cultnet.transport_profile.v0"]["schemaJson"]) + self.assertEqual(transport_profile_schema["required"], ["schemaVersion", "runtimeId", "transports"]) + transport = transport_profile_schema["properties"]["transports"]["items"] + self.assertEqual(transport["properties"]["protocol"]["enum"], ["tcp_framed", "litenetlib", "websocket", "rudp"]) + self.assertEqual( + transport["properties"]["reconnectPolicy"]["properties"]["schemaVersion"]["const"], + "cultnet.reconnect_policy.v0", + ) + channel = transport["properties"]["channels"]["items"] + self.assertEqual(channel["properties"]["ordering"]["enum"], ["ordered", "unordered", "sequenced"]) + + change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"]) + self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"]) + self.assertIn("document", change_schema["properties"]) + self.assertIn("schemaId", change_schema["properties"]) + self.assertIn("recordKey", change_schema["properties"]) + + log_schema = json.loads(by_version["cultnet.shard_log_response.v0"]["schemaJson"]) + self.assertIn("entries", log_schema["required"]) + self.assertIn("resyncRequired", log_schema["required"]) + self.assertIn("compactedThrough", log_schema["properties"]) + + hello_schema = json.loads(by_version["cultnet.hello.v0"]["schemaJson"]) + mutation_contract = hello_schema["properties"]["supportedMutationContracts"]["items"] + self.assertEqual(mutation_contract["type"], "object") + self.assertIn("operations", mutation_contract["properties"]) + self.assertEqual( + hello_schema["properties"]["transportProfiles"]["items"]["$ref"], + "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json", + ) + + consensus_schema = json.loads(by_version["cultnet.simulation_consensus_candidate.v0"]["schemaJson"]) + for required_field in ["witnessCount", "supportWeight", "totalWeight", "hasQuorum", "confidence"]: + self.assertIn(required_field, consensus_schema["required"]) + + def test_cultnet_schema_catalog_applies_filters_and_responses(self) -> None: + catalog = wire_message_schema_catalog(include_schema_json=True) + descriptor = catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json") + self.assertIsNotNone(descriptor) + assert descriptor is not None + self.assertEqual(descriptor.kind, "wire_message") + self.assertEqual(descriptor.schema_version, "cultnet.document_put_raw.v0") + self.assertIsNotNone(descriptor.schema_json) + + filtered = catalog.list(kinds=["wire_message"], schema_ids=[descriptor.schema_id]) + self.assertEqual(filtered, [descriptor]) + + response = catalog.create_response( + message_id="catalog-response", + include_schema_json=False, + schema_ids=[descriptor.schema_id], + kinds=["wire_message"], + ) + self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0") + self.assertNotIn("schemaJson", response["schemas"][0]) + + remote = CultNetSchemaCatalog() + applied = remote.apply_response({ + "schemaVersion": "cultnet.schema_catalog_response.v0", + "messageId": "remote", + "schemas": [ + CultNetSchemaDescriptor( + schema_id="schema:custom", + kind="shared_contract", + schema_version="custom.v0", + wire_contracts=("cultnet.schema.v0",), + content_hash="hash", + schema_json="{}", + ).to_wire() + ], + }) + self.assertEqual(applied[0].schema_id, "schema:custom") + self.assertEqual(remote.get("schema:custom"), applied[0]) + + def test_cultnet_shard_helpers_match_schema_v0_shape(self) -> None: + catalog = shard_catalog_request( + message_id="catalog-1", + schema_ids=["schema-a"], + record_keys=["record-a"], + ).to_wire() + self.assertEqual(catalog["schemaVersion"], "cultnet.shard_catalog_request.v0") + self.assertEqual(catalog["messageId"], "catalog-1") + self.assertEqual(catalog["schemaIds"], ["schema-a"]) + self.assertEqual(catalog["recordKeys"], ["record-a"]) + + log = shard_log_request( + message_id="log-1", + shard_id="interop", + shard_epoch=7, + after_sequence=3, + limit=2, + ).to_wire() + self.assertEqual(log["schemaVersion"], "cultnet.shard_log_request.v0") + self.assertEqual(log["messageId"], "log-1") + self.assertEqual(log["shardId"], "interop") + self.assertEqual(log["shardEpoch"], 7) + self.assertEqual(log["afterSequence"], 3) + self.assertEqual(log["limit"], 2) + + def test_cultnet_shard_catalog_applies_filters_and_responses(self) -> None: + catalog = CultNetShardCatalog() + primary = catalog.upsert(CultNetShardDescriptor( + shard_id="notes", + owner_runtime_id="python-runtime", + epoch=2, + is_primary=True, + schema_ids=("schema-note.v1",), + key_prefix="note:", + primary_endpoints=("cultnet://127.0.0.1:3075",), + read_replica_endpoints=("cultnet://127.0.0.1:3075",), + region="local", + )) + catalog.upsert({ + "shardId": "facts", + "ownerRuntimeId": "python-runtime", + "epoch": 1, + "schemaIds": ["schema-fact"], + "keyPrefix": "fact:", + "primaryEndpoints": ["cultnet://127.0.0.1:3076"], + }) + + self.assertTrue(primary.serves(schema_id="schema-note.v1", record_key="note:1")) + self.assertTrue(primary.serves(schema_id="schema-note", record_key="note:1")) + self.assertFalse(primary.serves(schema_id="schema-note", record_key="fact:1")) + self.assertEqual(catalog.list(schema_ids=["schema-note.v1"]), [primary]) + self.assertEqual(catalog.list(schema_ids=["schema-note"]), [primary]) + self.assertEqual(catalog.list(record_keys=["note:1"]), [primary]) + + response = catalog.create_response( + message_id="shards", + schema_ids=["schema-note"], + record_keys=["note:1"], + ) + self.assertEqual(response["schemaVersion"], "cultnet.shard_catalog_response.v0") + self.assertEqual(response["shards"][0]["shardId"], "notes") + self.assertEqual(response["shards"][0]["ownerRuntimeId"], "python-runtime") + + remote = CultNetShardCatalog() + applied = remote.apply_response(response) + self.assertEqual(applied[0].shard_id, "notes") + self.assertEqual(remote.get("notes"), applied[0]) + + def test_cultnet_shard_log_response_tracks_cursor_and_resync_state(self) -> None: + response = CultNetShardLogResponse.from_wire({ + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "log", + "shardId": "notes", + "shardEpoch": 2, + "entries": [ + CultNetShardLogEntry( + sequence=2, + change_kind="updated", + put={"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "put", "document": {"schemaId": "schema-note", "recordKey": "note:1", "payload": b"p"}}, + committed_at="2026-06-14T00:00:00Z", + ).to_wire(), + {"sequence": 3, "changeKind": "removed", "delete": {"schemaId": "schema-note", "recordKey": "note:1"}}, + ], + "resyncRequired": False, + }) + self.assertEqual(response.last_sequence, 3) + self.assertFalse(response.resync_required) + self.assertEqual(response.entries[0].put["document"]["recordKey"], "note:1") + self.assertIsInstance(response.entries[0].raw_document, CultNetRawDocumentRecord) + self.assertEqual(response.entries[0].raw_document.record_key, "note:1") + self.assertEqual(response.entries[1].delete_schema_id, "schema-note") + self.assertEqual(response.entries[1].delete_record_key, "note:1") + self.assertEqual(response.to_wire()["entries"][1]["delete"]["recordKey"], "note:1") + self.assertIs(response.require_usable(), response) + + resync = CultNetShardLogResponse.from_wire({ + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "log-resync", + "shardId": "notes", + "shardEpoch": 2, + "entries": [], + "resyncRequired": True, + "reason": "compacted", + "compactedThrough": 12, + }) + self.assertEqual(resync.last_sequence, 12) + with self.assertRaisesRegex(ValueError, "compacted"): + resync.require_usable() + + def test_cultnet_simulation_observation_helper_matches_schema_v0_shape(self) -> None: + claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit") + self.assertEqual(len(claim_hash), 64) + message = simulation_observation( + message_id="obs-1", + witness_runtime_id="python-test", + shard_id="interop", + shard_epoch=1, + frame=42, + subject_id="player-1", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="player-1 hit target-a", + observed_at="2026-06-13T00:00:02Z", + ).to_wire() + self.assertEqual(message["schemaVersion"], "cultnet.simulation_observation.v0") + self.assertEqual(message["messageId"], "obs-1") + self.assertEqual(message["observation"]["witnessRuntimeId"], "python-test") + self.assertEqual(message["observation"]["claimHash"], claim_hash) + self.assertEqual(message["observation"]["weight"], 1.0) + observation = CultNetSimulationObservation.from_wire(message) + self.assertEqual(observation.witness_runtime_id, "python-test") + self.assertEqual(observation.claim_summary, "player-1 hit target-a") + self.assertEqual(observation.to_message_wire(message_id="obs-2")["observation"], message["observation"]) + + def test_cultnet_witness_artifact_bundle_uses_csharp_slot_order(self) -> None: + import msgpack # type: ignore + + bundle = witness_artifact_bundle( + bundle_id="bundle-1", + witness_kind="interop-proof", + captured_at="2026-06-13T00:00:03Z", + subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"}, + contracts=[{"role": "payload", "schemaId": "schema-a"}], + artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}], + timing_witnesses=[{"stage": "roundtrip", "startedAt": "2026-06-13T00:00:03Z", "completedAt": "2026-06-13T00:00:04Z", "latencyMs": 1.0}], + provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-test"}, + ) + payload = encode_witness_artifact_bundle_payload(bundle) + decoded = decode_witness_artifact_bundle_payload(payload) + typed = CultNetWitnessArtifactBundle.from_wire(bundle) + typed_payload = typed.to_payload() + typed_decoded = CultNetWitnessArtifactBundle.from_payload(typed_payload) + slots = msgpack.unpackb(payload, raw=False) + + self.assertEqual(payload, typed_payload) + self.assertEqual(slots[0], "bundle-1") + self.assertEqual(slots[1], "interop-proof") + self.assertEqual(slots[2], "2026-06-13T00:00:03Z") + self.assertEqual(slots[3]["subjectId"], "note:python") + self.assertEqual(slots[4][0]["schemaId"], "schema-a") + self.assertEqual(slots[5][0]["uri"], "cultcache://bundle-1/log") + self.assertEqual(slots[6][0]["latencyMs"], 1.0) + self.assertEqual(slots[7]["runtimeId"], "python-test") + self.assertEqual(decoded["bundleId"], "bundle-1") + self.assertEqual(decoded["witnessKind"], "interop-proof") + self.assertEqual(decoded["subject"]["subjectId"], "note:python") + self.assertEqual(decoded["contracts"][0]["schemaId"], "schema-a") + self.assertEqual(decoded["artifacts"][0]["mediaType"], "text/plain") + self.assertEqual(decoded["provenance"]["runtimeId"], "python-test") + self.assertEqual(typed.bundle_id, "bundle-1") + self.assertEqual(typed_decoded.to_wire(), bundle) + + def test_python_interop_peer_filters_shard_snapshot_by_logged_membership(self) -> None: + runtime_id = f"python-interop-test-{uuid4().hex}" + state = build_state( + runtime_id=runtime_id, + runtime_kind="python", + display_name="Python Interop Test", + agent_id="python-interop-test-agent", + schema_path=str(Path("packages/cultnet-ts/integration/contracts/cultnet.interop-note.schema.json")), + ) + binding = state.bindings["note"] + logged_value = { + "schemaVersion": "cultnet.interop_note.v0", + "documentId": "note:logged", + "authorRuntimeId": runtime_id, + "title": "Logged", + "body": "This record belongs to the shard log.", + "tags": ["interop", "logged"], + } + unlogged_value = { + **logged_value, + "documentId": "note:unlogged", + "title": "Unlogged", + "body": "This record is in cache but not in the shard log.", + } + state.cache.put(binding.document, "note:logged", logged_value) + state.cache.put(binding.document, "note:unlogged", unlogged_value) + logged_record = { + "schemaId": state.note_schema_id, + "recordKey": "note:logged", + "storedAt": "2026-06-14T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": binding.document.encode_payload(logged_value), + } + append_shard_log_put( + state, + {"messageId": "logged-put"}, + logged_record, + ) + + response = raw_snapshot_response( + state, + { + "schemaVersion": "cultnet.snapshot_request.v0", + "messageId": "interop-shard-snapshot", + "schemaIds": [state.note_schema_id], + "shardId": state.shard_id, + }, + ) + + self.assertEqual(response["shardId"], state.shard_id) + self.assertEqual(response["shardLogSequence"], 1) + self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"]) + + def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None: + from cultnet_py import CultNetSimulationConsensus + + consensus = CultNetSimulationConsensus( + CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0) + ) + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") + observations = [ + { + "witnessRuntimeId": "watcher-1", + "shardId": "arena", + "shardEpoch": 4, + "frame": 100, + "subjectId": "bob", + "claimKind": "hit", + "claimHash": claim_hash, + "claimSummary": "alice shot bob first", + "weight": 1.0, + }, + { + "witnessRuntimeId": "watcher-1", + "shardId": "arena", + "shardEpoch": 4, + "frame": 100, + "subjectId": "bob", + "claimKind": "hit", + "claimHash": "stale-duplicate", + "weight": 0.5, + }, + { + "witnessRuntimeId": "watcher-2", + "shardId": "arena", + "shardEpoch": 4, + "frame": 100, + "subjectId": "bob", + "claimKind": "hit", + "claimHash": claim_hash, + "weight": 1.0, + }, + ] + + candidates = consensus.build_candidates(observations) + typed_candidates = consensus.build_candidate_objects([ + CultNetSimulationObservation.from_wire(observation) + for observation in observations + ]) + + self.assertEqual(len(candidates), 1) + self.assertEqual(candidates[0]["claimHash"], claim_hash) + self.assertEqual(candidates[0]["witnessCount"], 2) + self.assertEqual(candidates[0]["supportWeight"], 2.0) + self.assertTrue(candidates[0]["hasQuorum"]) + self.assertEqual(len(typed_candidates), 1) + self.assertIsInstance(typed_candidates[0], CultNetSimulationConsensusCandidate) + self.assertEqual(typed_candidates[0].claim_hash, claim_hash) + self.assertEqual(typed_candidates[0].to_wire(), candidates[0]) + self.assertEqual(CultNetSimulationConsensusCandidate.from_wire(candidates[0]), typed_candidates[0]) + + +class CultNetRudpFragmentBoundTests(unittest.TestCase): + def _pair(self) -> tuple[CultNetRudpSession, CultNetRudpSession]: + sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=311, initial_sequence=1)) + receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=311, initial_sequence=100)) + sender.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 311, 90, 0, 0, "control")) + receiver.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 311, 91, 0, 0, "control")) + return sender, receiver + + def test_stranded_fragment_sets_are_bounded_and_evicted_oldest_first(self) -> None: + sender, receiver = self._pair() + receiver._max_pending_fragment_sets = 4 + + # Eight payloads each lose their last fragment; the map must not grow past four. + stranded = [] + for fill in range(1, 9): + packets = sender.send_many("media", bytes([fill]) * 2500, max_fragment_bytes=1000) + self.assertEqual(len(packets), 3) + for packet in packets[:2]: + self.assertEqual(receiver.receive(packet).delivered, ()) + stranded.append(packets[2]) + self.assertEqual(len(receiver._fragment_buffers), 4) + self.assertEqual(receiver.fragment_sets_evicted, 4) + + # A complete payload still lands. + delivered = [] + for packet in sender.send_many("media", bytes([9]) * 2500, max_fragment_bytes=1000): + delivered.extend(receiver.receive(packet).delivered) + self.assertEqual([frame.payload for frame in delivered], [bytes([9]) * 2500]) + self.assertEqual(receiver.fragment_sets_evicted, 5) + + # Oldest-first: set 8 still pends and completes late; set 1 was evicted. + self.assertEqual(receiver.receive(stranded[7]).delivered[0].payload, bytes([8]) * 2500) + self.assertEqual(receiver.receive(stranded[0]).delivered, ()) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultcache-py/tools/GameCult.Caching.Benchmark/GameCult.Caching.Benchmark.csproj b/packages/cultnet-py/tools/GameCult.Caching.Benchmark/GameCult.Caching.Benchmark.csproj similarity index 100% rename from packages/cultcache-py/tools/GameCult.Caching.Benchmark/GameCult.Caching.Benchmark.csproj rename to packages/cultnet-py/tools/GameCult.Caching.Benchmark/GameCult.Caching.Benchmark.csproj diff --git a/packages/cultcache-py/tools/GameCult.Caching.Benchmark/Program.cs b/packages/cultnet-py/tools/GameCult.Caching.Benchmark/Program.cs similarity index 96% rename from packages/cultcache-py/tools/GameCult.Caching.Benchmark/Program.cs rename to packages/cultnet-py/tools/GameCult.Caching.Benchmark/Program.cs index 698b07ec..14ec8d5e 100644 --- a/packages/cultcache-py/tools/GameCult.Caching.Benchmark/Program.cs +++ b/packages/cultnet-py/tools/GameCult.Caching.Benchmark/Program.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Text.Json; using GameCult.Caching; +using MessagePack; var records = 5000; var emitJson = false; @@ -93,12 +94,15 @@ internal sealed record BenchmarkResult(string Runtime, int Records, IReadOnlyLis internal sealed record BenchmarkMetric(string Name, int Operations, double ElapsedMs, double OpsPerSecond); [CultDocument("bench.item", "bench.item.v1")] +[MessagePackObject(AllowPrivate = true)] internal sealed class BenchItem { - [CultName] + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public string Category { get; set; } = string.Empty; + [Key(2)] public int Value { get; set; } } diff --git a/packages/cultnet-rs/Cargo.toml b/packages/cultnet-rs/Cargo.toml index 41d14b52..f289d86e 100644 --- a/packages/cultnet-rs/Cargo.toml +++ b/packages/cultnet-rs/Cargo.toml @@ -16,6 +16,7 @@ anyhow = "1" base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } cultcache-rs = { path = "../cultcache-rs" } +ed25519-dalek = "2.2" hmac = "0.12" rand = "0.9" rmp-serde = "1.3" @@ -24,9 +25,13 @@ serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" serde_json = "1" sha2 = "0.10" +zeroize = "1" socket2 = "0.5" uuid = { version = "1", features = ["v4"] } +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security_Cryptography"] } + [dev-dependencies] pretty_assertions = "1.4" tempfile = "3" diff --git a/packages/cultnet-rs/README.md b/packages/cultnet-rs/README.md index 69ab72c4..7ade81dc 100644 --- a/packages/cultnet-rs/README.md +++ b/packages/cultnet-rs/README.md @@ -43,6 +43,7 @@ The initial tests prove: - shard catalog request/response filtering and response application - CultCache snapshot replication through registered typed documents - raw snapshot replication that preserves the original payload bytes +- raw snapshot replication into a local same-schema Rust alias type - document mutation contract advertisement through hello frames and registries ## Schema Discovery @@ -110,12 +111,65 @@ CultCache along with the typed envelope metadata. Combined with `CultCache::put_envelope::()`, that lets a bit-compatible neighbor ingest the document without re-encoding the payload first. +The receiving Rust type does not have to be the sender's concrete Rust type. If +the local registry binds a different `DatabaseEntry` to the same schema id, +`sync_raw_document_from_snapshot_response::(...)` hydrates one local +alias type directly: + +```rust +let mut target_registry = CultNetDocumentRegistry::new(); +target_registry.register(CultNetDocumentBinding::for_entry_with_schema_id::( + "aetheria.note.v1".to_string(), + "aetheria.note.v1".to_string(), +)); + +let note = target_registry.sync_raw_document_from_snapshot_response::( + &mut target_cache, + &snapshot_response, + "note:bridge", +)?; +``` + That still is not zero-copy in the religious sense. Frames allocate, bytes move, and the receiving cache still decodes once to keep typed reads and validation honest. The win is narrower and realer: identical payload bytes stop getting decoded into generic sludge and then encoded right back into the same bytes for no reason. +## Reactive Documents + +For local state that should feel owned by the caller, Rust can wrap a registered +CultCache record as a managed reactive document: + +```rust +use std::sync::{Arc, Mutex}; +use cultnet_rs::{CultNetReactiveDocumentOptions, CultNetDocumentRegistry}; + +let cache = Arc::new(Mutex::new(target_cache)); +let note = registry.reactive_document::( + Arc::clone(&cache), + "note:bridge", + CultNetReactiveDocumentOptions::default(), +)?; + +{ + let current = note.current(); + current.lock().unwrap().body = "local prediction".to_string(); +} +``` + +The handle polls the serialized `current` value on the configured debounce +cadence and flushes changed state back through the typed cache. `update(...)`, +`set_current(...)`, `mark_dirty()`, and `flush()` are available when the caller +wants a more explicit Rust shape. + +When a canonical document put arrives while local state is dirty, +`apply_document_put_message(...)` or `apply_raw_document_put_message(...)` +records a `CultNetReactiveDocumentReconciliation`: the canonical value, the +predicted local value, and a delta that can be smoothed by the caller. CultCache +payloads serialize by numeric field slot, so Rust tuple-backed deltas use slot +keys such as `"0"` and `"1"` when field names are not present on the wire. + ## CultMesh Rust Facade Rust is still the low-level substrate runtime, not a clone of the C# game diff --git a/packages/cultnet-rs/examples/cultnet_interop_peer.rs b/packages/cultnet-rs/examples/cultnet_interop_peer.rs index ab00c6c8..07f6f400 100644 --- a/packages/cultnet-rs/examples/cultnet_interop_peer.rs +++ b/packages/cultnet-rs/examples/cultnet_interop_peer.rs @@ -256,7 +256,7 @@ fn serve(config: PeerConfig) -> Result<()> { register_capability_entry_types(&mut cache)?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(runtime_store_path( &config.runtime_id, - ))); + )))?; cache.pull_all_backing_stores()?; let note = build_note(&config.runtime_id, &config.display_name); cache.put(¬e.document_id, ¬e)?; @@ -380,7 +380,7 @@ fn dial(config: DialConfig) -> Result<()> { register_capability_entry_types(&mut cache)?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(runtime_store_path( &format!("{}-dial", config.runtime_id), - ))); + )))?; cache.pull_all_backing_stores()?; let mut document_registry = CultNetDocumentRegistry::new(); @@ -986,6 +986,9 @@ fn run_rudp_server( if let Some(reply) = result.reply { send_rudp_packet(&socket, remote_addr, &reply)?; } + for ready in result.ready_to_send { + send_rudp_packet(&socket, remote_addr, &ready)?; + } if packet.packet_type == CultNetRudpPacketType::Data { let ack = peer.session.create_ack(); send_rudp_packet(&socket, remote_addr, &ack)?; @@ -1471,6 +1474,7 @@ impl SchemaMessageSender for RudpSessionSender<'_> { ordered: true, sequenced: false, now_ms: rudp_now_ms(), + reliable_expire_after_ms: None, }, Some(RUDP_INTEROP_MAX_FRAGMENT_BYTES), )?; diff --git a/packages/cultnet-rs/examples/media_delivery_probe.rs b/packages/cultnet-rs/examples/media_delivery_probe.rs new file mode 100644 index 00000000..397eb656 --- /dev/null +++ b/packages/cultnet-rs/examples/media_delivery_probe.rs @@ -0,0 +1,183 @@ +//! Measures what a media channel actually costs on the wire under loss. +//! +//! The claim under test: a reliable media channel retransmits lost packets, so +//! it adds load exactly when the link has least to give, while an unreliable one +//! drops them and holds its rate. Run both halves through `cultnet-impair` with +//! a `loss_basis_points` profile and compare bytes on the wire against bytes of +//! payload offered. +//! +//! ```text +//! media_delivery_probe recv +//! media_delivery_probe send +//! ``` +//! +//! The sender reports amplification: wire bytes divided by offered payload +//! bytes. At 1.0 nothing was retransmitted. Well above 1.0 under loss is the +//! pathology. + +use std::env; +use std::net::{SocketAddr, UdpSocket}; +use std::time::{Duration, Instant}; + +use anyhow::{Result, anyhow}; +use cultnet_rs::{ + CultNetRudpServerHubOptions, CultNetRudpSocketTransportConnection, + CultNetRudpSocketTransportOptions, CultNetTransportDelivery, +}; + +const CHANNEL: &str = "media"; +const DRAIN: Duration = Duration::from_secs(5); + +fn main() -> Result<()> { + let args: Vec = env::args().collect(); + match args.get(1).map(String::as_str) { + Some("recv") => recv( + args.get(2).ok_or_else(|| anyhow!("bind addr"))?.parse()?, + args.get(3).ok_or_else(|| anyhow!("expect count"))?.parse()?, + ), + Some("send") => send( + args.get(2).ok_or_else(|| anyhow!("endpoint"))?.parse()?, + match args.get(3).map(String::as_str) { + Some("unreliable") => CultNetTransportDelivery::Unreliable, + Some("reliable") => CultNetTransportDelivery::Reliable, + other => return Err(anyhow!("delivery must be reliable|unreliable, got {other:?}")), + }, + args.get(4).ok_or_else(|| anyhow!("payload bytes"))?.parse()?, + args.get(5).ok_or_else(|| anyhow!("count"))?.parse()?, + args.get(6).map(|v| v.parse()).transpose()?.unwrap_or(12.0), + args.get(7).map(|v| v.parse()).transpose()?, + ), + _ => Err(anyhow!( + "usage: media_delivery_probe recv | send [mbps] [max_fragment_bytes]" + )), + } +} + +fn recv(bind: SocketAddr, expect: u64) -> Result<()> { + let socket = UdpSocket::bind(bind)?; + socket.set_nonblocking(true)?; + let mut hub = cultnet_rs::CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new( + "probe-recv", + socket, + 0x0BE0_0001, + ))?; + + println!("listening on {bind}, expecting {expect} frames"); + let started = Instant::now(); + let mut frames = 0u64; + let mut payload_bytes = 0u64; + let mut last_progress = Instant::now(); + + loop { + while let Some(event) = hub.receive_event_once()? { + if let cultnet_rs::CultNetRudpServerEvent::Frame { frame, .. } = event + && frame.channel_id == CHANNEL + { + frames += 1; + payload_bytes += frame.payload.len() as u64; + last_progress = Instant::now(); + } + } + if frames >= expect || last_progress.elapsed() > DRAIN { + break; + } + std::thread::sleep(Duration::from_micros(200)); + } + + let stats = hub.stats(); + let elapsed = started.elapsed().as_secs_f64(); + println!("RESULT recv frames={frames} expected={expect} payload_bytes={payload_bytes}"); + println!( + "RESULT recv wire_bytes_in={} wire_bytes_out={} elapsed_s={elapsed:.2} delivered_pct={:.2} fragment_sets_evicted={}", + stats.bytes_received, + stats.bytes_sent, + (frames as f64 / expect as f64) * 100.0, + stats.fragment_sets_evicted + ); + Ok(()) +} + +fn send( + endpoint: SocketAddr, + delivery: CultNetTransportDelivery, + payload_bytes: usize, + count: u64, + target_mbps: f64, + max_fragment_bytes: Option, +) -> Result<()> { + let socket = UdpSocket::bind("0.0.0.0:0")?; + socket.set_nonblocking(true)?; + let mut options = + CultNetRudpSocketTransportOptions::client("probe-send", socket, endpoint, 0x0BE0_0001); + options.media_delivery = Some(delivery); + options.media_reliable_expire_after_ms = None; + // Fragment above this many bytes so a lost datagram costs a whole payload; + // None sends each payload as one datagram, which loopback carries whole. + options.max_fragment_bytes = max_fragment_bytes; + + let mut transport = CultNetRudpSocketTransportConnection::new(options)?; + transport.connect(b"probe".to_vec())?; + let deadline = Instant::now() + Duration::from_secs(10); + while !transport.connected() { + let _ = transport.receive_once()?; + transport.poll_resends()?; + if Instant::now() >= deadline { + return Err(anyhow!("timed out connecting to {endpoint}")); + } + std::thread::sleep(Duration::from_millis(2)); + } + + // Pace to a realistic media rate. An unpaced blast overruns the receiver's + // socket buffer and manufactures loss that has nothing to do with the link. + let per_packet = Duration::from_secs_f64((payload_bytes as f64 * 8.0) / (target_mbps * 1e6)); + let payload = vec![0xA5u8; payload_bytes]; + let offered = payload_bytes as u64 * count; + let started = Instant::now(); + let mut sent = 0u64; + let mut would_block = 0u64; + + while sent < count { + match transport.send(CHANNEL, payload.clone()) { + Ok(()) => sent += 1, + Err(error) if error.to_string().contains("would block") => { + would_block += 1; + transport.poll_resends()?; + let _ = transport.receive_once()?; + std::thread::sleep(Duration::from_micros(500)); + } + Err(error) => return Err(error), + } + if sent % 64 == 0 { + let _ = transport.receive_once()?; + transport.poll_resends()?; + } + let due = started + per_packet.mul_f64(sent as f64); + if let Some(wait) = due.checked_duration_since(Instant::now()) { + std::thread::sleep(wait); + } + } + let send_elapsed = started.elapsed(); + + // Let retransmission run: this is where a reliable channel spends what an + // unreliable one does not. + let drain_start = Instant::now(); + while drain_start.elapsed() < DRAIN { + let _ = transport.receive_once()?; + transport.poll_resends()?; + std::thread::sleep(Duration::from_millis(1)); + } + + let stats = transport.stats(); + let amplification = stats.bytes_sent as f64 / offered as f64; + println!( + "RESULT send delivery={delivery:?} payload_bytes={payload_bytes} count={count} offered={offered} target_mbps={target_mbps}" + ); + println!( + "RESULT send wire_bytes={} amplification={amplification:.3} send_s={:.2} total_s={:.2} would_block={would_block} reliable_expired={}", + stats.bytes_sent, + send_elapsed.as_secs_f64(), + started.elapsed().as_secs_f64(), + stats.reliable_packets_expired + ); + Ok(()) +} diff --git a/packages/cultnet-rs/examples/runtime_presence_vector.rs b/packages/cultnet-rs/examples/runtime_presence_vector.rs new file mode 100644 index 00000000..04571a8b --- /dev/null +++ b/packages/cultnet-rs/examples/runtime_presence_vector.rs @@ -0,0 +1,67 @@ +//! Emit canonical bytes for one fixed runtime-presence record. +//! +//! This is the reference the TypeScript fork is tested against. The two forks +//! agree only if `gamecult.runtime_presence_health.v2` means the same bytes in +//! both, and the only way to know that is to have one produce the vector and +//! the other reproduce it. A fixture written by hand for both sides would agree +//! with itself and prove nothing. + +use cultnet_rs::{GameCultRuntimeCapability, GameCultRuntimePresenceHealthRecord}; + +fn main() -> anyhow::Result<()> { + let record = GameCultRuntimePresenceHealthRecord { + schema_version: "gamecult.runtime_presence_health.v2".into(), + target: "heimdall".into(), + expected_projection_sha256: format!("sha256-{}", "a".repeat(64)), + plan_id: format!("sha256-{}", "b".repeat(64)), + incarnation_id: "service-incarnation-1".into(), + sealed_release_id: format!("sha256-{}", "c".repeat(64)), + activation_witness_sha256: format!("sha256-{}", "d".repeat(64)), + state_schema_generation: Some("v1".into()), + state_contract_sha256: Some(format!("sha256-{}", "e".repeat(64))), + runtime_id: "heimdall-yggdrasil".into(), + runtime_instance_id: format!("sha256-{}", "f".repeat(64)), + bound_endpoint: Some("http://127.0.0.1:14101".into()), + capabilities: vec![ + GameCultRuntimeCapability { + capability: "heimdall.access".into(), + schema: "heimdall.access.v1".into(), + compatibility: "v1".into(), + capacity: 2, + }, + GameCultRuntimeCapability { + capability: "zeta.runtime".into(), + schema: "zeta.runtime.v1".into(), + compatibility: "v1".into(), + capacity: 1, + }, + ], + health_contract: "heimdall.cultnet-rudp-provider-health".into(), + state: "warming".into(), + detail: "Heimdall candidate warming".into(), + write_lease_sha256: None, + signer_identity_id: "1".repeat(64), + publisher_sequence: 7, + observed_at_unix_millis: 1_757_000_000_000, + signature_algorithm: "ed25519".into(), + signature: Vec::new(), + activation_signer_identity_id: "2".repeat(64), + activation_signature: Vec::new(), + }; + + // The proof payload: both signature fields empty. This is what both keys + // sign, so it is the vector that matters most. + let proof = record.canonical_proof_payload()?; + println!("proof {}", hex(&proof)); + + // And the complete record, with both proofs filled, to pin the signed shape. + let mut signed = record; + signed.signature = vec![0x11; 64]; + signed.activation_signature = vec![0x22; 64]; + println!("signed {}", hex(&rmp_serde::to_vec(&signed)?)); + Ok(()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index cf828e15..b5c89ff7 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -142,6 +142,12 @@ pub struct CultNetTransportChannel { pub max_fragment_bytes: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub max_pending_reliable_packets: Option, + /// Drop a reliable packet on this channel instead of retransmitting it once + /// this many milliseconds have passed since the send. Omitted when absent, + /// so a profile that does not set it is byte-identical to one from a + /// runtime that does not know the field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reliable_expire_after_ms: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -354,6 +360,39 @@ pub enum CultNetMessage { message_id: String, shards: Vec, }, + #[serde(rename = "cultnet.operation_request.v0", rename_all = "camelCase")] + OperationRequest { + message_id: String, + service_id: String, + operation: String, + payload_schema: String, + #[serde(default = "messagepack_base64_encoding")] + payload_encoding: String, + payload: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + target_runtime_id: Option, + }, + #[serde(rename = "cultnet.operation_response.v0", rename_all = "camelCase")] + OperationResponse { + message_id: String, + service_id: String, + operation: String, + status: String, + payload_schema: String, + #[serde(default = "messagepack_base64_encoding")] + payload_encoding: String, + payload: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + diagnostics: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_runtime_id: Option, + }, +} + +fn messagepack_base64_encoding() -> String { + "messagepack-base64".to_string() } pub fn parse_cultnet_message( @@ -560,6 +599,45 @@ fn validate_message(message: &CultNetMessage) -> Result<()> { validate_shard_descriptor(shard)?; } } + CultNetMessage::OperationRequest { + message_id, + service_id, + operation, + payload_schema, + payload_encoding, + payload, + source_runtime_id, + target_runtime_id, + } => { + require_non_empty(message_id, "messageId")?; + require_non_empty(service_id, "serviceId")?; + require_non_empty(operation, "operation")?; + require_non_empty(payload_schema, "payloadSchema")?; + require_exact(payload_encoding, "messagepack-base64", "payloadEncoding")?; + require_non_empty(payload, "payload")?; + require_optional_non_empty(source_runtime_id.as_deref(), "sourceRuntimeId")?; + require_optional_non_empty(target_runtime_id.as_deref(), "targetRuntimeId")?; + } + CultNetMessage::OperationResponse { + message_id, + service_id, + operation, + status, + payload_schema, + payload_encoding, + payload, + diagnostics: _, + source_runtime_id, + } => { + require_non_empty(message_id, "messageId")?; + require_non_empty(service_id, "serviceId")?; + require_non_empty(operation, "operation")?; + require_non_empty(status, "status")?; + require_non_empty(payload_schema, "payloadSchema")?; + require_exact(payload_encoding, "messagepack-base64", "payloadEncoding")?; + require_non_empty(payload, "payload")?; + require_optional_non_empty(source_runtime_id.as_deref(), "sourceRuntimeId")?; + } } Ok(()) } @@ -655,6 +733,13 @@ fn require_non_empty(value: &str, field: &str) -> Result<()> { Ok(()) } +fn require_exact(value: &str, expected: &str, field: &str) -> Result<()> { + if value != expected { + return Err(anyhow!("{field} must be {expected}")); + } + Ok(()) +} + fn require_optional_non_empty(value: Option<&str>, field: &str) -> Result<()> { if let Some(value) = value { require_non_empty(value, field)?; diff --git a/packages/cultnet-rs/src/cultmesh.rs b/packages/cultnet-rs/src/cultmesh.rs index 20d21232..14998396 100644 --- a/packages/cultnet-rs/src/cultmesh.rs +++ b/packages/cultnet-rs/src/cultmesh.rs @@ -318,6 +318,8 @@ impl CultMesh { ) -> Result { let socket = bind_rudp_socket(&options)?; CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { + media_reliable_expire_after_ms: None, + media_delivery: None, runtime_id: runtime_id.into(), socket, mode: CultNetRudpSocketMode::Server, @@ -341,6 +343,8 @@ impl CultMesh { ) -> Result { let socket = bind_rudp_socket(&options)?; CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { + media_reliable_expire_after_ms: None, + media_delivery: None, runtime_id: runtime_id.into(), socket, mode: CultNetRudpSocketMode::Client, diff --git a/packages/cultnet-rs/src/idunn_deployment_brake.rs b/packages/cultnet-rs/src/idunn_deployment_brake.rs new file mode 100644 index 00000000..e790f2a1 --- /dev/null +++ b/packages/cultnet-rs/src/idunn_deployment_brake.rs @@ -0,0 +1,492 @@ +use anyhow::{Result, bail}; +use cultcache_rs::DatabaseEntry; + +use crate::{ + IdunnDeploymentBrakeOperatorIdentity, IdunnDeploymentBrakeReleasePurpose, + ServiceIdentitySignature, ServiceIdentityTrustAnchor, verify_service_identity_signature, +}; + +pub const IDUNN_DEPLOYMENT_BRAKE_SCHEMA: &str = "idunn.deployment_brake.v1"; +pub const IDUNN_DEPLOYMENT_BRAKE_TYPE: &str = "idunn.deployment_brake"; +pub const IDUNN_DEPLOYMENT_BRAKE_ID: &str = "idunn/deployment-brake"; +pub const IDUNN_DEPLOYMENT_BRAKE_AUTHORITY: &str = "idunn.deployment-brake"; +pub const IDUNN_DEPLOYMENT_BRAKE_SCOPE: &str = "deployment"; +pub const IDUNN_DEPLOYMENT_RELEASE_PURPOSE: &str = "idunn.deployment-release.v1"; +pub const IDUNN_DEPLOYMENT_RELEASE_MAX_LIFETIME_MILLIS: u64 = 15 * 60 * 1_000; + +/// Canonical deployment-facing brake projection consumed by Idunn. A released +/// status is not permission by itself: the authorization fields bind one +/// release and one deployment attempt for a short, explicit interval. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "idunn.deployment_brake", schema = "idunn.deployment_brake.v1")] +pub struct IdunnDeploymentBrakeRecord { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub brake_id: String, + #[cultcache(key = 2)] + pub authority: String, + #[cultcache(key = 3)] + pub runtime_id: String, + #[cultcache(key = 4)] + pub status: String, + #[cultcache(key = 5)] + pub scope: String, + #[cultcache(key = 6)] + pub reason: String, + #[cultcache(key = 7)] + pub observed_at_unix_millis: u64, + #[cultcache(key = 8)] + pub expires_at_unix_millis: Option, + #[cultcache(key = 9)] + pub authorization_id: Option, + #[cultcache(key = 10)] + pub authorization_purpose: Option, + #[cultcache(key = 11)] + pub authorized_release_id: Option, + #[cultcache(key = 12)] + pub authorized_deployment_id: Option, + #[cultcache(key = 13)] + pub authorized_by: Option, + #[cultcache(key = 14)] + pub authorization_issued_at_unix_millis: Option, + #[cultcache(key = 15)] + pub authorization_expires_at_unix_millis: Option, + #[cultcache(key = 16)] + pub signature_algorithm: Option, + #[cultcache(key = 17)] + pub signature: Option>, + #[cultcache(key = 18)] + pub private_state_exposed: bool, + /// Human/operator principal responsible for the latest transition. This is + /// distinct from `authorized_by`, which is the cryptographic identity id. + #[cultcache(key = 19)] + pub updated_by: String, +} + +impl IdunnDeploymentBrakeRecord { + pub fn validate(&self) -> Result<()> { + if self.schema_version != IDUNN_DEPLOYMENT_BRAKE_SCHEMA { + bail!("deployment brake schema is unsupported"); + } + identifier(&self.brake_id, "brake id")?; + identifier(&self.authority, "authority")?; + identifier(&self.runtime_id, "runtime id")?; + identifier(&self.reason, "reason")?; + identifier(&self.scope, "scope")?; + identifier(&self.updated_by, "updating operator")?; + if !matches!(self.status.as_str(), "engaged" | "released") + || self.observed_at_unix_millis == 0 + || self + .expires_at_unix_millis + .is_some_and(|v| v <= self.observed_at_unix_millis) + || self.private_state_exposed + { + bail!("deployment brake shape, lifetime, or privacy is invalid"); + } + + let authorization = [ + self.authorization_id.as_ref(), + self.authorization_purpose.as_ref(), + self.authorized_release_id.as_ref(), + self.authorized_deployment_id.as_ref(), + self.authorized_by.as_ref(), + self.signature_algorithm.as_ref(), + ]; + let has_authorization = authorization.iter().any(|v| v.is_some()) + || self.authorization_issued_at_unix_millis.is_some() + || self.authorization_expires_at_unix_millis.is_some() + || self.signature.is_some(); + if self.status == "engaged" { + if has_authorization { + bail!("engaged deployment brake must not carry release authorization"); + } + return Ok(()); + } + if authorization.iter().any(|v| v.is_none()) + || self.authorization_issued_at_unix_millis.is_none() + || self.authorization_expires_at_unix_millis.is_none() + || self.signature.is_none() + { + bail!("released deployment brake authorization is incomplete"); + } + for (value, label) in [ + (&self.authorization_id, "authorization id"), + (&self.authorized_release_id, "authorized release id"), + (&self.authorized_deployment_id, "authorized deployment id"), + (&self.authorized_by, "authorizer"), + ] { + identifier(value.as_deref().unwrap(), label)?; + } + let issued = self.authorization_issued_at_unix_millis.unwrap(); + let expires = self.authorization_expires_at_unix_millis.unwrap(); + if self.authorization_purpose.as_deref() != Some(IDUNN_DEPLOYMENT_RELEASE_PURPOSE) + || self.signature_algorithm.as_deref() != Some("ed25519") + || self.signature.as_ref().is_none_or(|v| v.len() != 64) + || issued < self.observed_at_unix_millis + || expires <= issued + || expires - issued > IDUNN_DEPLOYMENT_RELEASE_MAX_LIFETIME_MILLIS + || self + .expires_at_unix_millis + .is_some_and(|brake_expiry| expires > brake_expiry) + { + bail!("deployment release authorization purpose, signature, or lifetime is invalid"); + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug)] +pub enum IdunnDeploymentBrakeObservation<'a> { + Missing, + Corrupt, + Present(&'a IdunnDeploymentBrakeRecord), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IdunnDeploymentBrakeDenial { + Missing, + Corrupt, + Foreign, + WrongRuntime, + WrongScope, + Engaged, + Expired, + ReleaseMismatch, + DeploymentMismatch, + InvalidSignature, +} + +fn classify_idunn_deployment_brake( + observation: IdunnDeploymentBrakeObservation<'_>, + runtime_id: &str, + release_id: &str, + deployment_id: &str, + now_unix_millis: u64, +) -> std::result::Result<(), IdunnDeploymentBrakeDenial> { + let record = match observation { + IdunnDeploymentBrakeObservation::Missing => { + return Err(IdunnDeploymentBrakeDenial::Missing); + } + IdunnDeploymentBrakeObservation::Corrupt => { + return Err(IdunnDeploymentBrakeDenial::Corrupt); + } + IdunnDeploymentBrakeObservation::Present(record) => record, + }; + if record.validate().is_err() { + return Err(IdunnDeploymentBrakeDenial::Corrupt); + } + if record.brake_id != IDUNN_DEPLOYMENT_BRAKE_ID + || record.authority != IDUNN_DEPLOYMENT_BRAKE_AUTHORITY + { + return Err(IdunnDeploymentBrakeDenial::Foreign); + } + if record.runtime_id != runtime_id { + return Err(IdunnDeploymentBrakeDenial::WrongRuntime); + } + if !matches!(record.scope.as_str(), "all" | IDUNN_DEPLOYMENT_BRAKE_SCOPE) { + return Err(IdunnDeploymentBrakeDenial::WrongScope); + } + if record.status == "engaged" { + return Err(IdunnDeploymentBrakeDenial::Engaged); + } + if record + .expires_at_unix_millis + .is_some_and(|v| now_unix_millis >= v) + || record + .authorization_expires_at_unix_millis + .is_none_or(|v| now_unix_millis >= v) + || record + .authorization_issued_at_unix_millis + .is_none_or(|v| now_unix_millis < v) + { + return Err(IdunnDeploymentBrakeDenial::Expired); + } + if record.authorized_release_id.as_deref() != Some(release_id) { + return Err(IdunnDeploymentBrakeDenial::ReleaseMismatch); + } + if record.authorized_deployment_id.as_deref() != Some(deployment_id) { + return Err(IdunnDeploymentBrakeDenial::DeploymentMismatch); + } + Ok(()) +} + +pub fn evaluate_idunn_deployment_brake( + observation: IdunnDeploymentBrakeObservation<'_>, + operator_anchor: &ServiceIdentityTrustAnchor, + runtime_id: &str, + release_id: &str, + deployment_id: &str, + now_unix_millis: u64, +) -> std::result::Result<(), IdunnDeploymentBrakeDenial> { + classify_idunn_deployment_brake( + observation, + runtime_id, + release_id, + deployment_id, + now_unix_millis, + )?; + let IdunnDeploymentBrakeObservation::Present(record) = observation else { + unreachable!("successful classification always has a present record") + }; + verify_idunn_deployment_brake_authorization(record, operator_anchor) + .map_err(|_| IdunnDeploymentBrakeDenial::InvalidSignature) +} + +/// Verify the release grant using the dedicated operator public anchor. The +/// signed payload is the complete positional record with the signature field +/// empty, so authority, runtime, target IDs, and both time bounds are covered. +pub fn verify_idunn_deployment_brake_authorization( + record: &IdunnDeploymentBrakeRecord, + anchor: &ServiceIdentityTrustAnchor, +) -> Result<()> { + record.validate()?; + if record.status != "released" { + bail!("engaged deployment brake has no release authorization to verify"); + } + let identity_id = record + .authorized_by + .as_ref() + .ok_or_else(|| anyhow::anyhow!("deployment brake authorizer is absent"))?; + let signature = record + .signature + .as_ref() + .ok_or_else(|| anyhow::anyhow!("deployment brake signature is absent"))?; + let mut unsigned = record.clone(); + unsigned.signature = None; + verify_service_identity_signature::< + IdunnDeploymentBrakeOperatorIdentity, + IdunnDeploymentBrakeReleasePurpose, + >( + anchor, + &rmp_serde::to_vec(&unsigned)?, + &ServiceIdentitySignature { + identity_id: identity_id.clone(), + signature: signature.clone(), + }, + ) +} + +fn identifier(value: &str, label: &str) -> Result<()> { + if value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) { + bail!("{label} is empty, oversized, or contains control characters"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::enroll_service_identity_at; + + fn released() -> IdunnDeploymentBrakeRecord { + IdunnDeploymentBrakeRecord { + schema_version: IDUNN_DEPLOYMENT_BRAKE_SCHEMA.into(), + brake_id: IDUNN_DEPLOYMENT_BRAKE_ID.into(), + authority: IDUNN_DEPLOYMENT_BRAKE_AUTHORITY.into(), + runtime_id: "yggdrasil".into(), + status: "released".into(), + scope: "deployment".into(), + reason: "operator authorized one attempt".into(), + observed_at_unix_millis: 100, + expires_at_unix_millis: Some(1000), + authorization_id: Some("auth/r4".into()), + authorization_purpose: Some(IDUNN_DEPLOYMENT_RELEASE_PURPOSE.into()), + authorized_release_id: Some("release-4".into()), + authorized_deployment_id: Some("deployment-4".into()), + authorized_by: Some("operator/metacrat".into()), + authorization_issued_at_unix_millis: Some(100), + authorization_expires_at_unix_millis: Some(900), + signature_algorithm: Some("ed25519".into()), + signature: Some(vec![7; 64]), + private_state_exposed: false, + updated_by: "operator/metacrat".into(), + } + } + + #[test] + fn only_exact_bounded_release_is_permitted() { + let value = released(); + assert_eq!( + classify_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Present(&value), + "yggdrasil", + "release-4", + "deployment-4", + 500 + ), + Ok(()) + ); + assert_eq!( + classify_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Missing, + "yggdrasil", + "release-4", + "deployment-4", + 500 + ), + Err(IdunnDeploymentBrakeDenial::Missing) + ); + assert_eq!( + classify_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Corrupt, + "yggdrasil", + "release-4", + "deployment-4", + 500 + ), + Err(IdunnDeploymentBrakeDenial::Corrupt) + ); + assert_eq!( + classify_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Present(&value), + "yggdrasil", + "other", + "deployment-4", + 500 + ), + Err(IdunnDeploymentBrakeDenial::ReleaseMismatch) + ); + assert_eq!( + classify_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Present(&value), + "yggdrasil", + "release-4", + "other", + 500 + ), + Err(IdunnDeploymentBrakeDenial::DeploymentMismatch) + ); + assert_eq!( + classify_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Present(&value), + "yggdrasil", + "release-4", + "deployment-4", + 900 + ), + Err(IdunnDeploymentBrakeDenial::Expired) + ); + } + + #[test] + fn hostile_authority_scope_and_shape_substitutions_fail_closed() { + let mut value = released(); + value.authority = "idunn".into(); + assert_eq!( + classify_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Present(&value), + "yggdrasil", + "release-4", + "deployment-4", + 500 + ), + Err(IdunnDeploymentBrakeDenial::Foreign) + ); + let mut value = released(); + value.runtime_id = "nightwing".into(); + assert_eq!( + classify_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Present(&value), + "yggdrasil", + "release-4", + "deployment-4", + 500 + ), + Err(IdunnDeploymentBrakeDenial::WrongRuntime) + ); + let mut value = released(); + value.scope = "persona.public_speech".into(); + assert_eq!( + classify_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Present(&value), + "yggdrasil", + "release-4", + "deployment-4", + 500 + ), + Err(IdunnDeploymentBrakeDenial::WrongScope) + ); + let mut value = released(); + value.signature = Some(vec![0; 63]); + assert_eq!( + classify_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Present(&value), + "yggdrasil", + "release-4", + "deployment-4", + 500 + ), + Err(IdunnDeploymentBrakeDenial::Corrupt) + ); + let mut value = released(); + value.authorization_expires_at_unix_millis = + Some(100 + IDUNN_DEPLOYMENT_RELEASE_MAX_LIFETIME_MILLIS + 1); + assert!(value.validate().is_err()); + } + + #[test] + fn engaged_record_cannot_smuggle_authorization() { + let mut value = released(); + value.status = "engaged".into(); + assert!(value.validate().is_err()); + value.authorization_id = None; + value.authorization_purpose = None; + value.authorized_release_id = None; + value.authorized_deployment_id = None; + value.authorized_by = None; + value.authorization_issued_at_unix_millis = None; + value.authorization_expires_at_unix_millis = None; + value.signature_algorithm = None; + value.signature = None; + value.validate().unwrap(); + assert_eq!( + classify_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Present(&value), + "yggdrasil", + "release-4", + "deployment-4", + 500 + ), + Err(IdunnDeploymentBrakeDenial::Engaged) + ); + } + + #[test] + fn evaluation_requires_the_dedicated_operator_signature() -> Result<()> { + let temp = tempfile::tempdir()?; + let signer = enroll_service_identity_at::( + &temp.path().join("operator.cc"), + )?; + let anchor = signer.trust_anchor()?; + let mut value = released(); + value.authorized_by = Some(anchor.identity_id.clone()); + value.signature = None; + let proof = signer.sign::(&rmp_serde::to_vec(&value)?); + value.signature = Some(proof.signature); + assert_eq!( + evaluate_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Present(&value), + &anchor, + "yggdrasil", + "release-4", + "deployment-4", + 500, + ), + Ok(()) + ); + value.reason = "tampered after authorization".into(); + assert_eq!( + evaluate_idunn_deployment_brake( + IdunnDeploymentBrakeObservation::Present(&value), + &anchor, + "yggdrasil", + "release-4", + "deployment-4", + 500, + ), + Err(IdunnDeploymentBrakeDenial::InvalidSignature) + ); + Ok(()) + } +} diff --git a/packages/cultnet-rs/src/idunn_lifecycle_brake.rs b/packages/cultnet-rs/src/idunn_lifecycle_brake.rs new file mode 100644 index 00000000..86eefa64 --- /dev/null +++ b/packages/cultnet-rs/src/idunn_lifecycle_brake.rs @@ -0,0 +1,325 @@ +use anyhow::{Result, bail}; +use cultcache_rs::DatabaseEntry; + +pub const IDUNN_LIFECYCLE_BRAKE_SCHEMA: &str = "idunn.lifecycle_brake.v1"; +pub const IDUNN_LIFECYCLE_BRAKE_TYPE: &str = "idunn.lifecycle_brake"; +pub const IDUNN_LIFECYCLE_BRAKE_AUTHORITY: &str = "idunn.root"; +pub const IDUNN_LIFECYCLE_BRAKE_SCOPE: &str = "continuity-restart"; + +/// Root-owned, per-target continuity brake. This document has no authority over +/// Idunn startup, deployment, route admission, or any target other than the one +/// named in `target`. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "idunn.lifecycle_brake", schema = "idunn.lifecycle_brake.v1")] +pub struct IdunnLifecycleBrakeRecord { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub authority: String, + #[cultcache(key = 2)] + pub runtime_id: String, + #[cultcache(key = 3)] + pub target: String, + #[cultcache(key = 4)] + pub scope: String, + #[cultcache(key = 5)] + pub status: String, + #[cultcache(key = 6)] + pub reason: String, + #[cultcache(key = 7)] + pub updated_at_unix_millis: u64, + #[cultcache(key = 8)] + pub released_until_unix_millis: Option, +} + +impl IdunnLifecycleBrakeRecord { + pub fn validate(&self) -> Result<()> { + if self.schema_version != IDUNN_LIFECYCLE_BRAKE_SCHEMA { + bail!("lifecycle brake schema is unsupported"); + } + identifier(&self.authority, "authority")?; + identifier(&self.runtime_id, "runtime id")?; + identifier(&self.target, "target")?; + identifier(&self.scope, "scope")?; + identifier(&self.reason, "reason")?; + if !matches!(self.status.as_str(), "engaged" | "released") + || self.updated_at_unix_millis == 0 + { + bail!("lifecycle brake status or update time is invalid"); + } + if self.status == "engaged" && self.released_until_unix_millis.is_some() { + bail!("engaged lifecycle brake cannot carry a release expiry"); + } + if self + .released_until_unix_millis + .is_some_and(|expires| expires <= self.updated_at_unix_millis) + { + bail!("lifecycle brake release expiry is not after its update"); + } + Ok(()) + } + + pub fn canonical_bytes(&self) -> Result> { + self.validate()?; + Ok(rmp_serde::to_vec(self)?) + } + + pub fn decode_canonical(payload: &[u8]) -> Result { + if messagepack_array_len(payload) != Some(9) { + bail!("lifecycle brake is not the 9-field positional contract"); + } + let value: Self = rmp_serde::from_slice(payload)?; + value.validate()?; + if rmp_serde::to_vec(&value)? != payload { + bail!("lifecycle brake encoding is not canonical"); + } + Ok(value) + } +} + +#[derive(Clone, Copy, Debug)] +pub enum IdunnLifecycleBrakeObservation<'a> { + Missing, + Corrupt, + Present(&'a IdunnLifecycleBrakeRecord), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IdunnLifecycleBrakeDenial { + Corrupt, + Foreign, + WrongRuntime, + WrongTarget, + Engaged, + Expired, +} + +/// Decide only whether Idunn may restart one already-admitted target to keep it +/// alive. Absence is the default allow state; a present record must bind the +/// exact Idunn runtime and target and must be explicitly released. +pub fn evaluate_idunn_continuity_restart( + observation: IdunnLifecycleBrakeObservation<'_>, + runtime_id: &str, + target: &str, + now_unix_millis: u64, +) -> std::result::Result<(), IdunnLifecycleBrakeDenial> { + let record = match observation { + IdunnLifecycleBrakeObservation::Missing => return Ok(()), + IdunnLifecycleBrakeObservation::Corrupt => { + return Err(IdunnLifecycleBrakeDenial::Corrupt); + } + IdunnLifecycleBrakeObservation::Present(record) => record, + }; + if record.validate().is_err() { + return Err(IdunnLifecycleBrakeDenial::Corrupt); + } + if record.authority != IDUNN_LIFECYCLE_BRAKE_AUTHORITY + || record.scope != IDUNN_LIFECYCLE_BRAKE_SCOPE + { + return Err(IdunnLifecycleBrakeDenial::Foreign); + } + if record.runtime_id != runtime_id { + return Err(IdunnLifecycleBrakeDenial::WrongRuntime); + } + if record.target != target { + return Err(IdunnLifecycleBrakeDenial::WrongTarget); + } + if record.status == "engaged" { + return Err(IdunnLifecycleBrakeDenial::Engaged); + } + if record + .released_until_unix_millis + .is_some_and(|expires| now_unix_millis >= expires) + { + return Err(IdunnLifecycleBrakeDenial::Expired); + } + Ok(()) +} + +fn identifier(value: &str, label: &str) -> Result<()> { + if value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) { + bail!("{label} is empty, oversized, or contains control characters"); + } + Ok(()) +} + +fn messagepack_array_len(payload: &[u8]) -> Option { + match *payload.first()? { + marker @ 0x90..=0x9f => Some((marker & 0x0f) as usize), + 0xdc if payload.len() >= 3 => Some(u16::from_be_bytes([payload[1], payload[2]]) as usize), + 0xdd if payload.len() >= 5 => { + Some(u32::from_be_bytes([payload[1], payload[2], payload[3], payload[4]]) as usize) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn released() -> IdunnLifecycleBrakeRecord { + IdunnLifecycleBrakeRecord { + schema_version: IDUNN_LIFECYCLE_BRAKE_SCHEMA.into(), + authority: IDUNN_LIFECYCLE_BRAKE_AUTHORITY.into(), + runtime_id: "idunn-yggdrasil".into(), + target: "ghostlight".into(), + scope: IDUNN_LIFECYCLE_BRAKE_SCOPE.into(), + status: "released".into(), + reason: "continuity restart permitted".into(), + updated_at_unix_millis: 100, + released_until_unix_millis: None, + } + } + + fn evaluate( + observation: IdunnLifecycleBrakeObservation<'_>, + target: &str, + now: u64, + ) -> std::result::Result<(), IdunnLifecycleBrakeDenial> { + evaluate_idunn_continuity_restart(observation, "idunn-yggdrasil", target, now) + } + + #[test] + fn lifecycle_brake_round_trips_only_as_the_canonical_positional_contract() -> Result<()> { + let value = released(); + let encoded = value.canonical_bytes()?; + assert_eq!(encoded[0], 0x99); + assert_eq!( + IdunnLifecycleBrakeRecord::decode_canonical(&encoded)?, + value + ); + + let mut noncanonical = vec![0xdc, 0, 9]; + noncanonical.extend_from_slice(&encoded[1..]); + assert!(IdunnLifecycleBrakeRecord::decode_canonical(&noncanonical).is_err()); + + let mut extended = encoded; + extended[0] = 0x9a; + extended.push(0xc0); + assert!(IdunnLifecycleBrakeRecord::decode_canonical(&extended).is_err()); + Ok(()) + } + + #[test] + fn absence_and_explicit_release_allow_only_the_named_continuity_restart() { + assert_eq!( + evaluate(IdunnLifecycleBrakeObservation::Missing, "ghostlight", 500), + Ok(()) + ); + + let mut value = released(); + assert_eq!( + evaluate( + IdunnLifecycleBrakeObservation::Present(&value), + "ghostlight", + 500 + ), + Ok(()) + ); + value.released_until_unix_millis = Some(900); + assert_eq!( + evaluate( + IdunnLifecycleBrakeObservation::Present(&value), + "ghostlight", + 899 + ), + Ok(()) + ); + assert_eq!( + evaluate( + IdunnLifecycleBrakeObservation::Present(&value), + "ghostlight", + 900 + ), + Err(IdunnLifecycleBrakeDenial::Expired) + ); + assert_eq!( + evaluate(IdunnLifecycleBrakeObservation::Missing, "odin", 900), + Ok(()) + ); + assert_eq!( + evaluate(IdunnLifecycleBrakeObservation::Present(&value), "odin", 500), + Err(IdunnLifecycleBrakeDenial::WrongTarget) + ); + } + + #[test] + fn corrupt_foreign_wrong_runtime_and_wrong_target_records_deny() { + assert_eq!( + evaluate(IdunnLifecycleBrakeObservation::Corrupt, "ghostlight", 500), + Err(IdunnLifecycleBrakeDenial::Corrupt) + ); + + let mut value = released(); + value.status = "paused".into(); + assert_eq!( + evaluate( + IdunnLifecycleBrakeObservation::Present(&value), + "ghostlight", + 500 + ), + Err(IdunnLifecycleBrakeDenial::Corrupt) + ); + + let mut value = released(); + value.authority = "service-owned".into(); + assert_eq!( + evaluate( + IdunnLifecycleBrakeObservation::Present(&value), + "ghostlight", + 500 + ), + Err(IdunnLifecycleBrakeDenial::Foreign) + ); + + let mut value = released(); + value.scope = "deployment".into(); + assert_eq!( + evaluate( + IdunnLifecycleBrakeObservation::Present(&value), + "ghostlight", + 500 + ), + Err(IdunnLifecycleBrakeDenial::Foreign) + ); + + let value = released(); + assert_eq!( + evaluate_idunn_continuity_restart( + IdunnLifecycleBrakeObservation::Present(&value), + "idunn-nightwing", + "ghostlight", + 500 + ), + Err(IdunnLifecycleBrakeDenial::WrongRuntime) + ); + assert_eq!( + evaluate(IdunnLifecycleBrakeObservation::Present(&value), "odin", 500), + Err(IdunnLifecycleBrakeDenial::WrongTarget) + ); + } + + #[test] + fn engaged_always_denies_and_cannot_smuggle_a_release_expiry() { + let mut value = released(); + value.status = "engaged".into(); + assert_eq!( + evaluate( + IdunnLifecycleBrakeObservation::Present(&value), + "ghostlight", + 500 + ), + Err(IdunnLifecycleBrakeDenial::Engaged) + ); + value.released_until_unix_millis = Some(900); + assert_eq!( + evaluate( + IdunnLifecycleBrakeObservation::Present(&value), + "ghostlight", + 500 + ), + Err(IdunnLifecycleBrakeDenial::Corrupt) + ); + } +} diff --git a/packages/cultnet-rs/src/lib.rs b/packages/cultnet-rs/src/lib.rs index 6519ef24..074c2a29 100644 --- a/packages/cultnet-rs/src/lib.rs +++ b/packages/cultnet-rs/src/lib.rs @@ -1,6 +1,16 @@ mod contracts; +mod idunn_deployment_brake; +mod idunn_lifecycle_brake; +mod media_stream_contracts; +mod media_stream_wire; +mod provider_health_contracts; +mod runtime_authority_contracts; +mod service_identity; +mod snapshot_query; mod cultmesh; mod framing; +mod operation_service; +mod provider_session; mod replication; mod rudp; mod schema_discovery; @@ -9,8 +19,18 @@ mod shard_catalog; mod transport; pub use contracts::*; +pub use idunn_deployment_brake::*; +pub use idunn_lifecycle_brake::*; +pub use media_stream_contracts::*; +pub use media_stream_wire::*; +pub use provider_health_contracts::*; +pub use runtime_authority_contracts::*; +pub use service_identity::*; +pub use snapshot_query::*; pub use cultmesh::*; pub use framing::*; +pub use operation_service::*; +pub use provider_session::*; pub use replication::*; pub use rudp::*; pub use schema_discovery::*; diff --git a/packages/cultnet-rs/src/media_stream_contracts.rs b/packages/cultnet-rs/src/media_stream_contracts.rs new file mode 100644 index 00000000..5704912e --- /dev/null +++ b/packages/cultnet-rs/src/media_stream_contracts.rs @@ -0,0 +1,578 @@ +//! CultMesh Media Stream: the typed contract for carrying live audio and video +//! over CultNet. +//! +//! These records describe a media stream and nothing about who produced it. A +//! capture broker, an editor runtime, a recorder, or a synthetic source may all +//! publish them; an OBS bridge, a compositor, a recorder, or a browser lowering +//! may all consume them. Producers advertise their streams through Odin and +//! consumers resolve them there, so neither end needs to name the other. +//! +//! They lived in `odin-core` as `muninn.media_*`, named after the one producer +//! that happened to emit them first. Every field here is producer-agnostic — the +//! name was the only coupling, and a second producer would have had to +//! impersonate Muninn or fork the shape. +//! +//! # Layout is wire contract +//! +//! CultCache encodes these positionally. Field order and `key` indices are the +//! wire format: append new fields with `default` at the next free index, never +//! reorder or renumber. `missing_video_chunk_keys` shows the pattern. +//! +//! Payloads carry `bytes` so they serialize as MessagePack `bin` rather than an +//! array of integers, which is what the C#, TypeScript and Python runtimes +//! expect and what keeps a 848-byte payload at 851 bytes instead of 1234. Note +//! that this is the derive's own attribute — `#[serde(with = "serde_bytes")]` +//! is inert here, because `DatabaseEntry` writes its own `Serialize`. +//! +//! # Timebase +//! +//! `pts_ticks`, `duration_ticks` and `deadline_ticks` are expressed in +//! `timebase_num / timebase_den` units, carried per record so a consumer never +//! has to assume a producer's clock. `deadline_ticks` is the presentation +//! deadline past which a late arrival is worth dropping rather than displaying. +//! +//! # Reliability +//! +//! Nothing here specifies a delivery guarantee. Whether a stream rides a +//! reliable or lossy CultNet channel, and whether it carries parity, is a +//! transport decision made per stream — not a property of the payload shape. +//! Video parity shards exist for producers that choose forward error correction; +//! audio currently has no parity record, and a producer emitting audio on a +//! lossy channel needs one. + +use cultcache_rs::DatabaseEntry; + +pub const GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA: &str = "gamecult.media_video_access_unit.v1"; +pub const GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA: &str = "gamecult.media_video_parity_shard.v2"; +pub const GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA: &str = "gamecult.media_audio_packet.v1"; +pub const GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA: &str = "gamecult.media_receiver_feedback.v1"; +pub const GAMECULT_MEDIA_STREAM_ADVERTISEMENT_SCHEMA: &str = + "gamecult.media_stream_advertisement.v1"; +pub const GAMECULT_MEDIA_STREAM_REQUEST_SCHEMA: &str = "gamecult.media_stream_request.v1"; + +/// A media stream a producer can serve on request, as it appears in Odin. +/// +/// Odin admits these without any registration: it derives the document type +/// from the schema id (`prefix.vN` -> `prefix`) and refuses only a short list +/// of authority-owned types (`gamecult.runtime_presence_health`, +/// `odin.runtime_topology_correlation`, `idunn.expected_incarnation`, +/// `idunn.runtime_activation`, `idunn.process_write_lease`, +/// `gamecult.service_trust_anchor`). A new record whose type collided with one +/// of those would be rejected at the catalog with no mention of the collision. +/// +/// This is the record a consumer's picker is built from: which streams exist, +/// which video and audio sources each can capture, which codecs it can encode, +/// the defaults it would use, and where it serves the stream from. It is state, not media — it travels as a +/// CultMesh document through Odin, never on the media channel — and it names no +/// consumer. `producer_id` is the runtime that answers requests for it. +/// +/// Keyed by `stream_id`. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "gamecult.media_stream_advertisement", + schema = "gamecult.media_stream_advertisement.v1" +)] +pub struct GameCultMediaStreamAdvertisementRecord { + #[cultcache(key = 0)] + pub stream_id: String, + #[cultcache(key = 1)] + pub producer_id: String, + #[cultcache(key = 2)] + pub label: String, + /// `available`, `streaming`, or `unavailable`. + #[cultcache(key = 3)] + pub state: String, + #[cultcache(key = 4)] + pub video_source_ids: Vec, + #[cultcache(key = 5)] + pub video_source_labels: Vec, + #[cultcache(key = 6)] + pub audio_source_ids: Vec, + #[cultcache(key = 7)] + pub audio_source_labels: Vec, + /// Codecs the producer can encode video as, e.g. `h264`. + #[cultcache(key = 8)] + pub video_codecs: Vec, + /// Codecs the producer can emit audio as, e.g. `pcm-f32le-interleaved`. + #[cultcache(key = 9)] + pub audio_codecs: Vec, + #[cultcache(key = 10)] + pub audio_sample_rate: u32, + #[cultcache(key = 11)] + pub audio_channels: u32, + #[cultcache(key = 12)] + pub default_video_bitrate_kbps: u32, + #[cultcache(key = 13)] + pub default_latency_budget_ms: u32, + #[cultcache(key = 14)] + pub media_packet_bytes: u32, + /// `host:port` the producer serves this stream from. A consumer dials it + /// with `media_connection_id` once its request is answered `running`; + /// the producer listens. The consumer's host admits nothing inbound, + /// which is what lets a viewer install a plugin without opening a port. + #[cultcache(key = 15)] + pub media_endpoint: String, + /// The CultNet RUDP connection id a consumer dials `media_endpoint` with. + #[cultcache(key = 16)] + pub media_connection_id: u32, + #[cultcache(key = 17)] + pub updated_at: String, +} + +/// A consumer asking a producer to start or stop serving a stream to it. +/// +/// The producer answers by rewriting `state` and `detail` on the same record +/// key, so a consumer watches one document for the outcome. A newer request for +/// the same `stream_id` and `receiver_id` supersedes an older one. +/// +/// It carries no consumer endpoint. The consumer dials the advertised +/// `media_endpoint` and identifies itself on the wire by `receiver_id`. +/// +/// Keyed by `media_stream_request_key(stream_id, receiver_id)`. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "gamecult.media_stream_request", + schema = "gamecult.media_stream_request.v1" +)] +pub struct GameCultMediaStreamRequestRecord { + #[cultcache(key = 0)] + pub request_id: String, + #[cultcache(key = 1)] + pub stream_id: String, + #[cultcache(key = 2)] + pub producer_id: String, + #[cultcache(key = 3)] + pub receiver_id: String, + /// `start` or `stop`. + #[cultcache(key = 4)] + pub action: String, + /// `pending`, `running`, `stopped`, or `failed`; the producer owns it after + /// `pending`. + #[cultcache(key = 5)] + pub state: String, + /// Empty means no video. + #[cultcache(key = 6)] + pub video_source_id: String, + /// Empty means no audio. + #[cultcache(key = 7)] + pub audio_source_id: String, + #[cultcache(key = 8)] + pub video_codec: String, + #[cultcache(key = 9)] + pub audio_codec: String, + #[cultcache(key = 10)] + pub video_bitrate_kbps: u32, + #[cultcache(key = 11)] + pub latency_budget_ms: u32, + #[cultcache(key = 12)] + pub media_packet_bytes: u32, + #[cultcache(key = 13)] + pub detail: String, + #[cultcache(key = 14)] + pub updated_at: String, +} + +pub const MEDIA_STREAM_REQUEST_ACTIONS: [&str; 2] = ["start", "stop"]; +pub const MEDIA_STREAM_REQUEST_STATES: [&str; 4] = ["pending", "running", "stopped", "failed"]; +pub const MEDIA_STREAM_ADVERTISEMENT_STATES: [&str; 3] = ["available", "streaming", "unavailable"]; + +/// One live request per stream per receiver: a newer one replaces the older. +pub fn media_stream_request_key(stream_id: &str, receiver_id: &str) -> String { + format!("{stream_id}:{receiver_id}") +} + +pub fn validate_media_stream_advertisement( + record: &GameCultMediaStreamAdvertisementRecord, +) -> anyhow::Result<()> { + use anyhow::anyhow; + for (name, value) in [ + ("stream_id", &record.stream_id), + ("producer_id", &record.producer_id), + ("label", &record.label), + ("updated_at", &record.updated_at), + ] { + if value.is_empty() { + return Err(anyhow!("media stream advertisement {name} must be non-empty")); + } + } + if !MEDIA_STREAM_ADVERTISEMENT_STATES.contains(&record.state.as_str()) { + return Err(anyhow!( + "media stream advertisement state {:?} is not one of {:?}", + record.state, + MEDIA_STREAM_ADVERTISEMENT_STATES + )); + } + if record.video_source_ids.len() != record.video_source_labels.len() { + return Err(anyhow!( + "media stream advertisement has {} video source ids and {} labels", + record.video_source_ids.len(), + record.video_source_labels.len() + )); + } + if record.audio_source_ids.len() != record.audio_source_labels.len() { + return Err(anyhow!( + "media stream advertisement has {} audio source ids and {} labels", + record.audio_source_ids.len(), + record.audio_source_labels.len() + )); + } + if !record.audio_source_ids.is_empty() && (record.audio_sample_rate == 0 || record.audio_channels == 0) { + return Err(anyhow!( + "media stream advertisement offers audio without a sample rate and channel count" + )); + } + if record.media_connection_id == 0 { + return Err(anyhow!("media stream advertisement media_connection_id must be non-zero")); + } + if !is_host_port(&record.media_endpoint) { + return Err(anyhow!( + "media stream advertisement media_endpoint {:?} must be host:port", + record.media_endpoint + )); + } + Ok(()) +} + +pub fn validate_media_stream_request( + record: &GameCultMediaStreamRequestRecord, +) -> anyhow::Result<()> { + use anyhow::anyhow; + for (name, value) in [ + ("request_id", &record.request_id), + ("stream_id", &record.stream_id), + ("producer_id", &record.producer_id), + ("receiver_id", &record.receiver_id), + ("updated_at", &record.updated_at), + ] { + if value.is_empty() { + return Err(anyhow!("media stream request {name} must be non-empty")); + } + } + if !MEDIA_STREAM_REQUEST_ACTIONS.contains(&record.action.as_str()) { + return Err(anyhow!( + "media stream request action {:?} is not one of {:?}", + record.action, + MEDIA_STREAM_REQUEST_ACTIONS + )); + } + if !MEDIA_STREAM_REQUEST_STATES.contains(&record.state.as_str()) { + return Err(anyhow!( + "media stream request state {:?} is not one of {:?}", + record.state, + MEDIA_STREAM_REQUEST_STATES + )); + } + if record.action == "start" { + if record.video_source_id.is_empty() && record.audio_source_id.is_empty() { + return Err(anyhow!("media stream request asks for neither video nor audio")); + } + if !record.video_source_id.is_empty() && record.video_codec.is_empty() { + return Err(anyhow!("media stream request names a video source without a codec")); + } + if !record.audio_source_id.is_empty() && record.audio_codec.is_empty() { + return Err(anyhow!("media stream request names an audio source without a codec")); + } + } + Ok(()) +} + +/// A hostname or IP and a non-zero port. Not `SocketAddr`: a producer may +/// advertise a name the consumer resolves. +fn is_host_port(value: &str) -> bool { + let Some((host, port)) = value.rsplit_once(':') else { + return false; + }; + let host = host.trim_start_matches('[').trim_end_matches(']'); + !host.is_empty() && port.parse::().is_ok_and(|port| port != 0) +} + +#[cfg(test)] +mod media_stream_catalog_tests { + use super::*; + + fn advertisement() -> GameCultMediaStreamAdvertisementRecord { + GameCultMediaStreamAdvertisementRecord { + stream_id: "muninn.raven.av.rudp".into(), + producer_id: "raven".into(), + label: "Raven desktop".into(), + state: "available".into(), + video_source_ids: vec!["display:0".into()], + video_source_labels: vec!["Display 1".into()], + audio_source_ids: vec!["wasapi-loopback:Realtek".into()], + audio_source_labels: vec!["Realtek loopback".into()], + video_codecs: vec!["h264".into()], + audio_codecs: vec!["pcm-f32le-interleaved".into()], + audio_sample_rate: 48_000, + audio_channels: 2, + default_video_bitrate_kbps: 12_000, + default_latency_budget_ms: 250, + media_packet_bytes: 848, + media_endpoint: "192.168.178.20:5220".into(), + media_connection_id: 0x6d75_0001, + updated_at: "2026-09-10T00:00:00Z".into(), + } + } + + fn request() -> GameCultMediaStreamRequestRecord { + GameCultMediaStreamRequestRecord { + request_id: "req-1".into(), + stream_id: "muninn.raven.av.rudp".into(), + producer_id: "raven".into(), + receiver_id: "starfire.obs".into(), + action: "start".into(), + state: "pending".into(), + video_source_id: "display:0".into(), + audio_source_id: "wasapi-loopback:Realtek".into(), + video_codec: "h264".into(), + audio_codec: "pcm-f32le-interleaved".into(), + video_bitrate_kbps: 12_000, + latency_budget_ms: 250, + media_packet_bytes: 848, + detail: String::new(), + updated_at: "2026-09-10T00:00:00Z".into(), + } + } + + #[test] + fn a_complete_advertisement_and_request_validate() -> anyhow::Result<()> { + validate_media_stream_advertisement(&advertisement())?; + validate_media_stream_request(&request())?; + Ok(()) + } + + #[test] + fn source_ids_and_labels_must_pair_up() { + let mut record = advertisement(); + record.video_source_labels.clear(); + let error = validate_media_stream_advertisement(&record).unwrap_err(); + assert!(error.to_string().contains("video source ids"), "{error}"); + } + + #[test] + fn audio_on_offer_needs_a_sample_rate_and_channels() { + let mut record = advertisement(); + record.audio_channels = 0; + assert!(validate_media_stream_advertisement(&record).is_err()); + record.audio_source_ids.clear(); + record.audio_source_labels.clear(); + assert!(validate_media_stream_advertisement(&record).is_ok(), "no audio, no requirement"); + } + + /// The producer is the listener, so it must say where. + #[test] + fn an_advertisement_says_where_the_stream_is_served() { + for bad in ["", "raven", "raven:", "raven:0", ":5220"] { + let mut record = advertisement(); + record.media_endpoint = bad.into(); + assert!(validate_media_stream_advertisement(&record).unwrap_err().to_string().contains("host:port"), "{bad:?}"); + } + for good in ["raven:5220", "10.77.0.2:5220", "[::1]:5220"] { + let mut record = advertisement(); + record.media_endpoint = good.into(); + assert!(validate_media_stream_advertisement(&record).is_ok(), "{good:?}"); + } + } + + #[test] + fn a_start_needs_something_to_send() { + let mut record = request(); + record.video_source_id.clear(); + record.audio_source_id.clear(); + assert!(validate_media_stream_request(&record).unwrap_err().to_string().contains("neither")); + let mut record = request(); + record.video_codec.clear(); + assert!(validate_media_stream_request(&record).unwrap_err().to_string().contains("codec")); + } + + #[test] + fn a_stop_needs_no_sources() -> anyhow::Result<()> { + let mut record = request(); + record.action = "stop".into(); + record.video_source_id.clear(); + record.audio_source_id.clear(); + validate_media_stream_request(&record) + } + + #[test] + fn unknown_actions_and_states_are_refused() { + let mut record = request(); + record.action = "pause".into(); + assert!(validate_media_stream_request(&record).is_err()); + let mut record = request(); + record.state = "done".into(); + assert!(validate_media_stream_request(&record).is_err()); + } + + #[test] + fn one_request_per_stream_per_receiver() { + assert_eq!(media_stream_request_key("s", "starfire.obs"), "s:starfire.obs"); + } + + /// The records round-trip through the same positional encoding every + /// other runtime reads, so a Python or TypeScript consumer sees the same + /// slots. + #[test] + fn records_round_trip_positionally() -> anyhow::Result<()> { + let record = advertisement(); + let encoded = rmp_serde::to_vec(&record)?; + let decoded: GameCultMediaStreamAdvertisementRecord = rmp_serde::from_slice(&encoded)?; + assert_eq!(decoded, record); + let record = request(); + let encoded = rmp_serde::to_vec(&record)?; + let decoded: GameCultMediaStreamRequestRecord = rmp_serde::from_slice(&encoded)?; + assert_eq!(decoded, record); + Ok(()) + } +} + +/// One coded video access unit, possibly split across `chunk_count` records +/// sharing a `frame_id`. A consumer reassembles by `(stream_id, session_id, +/// frame_id)` and orders by `chunk_index`. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "gamecult.media_video_access_unit", + schema = "gamecult.media_video_access_unit.v1" +)] +pub struct GameCultMediaVideoAccessUnitRecord { + #[cultcache(key = 0)] + pub stream_id: String, + #[cultcache(key = 1)] + pub session_id: String, + #[cultcache(key = 2)] + pub frame_id: u64, + #[cultcache(key = 3)] + pub codec: String, + #[cultcache(key = 4)] + pub pts_ticks: i64, + #[cultcache(key = 5)] + pub duration_ticks: u32, + #[cultcache(key = 6)] + pub timebase_num: u32, + #[cultcache(key = 7)] + pub timebase_den: u32, + #[cultcache(key = 8)] + pub keyframe: bool, + #[cultcache(key = 9)] + pub dependency_frame_id: Option, + #[cultcache(key = 10)] + pub deadline_ticks: i64, + #[cultcache(key = 11)] + pub chunk_index: u16, + #[cultcache(key = 12)] + pub chunk_count: u16, + #[cultcache(key = 13, bytes)] + pub payload: Vec, +} + +/// Forward-error-correction parity for one video access unit. Carries the +/// framing fields of the access unit it protects so a receiver can recover +/// without having seen the original, plus the shard geometry needed to run +/// recovery: `parity_index` within `parity_count`, and the chunk sizes the +/// parity was computed over. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "gamecult.media_video_parity_shard", + schema = "gamecult.media_video_parity_shard.v2" +)] +pub struct GameCultMediaVideoParityShardRecord { + #[cultcache(key = 0)] + pub stream_id: String, + #[cultcache(key = 1)] + pub session_id: String, + #[cultcache(key = 2)] + pub frame_id: u64, + #[cultcache(key = 3)] + pub codec: String, + #[cultcache(key = 4)] + pub pts_ticks: i64, + #[cultcache(key = 5)] + pub duration_ticks: u32, + #[cultcache(key = 6)] + pub timebase_num: u32, + #[cultcache(key = 7)] + pub timebase_den: u32, + #[cultcache(key = 8)] + pub keyframe: bool, + #[cultcache(key = 9)] + pub dependency_frame_id: Option, + #[cultcache(key = 10)] + pub deadline_ticks: i64, + #[cultcache(key = 11)] + pub chunk_count: u16, + #[cultcache(key = 12)] + pub parity_index: u16, + #[cultcache(key = 13)] + pub parity_count: u16, + #[cultcache(key = 14)] + pub chunk_payload_bytes: u32, + #[cultcache(key = 15)] + pub last_chunk_payload_bytes: u32, + #[cultcache(key = 16, bytes)] + pub payload: Vec, +} + +/// One coded audio packet. Unlike video these are not chunked: a packet is +/// whole or it is absent, and `payload` carries whatever `codec` names. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "gamecult.media_audio_packet", + schema = "gamecult.media_audio_packet.v1" +)] +pub struct GameCultMediaAudioPacketRecord { + #[cultcache(key = 0)] + pub stream_id: String, + #[cultcache(key = 1)] + pub session_id: String, + #[cultcache(key = 2)] + pub packet_id: u64, + #[cultcache(key = 3)] + pub codec: String, + #[cultcache(key = 4)] + pub pts_ticks: i64, + #[cultcache(key = 5)] + pub duration_ticks: u32, + #[cultcache(key = 6)] + pub timebase_num: u32, + #[cultcache(key = 7)] + pub timebase_den: u32, + #[cultcache(key = 8)] + pub deadline_ticks: i64, + #[cultcache(key = 9, bytes)] + pub payload: Vec, +} + +/// What a receiver observed, published back to the producer so it can adapt: +/// what decoded, what is missing, what arrived too late, and how much queue and +/// jitter the receiver is carrying. +/// +/// This is the only record here that flows consumer-to-producer. A producer is +/// free to ignore it; it reports observation, it does not issue commands. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "gamecult.media_receiver_feedback", + schema = "gamecult.media_receiver_feedback.v1" +)] +pub struct GameCultMediaReceiverFeedbackRecord { + #[cultcache(key = 0)] + pub stream_id: String, + #[cultcache(key = 1)] + pub session_id: String, + #[cultcache(key = 2)] + pub receiver_id: String, + #[cultcache(key = 3)] + pub highest_decodable_frame_id: Option, + #[cultcache(key = 4)] + pub missing_frame_ids: Vec, + #[cultcache(key = 5)] + pub late_frame_ids: Vec, + #[cultcache(key = 6)] + pub requested_keyframe: bool, + #[cultcache(key = 7)] + pub jitter_us: i64, + #[cultcache(key = 8)] + pub decode_queue_us: i64, + #[cultcache(key = 9)] + pub observed_at: String, + #[cultcache(key = 10, default)] + pub missing_video_chunk_keys: Vec, +} diff --git a/packages/cultnet-rs/src/media_stream_wire.rs b/packages/cultnet-rs/src/media_stream_wire.rs new file mode 100644 index 00000000..9b7fbc1b --- /dev/null +++ b/packages/cultnet-rs/src/media_stream_wire.rs @@ -0,0 +1,581 @@ +//! How a CultMesh Media Stream record travels: the envelope both ends must +//! agree on. +//! +//! The records in `media_stream_contracts` describe media. This describes how +//! one reaches a peer — wrapped in `cultnet.document_put_raw.v0` with a schema +//! id, a record key, and provenance. It lives here because a producer and a +//! consumer that never link each other still have to agree on it exactly, and +//! the last time they did not, one end was Rust and the other was 6,515 lines +//! of C++ that had drifted. +//! +//! # No producer's name belongs in a shared envelope +//! +//! The Muninn implementation this replaces hardcoded `"muninn-media:"` into +//! every message id and `"muninn.media"` into every tag, so a second producer +//! would have had to label its traffic as Muninn's. The producer namespace is a +//! parameter here: see [`MediaWireProvenance`]. +//! +//! # Record keys are addresses, not decoration +//! +//! A key identifies one piece of media within a session, so a consumer can tell +//! a retransmission from a new frame and a chunk from its siblings. The shapes +//! are fixed by this module rather than by each producer, because a consumer +//! parses them. + +use anyhow::{Result, anyhow}; + +use crate::contracts::{ + CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetWireContract, + decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, +}; +use crate::media_stream_contracts::{ + GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA, GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA, + GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA, GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA, + GameCultMediaAudioPacketRecord, GameCultMediaReceiverFeedbackRecord, + GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord, +}; + +/// The CultNet channel video and receiver feedback ride. +pub const GAMECULT_MEDIA_CHANNEL: &str = "media"; + +/// The CultNet channel audio rides. Audio has its own channel so one session +/// can carry it reliably with a short expiry while video stays lossy under +/// parity; the RUDP transport profile owns each channel's delivery. +pub const GAMECULT_MEDIA_AUDIO_CHANNEL: &str = "audio"; + +/// One media record on its way somewhere. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GameCultMediaWireRecord { + Video(GameCultMediaVideoAccessUnitRecord), + VideoParity(GameCultMediaVideoParityShardRecord), + Audio(GameCultMediaAudioPacketRecord), + Feedback(GameCultMediaReceiverFeedbackRecord), +} + +/// Who is sending, and when. `producer` namespaces message ids and tags so a +/// stream can be attributed without the envelope knowing any producer by name. +#[derive(Clone, Copy, Debug)] +pub struct MediaWireProvenance<'a> { + pub stored_at: &'a str, + pub runtime_id: &'a str, + pub role: &'a str, + pub producer: &'a str, +} + +impl MediaWireProvenance<'_> { + fn validate(&self) -> Result<()> { + for (name, value) in [ + ("stored_at", self.stored_at), + ("runtime_id", self.runtime_id), + ("role", self.role), + ("producer", self.producer), + ] { + if value.is_empty() { + return Err(anyhow!("media wire provenance {name} must be non-empty")); + } + } + Ok(()) + } +} + +impl GameCultMediaWireRecord { + pub fn schema_id(&self) -> &'static str { + match self { + Self::Video(_) => GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA, + Self::VideoParity(_) => GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA, + Self::Audio(_) => GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA, + Self::Feedback(_) => GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA, + } + } + + /// Addresses this record within its session. + pub fn record_key(&self) -> String { + match self { + Self::Video(record) => format!( + "{}:{}:video:{}:{}", + record.stream_id, record.session_id, record.frame_id, record.chunk_index + ), + Self::VideoParity(record) => format!( + "{}:{}:video-parity:{}:{}", + record.stream_id, record.session_id, record.frame_id, record.parity_index + ), + Self::Audio(record) => format!( + "{}:{}:audio:{}", + record.stream_id, record.session_id, record.packet_id + ), + Self::Feedback(record) => format!( + "{}:{}:feedback:{}", + record.stream_id, record.session_id, record.receiver_id + ), + } + } + + /// The record's own MessagePack form. + /// + /// Serializing the record directly is only correct because the payload + /// fields carry `#[cultcache(key = N, bytes)]`. Muninn previously mirrored + /// each record into a parallel tuple struct — every field restated, in + /// order, by hand — solely so `#[serde(with = "serde_bytes")]` could be + /// applied to the payload, because the `DatabaseEntry` derive does not read + /// serde attributes. Those shadow structs are gone; the derive emits `bin` + /// itself now. Do not reintroduce one. + fn payload_bytes(&self) -> Result> { + match self { + Self::Video(record) => rmp_serde::to_vec(record), + Self::VideoParity(record) => rmp_serde::to_vec(record), + Self::Audio(record) => rmp_serde::to_vec(record), + Self::Feedback(record) => rmp_serde::to_vec(record), + } + .map_err(Into::into) + } +} + +/// Wraps a media record for the wire. +pub fn encode_media_wire_record( + record: &GameCultMediaWireRecord, + provenance: MediaWireProvenance<'_>, +) -> Result> { + provenance.validate()?; + + let schema_id = record.schema_id(); + let record_key = record.record_key(); + let message = CultNetMessage::DocumentPutRaw { + message_id: format!( + "{}-media:{}:{}", + provenance.producer, + schema_id, + record_key.replace(':', "-") + ), + document: CultNetRawDocumentRecord { + schema_id: schema_id.to_string(), + record_key, + stored_at: provenance.stored_at.to_string(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: record.payload_bytes()?, + source_runtime_id: Some(provenance.runtime_id.to_string()), + source_agent_id: None, + source_role: Some(provenance.role.to_string()), + tags: Some(vec![format!("{}.media", provenance.producer)]), + }, + }; + + encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).map_err(Into::into) +} + +/// Unwraps a media record from the wire. +pub fn decode_media_wire_record(payload: &[u8]) -> Result { + let message = decode_cultnet_message_from_slice(payload, CultNetWireContract::CultNetSchemaV0)?; + let CultNetMessage::DocumentPutRaw { document, .. } = message else { + return Err(anyhow!("expected cultnet.document_put_raw.v0")); + }; + if document.payload_encoding != CultNetRawPayloadEncoding::Messagepack { + return Err(anyhow!("media document payload must be MessagePack")); + } + + let record = match document.schema_id.as_str() { + GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA => { + let record: GameCultMediaVideoAccessUnitRecord = + rmp_serde::from_slice(&document.payload)?; + validate_video_record(&record)?; + GameCultMediaWireRecord::Video(record) + } + GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA => { + let record: GameCultMediaVideoParityShardRecord = + rmp_serde::from_slice(&document.payload)?; + validate_video_parity_record(&record)?; + GameCultMediaWireRecord::VideoParity(record) + } + GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA => { + let record: GameCultMediaAudioPacketRecord = + rmp_serde::from_slice(&document.payload)?; + validate_audio_record(&record)?; + GameCultMediaWireRecord::Audio(record) + } + GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA => { + let record: GameCultMediaReceiverFeedbackRecord = + rmp_serde::from_slice(&document.payload)?; + validate_feedback_record(&record)?; + GameCultMediaWireRecord::Feedback(record) + } + other => return Err(anyhow!("unsupported media schema {other}")), + }; + + // The key is derived from the record's own contents, so a mismatch means the + // envelope and its payload disagree about what this is. Admitting it would + // let a consumer address media by a key its producer never meant. + let expected = record.record_key(); + if document.record_key != expected { + return Err(anyhow!( + "record key mismatch: envelope says {}, record derives {expected}", + document.record_key + )); + } + + Ok(record) +} + +// --------------------------------------------------------------------------- +// Validation +// +// Moved here from Muninn with the envelope. A decoder that does not validate +// leaves each consumer to invent its own idea of a well-formed record, which is +// how the C++ receiver and the Rust sender came to disagree while both looked +// correct in isolation. Muninn's tests for these came too; they now cover the +// contract rather than one producer's copy of it. +// --------------------------------------------------------------------------- + +/// Addresses one chunk of a video frame. The string form appears in receiver +/// feedback and is parsed by consumers, so its shape is contract. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct VideoChunkKey { + pub frame_id: u64, + pub chunk_index: u16, +} + +impl VideoChunkKey { + pub fn new(frame_id: u64, chunk_index: u16) -> Self { + Self { + frame_id, + chunk_index, + } + } + + pub fn parse(value: &str) -> Result { + if value.matches(':').count() != 1 { + return Err(anyhow!( + "video chunk key must contain exactly one ':' separator" + )); + } + let (frame_id, chunk_index) = value + .split_once(':') + .ok_or_else(|| anyhow!("video chunk key must be ':'"))?; + Ok(Self { + frame_id: frame_id + .parse() + .map_err(|_| anyhow!("video chunk key frame_id must be an integer"))?, + chunk_index: chunk_index + .parse() + .map_err(|_| anyhow!("video chunk key chunk_index must be an integer"))?, + }) + } + + pub fn as_feedback_key(&self) -> String { + format!("{}:{}", self.frame_id, self.chunk_index) + } +} + +pub fn video_chunk_feedback_key(frame_id: u64, chunk_index: u16) -> String { + VideoChunkKey::new(frame_id, chunk_index).as_feedback_key() +} + +pub fn normalize_video_chunk_feedback_keys(keys: Vec) -> Result> { + let mut parsed = keys + .iter() + .map(|key| VideoChunkKey::parse(key)) + .collect::>>()?; + parsed.sort_unstable(); + parsed.dedup(); + Ok(parsed + .into_iter() + .map(|key| key.as_feedback_key()) + .collect()) +} + +pub fn validate_video_record(record: &GameCultMediaVideoAccessUnitRecord) -> Result<()> { + if record.stream_id.is_empty() { + return Err(anyhow!("video media record stream_id must be non-empty")); + } + if record.session_id.is_empty() { + return Err(anyhow!("video media record session_id must be non-empty")); + } + if record.codec.is_empty() { + return Err(anyhow!("video media record codec must be non-empty")); + } + if record.timebase_num == 0 || record.timebase_den == 0 { + return Err(anyhow!("video media record timebase must be non-zero")); + } + if record.duration_ticks == 0 { + return Err(anyhow!( + "video media record duration_ticks must be greater than zero" + )); + } + if record.deadline_ticks < record.pts_ticks { + return Err(anyhow!( + "video media record deadline_ticks must not precede pts_ticks" + )); + } + if record.chunk_count == 0 { + return Err(anyhow!("video media record chunk_count must be non-zero")); + } + if record.chunk_index >= record.chunk_count { + return Err(anyhow!( + "video media record chunk_index {} is outside chunk_count {}", + record.chunk_index, + record.chunk_count + )); + } + if record.payload.is_empty() { + return Err(anyhow!("video media record payload must be non-empty")); + } + Ok(()) +} + +pub fn validate_video_parity_record(record: &GameCultMediaVideoParityShardRecord) -> Result<()> { + if record.stream_id.is_empty() { + return Err(anyhow!( + "video parity media record stream_id must be non-empty" + )); + } + if record.session_id.is_empty() { + return Err(anyhow!( + "video parity media record session_id must be non-empty" + )); + } + if record.codec.is_empty() { + return Err(anyhow!("video parity media record codec must be non-empty")); + } + if record.timebase_num == 0 || record.timebase_den == 0 { + return Err(anyhow!( + "video parity media record timebase must be non-zero" + )); + } + if record.duration_ticks == 0 { + return Err(anyhow!( + "video parity media record duration_ticks must be greater than zero" + )); + } + if record.deadline_ticks < record.pts_ticks { + return Err(anyhow!( + "video parity media record deadline_ticks must not precede pts_ticks" + )); + } + if record.chunk_count == 0 { + return Err(anyhow!( + "video parity media record chunk_count must be non-zero" + )); + } + if record.parity_count == 0 || record.parity_index >= record.parity_count { + return Err(anyhow!( + "video parity media record has invalid parity stripe metadata" + )); + } + if record.parity_count > record.chunk_count { + return Err(anyhow!( + "video parity media record parity_count exceeds chunk_count" + )); + } + if record.chunk_payload_bytes == 0 || record.last_chunk_payload_bytes == 0 { + return Err(anyhow!( + "video parity media record chunk lengths must be non-zero" + )); + } + if record.last_chunk_payload_bytes > record.chunk_payload_bytes { + return Err(anyhow!( + "video parity media record last chunk length exceeds regular chunk length" + )); + } + if record.payload.is_empty() { + return Err(anyhow!( + "video parity media record payload must be non-empty" + )); + } + if record.payload.len() > record.chunk_payload_bytes as usize { + return Err(anyhow!( + "video parity media record payload exceeds declared chunk length" + )); + } + Ok(()) +} + +pub fn validate_audio_record(record: &GameCultMediaAudioPacketRecord) -> Result<()> { + if record.stream_id.is_empty() { + return Err(anyhow!("audio media record stream_id must be non-empty")); + } + if record.session_id.is_empty() { + return Err(anyhow!("audio media record session_id must be non-empty")); + } + if record.codec.is_empty() { + return Err(anyhow!("audio media record codec must be non-empty")); + } + if record.timebase_num == 0 || record.timebase_den == 0 { + return Err(anyhow!("audio media record timebase must be non-zero")); + } + if record.duration_ticks == 0 { + return Err(anyhow!( + "audio media record duration_ticks must be greater than zero" + )); + } + if record.deadline_ticks < record.pts_ticks { + return Err(anyhow!( + "audio media record deadline_ticks must not precede pts_ticks" + )); + } + if record.payload.is_empty() { + return Err(anyhow!("audio media record payload must be non-empty")); + } + Ok(()) +} + +pub fn validate_feedback_record(record: &GameCultMediaReceiverFeedbackRecord) -> Result<()> { + if record.stream_id.is_empty() { + return Err(anyhow!( + "receiver feedback media record stream_id must be non-empty" + )); + } + if record.session_id.is_empty() { + return Err(anyhow!( + "receiver feedback media record session_id must be non-empty" + )); + } + if record.receiver_id.is_empty() { + return Err(anyhow!( + "receiver feedback media record receiver_id must be non-empty" + )); + } + if record.observed_at.is_empty() { + return Err(anyhow!( + "receiver feedback media record observed_at must be non-empty" + )); + } + if record.jitter_us < 0 { + return Err(anyhow!( + "receiver feedback media record jitter_us must be non-negative" + )); + } + if record.decode_queue_us < 0 { + return Err(anyhow!( + "receiver feedback media record decode_queue_us must be non-negative" + )); + } + normalize_video_chunk_feedback_keys(record.missing_video_chunk_keys.clone())?; + Ok(()) +} + +/// What a receiver has to say about a stream. The record it builds is the +/// contract's; this normalises it so both ends agree on its shape: damage +/// lists sorted and unique, chunk keys well-formed, and a keyframe requested +/// whenever a whole frame is missing, since a producer's repair cache holds +/// chunks, not references. Missing chunks alone are a repair request, which +/// exists precisely so that a keyframe is not needed. +#[derive(Clone, Debug)] +pub struct ReceiverFeedbackOptions<'a> { + pub stream_id: &'a str, + pub session_id: &'a str, + pub receiver_id: &'a str, + pub highest_decodable_frame_id: Option, + pub missing_frame_ids: Vec, + pub missing_video_chunk_keys: Vec, + pub late_frame_ids: Vec, + pub requested_keyframe: bool, + pub jitter_us: i64, + pub decode_queue_us: i64, + pub observed_at: &'a str, +} + +pub fn build_receiver_feedback( + options: ReceiverFeedbackOptions<'_>, +) -> Result { + let mut missing_frame_ids = options.missing_frame_ids; + missing_frame_ids.sort_unstable(); + missing_frame_ids.dedup(); + let missing_video_chunk_keys = + normalize_video_chunk_feedback_keys(options.missing_video_chunk_keys)?; + let mut late_frame_ids = options.late_frame_ids; + late_frame_ids.sort_unstable(); + late_frame_ids.dedup(); + let requested_keyframe = options.requested_keyframe || !missing_frame_ids.is_empty(); + + let record = GameCultMediaReceiverFeedbackRecord { + stream_id: options.stream_id.to_string(), + session_id: options.session_id.to_string(), + receiver_id: options.receiver_id.to_string(), + highest_decodable_frame_id: options.highest_decodable_frame_id, + missing_frame_ids, + late_frame_ids, + requested_keyframe, + jitter_us: options.jitter_us, + decode_queue_us: options.decode_queue_us, + observed_at: options.observed_at.to_string(), + missing_video_chunk_keys, + }; + validate_feedback_record(&record)?; + Ok(record) +} + +#[cfg(test)] +mod receiver_feedback_tests { + use super::*; + + fn options() -> ReceiverFeedbackOptions<'static> { + ReceiverFeedbackOptions { + stream_id: "muninn.raven.av.rudp", + session_id: "session-1", + receiver_id: "starfire.obs", + highest_decodable_frame_id: Some(40), + missing_frame_ids: Vec::new(), + missing_video_chunk_keys: Vec::new(), + late_frame_ids: Vec::new(), + requested_keyframe: false, + jitter_us: 0, + decode_queue_us: 2_000, + observed_at: "2026-06-18T00:00:00Z", + } + } + + #[test] + fn builds_receiver_feedback_with_sorted_unique_damage_lists() -> Result<()> { + let feedback = build_receiver_feedback(ReceiverFeedbackOptions { + missing_frame_ids: vec![43, 42, 43], + missing_video_chunk_keys: vec![ + video_chunk_feedback_key(43, 2), + video_chunk_feedback_key(42, 1), + video_chunk_feedback_key(42, 1), + ], + late_frame_ids: vec![39, 39, 38], + jitter_us: 700, + ..options() + })?; + + assert_eq!(feedback.missing_frame_ids, vec![42, 43]); + assert_eq!(feedback.missing_video_chunk_keys, vec!["42:1", "43:2"]); + assert_eq!(feedback.late_frame_ids, vec![38, 39]); + assert!(feedback.requested_keyframe, "a whole missing frame is a lost reference"); + Ok(()) + } + + /// A producer answers a keyframe request with an IDR. A repair request is + /// the cheaper alternative and must not carry one by accident. + #[test] + fn a_repair_request_does_not_ask_for_a_keyframe() -> Result<()> { + let feedback = build_receiver_feedback(ReceiverFeedbackOptions { + missing_video_chunk_keys: vec![video_chunk_feedback_key(42, 1)], + ..options() + })?; + assert!(!feedback.requested_keyframe); + Ok(()) + } + + #[test] + fn rejects_negative_receiver_feedback_timing() { + let error = build_receiver_feedback(ReceiverFeedbackOptions { jitter_us: -1, ..options() }) + .unwrap_err(); + assert!(error.to_string().contains("jitter_us")); + } + + #[test] + fn rejects_malformed_receiver_feedback_chunk_keys() { + let error = build_receiver_feedback(ReceiverFeedbackOptions { + missing_video_chunk_keys: vec!["frame:chunk".to_string()], + ..options() + }) + .unwrap_err(); + assert!(error.to_string().contains("frame_id"), "{error}"); + } + + #[test] + fn rejects_an_empty_receiver_id() { + let error = build_receiver_feedback(ReceiverFeedbackOptions { receiver_id: "", ..options() }) + .unwrap_err(); + assert!(error.to_string().contains("receiver_id")); + } +} diff --git a/packages/cultnet-rs/src/operation_service.rs b/packages/cultnet-rs/src/operation_service.rs new file mode 100644 index 00000000..84d60cfa --- /dev/null +++ b/packages/cultnet-rs/src/operation_service.rs @@ -0,0 +1,60 @@ +use std::net::UdpSocket; +use std::time::Duration; + +use anyhow::{Result, anyhow}; + +use crate::{ + CultNetMessage, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, +}; + +pub const CULTNET_OPERATION_CONNECTION_ID: u32 = 0x4355_4c54; + +pub struct CultNetOperationServer { + transport: CultNetRudpSocketTransportConnection, + endpoint: String, +} + +impl CultNetOperationServer { + pub fn bind(runtime_id: impl Into, host: &str, port: u16) -> Result { + let socket = UdpSocket::bind((host, port))?; + socket.set_read_timeout(Some(Duration::from_millis(25)))?; + let address = socket.local_addr()?; + let mut options = CultNetRudpSocketTransportOptions::server( + runtime_id, + socket, + CULTNET_OPERATION_CONNECTION_ID, + ); + options.max_fragment_bytes = Some(2048); + Ok(Self { + transport: CultNetRudpSocketTransportConnection::new(options)?, + endpoint: format!("rudp://{}:{}", address.ip(), address.port()), + }) + } + + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + pub fn serve_once( + &mut self, + mut handler: impl FnMut(CultNetMessage) -> Result, + ) -> Result { + let Some(request) = self.transport.receive_schema_message_once()? else { + self.transport.poll_resends()?; + return Ok(false); + }; + if !matches!(request, CultNetMessage::OperationRequest { .. }) { + return Err(anyhow!( + "CultNet operation service received a non-operation message" + )); + } + let response = handler(request)?; + if !matches!(response, CultNetMessage::OperationResponse { .. }) { + return Err(anyhow!( + "CultNet operation handler returned a non-operation response" + )); + } + self.transport.send_schema_message(&response)?; + Ok(true) + } +} diff --git a/packages/cultnet-rs/src/provider_health_contracts.rs b/packages/cultnet-rs/src/provider_health_contracts.rs new file mode 100644 index 00000000..632c74f2 --- /dev/null +++ b/packages/cultnet-rs/src/provider_health_contracts.rs @@ -0,0 +1,716 @@ +use anyhow::{Context, Result, anyhow, bail}; +use cultcache_rs::DatabaseEntry; +use serde::{Deserialize, Serialize}; + +use crate::runtime_authority_contracts::{ + GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE, + ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA, ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE, +}; +use crate::{ + GameCultProviderHealthIdentity, IdunnServiceIdentity, OdinTopologyIdentity, + derive_service_identity_id, +}; + +pub const GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA: &str = "gamecult.service_trust_anchor.v1"; +pub const IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA: &str = + "idunn.authenticated_provider_health_projection.v1"; +pub const IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA: &str = "idunn.signed_daemon_health.v1"; +pub const IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SIGNING_PURPOSE: &str = + "idunn.authenticated-provider-health-projection.v1"; +pub const IDUNN_PROVIDER_ACTIVE_REASON: &str = "authenticated_provider_active"; +pub const IDUNN_PROVIDER_WARMING_REASON: &str = "authenticated_provider_warming"; +pub const IDUNN_PROVIDER_DEGRADED_REASON: &str = "authenticated_provider_degraded"; +pub const IDUNN_PROVIDER_FAILED_REASON: &str = "authenticated_provider_failed"; +pub const IDUNN_DAEMON_HEALTH_TRUST_BINDING_SCHEMA: &str = "idunn.daemon_health_trust_binding.v1"; + +/// Provider-owned health statement. Ingress verifies `signature` over the +/// complete positional record with this field empty before admitting it. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "idunn.signed_daemon_health", + schema = "idunn.signed_daemon_health.v1" +)] +pub struct IdunnSignedDaemonHealthRecord { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub daemon_id: String, + #[cultcache(key = 2)] + pub health_contract: String, + #[cultcache(key = 3)] + pub source_runtime_id: String, + #[cultcache(key = 4)] + pub state: String, + #[cultcache(key = 5)] + pub detail: String, + #[cultcache(key = 6)] + pub signer_identity_id: String, + #[cultcache(key = 7)] + pub publisher_incarnation_id: String, + #[cultcache(key = 8)] + pub publisher_sequence: u64, + #[cultcache(key = 9)] + pub observed_at_unix_millis: u64, + #[cultcache(key = 10)] + pub release_id: Option, + #[cultcache(key = 11)] + pub release_witness_sha256: Option, + #[cultcache(key = 12)] + pub source_commit: Option, + #[cultcache(key = 13)] + pub deployment_id: Option, + #[cultcache(key = 14)] + pub signature_algorithm: String, + #[cultcache(key = 15, bytes)] + pub signature: Vec, + #[cultcache(key = 16)] + pub private_state_exposed: bool, + #[cultcache(key = 17, default)] + pub activation_witness_sha256: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct LegacyIdunnSignedDaemonHealthWire { + schema_version: String, + daemon_id: String, + health_contract: String, + source_runtime_id: String, + state: String, + detail: String, + signer_identity_id: String, + publisher_incarnation_id: String, + publisher_sequence: u64, + observed_at_unix_millis: u64, + release_id: Option, + release_witness_sha256: Option, + source_commit: Option, + deployment_id: Option, + signature_algorithm: String, + #[serde(with = "serde_bytes")] + signature: Vec, + private_state_exposed: bool, +} + +impl From for IdunnSignedDaemonHealthRecord { + fn from(value: LegacyIdunnSignedDaemonHealthWire) -> Self { + Self { + schema_version: value.schema_version, + daemon_id: value.daemon_id, + health_contract: value.health_contract, + source_runtime_id: value.source_runtime_id, + state: value.state, + detail: value.detail, + signer_identity_id: value.signer_identity_id, + publisher_incarnation_id: value.publisher_incarnation_id, + publisher_sequence: value.publisher_sequence, + observed_at_unix_millis: value.observed_at_unix_millis, + release_id: value.release_id, + release_witness_sha256: value.release_witness_sha256, + source_commit: value.source_commit, + deployment_id: value.deployment_id, + signature_algorithm: value.signature_algorithm, + signature: value.signature, + private_state_exposed: value.private_state_exposed, + activation_witness_sha256: None, + } + } +} + +impl IdunnSignedDaemonHealthRecord { + /// Decode the canonical current or pre-activation positional wire and + /// return the exact wire generation's unsigned signature payload. + pub fn decode_canonical_signed_payload(payload: &[u8]) -> Result<(Self, Vec)> { + match messagepack_array_len(payload) { + Some(18) => { + let statement: Self = rmp_serde::from_slice(payload) + .context("decoding generic signed daemon health")?; + if rmp_serde::to_vec(&statement)? != payload { + bail!("signed daemon health payload is not canonical positional MessagePack"); + } + let mut unsigned = statement.clone(); + unsigned.signature.clear(); + Ok((statement, rmp_serde::to_vec(&unsigned)?)) + } + Some(17) => { + let legacy: LegacyIdunnSignedDaemonHealthWire = rmp_serde::from_slice(payload) + .context("decoding legacy generic signed daemon health")?; + if rmp_serde::to_vec(&legacy)? != payload { + bail!("signed daemon health payload is not canonical positional MessagePack"); + } + let mut unsigned = legacy.clone(); + unsigned.signature.clear(); + let unsigned_payload = rmp_serde::to_vec(&unsigned)?; + Ok((legacy.into(), unsigned_payload)) + } + _ => bail!("signed daemon health payload is not canonical positional MessagePack"), + } + } + + pub fn validate(&self) -> Result<()> { + if self.schema_version != IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA { + bail!("signed daemon health schema is unsupported"); + } + validate_identifier(&self.daemon_id, "daemon id")?; + validate_identifier(&self.health_contract, "health contract")?; + validate_identifier(&self.source_runtime_id, "source runtime id")?; + validate_identifier(&self.signer_identity_id, "signer identity id")?; + validate_identifier(&self.publisher_incarnation_id, "publisher incarnation id")?; + if !matches!( + self.state.as_str(), + "active" | "warming" | "degraded" | "failed" + ) || self.detail.len() > 512 + || self.signature_algorithm != "ed25519" + || self.signature.len() != 64 + || self.publisher_sequence == 0 + || self.observed_at_unix_millis == 0 + { + bail!("signed daemon health shape is invalid"); + } + validate_optional_release_binding( + &self.release_id, + &self.release_witness_sha256, + &self.source_commit, + )?; + validate_optional_identifier(&self.deployment_id, "deployment id")?; + validate_optional_sha256(&self.activation_witness_sha256, "activation witness sha256")?; + if self.private_state_exposed { + bail!("signed daemon health exposes private state"); + } + Ok(()) + } +} + +fn messagepack_array_len(payload: &[u8]) -> Option { + match *payload.first()? { + marker @ 0x90..=0x9f => Some(usize::from(marker & 0x0f)), + 0xdc => Some(usize::from(u16::from_be_bytes([ + *payload.get(1)?, + *payload.get(2)?, + ]))), + 0xdd => usize::try_from(u32::from_be_bytes([ + *payload.get(1)?, + *payload.get(2)?, + *payload.get(3)?, + *payload.get(4)?, + ])) + .ok(), + _ => None, + } +} + +/// Root-owned admission binding. Store ownership is the physical authority; +/// this document names the only signer accepted for one daemon/contract/runtime. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "idunn.daemon_health_trust_binding", + schema = "idunn.daemon_health_trust_binding.v1" +)] +pub struct IdunnDaemonHealthTrustBindingRecord { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub binding_id: String, + #[cultcache(key = 2)] + pub daemon_id: String, + #[cultcache(key = 3)] + pub health_contract: String, + #[cultcache(key = 4)] + pub source_runtime_id: String, + #[cultcache(key = 5)] + pub signer_identity_id: String, + #[cultcache(key = 6)] + pub signer_public_key: Vec, + #[cultcache(key = 7)] + pub binding_authority: String, + #[cultcache(key = 8)] + pub bound_at_unix_millis: u64, + #[cultcache(key = 9)] + pub release_binding_required: bool, + #[cultcache(key = 10)] + pub private_state_exposed: bool, +} + +impl IdunnDaemonHealthTrustBindingRecord { + pub fn validate(&self) -> Result<()> { + if self.schema_version != IDUNN_DAEMON_HEALTH_TRUST_BINDING_SCHEMA { + bail!("daemon health trust binding schema is unsupported"); + } + validate_identifier(&self.binding_id, "binding id")?; + validate_identifier(&self.daemon_id, "daemon id")?; + validate_identifier(&self.health_contract, "health contract")?; + validate_identifier(&self.source_runtime_id, "source runtime id")?; + validate_identifier(&self.signer_identity_id, "signer identity id")?; + if self.signer_public_key.len() != 32 + || self.binding_authority != "root" + || self.bound_at_unix_millis == 0 + { + bail!("daemon health trust binding authority or key is invalid"); + } + if self.private_state_exposed { + bail!("daemon health trust binding exposes private state"); + } + if self.signer_identity_id + != derive_service_identity_id::( + &self.signer_public_key, + )? + { + bail!("daemon health trust binding identity does not match its public key"); + } + Ok(()) + } +} + +/// Root-distributed public key binding for one service-owned signed contract. +/// Consumers pin this document; a self-declared key inside a signed projection +/// is never authority. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "gamecult.service_trust_anchor", + schema = "gamecult.service_trust_anchor.v1" +)] +pub struct GameCultServiceTrustAnchorRecord { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub trust_anchor_id: String, + #[cultcache(key = 2)] + pub service_id: String, + #[cultcache(key = 3)] + pub runtime_id: String, + #[cultcache(key = 4)] + pub signer_identity_id: String, + #[cultcache(key = 5)] + pub signer_public_key: Vec, + #[cultcache(key = 6)] + pub signature_algorithm: String, + #[cultcache(key = 7)] + pub signing_purpose: String, + #[cultcache(key = 8)] + pub signed_schema: String, + #[cultcache(key = 9)] + pub binding_authority: String, + #[cultcache(key = 10)] + pub bound_at_unix_millis: u64, + #[cultcache(key = 11)] + pub expires_at_unix_millis: Option, + #[cultcache(key = 12)] + pub private_state_exposed: bool, +} + +impl GameCultServiceTrustAnchorRecord { + pub fn validate(&self) -> Result<()> { + if self.schema_version != GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA { + bail!("service trust anchor schema is unsupported"); + } + validate_identifier(&self.trust_anchor_id, "trust anchor id")?; + validate_identifier(&self.service_id, "service id")?; + validate_identifier(&self.runtime_id, "runtime id")?; + validate_identifier(&self.signer_identity_id, "signer identity id")?; + validate_identifier(&self.signing_purpose, "signing purpose")?; + validate_identifier(&self.signed_schema, "signed schema")?; + if self.signer_public_key.len() != 32 + || self.signature_algorithm != "ed25519" + || self.binding_authority != "root" + || self.bound_at_unix_millis == 0 + || self + .expires_at_unix_millis + .is_some_and(|expires_at| expires_at <= self.bound_at_unix_millis) + || self.private_state_exposed + { + bail!("service trust anchor authority, key, lifetime, or privacy is invalid"); + } + if self.signed_schema == IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA + && (self.service_id != "idunn" + || self.signing_purpose + != IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SIGNING_PURPOSE + || self.signer_identity_id + != derive_service_identity_id::(&self.signer_public_key)?) + { + bail!("Idunn provider-health trust anchor profile is invalid"); + } + let is_runtime_presence_profile = self + .signed_schema + .starts_with("gamecult.runtime_presence_health.") + || self + .signing_purpose + .starts_with("gamecult.runtime_presence_health."); + if is_runtime_presence_profile + && (self.signed_schema != GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA + || self.signing_purpose != GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE + || self.signer_identity_id + != derive_service_identity_id::( + &self.signer_public_key, + )?) + { + bail!("runtime presence trust anchor profile is invalid"); + } + if (self.signed_schema == ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA + || self.signing_purpose == ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE) + && (self.signed_schema != ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA + || self.signing_purpose != ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE + || self.service_id != "odin" + || self.signer_identity_id + != derive_service_identity_id::(&self.signer_public_key)?) + { + bail!("Odin topology correlation trust anchor profile is invalid"); + } + Ok(()) + } +} + +/// Idunn-owned public projection of one current authenticated generic provider +/// admission. Absence and dependency failure produce no record; they are not +/// provider states Idunn may manufacture. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "idunn.authenticated_provider_health_projection", + schema = "idunn.authenticated_provider_health_projection.v1" +)] +pub struct IdunnAuthenticatedProviderHealthProjectionRecord { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub projection_id: String, + #[cultcache(key = 2)] + pub daemon_id: String, + #[cultcache(key = 3)] + pub health_contract: String, + #[cultcache(key = 4)] + pub provider_state: String, + #[cultcache(key = 5)] + pub reason_code: String, + #[cultcache(key = 6)] + pub provider_observed_at_unix_millis: u64, + #[cultcache(key = 7)] + pub admitted_at_unix_millis: u64, + #[cultcache(key = 8)] + pub evaluated_at_unix_millis: u64, + #[cultcache(key = 9)] + pub trust_binding_id: String, + #[cultcache(key = 10)] + pub trust_binding_sha256: String, + #[cultcache(key = 11)] + pub signed_health_sha256: String, + #[cultcache(key = 12)] + pub authenticated_admission_sha256: String, + #[cultcache(key = 13)] + pub provider_signer_identity_id: String, + #[cultcache(key = 14)] + pub provider_incarnation_id: String, + #[cultcache(key = 15)] + pub provider_sequence: u64, + #[cultcache(key = 16)] + pub release_id: Option, + #[cultcache(key = 17)] + pub release_witness_sha256: Option, + #[cultcache(key = 18)] + pub source_commit: Option, + #[cultcache(key = 19)] + pub deployment_id: Option, + #[cultcache(key = 20)] + pub idunn_runtime_id: String, + #[cultcache(key = 21)] + pub idunn_signer_identity_id: String, + #[cultcache(key = 22)] + pub projection_incarnation_id: String, + #[cultcache(key = 23)] + pub projection_sequence: u64, + #[cultcache(key = 24)] + pub signature_algorithm: String, + #[cultcache(key = 25)] + pub signature: Vec, + #[cultcache(key = 26)] + pub private_state_exposed: bool, + #[cultcache(key = 27)] + pub expires_at_unix_millis: u64, +} + +/// Closed derivation for an authenticated provider state. `None` means no +/// provider-health projection may be published. +pub fn authenticated_provider_health_reason_code(state: &str) -> Option<&'static str> { + match state { + "active" => Some(IDUNN_PROVIDER_ACTIVE_REASON), + "warming" => Some(IDUNN_PROVIDER_WARMING_REASON), + "degraded" => Some(IDUNN_PROVIDER_DEGRADED_REASON), + "failed" => Some(IDUNN_PROVIDER_FAILED_REASON), + _ => None, + } +} + +impl IdunnAuthenticatedProviderHealthProjectionRecord { + pub fn validate(&self) -> Result<()> { + if self.schema_version != IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA { + bail!("authenticated provider health projection schema is unsupported"); + } + validate_identifier(&self.projection_id, "projection id")?; + validate_identifier(&self.daemon_id, "daemon id")?; + validate_identifier(&self.health_contract, "health contract")?; + validate_identifier(&self.trust_binding_id, "trust binding id")?; + validate_identifier( + &self.provider_signer_identity_id, + "provider signer identity id", + )?; + validate_identifier(&self.provider_incarnation_id, "provider incarnation id")?; + validate_identifier(&self.idunn_runtime_id, "Idunn runtime id")?; + validate_identifier(&self.idunn_signer_identity_id, "Idunn signer identity id")?; + validate_identifier(&self.projection_incarnation_id, "projection incarnation id")?; + let reason_is_valid = authenticated_provider_health_reason_code(&self.provider_state) + .is_some_and(|reason| reason == self.reason_code); + if !reason_is_valid + || self.provider_observed_at_unix_millis == 0 + || self.admitted_at_unix_millis < self.provider_observed_at_unix_millis + || self.evaluated_at_unix_millis < self.admitted_at_unix_millis + || self.expires_at_unix_millis <= self.evaluated_at_unix_millis + || self.provider_sequence == 0 + || self.projection_sequence == 0 + || !is_sha256(&self.trust_binding_sha256) + || !is_sha256(&self.signed_health_sha256) + || !is_sha256(&self.authenticated_admission_sha256) + || self.signature_algorithm != "ed25519" + || self.signature.len() != 64 + || self.private_state_exposed + { + bail!( + "authenticated provider health projection shape, lineage, reason, signature, or privacy is invalid" + ); + } + validate_optional_release_binding( + &self.release_id, + &self.release_witness_sha256, + &self.source_commit, + )?; + validate_optional_identifier(&self.deployment_id, "deployment id")?; + if self.deployment_id.is_some() != self.release_id.is_some() { + bail!("authenticated provider health projection release lineage is partial"); + } + Ok(()) + } +} + +fn validate_identifier(value: &str, label: &str) -> Result<()> { + if value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) { + bail!("{label} is empty, oversized, or contains control characters"); + } + Ok(()) +} + +fn validate_optional_identifier(value: &Option, label: &str) -> Result<()> { + if let Some(value) = value { + validate_identifier(value, label)?; + } + Ok(()) +} + +fn validate_optional_sha256(value: &Option, label: &str) -> Result<()> { + if value.as_deref().is_some_and(|value| !is_sha256(value)) { + bail!("{label} is malformed"); + } + Ok(()) +} + +fn validate_optional_release_binding( + release_id: &Option, + witness: &Option, + source_commit: &Option, +) -> Result<()> { + if release_id.is_some() || witness.is_some() || source_commit.is_some() { + validate_optional_identifier(release_id, "release id")?; + let witness = witness + .as_deref() + .ok_or_else(|| anyhow!("release witness is absent"))?; + let commit = source_commit + .as_deref() + .ok_or_else(|| anyhow!("source commit is absent"))?; + if release_id.is_none() || !is_sha256(witness) || !is_lower_hex(commit, 40) { + bail!("release binding is incomplete or malformed"); + } + } + Ok(()) +} + +fn is_sha256(value: &str) -> bool { + value + .strip_prefix("sha256-") + .is_some_and(|digest| is_lower_hex(digest, 64)) +} + +fn is_lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn signed_health() -> IdunnSignedDaemonHealthRecord { + IdunnSignedDaemonHealthRecord { + schema_version: IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA.into(), + daemon_id: "epiphany".into(), + health_contract: "epiphany.cultnet-rudp-runtime-health".into(), + source_runtime_id: "epiphany-yggdrasil".into(), + state: "active".into(), + detail: "managed-services-current".into(), + signer_identity_id: "provider-signing-key".into(), + publisher_incarnation_id: "9c2cba2e-c2de-4750-b222-b732f97d0435".into(), + publisher_sequence: 1, + observed_at_unix_millis: 100, + release_id: Some("release-1".into()), + release_witness_sha256: Some(format!("sha256-{}", "d".repeat(64))), + source_commit: Some("e".repeat(40)), + deployment_id: Some("deployment-1".into()), + signature_algorithm: "ed25519".into(), + signature: vec![6; 64], + private_state_exposed: false, + activation_witness_sha256: Some(format!("sha256-{}", "f".repeat(64))), + } + } + + fn anchor() -> GameCultServiceTrustAnchorRecord { + let public_key = vec![5; 32]; + GameCultServiceTrustAnchorRecord { + schema_version: GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into(), + trust_anchor_id: "root/idunn/provider-health".into(), + service_id: "idunn".into(), + runtime_id: "idunn-yggdrasil".into(), + signer_identity_id: derive_service_identity_id::(&public_key) + .unwrap(), + signer_public_key: public_key, + signature_algorithm: "ed25519".into(), + signing_purpose: IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SIGNING_PURPOSE.into(), + signed_schema: IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA.into(), + binding_authority: "root".into(), + bound_at_unix_millis: 100, + expires_at_unix_millis: Some(200), + private_state_exposed: false, + } + } + + fn projection() -> IdunnAuthenticatedProviderHealthProjectionRecord { + IdunnAuthenticatedProviderHealthProjectionRecord { + schema_version: IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA.into(), + projection_id: "authenticated-provider-health:epiphany".into(), + daemon_id: "epiphany".into(), + health_contract: "health.v1".into(), + provider_state: "active".into(), + reason_code: IDUNN_PROVIDER_ACTIVE_REASON.into(), + provider_observed_at_unix_millis: 100, + admitted_at_unix_millis: 101, + evaluated_at_unix_millis: 102, + trust_binding_id: "root/epiphany".into(), + trust_binding_sha256: format!("sha256-{}", "a".repeat(64)), + signed_health_sha256: format!("sha256-{}", "b".repeat(64)), + authenticated_admission_sha256: format!("sha256-{}", "c".repeat(64)), + provider_signer_identity_id: "provider-signing-key".into(), + provider_incarnation_id: "provider/boot/1".into(), + provider_sequence: 1, + release_id: Some("release-1".into()), + release_witness_sha256: Some(format!("sha256-{}", "d".repeat(64))), + source_commit: Some("e".repeat(40)), + deployment_id: Some("deployment-1".into()), + idunn_runtime_id: "idunn-yggdrasil".into(), + idunn_signer_identity_id: "idunn-signing-key".into(), + projection_incarnation_id: "idunn/boot/1".into(), + projection_sequence: 1, + signature_algorithm: "ed25519".into(), + signature: vec![6; 64], + private_state_exposed: false, + expires_at_unix_millis: 200, + } + } + + #[test] + fn contracts_accept_exact_root_and_lineage_shapes() -> Result<()> { + let anchor = anchor(); + let projection = projection(); + let health = signed_health(); + anchor.validate()?; + projection.validate()?; + health.validate()?; + let encoded = rmp_serde::to_vec(&health)?; + assert_eq!(encoded.first().copied(), Some(0xdc)); + assert_eq!(&encoded[1..3], &[0, 18]); + assert!( + encoded + .windows(4) + .any(|window| window == [0xc4, 0x40, 6, 6]), + "provider signature must use the portable MessagePack binary shape" + ); + assert_eq!( + rmp_serde::from_slice::(&encoded)?, + health + ); + assert_eq!( + rmp_serde::from_slice::(&rmp_serde::to_vec( + &anchor + )?)?, + anchor + ); + assert_eq!( + rmp_serde::from_slice::( + &rmp_serde::to_vec(&projection)? + )?, + projection + ); + assert_eq!(authenticated_provider_health_reason_code("missing"), None); + Ok(()) + } + + #[test] + fn activation_binding_is_additive_for_legacy_generic_health_records() -> Result<()> { + let mut health = signed_health(); + health.activation_witness_sha256 = None; + let mut legacy = rmp_serde::to_vec(&health)?; + assert_eq!(&legacy[..3], &[0xdc, 0, 18]); + assert_eq!(legacy.pop(), Some(0xc0)); + legacy[2] = 17; + let decoded: IdunnSignedDaemonHealthRecord = rmp_serde::from_slice(&legacy)?; + assert_eq!(decoded.activation_witness_sha256, None); + decoded.validate()?; + let (decoded, unsigned) = + IdunnSignedDaemonHealthRecord::decode_canonical_signed_payload(&legacy)?; + assert_eq!(decoded.activation_witness_sha256, None); + assert_eq!(&unsigned[..3], &[0xdc, 0, 17]); + Ok(()) + } + + #[test] + fn contracts_refuse_substitution_and_manufactured_states() { + let mut value = anchor(); + value.binding_authority = "idunn".into(); + assert!(value.validate().is_err()); + let mut value = anchor(); + value.signer_identity_id = "caller-selected".into(); + assert!(value.validate().is_err()); + let mut value = projection(); + value.reason_code = "release_drift".into(); + assert!(value.validate().is_err()); + let mut value = projection(); + value.provider_state = "missing".into(); + assert!(value.validate().is_err()); + let mut value = projection(); + value.deployment_id = None; + assert!(value.validate().is_err()); + let mut value = projection(); + value.signature.clear(); + assert!(value.validate().is_err()); + let mut value = projection(); + value.signed_health_sha256 = format!("sha256-{}", "A".repeat(64)); + assert!(value.validate().is_err()); + let mut value = signed_health(); + value.publisher_sequence = 0; + assert!(value.validate().is_err()); + let mut value = signed_health(); + value.activation_witness_sha256 = Some(format!("sha256-{}", "F".repeat(64))); + assert!(value.validate().is_err()); + let mut value = signed_health(); + value.release_witness_sha256 = None; + assert!(value.validate().is_err()); + let mut value = signed_health(); + value.private_state_exposed = true; + assert!(value.validate().is_err()); + } +} diff --git a/packages/cultnet-rs/src/provider_session.rs b/packages/cultnet-rs/src/provider_session.rs new file mode 100644 index 00000000..9965b0ef --- /dev/null +++ b/packages/cultnet-rs/src/provider_session.rs @@ -0,0 +1,625 @@ +use anyhow::{Result, anyhow}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use chrono::DateTime; +use rmpv::Value; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::{CultNetMessage, CultNetRawDocumentRecord}; + +pub const CULTMESH_PROVIDER_SESSION_SERVICE_ID: &str = "gamecult.mesh.provider_session"; +pub const CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING: &str = "messagepack-base64"; + +pub const CULTMESH_PROVIDER_REGISTER_OPERATION: &str = "provider.register"; +pub const CULTMESH_PROVIDER_RENEW_OPERATION: &str = "provider.renew"; +pub const CULTMESH_PROVIDER_PUBLICATION_PUT_OPERATION: &str = "provider.publication.put"; +pub const CULTMESH_PROVIDER_PUBLICATION_DELETE_OPERATION: &str = "provider.publication.delete"; +pub const CULTMESH_PROVIDER_RECEIPT_PUT_OPERATION: &str = "provider.receipt.put"; +pub const CULTMESH_PROVIDER_WITHDRAW_OPERATION: &str = "provider.withdraw"; + +pub const CULTMESH_PROVIDER_REGISTRATION_SCHEMA: &str = "gamecult.mesh.provider_registration.v1"; +pub const CULTMESH_PROVIDER_LEASE_SCHEMA: &str = "gamecult.mesh.provider_lease.v1"; +pub const CULTMESH_PROVIDER_LEASE_RENEWAL_SCHEMA: &str = "gamecult.mesh.provider_lease_renewal.v1"; +pub const CULTMESH_PROVIDER_PUBLICATION_PUT_SCHEMA: &str = + "gamecult.mesh.provider_publication_put.v1"; +pub const CULTMESH_PROVIDER_PUBLICATION_DELETE_SCHEMA: &str = + "gamecult.mesh.provider_publication_delete.v1"; +pub const CULTMESH_PROVIDER_COMMAND_SCHEMA: &str = "gamecult.mesh.provider_command.v1"; +pub const CULTMESH_PROVIDER_RECEIPT_PUT_SCHEMA: &str = "gamecult.mesh.provider_receipt_put.v1"; +pub const CULTMESH_PROVIDER_WITHDRAWAL_SCHEMA: &str = "gamecult.mesh.provider_withdrawal.v1"; +pub const CULTMESH_PROVIDER_MUTATION_ACCEPTANCE_SCHEMA: &str = + "gamecult.mesh.provider_mutation_acceptance.v1"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CultMeshProviderOperationStatus { + Ok, + Conflict, + Expired, + Denied, + Invalid, +} + +impl CultMeshProviderOperationStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Conflict => "conflict", + Self::Expired => "expired", + Self::Denied => "denied", + Self::Invalid => "invalid", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "ok" => Ok(Self::Ok), + "conflict" => Ok(Self::Conflict), + "expired" => Ok(Self::Expired), + "denied" => Ok(Self::Denied), + "invalid" => Ok(Self::Invalid), + _ => Err(anyhow!("Unsupported provider-session status {value:?}")), + } + } +} + +pub trait CultMeshProviderSessionPayload { + fn validate(&self) -> Result<()>; +} + +/// Provider-owned evidence carried verbatim in the RUDP Connect payload. +/// `client_session_id` distinguishes process/session generations; the optional +/// token remains opaque authorization evidence for the receiving service. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultMeshProviderConnectEvidenceWire { + pub client_session_id: String, + #[serde(default)] + pub session_token: Option, +} + +impl CultMeshProviderSessionPayload for CultMeshProviderConnectEvidenceWire { + fn validate(&self) -> Result<()> { + require_text(&self.client_session_id, "clientSessionId")?; + require_optional_text(self.session_token.as_deref(), "sessionToken") + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultMeshProviderRegistrationWire { + pub provider_id: String, + pub service_instance_id: String, + pub endpoint_id: String, + pub verse_id: String, + pub requested_lease_duration_ms: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authority_lease_id: Option, +} + +impl CultMeshProviderSessionPayload for CultMeshProviderRegistrationWire { + fn validate(&self) -> Result<()> { + require_identity( + &self.provider_id, + &self.service_instance_id, + &self.endpoint_id, + &self.verse_id, + )?; + require_positive(self.requested_lease_duration_ms, "requestedLeaseDurationMs")?; + require_optional_text(self.authority_lease_id.as_deref(), "authorityLeaseId") + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultMeshProviderLeaseWire { + pub provider_id: String, + pub service_instance_id: String, + pub endpoint_id: String, + pub verse_id: String, + pub lease_id: String, + pub valid_from_utc: String, + pub expires_at_utc: String, +} + +impl CultMeshProviderSessionPayload for CultMeshProviderLeaseWire { + fn validate(&self) -> Result<()> { + require_identity( + &self.provider_id, + &self.service_instance_id, + &self.endpoint_id, + &self.verse_id, + )?; + require_text(&self.lease_id, "leaseId")?; + let valid_from = require_timestamp(&self.valid_from_utc, "validFromUtc")?; + let expires_at = require_timestamp(&self.expires_at_utc, "expiresAtUtc")?; + if expires_at <= valid_from { + return Err(anyhow!("Provider lease must expire after validFromUtc")); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultMeshProviderLeaseRenewalWire { + pub lease_id: String, + pub requested_lease_duration_ms: i32, +} + +impl CultMeshProviderSessionPayload for CultMeshProviderLeaseRenewalWire { + fn validate(&self) -> Result<()> { + require_text(&self.lease_id, "leaseId")?; + require_positive(self.requested_lease_duration_ms, "requestedLeaseDurationMs") + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultMeshProviderPublicationPutWire { + pub lease_id: String, + pub publication_id: String, + pub document: CultNetRawDocumentRecord, +} + +impl CultMeshProviderSessionPayload for CultMeshProviderPublicationPutWire { + fn validate(&self) -> Result<()> { + require_text(&self.lease_id, "leaseId")?; + require_text(&self.publication_id, "publicationId")?; + validate_raw_document(&self.document) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultMeshProviderPublicationDeleteWire { + pub lease_id: String, + pub publication_id: String, + pub schema_id: String, + pub record_key: String, +} + +impl CultMeshProviderSessionPayload for CultMeshProviderPublicationDeleteWire { + fn validate(&self) -> Result<()> { + require_text(&self.lease_id, "leaseId")?; + require_text(&self.publication_id, "publicationId")?; + require_text(&self.schema_id, "schemaId")?; + require_text(&self.record_key, "recordKey") + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultMeshProviderCommandWire { + pub command_id: String, + pub command_kind: String, + pub provider_id: String, + pub service_instance_id: String, + pub payload: Value, +} + +impl CultMeshProviderSessionPayload for CultMeshProviderCommandWire { + fn validate(&self) -> Result<()> { + require_text(&self.command_id, "commandId")?; + require_text(&self.command_kind, "commandKind")?; + require_text(&self.provider_id, "providerId")?; + require_text(&self.service_instance_id, "serviceInstanceId") + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CultMeshProviderReceiptStateWire { + Applied, + Rejected, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultMeshProviderCommandReceiptWire { + pub receipt_id: String, + pub command_id: String, + pub command_kind: String, + pub provider_id: String, + pub service_instance_id: String, + pub state: CultMeshProviderReceiptStateWire, + pub completed_at_utc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl CultMeshProviderSessionPayload for CultMeshProviderCommandReceiptWire { + fn validate(&self) -> Result<()> { + require_text(&self.receipt_id, "receiptId")?; + require_text(&self.command_id, "commandId")?; + require_text(&self.command_kind, "commandKind")?; + require_text(&self.provider_id, "providerId")?; + require_text(&self.service_instance_id, "serviceInstanceId")?; + require_timestamp(&self.completed_at_utc, "completedAtUtc")?; + require_optional_text(self.error.as_deref(), "error") + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultMeshProviderReceiptPutWire { + pub lease_id: String, + pub receipt: CultMeshProviderCommandReceiptWire, +} + +impl CultMeshProviderSessionPayload for CultMeshProviderReceiptPutWire { + fn validate(&self) -> Result<()> { + require_text(&self.lease_id, "leaseId")?; + self.receipt.validate() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultMeshProviderWithdrawalWire { + pub lease_id: String, +} + +impl CultMeshProviderSessionPayload for CultMeshProviderWithdrawalWire { + fn validate(&self) -> Result<()> { + require_text(&self.lease_id, "leaseId") + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultMeshProviderMutationAcceptanceWire { + pub accepted_at_utc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lease_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publication_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receipt_id: Option, +} + +impl CultMeshProviderSessionPayload for CultMeshProviderMutationAcceptanceWire { + fn validate(&self) -> Result<()> { + require_timestamp(&self.accepted_at_utc, "acceptedAtUtc")?; + require_optional_text(self.lease_id.as_deref(), "leaseId")?; + require_optional_text(self.publication_id.as_deref(), "publicationId")?; + require_optional_text(self.command_id.as_deref(), "commandId")?; + require_optional_text(self.receipt_id.as_deref(), "receiptId") + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CultMeshProviderErrorWire {} + +impl CultMeshProviderSessionPayload for CultMeshProviderErrorWire { + fn validate(&self) -> Result<()> { + Ok(()) + } +} + +pub fn encode_provider_session_payload(value: &T) -> Result +where + T: CultMeshProviderSessionPayload + Serialize, +{ + value.validate()?; + Ok(STANDARD.encode(rmp_serde::to_vec_named(value)?)) +} + +pub fn encode_provider_connect_evidence( + evidence: &CultMeshProviderConnectEvidenceWire, +) -> Result> { + evidence.validate()?; + Ok(rmp_serde::to_vec_named(evidence)?) +} + +pub fn decode_provider_connect_evidence( + payload: &[u8], +) -> Result { + if payload.is_empty() { + return Err(anyhow!( + "Provider Connect evidence must be a non-empty MessagePack map" + )); + } + let evidence: CultMeshProviderConnectEvidenceWire = rmp_serde::from_slice(payload)?; + evidence.validate()?; + Ok(evidence) +} + +pub fn decode_provider_session_payload(payload: &str) -> Result +where + T: CultMeshProviderSessionPayload + DeserializeOwned, +{ + require_text(payload, "payload")?; + let bytes = STANDARD + .decode(payload) + .map_err(|error| anyhow!("Provider-session payload must be MessagePack base64: {error}"))?; + let value: T = rmp_serde::from_slice(&bytes)?; + value.validate()?; + Ok(value) +} + +pub fn decode_provider_command_document( + document: &CultNetRawDocumentRecord, +) -> Result { + if document.schema_id != CULTMESH_PROVIDER_COMMAND_SCHEMA { + return Err(anyhow!( + "Expected provider command schema {:?}, received {:?}", + CULTMESH_PROVIDER_COMMAND_SCHEMA, + document.schema_id + )); + } + let command: CultMeshProviderCommandWire = rmp_serde::from_slice(&document.payload)?; + command.validate()?; + Ok(command) +} + +pub fn create_provider_session_request( + message_id: impl Into, + operation: impl Into, + payload_schema: impl Into, + payload: &T, + source_runtime_id: Option, + target_runtime_id: Option, +) -> Result +where + T: CultMeshProviderSessionPayload + Serialize, +{ + let message_id = message_id.into(); + let operation = operation.into(); + let payload_schema = payload_schema.into(); + require_text(&message_id, "messageId")?; + validate_operation_schema(&operation, &payload_schema)?; + Ok(CultNetMessage::OperationRequest { + message_id, + service_id: CULTMESH_PROVIDER_SESSION_SERVICE_ID.to_string(), + operation, + payload_schema, + payload_encoding: CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING.to_string(), + payload: encode_provider_session_payload(payload)?, + source_runtime_id, + target_runtime_id, + }) +} + +pub fn decode_provider_session_request( + request: &CultNetMessage, + expected_operation: &str, + expected_payload_schema: &str, +) -> Result +where + T: CultMeshProviderSessionPayload + DeserializeOwned, +{ + let CultNetMessage::OperationRequest { + service_id, + operation, + payload_schema, + payload_encoding, + payload, + .. + } = request + else { + return Err(anyhow!("Expected a CultNet operation request")); + }; + require_envelope( + service_id, + operation, + payload_schema, + payload_encoding, + expected_operation, + expected_payload_schema, + )?; + decode_provider_session_payload(payload) +} + +pub fn create_provider_session_response( + request: &CultNetMessage, + status: CultMeshProviderOperationStatus, + payload_schema: impl Into, + payload: &T, + source_runtime_id: Option, +) -> Result +where + T: CultMeshProviderSessionPayload + Serialize, +{ + let CultNetMessage::OperationRequest { + message_id, + operation, + .. + } = request + else { + return Err(anyhow!("Expected a CultNet operation request")); + }; + require_text(message_id, "messageId")?; + require_text(operation, "operation")?; + let payload_schema = payload_schema.into(); + require_text(&payload_schema, "payloadSchema")?; + Ok(CultNetMessage::OperationResponse { + message_id: message_id.clone(), + service_id: CULTMESH_PROVIDER_SESSION_SERVICE_ID.to_string(), + operation: operation.clone(), + status: status.as_str().to_string(), + payload_schema, + payload_encoding: CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING.to_string(), + payload: encode_provider_session_payload(payload)?, + diagnostics: Vec::new(), + source_runtime_id, + }) +} + +pub fn create_provider_session_error_response( + request: &CultNetMessage, + status: CultMeshProviderOperationStatus, + diagnostics: Vec, + source_runtime_id: Option, +) -> Result { + if status == CultMeshProviderOperationStatus::Ok { + return Err(anyhow!( + "Provider-session error responses require a non-ok status" + )); + } + if diagnostics.is_empty() { + return Err(anyhow!( + "Provider-session error responses require diagnostics" + )); + } + for diagnostic in &diagnostics { + require_text(diagnostic, "diagnostics[]")?; + } + let mut response = create_provider_session_response( + request, + status, + CULTMESH_PROVIDER_MUTATION_ACCEPTANCE_SCHEMA, + &CultMeshProviderErrorWire {}, + source_runtime_id, + )?; + let CultNetMessage::OperationResponse { + diagnostics: response_diagnostics, + .. + } = &mut response + else { + unreachable!("provider-session response helper returned a request") + }; + *response_diagnostics = diagnostics; + Ok(response) +} + +pub fn decode_provider_session_response( + response: &CultNetMessage, + expected_operation: &str, + expected_payload_schema: &str, +) -> Result<(CultMeshProviderOperationStatus, T)> +where + T: CultMeshProviderSessionPayload + DeserializeOwned, +{ + let CultNetMessage::OperationResponse { + service_id, + operation, + status, + payload_schema, + payload_encoding, + payload, + .. + } = response + else { + return Err(anyhow!("Expected a CultNet operation response")); + }; + require_envelope( + service_id, + operation, + payload_schema, + payload_encoding, + expected_operation, + expected_payload_schema, + )?; + Ok(( + CultMeshProviderOperationStatus::parse(status)?, + decode_provider_session_payload(payload)?, + )) +} + +fn validate_operation_schema(operation: &str, payload_schema: &str) -> Result<()> { + let expected = match operation { + CULTMESH_PROVIDER_REGISTER_OPERATION => CULTMESH_PROVIDER_REGISTRATION_SCHEMA, + CULTMESH_PROVIDER_RENEW_OPERATION => CULTMESH_PROVIDER_LEASE_RENEWAL_SCHEMA, + CULTMESH_PROVIDER_PUBLICATION_PUT_OPERATION => CULTMESH_PROVIDER_PUBLICATION_PUT_SCHEMA, + CULTMESH_PROVIDER_PUBLICATION_DELETE_OPERATION => { + CULTMESH_PROVIDER_PUBLICATION_DELETE_SCHEMA + } + CULTMESH_PROVIDER_RECEIPT_PUT_OPERATION => CULTMESH_PROVIDER_RECEIPT_PUT_SCHEMA, + CULTMESH_PROVIDER_WITHDRAW_OPERATION => CULTMESH_PROVIDER_WITHDRAWAL_SCHEMA, + _ => { + return Err(anyhow!( + "Unsupported provider-session operation {operation:?}" + )); + } + }; + if payload_schema != expected { + return Err(anyhow!( + "Provider-session operation {operation:?} requires payload schema {expected:?}" + )); + } + Ok(()) +} + +fn require_envelope( + service_id: &str, + operation: &str, + payload_schema: &str, + payload_encoding: &str, + expected_operation: &str, + expected_payload_schema: &str, +) -> Result<()> { + if service_id != CULTMESH_PROVIDER_SESSION_SERVICE_ID { + return Err(anyhow!( + "Unexpected provider-session service id {service_id:?}" + )); + } + if operation != expected_operation { + return Err(anyhow!( + "Unexpected provider-session operation {operation:?}" + )); + } + if payload_schema != expected_payload_schema { + return Err(anyhow!( + "Unexpected provider-session payload schema {payload_schema:?}" + )); + } + if payload_encoding != CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING { + return Err(anyhow!( + "Unexpected provider-session payload encoding {payload_encoding:?}" + )); + } + Ok(()) +} + +fn validate_raw_document(document: &CultNetRawDocumentRecord) -> Result<()> { + require_text(&document.schema_id, "document.schemaId")?; + require_text(&document.record_key, "document.recordKey")?; + Ok(()) +} + +fn require_identity( + provider_id: &str, + service_instance_id: &str, + endpoint_id: &str, + verse_id: &str, +) -> Result<()> { + require_text(provider_id, "providerId")?; + require_text(service_instance_id, "serviceInstanceId")?; + require_text(endpoint_id, "endpointId")?; + require_text(verse_id, "verseId") +} + +fn require_text(value: &str, field: &str) -> Result<()> { + if value.trim().is_empty() { + return Err(anyhow!("Provider-session {field} must not be empty")); + } + Ok(()) +} + +fn require_optional_text(value: Option<&str>, field: &str) -> Result<()> { + if let Some(value) = value { + require_text(value, field)?; + } + Ok(()) +} + +fn require_positive(value: i32, field: &str) -> Result<()> { + if value <= 0 { + return Err(anyhow!( + "Provider-session {field} must be greater than zero" + )); + } + Ok(()) +} + +fn require_timestamp(value: &str, field: &str) -> Result> { + require_text(value, field)?; + DateTime::parse_from_rfc3339(value) + .map_err(|error| anyhow!("Provider-session {field} must be RFC3339: {error}")) +} diff --git a/packages/cultnet-rs/src/replication.rs b/packages/cultnet-rs/src/replication.rs index 492cf317..7073f1a9 100644 --- a/packages/cultnet-rs/src/replication.rs +++ b/packages/cultnet-rs/src/replication.rs @@ -9,6 +9,13 @@ use serde::de::DeserializeOwned; use serde_json::Value; use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::thread; +use std::thread::JoinHandle; +use std::time::Duration; use crate::CultNetDocumentMutationContract; use crate::CultNetDocumentRecord; @@ -67,6 +74,56 @@ pub struct CultNetDocumentRegistry { bindings_by_schema_id: BTreeMap, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultNetReactiveDocumentOptions { + pub flush_delay: Duration, + pub detect_local_changes: bool, + pub replace_dirty_current_on_canonical_snapshot: bool, +} + +impl Default for CultNetReactiveDocumentOptions { + fn default() -> Self { + Self { + flush_delay: Duration::from_millis(16), + detect_local_changes: true, + replace_dirty_current_on_canonical_snapshot: false, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CultNetReactiveDocumentReconciliation { + pub canonical: T, + pub predicted: T, + pub delta: BTreeMap, + pub version: u64, + pub received_at: String, +} + +struct CultNetReactiveDocumentState { + dirty: bool, + flushing: bool, + flush_queued: bool, + last_clean_payload: Vec, + reconciliation: Option>, + reconciliation_version: u64, + last_error: Option, +} + +pub struct CultNetReactiveDocument +where + T: DatabaseEntry + Serialize + DeserializeOwned, +{ + registry: CultNetDocumentRegistry, + cache: Arc>, + record_key: String, + current: Arc>, + state: Arc>>, + options: CultNetReactiveDocumentOptions, + disposed: Arc, + detect_worker: Option>, +} + impl CultNetDocumentRegistry { pub fn new() -> Self { Self::default() @@ -352,6 +409,40 @@ impl CultNetDocumentRegistry { Ok(applied) } + pub fn sync_document_from_snapshot_response( + &self, + cache: &mut CultCache, + response: &CultNetMessage, + record_key: &str, + ) -> Result + where + T: DatabaseEntry + Serialize + DeserializeOwned, + { + let CultNetMessage::SnapshotResponse { documents, .. } = response else { + return Err(anyhow!("expected cultnet.snapshot_response.v0")); + }; + let binding = self.require_binding(T::TYPE)?; + let document = documents + .iter() + .find(|document| { + document.schema_id == binding.schema_id && document.record_key == record_key + }) + .ok_or_else(|| { + anyhow!( + "No CultNet snapshot document for schema {:?} and record key {:?}", + binding.schema_id, + record_key + ) + })?; + self.apply_document_put_message::( + cache, + &CultNetMessage::DocumentPut { + message_id: "snapshot-sync".to_string(), + document: document.clone(), + }, + ) + } + pub fn apply_raw_snapshot_response( &self, cache: &mut CultCache, @@ -380,6 +471,52 @@ impl CultNetDocumentRegistry { Ok(applied) } + pub fn sync_raw_document_from_snapshot_response( + &self, + cache: &mut CultCache, + response: &CultNetMessage, + record_key: &str, + ) -> Result + where + T: DatabaseEntry + Serialize + DeserializeOwned, + { + let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else { + return Err(anyhow!("expected cultnet.snapshot_response_raw.v0")); + }; + let binding = self.require_binding(T::TYPE)?; + let document = documents + .iter() + .find(|document| { + document.schema_id == binding.schema_id && document.record_key == record_key + }) + .ok_or_else(|| { + anyhow!( + "No raw CultNet snapshot document for schema {:?} and record key {:?}", + binding.schema_id, + record_key + ) + })?; + self.apply_raw_document_put_message::( + cache, + &CultNetMessage::DocumentPutRaw { + message_id: "snapshot-sync-raw".to_string(), + document: document.clone(), + }, + ) + } + + pub fn reactive_document( + &self, + cache: Arc>, + record_key: impl Into, + options: CultNetReactiveDocumentOptions, + ) -> Result> + where + T: DatabaseEntry + Serialize + DeserializeOwned, + { + CultNetReactiveDocument::new(self.clone(), cache, record_key, options) + } + fn document_record_from_envelope( &self, envelope: &CultCacheEnvelope, @@ -403,7 +540,7 @@ impl CultNetDocumentRegistry { }) } - fn raw_document_record_from_envelope( + pub fn raw_document_record_from_envelope( &self, envelope: &CultCacheEnvelope, ) -> Result { @@ -428,6 +565,443 @@ impl CultNetDocumentRegistry { } } +impl CultNetReactiveDocument +where + T: DatabaseEntry + Serialize + DeserializeOwned, +{ + pub fn new( + registry: CultNetDocumentRegistry, + cache: Arc>, + record_key: impl Into, + options: CultNetReactiveDocumentOptions, + ) -> Result { + let record_key = record_key.into(); + if record_key.trim().is_empty() { + return Err(anyhow!("record_key must be non-empty")); + } + registry.require_binding(T::TYPE)?; + let current = { + let cache = cache + .lock() + .map_err(|_| anyhow!("CultCache mutex poisoned"))?; + cache.get_required::(&record_key)? + }; + let last_clean_payload = serialize_typed(¤t)?; + let current = Arc::new(Mutex::new(current)); + let state = Arc::new(Mutex::new(CultNetReactiveDocumentState { + dirty: false, + flushing: false, + flush_queued: false, + last_clean_payload, + reconciliation: None, + reconciliation_version: 0, + last_error: None, + })); + let disposed = Arc::new(AtomicBool::new(false)); + let detect_worker = if options.detect_local_changes { + Some(start_reactive_detection_worker::( + Arc::clone(&cache), + Arc::clone(¤t), + Arc::clone(&state), + Arc::clone(&disposed), + options.flush_delay, + record_key.clone(), + )) + } else { + None + }; + Ok(Self { + registry, + cache, + record_key, + current, + state, + options, + disposed, + detect_worker, + }) + } + + pub fn current(&self) -> Arc> { + Arc::clone(&self.current) + } + + pub fn record_key(&self) -> &str { + &self.record_key + } + + pub fn is_dirty(&self) -> bool { + self.state.lock().map(|state| state.dirty).unwrap_or(false) + } + + pub fn reconciliation(&self) -> Option> { + self.state + .lock() + .ok() + .and_then(|state| state.reconciliation.clone()) + } + + pub fn last_error(&self) -> Option { + self.state + .lock() + .ok() + .and_then(|state| state.last_error.clone()) + } + + pub fn update(&self, update: impl FnOnce(&mut T)) -> Result { + let predicted = { + let mut current = self + .current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))?; + update(&mut current); + current.clone() + }; + { + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.dirty = true; + } + if self.options.flush_delay.is_zero() { + self.flush()?; + } + Ok(predicted) + } + + pub fn set_current(&self, value: T) -> Result { + { + let mut current = self + .current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))?; + *current = value.clone(); + } + { + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.dirty = true; + } + if self.options.flush_delay.is_zero() { + self.flush()?; + } + Ok(value) + } + + pub fn mark_dirty(&self) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.dirty = true; + Ok(()) + } + + pub fn refresh(&self) -> Result { + let canonical = { + let cache = self + .cache + .lock() + .map_err(|_| anyhow!("CultCache mutex poisoned"))?; + cache.get_required::(&self.record_key)? + }; + { + let mut current = self + .current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))?; + *current = canonical.clone(); + } + { + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.last_clean_payload = serialize_typed(&canonical)?; + state.dirty = false; + state.flush_queued = false; + state.reconciliation = None; + } + Ok(canonical) + } + + pub fn flush(&self) -> Result<()> { + flush_reactive_document::(&self.cache, &self.current, &self.state, &self.record_key) + } + + pub fn apply_document_put_message(&self, message: &CultNetMessage) -> Result { + let canonical = { + let mut cache = self + .cache + .lock() + .map_err(|_| anyhow!("CultCache mutex poisoned"))?; + self.registry + .apply_document_put_message::(&mut cache, message)? + }; + self.apply_canonical_value(canonical) + } + + pub fn apply_raw_document_put_message(&self, message: &CultNetMessage) -> Result { + let canonical = { + let mut cache = self + .cache + .lock() + .map_err(|_| anyhow!("CultCache mutex poisoned"))?; + self.registry + .apply_raw_document_put_message::(&mut cache, message)? + }; + self.apply_canonical_value(canonical) + } + + pub fn clear_reconciliation(&self) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.reconciliation = None; + Ok(()) + } + + fn apply_canonical_value(&self, canonical: T) -> Result { + let predicted = self + .current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))? + .clone(); + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + if state.dirty || state.flushing { + let delta = create_reconciliation_delta(&predicted, &canonical)?; + if delta.is_empty() { + state.reconciliation = None; + } else { + state.reconciliation_version += 1; + state.reconciliation = Some(CultNetReactiveDocumentReconciliation { + canonical: canonical.clone(), + predicted, + delta, + version: state.reconciliation_version, + received_at: now_utc_second(), + }); + } + if !self.options.replace_dirty_current_on_canonical_snapshot { + return Ok(canonical); + } + } + drop(state); + { + let mut current = self + .current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))?; + *current = canonical.clone(); + } + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.last_clean_payload = serialize_typed(&canonical)?; + state.reconciliation = None; + Ok(canonical) + } +} + +impl Drop for CultNetReactiveDocument +where + T: DatabaseEntry + Serialize + DeserializeOwned, +{ + fn drop(&mut self) { + self.disposed.store(true, Ordering::SeqCst); + let _ = self.detect_worker.take(); + } +} + +fn start_reactive_detection_worker( + cache: Arc>, + current: Arc>, + state: Arc>>, + disposed: Arc, + flush_delay: Duration, + record_key: String, +) -> JoinHandle<()> +where + T: DatabaseEntry + Serialize + DeserializeOwned, +{ + thread::spawn(move || { + let delay = if flush_delay.is_zero() { + Duration::from_millis(1) + } else { + flush_delay + }; + while !disposed.load(Ordering::SeqCst) { + thread::sleep(delay); + match detect_reactive_document_change::(¤t, &state).and_then(|dirty| { + if dirty { + flush_reactive_document::(&cache, ¤t, &state, &record_key)?; + } + Ok(()) + }) { + Ok(()) => {} + Err(error) => { + if let Ok(mut state) = state.lock() { + state.last_error = Some(error.to_string()); + } + } + } + } + }) +} + +fn detect_reactive_document_change( + current: &Arc>, + state: &Arc>>, +) -> Result +where + T: DatabaseEntry + Serialize + DeserializeOwned, +{ + let payload = { + let current = current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))?; + serialize_typed(&*current)? + }; + let mut state = state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + if !state.dirty && !state.flushing && payload != state.last_clean_payload { + state.dirty = true; + } + Ok(state.dirty && !state.flushing) +} + +fn flush_reactive_document( + cache: &Arc>, + current: &Arc>, + state: &Arc>>, + record_key: &str, +) -> Result<()> +where + T: DatabaseEntry + Serialize + DeserializeOwned, +{ + let predicted = { + let mut state_guard = state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + if !state_guard.dirty { + drop(state_guard); + if !detect_reactive_document_change::(current, state)? { + return Ok(()); + } + state_guard = state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + } + if state_guard.flushing { + state_guard.flush_queued = true; + return Ok(()); + } + state_guard.flushing = true; + state_guard.dirty = false; + current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))? + .clone() + }; + let payload = serialize_typed(&predicted)?; + { + let mut cache = cache + .lock() + .map_err(|_| anyhow!("CultCache mutex poisoned"))?; + cache.put::(record_key.to_string(), &predicted)?; + } + let should_flush_again = { + let mut state = state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.flushing = false; + state.last_clean_payload = payload; + state.last_error = None; + state.reconciliation = None; + let changed_after_flush = { + let current = current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))?; + serialize_typed(&*current)? != state.last_clean_payload + }; + if changed_after_flush { + state.dirty = true; + } + let should_flush_again = state.flush_queued || state.dirty; + state.flush_queued = false; + should_flush_again + }; + if should_flush_again { + flush_reactive_document::(cache, current, state, record_key)?; + } + Ok(()) +} + +fn serialize_typed(value: &T) -> Result> +where + T: Serialize, +{ + Ok(rmp_serde::to_vec(value)?) +} + +fn create_reconciliation_delta(predicted: &T, canonical: &T) -> Result> +where + T: Serialize, +{ + let predicted = serde_json::to_value(predicted)?; + let canonical = serde_json::to_value(canonical)?; + let mut delta = BTreeMap::new(); + match (predicted, canonical) { + (Value::Object(predicted), Value::Object(canonical)) => { + for (key, predicted_value) in predicted { + let canonical_value = canonical.get(&key).cloned().unwrap_or(Value::Null); + if predicted_value == canonical_value { + continue; + } + delta.insert( + key, + numeric_delta(&predicted_value, &canonical_value).unwrap_or(predicted_value), + ); + } + } + (Value::Array(predicted), Value::Array(canonical)) => { + for (index, predicted_value) in predicted.into_iter().enumerate() { + let canonical_value = canonical.get(index).cloned().unwrap_or(Value::Null); + if predicted_value == canonical_value { + continue; + } + delta.insert( + index.to_string(), + numeric_delta(&predicted_value, &canonical_value).unwrap_or(predicted_value), + ); + } + } + (predicted, canonical) if predicted != canonical => { + delta.insert( + "value".to_string(), + numeric_delta(&predicted, &canonical).unwrap_or(predicted), + ); + } + _ => {} + } + Ok(delta) +} + +fn numeric_delta(predicted: &Value, canonical: &Value) -> Option { + let predicted = predicted.as_f64()?; + let canonical = canonical.as_f64()?; + serde_json::Number::from_f64(predicted - canonical).map(Value::Number) +} + fn round_trip_typed(value: &T) -> Result where T: Serialize + DeserializeOwned, diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index 8566c38c..174739be 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -6,8 +6,9 @@ use std::collections::VecDeque; use std::io::ErrorKind; use std::net::SocketAddr; use std::net::UdpSocket; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use uuid::Uuid; use crate::CultNetMessage; use crate::CultNetReconnectController; @@ -29,6 +30,22 @@ use crate::encode_cultnet_message_to_vec; const RUDP_MAGIC: [u8; 4] = [0x43, 0x4e, 0x52, 0x30]; const RUDP_VERSION: u8 = 0; const RUDP_FIXED_HEADER_BYTES: usize = 36; +pub const CULTNET_RUDP_DEFAULT_MAX_PAYLOAD_BYTES: usize = 16 * 1024 * 1024; +pub const CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS: usize = 32; +const RUDP_RECEIVED_SEQUENCE_WINDOW: usize = 4_096; +/// Live media tolerates loss but not lateness: a frame retransmitted after this +/// long describes a moment that has already passed. State channels never +/// expire, so this default applies only to the media channel. +const DEFAULT_MEDIA_RELIABLE_EXPIRE_AFTER_MS: u64 = 75; +/// A peer chooses its own channel ids and its own send order, so the per-channel +/// maps and the out-of-order hold buffer are both attacker-sized unless capped. +const RUDP_MAX_TRACKED_CHANNELS: usize = 64; +/// A peer cannot be trusted to pick the next sequence honestly. One packet far +/// ahead of the current highest would otherwise advance the receive state past +/// every legitimate sequence still in flight and starve the session. +const RUDP_RECEIVE_AHEAD_WINDOW: u32 = 1_024; +const RUDP_MAX_ORDERED_BUFFERED_FRAMES: usize = 1_024; +const RUDP_MAX_ORDERED_BUFFERED_BYTES: usize = 4 * 1024 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CultNetRudpPacketType { @@ -68,6 +85,7 @@ pub struct CultNetRudpDeliveredFrame { #[derive(Clone, Debug, PartialEq, Eq)] pub struct CultNetRudpReceiveResult { pub delivered: Vec, + pub ready_to_send: Vec, pub reply: Option, pub pong: bool, pub pong_payload: Vec, @@ -100,12 +118,40 @@ pub struct CultNetRudpSendOptions { pub ordered: bool, pub sequenced: bool, pub now_ms: u64, + /// Drop a reliable packet instead of retransmitting it once this many + /// milliseconds have passed since the send call. `None` retransmits until + /// acknowledged, which is correct for state and wrong for live media, where + /// a late frame is worse than a missing one. + pub reliable_expire_after_ms: Option, +} + +/// Identifies every transport packet belonging to one non-expiring reliable +/// send. Receipts are local to the session that issued them and cannot survive +/// a peer reset. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultNetRudpReliableSendReceipt { + session_scope: Uuid, + sequences: Vec, +} + +impl CultNetRudpReliableSendReceipt { + pub fn packet_sequences(&self) -> &[u32] { + &self.sequences + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CultNetRudpReliableSendStatus { + Pending, + Acknowledged, + Invalidated, } #[derive(Clone, Debug, PartialEq, Eq)] struct PendingReliablePacket { packet: CultNetRudpPacket, last_sent_at_ms: u64, + expires_at_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -121,40 +167,64 @@ struct FragmentBuffer { fragment_count: u16, payloads: BTreeMap>, sequences: BTreeMap, + /// Session-local arrival ordinal of the newest fragment in this set. A + /// set that stops being touched is one that lost a fragment; it is the + /// first to go when the pending-set bound is reached. + last_touched: u64, } pub struct CultNetRudpSession { + session_scope: Uuid, connection_id: u32, resend_delay_ms: u64, max_pending_reliable_packets: Option, + max_payload_bytes: Option, + max_pending_fragment_sets: usize, + initial_sequence: u32, next_sequence: u32, + next_sequenced_by_channel: BTreeMap, next_fragment_id: u16, connected: bool, last_received_at_ms: Option, highest_received_sequence: Option, received_sequences: BTreeSet, + latest_sequenced_by_channel: BTreeMap, pending_reliable: BTreeMap, + queued_reliable: VecDeque<(CultNetRudpPacket, Option)>, + reliable_packets_expired: u64, ordered_next_sequence_by_channel: BTreeMap, ordered_buffers: BTreeMap>, fragment_buffers: BTreeMap<(String, u16), FragmentBuffer>, + fragment_touches: u64, + fragment_sets_evicted: u64, } impl CultNetRudpSession { pub fn new(options: CultNetRudpSessionOptions) -> Self { Self { + session_scope: Uuid::new_v4(), connection_id: options.connection_id, resend_delay_ms: options.resend_delay_ms, max_pending_reliable_packets: options.max_pending_reliable_packets, + max_payload_bytes: None, + max_pending_fragment_sets: 64, + initial_sequence: options.initial_sequence, next_sequence: options.initial_sequence, + next_sequenced_by_channel: BTreeMap::new(), next_fragment_id: 1, connected: false, last_received_at_ms: None, highest_received_sequence: None, received_sequences: BTreeSet::new(), + latest_sequenced_by_channel: BTreeMap::new(), pending_reliable: BTreeMap::new(), + queued_reliable: VecDeque::new(), + reliable_packets_expired: 0, ordered_next_sequence_by_channel: BTreeMap::new(), ordered_buffers: BTreeMap::new(), fragment_buffers: BTreeMap::new(), + fragment_touches: 0, + fragment_sets_evicted: 0, } } @@ -170,14 +240,117 @@ impl CultNetRudpSession { self.connected } + pub fn assume_connected(&mut self, now_ms: u64) { + self.connected = true; + self.last_received_at_ms = Some(now_ms); + } + pub fn pending_reliable_sequences(&self) -> Vec { self.pending_reliable.keys().copied().collect() } + /// Reliable packets dropped by their expiry deadline rather than + /// acknowledged. A rising count means the peer is not keeping up. + pub fn reliable_packets_expired(&self) -> u64 { + self.reliable_packets_expired + } + + /// Incomplete fragment sets discarded to admit a newer one. Each is a + /// payload that lost at least one fragment on the wire; a rising count is + /// the link's loss rate multiplied by the fragment count, and the reason + /// media payloads should fit in one packet. + pub fn fragment_sets_evicted(&self) -> u64 { + self.fragment_sets_evicted + } + + pub fn queued_reliable_packet_count(&self) -> usize { + self.queued_reliable.len() + } + + pub fn outstanding_reliable_packet_count(&self) -> usize { + self.pending_reliable.len() + self.queued_reliable.len() + } + + fn pending_accept_for_resend(&mut self, now_ms: u64) -> Option { + let pending = self + .pending_reliable + .values_mut() + .find(|pending| pending.packet.packet_type == CultNetRudpPacketType::Accept)?; + pending.last_sent_at_ms = now_ms; + Some(pending.packet.clone()) + } + + fn reliable_send_status( + &self, + receipt: &CultNetRudpReliableSendReceipt, + ) -> CultNetRudpReliableSendStatus { + if receipt.session_scope != self.session_scope { + return CultNetRudpReliableSendStatus::Invalidated; + } + if receipt + .sequences + .iter() + .any(|sequence| self.pending_reliable.contains_key(sequence)) + { + CultNetRudpReliableSendStatus::Pending + } else { + CultNetRudpReliableSendStatus::Acknowledged + } + } + + fn non_expiring_reliable_receipt( + &self, + packets: &[CultNetRudpPacket], + ) -> Result { + if packets.is_empty() || packets.iter().any(|packet| !packet.reliable) { + return Err(anyhow!( + "RUDP acknowledgement receipts require a non-empty reliable packet set" + )); + } + Ok(CultNetRudpReliableSendReceipt { + session_scope: self.session_scope, + sequences: packets.iter().map(|packet| packet.sequence).collect(), + }) + } + pub fn last_received_at_ms(&self) -> Option { self.last_received_at_ms } + pub fn set_max_payload_bytes(&mut self, max_payload_bytes: Option) { + self.max_payload_bytes = max_payload_bytes; + } + + pub fn set_max_pending_fragment_sets( + &mut self, + max_pending_fragment_sets: usize, + ) -> Result<()> { + if max_pending_fragment_sets == 0 { + return Err(anyhow!( + "RUDP max_pending_fragment_sets must be greater than zero" + )); + } + self.max_pending_fragment_sets = max_pending_fragment_sets; + Ok(()) + } + + pub fn reset_peer_state(&mut self) { + self.session_scope = Uuid::new_v4(); + self.next_sequence = self.initial_sequence; + self.next_sequenced_by_channel.clear(); + self.next_fragment_id = 1; + self.connected = false; + self.last_received_at_ms = None; + self.highest_received_sequence = None; + self.received_sequences.clear(); + self.latest_sequenced_by_channel.clear(); + self.pending_reliable.clear(); + self.queued_reliable.clear(); + self.ordered_next_sequence_by_channel.clear(); + self.ordered_buffers.clear(); + self.fragment_buffers.clear(); + } + pub fn create_connect(&mut self, now_ms: u64, payload: Vec) -> Result { self.ensure_reliable_capacity(1)?; let packet = self.create_packet( @@ -188,7 +361,7 @@ impl CultNetRudpSession { true, false, ); - self.track_reliable(packet.clone(), now_ms); + self.track_reliable(packet.clone(), now_ms, None); Ok(packet) } @@ -208,6 +381,7 @@ impl CultNetRudpSession { self.ensure_reliable_capacity(1)?; self.remember_received(packet.sequence); + self.last_received_at_ms = Some(now_ms); self.connected = true; let response = self.create_packet( CultNetRudpPacketType::Accept, @@ -217,7 +391,7 @@ impl CultNetRudpSession { true, false, ); - self.track_reliable(response.clone(), now_ms); + self.track_reliable(response.clone(), now_ms, None); Ok(response) } @@ -227,6 +401,13 @@ impl CultNetRudpSession { payload: Vec, options: CultNetRudpSendOptions, ) -> Result { + if options.reliable + && self.pending_reliable.len() >= CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + { + return Err(anyhow!( + "RUDP reliable send window is full; receive acknowledgements before sending" + )); + } self.send_many(channel_id, payload, options, None)? .into_iter() .next() @@ -245,6 +426,16 @@ impl CultNetRudpSession { "Cannot send RUDP data before the session is connected" )); } + if options.ordered && !options.reliable { + return Err(anyhow!("RUDP ordered delivery requires reliability")); + } + self.require_payload_size(payload.len())?; + // Reclaim deadline-passed sends before measuring window capacity, so an + // expiring stream cannot be refused for a backlog it no longer owns. + self.purge_expired_reliable(options.now_ms); + let expires_at_ms = options + .reliable_expire_after_ms + .map(|ttl| options.now_ms.saturating_add(ttl)); if let Some(max_fragment_bytes) = max_fragment_bytes { if max_fragment_bytes == 0 { @@ -272,12 +463,13 @@ impl CultNetRudpSession { index as u16, fragment_count as u16, ); - if packet.reliable { - self.track_reliable(packet.clone(), options.now_ms); - } packets.push(packet); } - return Ok(packets); + return Ok(if options.reliable { + self.admit_reliable_packets(packets, options.now_ms, expires_at_ms) + } else { + packets + }); } } @@ -291,7 +483,7 @@ impl CultNetRudpSession { options.sequenced, ); if packet.reliable { - self.track_reliable(packet.clone(), options.now_ms); + return Ok(self.admit_reliable_packets(vec![packet], options.now_ms, expires_at_ms)); } Ok(vec![packet]) } @@ -303,6 +495,8 @@ impl CultNetRudpSession { ) -> Result { self.require_connection(packet)?; self.apply_acknowledgements(packet); + self.purge_expired_reliable(now_ms); + let ready_to_send = self.promote_queued_reliable(now_ms); self.last_received_at_ms = Some(now_ms); let expected_sequence_if_uninitialized = self .highest_received_sequence @@ -314,6 +508,7 @@ impl CultNetRudpSession { self.connected = true; return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), + ready_to_send, reply: None, pong: false, pong_payload: Vec::new(), @@ -323,9 +518,9 @@ impl CultNetRudpSession { } if packet.packet_type == CultNetRudpPacketType::Ping { - self.remember_received(packet.sequence); return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), + ready_to_send, reply: Some(self.create_packet( CultNetRudpPacketType::Pong, "control", @@ -344,9 +539,9 @@ impl CultNetRudpSession { if packet.packet_type == CultNetRudpPacketType::Ack || packet.packet_type == CultNetRudpPacketType::Pong { - self.remember_received(packet.sequence); return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), + ready_to_send, reply: None, pong: packet.packet_type == CultNetRudpPacketType::Pong, pong_payload: if packet.packet_type == CultNetRudpPacketType::Pong { @@ -360,10 +555,10 @@ impl CultNetRudpSession { } if packet.packet_type == CultNetRudpPacketType::Disconnect { - self.remember_received(packet.sequence); self.connected = false; return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), + ready_to_send, reply: None, pong: false, pong_payload: Vec::new(), @@ -375,6 +570,7 @@ impl CultNetRudpSession { if packet.packet_type != CultNetRudpPacketType::Data { return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), + ready_to_send, reply: None, pong: false, pong_payload: Vec::new(), @@ -383,11 +579,38 @@ impl CultNetRudpSession { }); } - let duplicate = self.received_sequences.contains(&packet.sequence); - self.remember_received(packet.sequence); + let far_ahead = packet.reliable + && self.highest_received_sequence.is_some_and(|highest| { + packet.sequence > highest + && packet.sequence - highest > RUDP_RECEIVE_AHEAD_WINDOW + }); + if far_ahead { + // Refuse without remembering. Recording it would move the highest + // received sequence out of reach of everything still in flight. + return Ok(CultNetRudpReceiveResult { + delivered: Vec::new(), + ready_to_send, + reply: None, + pong: false, + pong_payload: Vec::new(), + disconnected: false, + disconnect_reason: Vec::new(), + }); + } + + let duplicate = packet.reliable + && (self.received_sequences.contains(&packet.sequence) + || self.highest_received_sequence.is_some_and(|highest| { + packet.sequence < highest + && highest - packet.sequence >= RUDP_RECEIVED_SEQUENCE_WINDOW as u32 + })); + if packet.reliable { + self.remember_received(packet.sequence); + } if duplicate { return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), + ready_to_send, reply: None, pong: false, pong_payload: Vec::new(), @@ -399,6 +622,7 @@ impl CultNetRudpSession { let Some((frame, ordered, next_sequence)) = self.reassemble(packet)? else { return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), + ready_to_send, reply: None, pong: false, pong_payload: Vec::new(), @@ -407,12 +631,33 @@ impl CultNetRudpSession { }); }; let delivered = if ordered { - self.deliver_ordered(frame, next_sequence, expected_sequence_if_uninitialized) + self.deliver_ordered(frame, next_sequence, expected_sequence_if_uninitialized)? + } else if packet.sequenced { + if !self + .latest_sequenced_by_channel + .contains_key(&frame.channel_id) + && self.latest_sequenced_by_channel.len() >= RUDP_MAX_TRACKED_CHANNELS + { + return Err(anyhow!("RUDP session has too many sequenced channels")); + } + let latest = self + .latest_sequenced_by_channel + .get(&frame.channel_id) + .copied(); + let newest_sequence = next_sequence.saturating_sub(1); + if latest.is_some_and(|latest| newest_sequence <= latest) { + Vec::new() + } else { + self.latest_sequenced_by_channel + .insert(frame.channel_id.clone(), newest_sequence); + vec![frame] + } } else { vec![frame] }; Ok(CultNetRudpReceiveResult { delivered, + ready_to_send, reply: None, pong: false, pong_payload: Vec::new(), @@ -422,14 +667,49 @@ impl CultNetRudpSession { } pub fn create_ack(&mut self) -> CultNetRudpPacket { - self.create_packet( - CultNetRudpPacketType::Ack, - "control", - Vec::new(), - false, - false, - false, - ) + let (ack, ack_mask) = self.ack_state(); + CultNetRudpPacket { + packet_type: CultNetRudpPacketType::Ack, + connection_id: self.connection_id, + sequence: 0, + ack, + ack_mask, + channel_id: "control".to_string(), + reliable: false, + ordered: false, + sequenced: false, + fragment_id: 0, + fragment_index: 0, + fragment_count: 0, + payload: Vec::new(), + } + } + + pub fn create_ack_for(&self, sequence: u32) -> CultNetRudpPacket { + CultNetRudpPacket { + packet_type: CultNetRudpPacketType::Ack, + connection_id: self.connection_id, + sequence: 0, + ack: sequence, + ack_mask: 0, + channel_id: "control".to_string(), + reliable: false, + ordered: false, + sequenced: false, + fragment_id: 0, + fragment_index: 0, + fragment_count: 0, + payload: Vec::new(), + } + } + + pub fn create_ack_for_received(&mut self, sequence: u32) -> CultNetRudpPacket { + let (ack, _) = self.ack_state(); + if ack >= sequence && ack - sequence <= 32 { + self.create_ack() + } else { + self.create_ack_for(sequence) + } } pub fn create_ping(&mut self, payload: Vec) -> CultNetRudpPacket { @@ -516,11 +796,23 @@ impl CultNetRudpSession { fragment_index: u16, fragment_count: u16, ) -> CultNetRudpPacket { - let sequence = self.next_sequence; - self.next_sequence = self - .next_sequence - .checked_add(1) - .expect("sequence overflow"); + let sequence = if reliable { + let sequence = self.next_sequence; + self.next_sequence = self + .next_sequence + .saturating_add(1); + sequence + } else if sequenced { + let next = self + .next_sequenced_by_channel + .entry(channel_id.to_string()) + .or_insert(1); + let sequence = *next; + *next = next.saturating_add(1); + sequence + } else { + 0 + }; let (ack, ack_mask) = self.ack_state(); CultNetRudpPacket { packet_type, @@ -539,25 +831,88 @@ impl CultNetRudpSession { } } - fn track_reliable(&mut self, packet: CultNetRudpPacket, now_ms: u64) { + fn track_reliable( + &mut self, + packet: CultNetRudpPacket, + now_ms: u64, + expires_at_ms: Option, + ) { self.pending_reliable.insert( packet.sequence, PendingReliablePacket { packet, last_sent_at_ms: now_ms, + expires_at_ms, }, ); } + /// Drop pending and queued reliable packets past their deadline. The + /// deadline is fixed when the send is admitted, so time spent waiting in + /// the backlog counts against it exactly as time spent awaiting an ACK does. + fn purge_expired_reliable(&mut self, now_ms: u64) { + let before = self.pending_reliable.len() + self.queued_reliable.len(); + self.pending_reliable + .retain(|_, pending| pending.expires_at_ms.is_none_or(|at| now_ms <= at)); + self.queued_reliable + .retain(|(_, expires_at_ms)| expires_at_ms.is_none_or(|at| now_ms <= at)); + let after = self.pending_reliable.len() + self.queued_reliable.len(); + self.reliable_packets_expired = self + .reliable_packets_expired + .saturating_add((before - after) as u64); + } + + fn admit_reliable_packets( + &mut self, + packets: Vec, + now_ms: u64, + expires_at_ms: Option, + ) -> Vec { + let available = + CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS.saturating_sub(self.pending_reliable.len()); + let mut ready = Vec::with_capacity(available.min(packets.len())); + for packet in packets { + if ready.len() < available { + self.track_reliable(packet.clone(), now_ms, expires_at_ms); + ready.push(packet); + } else { + self.queued_reliable.push_back((packet, expires_at_ms)); + } + } + ready + } + + fn promote_queued_reliable(&mut self, now_ms: u64) -> Vec { + let available = + CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS.saturating_sub(self.pending_reliable.len()); + let mut ready = Vec::with_capacity(available.min(self.queued_reliable.len())); + for _ in 0..available { + let Some((packet, expires_at_ms)) = self.queued_reliable.pop_front() else { + break; + }; + self.track_reliable(packet.clone(), now_ms, expires_at_ms); + ready.push(packet); + } + ready + } + fn ensure_reliable_capacity(&self, packet_count: usize) -> Result<()> { if packet_count == 0 { return Ok(()); } if let Some(limit) = self.max_pending_reliable_packets { - if self.pending_reliable.len() + packet_count > limit { + if self.outstanding_reliable_packet_count() + packet_count > limit { return Err(anyhow!("RUDP reliable send queue is full")); } } + // Refuse before allocation rather than at it. A session that runs out of + // reliable sequence space must report it, not abort the process. + let required = u32::try_from(packet_count) + .ok() + .filter(|required| self.next_sequence.checked_add(*required).is_some()); + if required.is_none() { + return Err(anyhow!("RUDP reliable sequence space is exhausted")); + } Ok(()) } @@ -578,6 +933,13 @@ impl CultNetRudpSession { { self.highest_received_sequence = Some(sequence); } + if self.received_sequences.len() > RUDP_RECEIVED_SEQUENCE_WINDOW { + let keep_from = self + .highest_received_sequence + .unwrap_or(sequence) + .saturating_sub(RUDP_RECEIVED_SEQUENCE_WINDOW as u32 - 1); + self.received_sequences = self.received_sequences.split_off(&keep_from); + } } fn ack_state(&self) -> (u32, u32) { @@ -596,6 +958,7 @@ impl CultNetRudpSession { packet: &CultNetRudpPacket, ) -> Result> { if packet.fragment_count == 0 { + self.require_payload_size(packet.payload.len())?; return Ok(Some(( CultNetRudpDeliveredFrame { channel_id: packet.channel_id.clone(), @@ -618,6 +981,39 @@ impl CultNetRudpSession { } let key = (packet.channel_id.clone(), packet.fragment_id); + if !self.fragment_buffers.contains_key(&key) + && self.fragment_buffers.len() >= self.max_pending_fragment_sets + { + // A set that is still incomplete when the bound is reached has + // almost certainly lost a fragment; its siblings were delivered + // long ago. Drop the one untouched longest so the session keeps + // receiving. Erroring here made any lossy fragmenting channel a + // countdown to a dead receiver. + let stalest = self + .fragment_buffers + .iter() + .min_by_key(|(_, buffer)| buffer.last_touched) + .map(|(key, _)| key.clone()) + .expect("bound reached implies a pending set exists"); + self.fragment_buffers.remove(&stalest); + self.fragment_sets_evicted += 1; + } + let buffered_bytes = self + .fragment_buffers + .get(&key) + .map(|buffer| { + buffer + .payloads + .iter() + .filter(|(index, _)| **index != packet.fragment_index) + .map(|(_, payload)| payload.len()) + .sum::() + }) + .unwrap_or(0); + if let Err(error) = self.require_payload_size(buffered_bytes + packet.payload.len()) { + self.fragment_buffers.remove(&key); + return Err(error); + } let buffer = self .fragment_buffers .entry(key.clone()) @@ -627,12 +1023,15 @@ impl CultNetRudpSession { fragment_count: packet.fragment_count, payloads: BTreeMap::new(), sequences: BTreeMap::new(), + last_touched: 0, }); if buffer.fragment_count != packet.fragment_count || buffer.ordered != packet.ordered { return Err(anyhow!( "RUDP fragment metadata changed within a fragment set" )); } + self.fragment_touches += 1; + buffer.last_touched = self.fragment_touches; buffer .payloads .insert(packet.fragment_index, packet.payload.clone()); @@ -669,13 +1068,40 @@ impl CultNetRudpSession { ))) } + fn require_payload_size(&self, payload_bytes: usize) -> Result<()> { + if self + .max_payload_bytes + .is_some_and(|max_payload_bytes| payload_bytes > max_payload_bytes) + { + return Err(anyhow!("RUDP payload exceeds max_payload_bytes")); + } + Ok(()) + } + + fn ordered_buffered_frames(&self) -> usize { + self.ordered_buffers.values().map(BTreeMap::len).sum() + } + + fn ordered_buffered_bytes(&self) -> usize { + self.ordered_buffers + .values() + .flat_map(BTreeMap::values) + .map(|pending| pending.frame.payload.len()) + .sum() + } + fn deliver_ordered( &mut self, frame: CultNetRudpDeliveredFrame, next_sequence_after_frame: u32, expected_sequence_if_uninitialized: u32, - ) -> Vec { + ) -> Result> { let channel_id = frame.channel_id.clone(); + if !self.ordered_next_sequence_by_channel.contains_key(&channel_id) + && self.ordered_next_sequence_by_channel.len() >= RUDP_MAX_TRACKED_CHANNELS + { + return Err(anyhow!("RUDP session has too many ordered channels")); + } let mut next = if let Some(next) = self .ordered_next_sequence_by_channel .get(&channel_id) @@ -703,10 +1129,16 @@ impl CultNetRudpSession { } if frame.sequence < next { - return Vec::new(); + return Ok(Vec::new()); } if frame.sequence > next { + if self.ordered_buffered_frames() >= RUDP_MAX_ORDERED_BUFFERED_FRAMES + || self.ordered_buffered_bytes() + frame.payload.len() + > RUDP_MAX_ORDERED_BUFFERED_BYTES + { + return Err(anyhow!("RUDP ordered hold buffer is full")); + } self.ordered_buffers.entry(channel_id).or_default().insert( frame.sequence, PendingOrderedFrame { @@ -714,14 +1146,14 @@ impl CultNetRudpSession { next_sequence: next_sequence_after_frame, }, ); - return Vec::new(); + return Ok(Vec::new()); } self.ordered_next_sequence_by_channel .insert(channel_id.clone(), next_sequence_after_frame); let mut delivered = vec![frame]; delivered.extend(self.drain_ordered(&channel_id)); - delivered + Ok(delivered) } fn drain_ordered(&mut self, channel_id: &str) -> Vec { @@ -770,7 +1202,9 @@ impl CultNetRudpSession { fn allocate_fragment_id(&mut self) -> u16 { let fragment_id = self.next_fragment_id; - self.next_fragment_id = self.next_fragment_id.saturating_add(1); + // Wrap, never saturate: `saturating_add` parked every set after the + // 65,535th on one shared id, and the reassembler stitched them together. + self.next_fragment_id = self.next_fragment_id.wrapping_add(1); if self.next_fragment_id == 0 { self.next_fragment_id = 1; } @@ -808,6 +1242,9 @@ pub struct CultNetRudpSocketTransportOptions { pub max_fragment_bytes: Option, pub max_pending_reliable_packets: Option, pub reconnect_policy: Option, + pub media_reliable_expire_after_ms: Option, + /// `None` means the historical default, `Reliable`. + pub media_delivery: Option, } impl CultNetRudpSocketTransportOptions { @@ -818,6 +1255,8 @@ impl CultNetRudpSocketTransportOptions { connection_id: u32, ) -> Self { Self { + media_reliable_expire_after_ms: Some(DEFAULT_MEDIA_RELIABLE_EXPIRE_AFTER_MS), + media_delivery: None, runtime_id: runtime_id.into(), socket, mode: CultNetRudpSocketMode::Client, @@ -835,6 +1274,8 @@ impl CultNetRudpSocketTransportOptions { pub fn server(runtime_id: impl Into, socket: UdpSocket, connection_id: u32) -> Self { Self { + media_reliable_expire_after_ms: Some(DEFAULT_MEDIA_RELIABLE_EXPIRE_AFTER_MS), + media_delivery: None, runtime_id: runtime_id.into(), socket, mode: CultNetRudpSocketMode::Server, @@ -864,16 +1305,439 @@ pub struct CultNetRudpSocketTransportConnection { pong_payloads: VecDeque>, } -impl CultNetRudpSocketTransportConnection { - pub fn new(options: CultNetRudpSocketTransportOptions) -> Result { +pub struct CultNetRudpServerHubOptions { + pub runtime_id: String, + pub socket: UdpSocket, + pub connection_id: u32, + pub initial_sequence: u32, + pub resend_delay_ms: u64, + pub transport_id: Option, + pub max_payload_bytes: Option, + pub max_fragment_bytes: Option, + pub max_pending_reliable_packets: Option, + pub max_peers: usize, + pub media_reliable_expire_after_ms: Option, + /// `None` means the historical default, `Reliable`. + pub media_delivery: Option, +} + +impl CultNetRudpServerHubOptions { + pub fn new(runtime_id: impl Into, socket: UdpSocket, connection_id: u32) -> Self { + Self { + media_reliable_expire_after_ms: Some(DEFAULT_MEDIA_RELIABLE_EXPIRE_AFTER_MS), + media_delivery: None, + runtime_id: runtime_id.into(), + socket, + connection_id, + initial_sequence: 1, + resend_delay_ms: 250, + transport_id: None, + max_payload_bytes: None, + max_fragment_bytes: None, + max_pending_reliable_packets: None, + max_peers: 256, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultNetRudpServerSessionContext { + pub remote_addr: SocketAddr, + pub connection_id: u32, + /// Server-minted generation used to fence work from an older session at + /// the same UDP endpoint. + pub session_generation: u64, + /// Exact bytes supplied by the peer's most recently accepted Connect packet. + /// CultNet does not interpret these bytes; higher layers may use them as + /// transport evidence when making their own authorization decision. + pub connect_payload: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CultNetRudpServerEvent { + Connected { + session: CultNetRudpServerSessionContext, + }, + Frame { + session: CultNetRudpServerSessionContext, + frame: CultNetTransportFrame, + }, + Pong { + session: CultNetRudpServerSessionContext, + payload: Vec, + }, + Disconnected { + session: CultNetRudpServerSessionContext, + reason: Vec, + }, +} + +struct CultNetRudpServerPeer { + session: CultNetRudpSession, + context: CultNetRudpServerSessionContext, +} + +/// One UDP listener with an independent reliable session for every remote peer. +/// +/// The hub owns packet/session mechanics only. Registration, leases, commands, +/// and all other application policy belong to the service using the hub. +pub struct CultNetRudpServerHub { + socket: UdpSocket, + connection_id: u32, + initial_sequence: u32, + resend_delay_ms: u64, + max_pending_reliable_packets: Option, + max_payload_bytes: usize, + max_fragment_bytes: Option, + max_peers: usize, + next_session_generation: u64, + peers: BTreeMap, + pending_events: VecDeque, + pub profile: CultNetTransportProfile, + stats: CultNetTransportStats, +} + +impl CultNetRudpServerHub { + pub fn new(options: CultNetRudpServerHubOptions) -> Result { let local_addr = options.socket.local_addr()?; + if options.max_peers == 0 { + return Err(anyhow!( + "RUDP server hub max_peers must be greater than zero" + )); + } + let max_payload_bytes = options + .max_payload_bytes + .or(Some(CULTNET_RUDP_DEFAULT_MAX_PAYLOAD_BYTES as u32)); let profile = create_rudp_transport_profile( options.runtime_id, RudpTransportProfileOptions { + media_reliable_expire_after_ms: options.media_reliable_expire_after_ms, + media_delivery: options.media_delivery, transport_id: options.transport_id, host: Some(local_addr.ip().to_string()), port: Some(local_addr.port()), - max_payload_bytes: options.max_payload_bytes, + max_payload_bytes, + max_fragment_bytes: options.max_fragment_bytes, + max_pending_reliable_packets: options.max_pending_reliable_packets, + reconnect_policy: None, + }, + ); + Ok(Self { + socket: options.socket, + connection_id: options.connection_id, + initial_sequence: options.initial_sequence, + resend_delay_ms: options.resend_delay_ms, + max_pending_reliable_packets: options + .max_pending_reliable_packets + .map(|value| value as usize), + max_payload_bytes: max_payload_bytes.expect("RUDP hub has an effective payload limit") + as usize, + max_fragment_bytes: options.max_fragment_bytes.map(|value| value as usize), + max_peers: options.max_peers, + next_session_generation: 1, + peers: BTreeMap::new(), + pending_events: VecDeque::new(), + profile, + stats: CultNetTransportStats::default(), + }) + } + + pub fn local_addr(&self) -> Result { + Ok(self.socket.local_addr()?) + } + + pub fn stats(&self) -> CultNetTransportStats { + let mut stats = self.stats.clone(); + stats.reliable_packets_expired = self + .peers + .values() + .map(|peer| peer.session.reliable_packets_expired()) + .fold(0_u64, |total, expired| total.saturating_add(expired)); + stats.fragment_sets_evicted = self + .peers + .values() + .map(|peer| peer.session.fragment_sets_evicted()) + .fold(0_u64, |total, evicted| total.saturating_add(evicted)); + stats + } + + pub fn sessions(&self) -> Vec { + self.peers + .values() + .map(|peer| peer.context.clone()) + .collect() + } + + pub fn session(&self, remote_addr: SocketAddr) -> Option<&CultNetRudpServerSessionContext> { + self.peers.get(&remote_addr).map(|peer| &peer.context) + } + + pub fn send( + &mut self, + session: &CultNetRudpServerSessionContext, + channel_id: &str, + payload: Vec, + ) -> Result<()> { + let remote_addr = session.remote_addr; + let packets = { + let peer = self + .peers + .get_mut(&remote_addr) + .ok_or_else(|| anyhow!("Unknown RUDP server peer {remote_addr}"))?; + if peer.context.session_generation != session.session_generation { + return Err(anyhow!( + "RUDP server session generation {} is no longer active", + session.session_generation + )); + } + peer.session.send_many( + channel_id, + payload, + channel_send_options( + channel_id, + now_ms(), + channel_reliable_expire_after_ms(&self.profile, channel_id), + channel_delivery(&self.profile, channel_id), + ), + self.max_fragment_bytes, + )? + }; + for packet in packets { + self.send_packet(remote_addr, &packet)?; + } + self.stats.frames_sent += 1; + Ok(()) + } + + pub fn send_schema_message( + &mut self, + session: &CultNetRudpServerSessionContext, + message: &CultNetMessage, + ) -> Result<()> { + let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?; + self.send(session, "schema", payload) + } + + pub fn disconnect( + &mut self, + session: &CultNetRudpServerSessionContext, + reason: Vec, + ) -> Result { + let Some(peer) = self.peers.get(&session.remote_addr) else { + return Ok(false); + }; + if peer.context.session_generation != session.session_generation { + return Ok(false); + } + let mut peer = self + .peers + .remove(&session.remote_addr) + .expect("validated RUDP peer exists"); + let packet = peer.session.create_disconnect(reason); + self.send_packet(session.remote_addr, &packet)?; + Ok(true) + } + + /// The next event, or `None` only when nothing is waiting on the socket. + /// + /// Packets that carry no event for the caller — acknowledgements, pings, + /// a repeated Connect, a fragment that completes nothing yet — are + /// consumed here and reading continues. Returning `None` after one of + /// those made a caller that polls once per send fall behind its own + /// peers' acknowledgements, and a reliable channel then resent everything. + pub fn receive_event_once(&mut self) -> Result> { + loop { + if let Some(event) = self.pending_events.pop_front() { + return Ok(Some(event)); + } + if !self.receive_packet_once()? { + return Ok(None); + } + } + } + + /// Reads one datagram and applies it. `false` when the socket is empty. + fn receive_packet_once(&mut self) -> Result { + let mut wire = vec![0_u8; 65_535]; + let (received, remote_addr) = match self.socket.recv_from(&mut wire) { + Ok(value) => value, + Err(error) + if error.kind() == ErrorKind::WouldBlock + || error.kind() == ErrorKind::TimedOut + || error.kind() == ErrorKind::ConnectionReset => + { + return Ok(false); + } + Err(error) => return Err(error.into()), + }; + wire.truncate(received); + self.stats.bytes_received += received as u64; + let packet = decode_rudp_packet(&wire)?; + if packet.connection_id != self.connection_id { + return Ok(true); + } + + if packet.packet_type == CultNetRudpPacketType::Connect { + if let Some(peer) = self.peers.get_mut(&remote_addr) + && peer.context.connect_payload == packet.payload + { + let _ = peer.session.receive(&packet, now_ms())?; + let reply = peer + .session + .pending_accept_for_resend(now_ms()) + .unwrap_or_else(|| peer.session.create_ack()); + self.send_packet(remote_addr, &reply)?; + return Ok(true); + } + if !self.peers.contains_key(&remote_addr) && self.peers.len() >= self.max_peers { + return Err(anyhow!("RUDP server hub peer limit reached")); + } + let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: self.connection_id, + initial_sequence: self.initial_sequence, + resend_delay_ms: self.resend_delay_ms, + max_pending_reliable_packets: self.max_pending_reliable_packets, + }); + session.set_max_payload_bytes(Some(self.max_payload_bytes)); + let generation = self.next_session_generation; + self.next_session_generation = self + .next_session_generation + .checked_add(1) + .ok_or_else(|| anyhow!("RUDP server session generation exhausted"))?; + let context = CultNetRudpServerSessionContext { + remote_addr, + connection_id: packet.connection_id, + session_generation: generation, + connect_payload: packet.payload.clone(), + }; + let accept = session.accept_connect(&packet, now_ms(), Vec::new())?; + self.send_packet(remote_addr, &accept)?; + let replaced = self.peers.insert( + remote_addr, + CultNetRudpServerPeer { + session, + context: context.clone(), + }, + ); + if let Some(replaced) = replaced { + self.pending_events + .push_back(CultNetRudpServerEvent::Disconnected { + session: replaced.context, + reason: b"replaced by a new Connect generation".to_vec(), + }); + } + self.pending_events + .push_back(CultNetRudpServerEvent::Connected { session: context }); + return Ok(true); + } + + let Some(peer) = self.peers.get_mut(&remote_addr) else { + return Ok(true); + }; + let result = peer.session.receive(&packet, now_ms())?; + let context = peer.context.clone(); + let ack = if packet.reliable { + Some(peer.session.create_ack_for_received(packet.sequence)) + } else { + None + }; + if let Some(reply) = result.reply { + self.send_packet(remote_addr, &reply)?; + } + for ready in result.ready_to_send { + self.send_packet(remote_addr, &ready)?; + } + if let Some(ack) = ack { + self.send_packet(remote_addr, &ack)?; + } + if result.pong { + self.pending_events.push_back(CultNetRudpServerEvent::Pong { + session: context.clone(), + payload: result.pong_payload, + }); + } + for delivered in result.delivered { + self.stats.frames_received += 1; + self.pending_events + .push_back(CultNetRudpServerEvent::Frame { + session: context.clone(), + frame: CultNetTransportFrame { + channel_id: delivered.channel_id, + payload: delivered.payload, + }, + }); + } + if result.disconnected { + self.pending_events + .push_back(CultNetRudpServerEvent::Disconnected { + session: context, + reason: result.disconnect_reason, + }); + self.peers.remove(&remote_addr); + } + Ok(true) + } + + pub fn poll_resends(&mut self) -> Result<()> { + let now = now_ms(); + let mut packets = Vec::new(); + for (remote_addr, peer) in &mut self.peers { + packets.extend( + peer.session + .due_resends(now) + .into_iter() + .map(|packet| (*remote_addr, packet)), + ); + } + for (remote_addr, packet) in packets { + self.send_packet(remote_addr, &packet)?; + } + Ok(()) + } + + pub fn remove_timed_out_sessions( + &mut self, + timeout_ms: u64, + ) -> Vec { + let now = now_ms(); + let timed_out = self + .peers + .iter_mut() + .filter_map(|(remote_addr, peer)| { + peer.session + .check_timeout(now, timeout_ms) + .then_some((*remote_addr, peer.context.clone())) + }) + .collect::>(); + for (remote_addr, _) in &timed_out { + self.peers.remove(remote_addr); + } + timed_out.into_iter().map(|(_, context)| context).collect() + } + + fn send_packet(&mut self, remote_addr: SocketAddr, packet: &CultNetRudpPacket) -> Result<()> { + let wire = encode_rudp_packet(packet)?; + let sent = self.socket.send_to(&wire, remote_addr)?; + self.stats.bytes_sent += sent as u64; + Ok(()) + } +} + +impl CultNetRudpSocketTransportConnection { + pub fn new(options: CultNetRudpSocketTransportOptions) -> Result { + let local_addr = options.socket.local_addr()?; + let max_payload_bytes = options + .max_payload_bytes + .or(Some(CULTNET_RUDP_DEFAULT_MAX_PAYLOAD_BYTES as u32)); + let profile = create_rudp_transport_profile( + options.runtime_id, + RudpTransportProfileOptions { + media_reliable_expire_after_ms: options.media_reliable_expire_after_ms, + media_delivery: options.media_delivery, + transport_id: options.transport_id, + host: Some(local_addr.ip().to_string()), + port: Some(local_addr.port()), + max_payload_bytes, max_fragment_bytes: options.max_fragment_bytes, max_pending_reliable_packets: options.max_pending_reliable_packets, reconnect_policy: options.reconnect_policy, @@ -882,14 +1746,16 @@ impl CultNetRudpSocketTransportConnection { let max_pending_reliable_packets = options .max_pending_reliable_packets .map(|value| value as usize); + let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: options.connection_id, + initial_sequence: options.initial_sequence, + resend_delay_ms: options.resend_delay_ms, + max_pending_reliable_packets, + }); + session.set_max_payload_bytes(max_payload_bytes.map(|value| value as usize)); Ok(Self { socket: options.socket, - session: CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: options.connection_id, - initial_sequence: options.initial_sequence, - resend_delay_ms: options.resend_delay_ms, - max_pending_reliable_packets, - }), + session, mode: options.mode, remote_addr: options.remote_addr, profile, @@ -906,7 +1772,10 @@ impl CultNetRudpSocketTransportConnection { } pub fn stats(&self) -> CultNetTransportStats { - self.stats.clone() + let mut stats = self.stats.clone(); + stats.reliable_packets_expired = self.session.reliable_packets_expired(); + stats.fragment_sets_evicted = self.session.fragment_sets_evicted(); + stats } pub fn disconnect_reason(&self) -> Option<&[u8]> { @@ -928,17 +1797,69 @@ impl CultNetRudpSocketTransportConnection { } pub fn send(&mut self, channel_id: &str, payload: Vec) -> Result<()> { - let options = channel_send_options(channel_id, now_ms()); + let options = + channel_send_options( + channel_id, + now_ms(), + channel_reliable_expire_after_ms(&self.profile, channel_id), + channel_delivery(&self.profile, channel_id), + ); let packets = self.session .send_many(channel_id, payload, options, self.max_fragment_bytes)?; - for packet in packets { - self.send_packet(&packet)?; - } + self.send_data_packets(&packets)?; self.stats.frames_sent += 1; Ok(()) } + /// Sends one frame on a reliable channel whose packets cannot expire and + /// returns the transport receipt used to observe its exact acknowledgement. + pub fn send_reliable( + &mut self, + channel_id: &str, + payload: Vec, + ) -> Result { + let options = + channel_send_options( + channel_id, + now_ms(), + channel_reliable_expire_after_ms(&self.profile, channel_id), + channel_delivery(&self.profile, channel_id), + ); + // This lineage has no reliable-expiry concept, so every reliable channel is + // already non-expiring. Restore the expiry half of this guard if one is added. + if !options.reliable { + return Err(anyhow!( + "RUDP acknowledgement receipts require a non-expiring reliable channel" + )); + } + let packets = + self.session + .send_many(channel_id, payload, options, self.max_fragment_bytes)?; + self.send_data_packets(&packets)?; + let receipt = self.session.non_expiring_reliable_receipt(&packets)?; + self.stats.frames_sent += 1; + Ok(receipt) + } + + pub fn reliable_send_status( + &self, + receipt: &CultNetRudpReliableSendReceipt, + ) -> CultNetRudpReliableSendStatus { + self.session.reliable_send_status(receipt) + } + + fn send_data_packets(&mut self, packets: &[CultNetRudpPacket]) -> Result<()> { + let fragmented = packets.len() > 1; + for (index, packet) in packets.iter().enumerate() { + self.send_packet(packet)?; + if fragmented && (index + 1) % 16 == 0 { + thread::sleep(Duration::from_millis(1)); + } + } + Ok(()) + } + pub fn send_schema_message(&mut self, message: &CultNetMessage) -> Result<()> { let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?; self.send("schema", payload) @@ -974,51 +1895,122 @@ impl CultNetRudpSocketTransportConnection { self.session.check_timeout(now_ms(), timeout_ms) } - pub fn receive_once(&mut self) -> Result> { - if let Some(frame) = self.delivered_frames.pop_front() { - return Ok(Some(frame)); - } + pub fn pending_reliable_packet_count(&self) -> usize { + self.session.pending_reliable_sequences().len() + } + pub fn outstanding_reliable_packet_count(&self) -> usize { + self.session.outstanding_reliable_packet_count() + } + + pub fn flush_reliable(&mut self, timeout: Duration) -> Result<()> { + let original_timeout = self.socket.read_timeout()?; + let poll_timeout = original_timeout + .map(|configured| configured.min(Duration::from_millis(10))) + .unwrap_or(Duration::from_millis(10)); + self.socket.set_read_timeout(Some(poll_timeout))?; + + let deadline = Instant::now() + timeout; + let mut preserved_frames = VecDeque::new(); + let result = (|| { + while self.outstanding_reliable_packet_count() > 0 { + if Instant::now() >= deadline { + return Err(anyhow!( + "RUDP reliable flush timed out with {} packets outstanding", + self.outstanding_reliable_packet_count() + )); + } + if let Some(frame) = self.receive_once()? { + preserved_frames.push_back(frame); + } + self.poll_resends()?; + } + Ok(()) + })(); + + preserved_frames.append(&mut self.delivered_frames); + self.delivered_frames = preserved_frames; + let restore = self.socket.set_read_timeout(original_timeout); + match (result, restore) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error.into()), + (Ok(()), Ok(())) => Ok(()), + } + } + + /// The next delivered frame, or `None` only when nothing is waiting on + /// the socket. Acknowledgements, pongs and incomplete fragments are + /// consumed and reading continues; see the hub's `receive_event_once`. + pub fn receive_once(&mut self) -> Result> { + loop { + if let Some(frame) = self.delivered_frames.pop_front() { + return Ok(Some(frame)); + } + if !self.receive_packet_once()? { + return Ok(None); + } + } + } + + /// Reads one datagram and applies it. `false` when the socket is empty + /// or the session has been disconnected. + fn receive_packet_once(&mut self) -> Result { let mut wire = vec![0_u8; 65_535]; let (received, remote_addr) = match self.socket.recv_from(&mut wire) { Ok(value) => value, Err(error) - if error.kind() == ErrorKind::WouldBlock || error.kind() == ErrorKind::TimedOut => + if error.kind() == ErrorKind::WouldBlock + || error.kind() == ErrorKind::TimedOut + || error.kind() == ErrorKind::ConnectionReset => { - return Ok(None); + return Ok(false); } Err(error) => return Err(error.into()), }; wire.truncate(received); self.stats.bytes_received += received as u64; + let packet = decode_rudp_packet(&wire)?; if let Some(expected) = self.remote_addr { if expected != remote_addr { - return Ok(None); + if self.mode == CultNetRudpSocketMode::Server + && packet.packet_type == CultNetRudpPacketType::Connect + { + self.remote_addr = Some(remote_addr); + } else { + return Ok(true); + } } } else { + if self.mode == CultNetRudpSocketMode::Server + && packet.packet_type != CultNetRudpPacketType::Connect + { + return Ok(true); + } self.remote_addr = Some(remote_addr); } - - let packet = decode_rudp_packet(&wire)?; if self.mode == CultNetRudpSocketMode::Server && packet.packet_type == CultNetRudpPacketType::Connect { + self.session.reset_peer_state(); let accept = self.session.accept_connect(&packet, now_ms(), Vec::new())?; self.send_packet(&accept)?; - return Ok(None); + return Ok(true); } let result = self.session.receive(&packet, now_ms())?; if let Some(reply) = result.reply { self.send_packet(&reply)?; } + for ready in result.ready_to_send { + self.send_packet(&ready)?; + } if result.pong { self.pong_payloads.push_back(result.pong_payload); } if result.disconnected { self.disconnect_reason = Some(result.disconnect_reason); - return Ok(None); + return Ok(false); } for frame in result.delivered { @@ -1028,12 +2020,14 @@ impl CultNetRudpSocketTransportConnection { }); self.stats.frames_received += 1; } - let frame = self.delivered_frames.pop_front(); - if packet.packet_type == CultNetRudpPacketType::Accept || frame.is_some() { - let ack = self.session.create_ack(); + // Only what the sender is waiting on is acknowledged. An unreliable + // frame carries no sequence, so an ACK for it tells the sender + // nothing and costs it one datagram to read for every one it sent. + if packet.reliable || packet.packet_type == CultNetRudpPacketType::Accept { + let ack = self.session.create_ack_for_received(packet.sequence); self.send_packet(&ack)?; } - Ok(frame) + Ok(true) } pub fn poll_resends(&mut self) -> Result<()> { @@ -1157,6 +2151,9 @@ pub struct RudpTransportProfileOptions { pub max_fragment_bytes: Option, pub max_pending_reliable_packets: Option, pub reconnect_policy: Option, + pub media_reliable_expire_after_ms: Option, + /// `None` means the historical default, `Reliable`. + pub media_delivery: Option, } pub fn create_rudp_transport_profile( @@ -1187,6 +2184,7 @@ pub fn create_rudp_transport_profile( max_payload_bytes: options.max_payload_bytes, max_fragment_bytes: options.max_fragment_bytes, max_pending_reliable_packets: options.max_pending_reliable_packets, + reliable_expire_after_ms: None, }, CultNetTransportChannel { channel_id: "latest".to_string(), @@ -1195,6 +2193,7 @@ pub fn create_rudp_transport_profile( max_payload_bytes: options.max_payload_bytes, max_fragment_bytes: options.max_fragment_bytes, max_pending_reliable_packets: options.max_pending_reliable_packets, + reliable_expire_after_ms: None, }, CultNetTransportChannel { channel_id: "realtime".to_string(), @@ -1203,14 +2202,29 @@ pub fn create_rudp_transport_profile( max_payload_bytes: options.max_payload_bytes, max_fragment_bytes: options.max_fragment_bytes, max_pending_reliable_packets: options.max_pending_reliable_packets, + reliable_expire_after_ms: None, }, CultNetTransportChannel { channel_id: "media".to_string(), + delivery: options + .media_delivery + .unwrap_or(CultNetTransportDelivery::Reliable), + ordering: CultNetTransportOrdering::Unordered, + max_payload_bytes: options.max_payload_bytes, + max_fragment_bytes: options.max_fragment_bytes, + max_pending_reliable_packets: options.max_pending_reliable_packets, + reliable_expire_after_ms: options.media_reliable_expire_after_ms, + }, + // Audio beside lossy video on one session: reliable, but a packet + // older than the media expiry is not worth a retransmit. + CultNetTransportChannel { + channel_id: "audio".to_string(), delivery: CultNetTransportDelivery::Reliable, ordering: CultNetTransportOrdering::Unordered, max_payload_bytes: options.max_payload_bytes, max_fragment_bytes: options.max_fragment_bytes, max_pending_reliable_packets: options.max_pending_reliable_packets, + reliable_expire_after_ms: options.media_reliable_expire_after_ms, }, ], }], @@ -1319,31 +2333,77 @@ fn packet_type_to_code(packet_type: CultNetRudpPacketType) -> u8 { } } -fn channel_send_options(channel_id: &str, now_ms: u64) -> CultNetRudpSendOptions { +/// The advertised profile owns each channel's expiry. Resolving it here keeps +/// one answer for what a channel does, rather than a runtime field that can +/// disagree with the profile the peer was shown. +fn channel_reliable_expire_after_ms( + profile: &CultNetTransportProfile, + channel_id: &str, +) -> Option { + profile + .transports + .iter() + .flat_map(|transport| transport.channels.iter()) + .find(|channel| channel.channel_id == channel_id) + .and_then(|channel| channel.reliable_expire_after_ms) +} + +/// The advertised profile owns each channel's delivery, for the same reason it +/// owns expiry: a send path that hardcodes reliability can disagree with the +/// profile the peer was shown, and then no one can say what the channel does. +/// +/// Falls back to `Reliable` for a channel the profile does not describe, which +/// is what every channel got before delivery was configurable. +fn channel_delivery( + profile: &CultNetTransportProfile, + channel_id: &str, +) -> CultNetTransportDelivery { + profile + .transports + .iter() + .flat_map(|transport| transport.channels.iter()) + .find(|channel| channel.channel_id == channel_id) + .map(|channel| channel.delivery) + .unwrap_or(CultNetTransportDelivery::Reliable) +} + +fn channel_send_options( + channel_id: &str, + now_ms: u64, + reliable_expire_after_ms: Option, + delivery: CultNetTransportDelivery, +) -> CultNetRudpSendOptions { match channel_id { "schema" => CultNetRudpSendOptions { reliable: true, ordered: true, sequenced: false, now_ms, + reliable_expire_after_ms: None, }, "latest" => CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: true, now_ms, + reliable_expire_after_ms: None, }, - "media" => CultNetRudpSendOptions { - reliable: true, + // The one channel whose delivery a caller chooses. Media that has gone + // stale is worth dropping, not retransmitting: a reliable media channel + // under loss adds load exactly when the link has least to give. + "media" | "audio" => CultNetRudpSendOptions { + reliable: matches!(delivery, CultNetTransportDelivery::Reliable), ordered: false, sequenced: false, now_ms, + reliable_expire_after_ms, }, _ => CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms, + reliable_expire_after_ms: None, }, } } diff --git a/packages/cultnet-rs/src/runtime_authority_contracts.rs b/packages/cultnet-rs/src/runtime_authority_contracts.rs new file mode 100644 index 00000000..426be1fb --- /dev/null +++ b/packages/cultnet-rs/src/runtime_authority_contracts.rs @@ -0,0 +1,3309 @@ +use anyhow::{Context, Result, bail, ensure}; +use cultcache_rs::DatabaseEntry; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::io::{Read, Write}; +use zeroize::Zeroizing; + +pub const GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA: &str = "gamecult.runtime_presence_health.v2"; +pub const GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE: &str = + "gamecult.runtime_presence_health.v2"; +pub const GAMECULT_RUNTIME_ACTIVATION_PROOF_SIGNING_PURPOSE: &str = + "gamecult.runtime_presence.activation-proof.v1"; +pub const IDUNN_RUNTIME_ACTIVATION_SCHEMA: &str = "idunn.runtime_activation.v2"; +pub const IDUNN_RUNTIME_ACTIVATION_SIGNING_PURPOSE: &str = "idunn.runtime_activation.v2"; +pub const IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME: &str = "gamecult-idunn-runtime-activation-key"; +pub const IDUNN_PROCESS_WRITE_LEASE_SCHEMA: &str = "idunn.process_write_lease.v1"; +pub const IDUNN_EXPECTED_INCARNATION_SCHEMA: &str = "idunn.expected_incarnation.v2"; +pub const ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA: &str = "odin.runtime_topology_correlation.v2"; +pub const ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE: &str = + "odin.runtime_topology_correlation.v2"; + +const MAX_RUNTIME_CAPABILITIES: usize = 256; +const MAX_RUNTIME_DEPENDENCIES: usize = 256; +const MAX_TOPOLOGY_DISAGREEMENTS: usize = 256; +const IDUNN_RUNTIME_ACTIVATION_ID_DOMAIN: &[u8] = b"idunn.runtime-activation.id.v1\0"; +const IDUNN_RUNTIME_ACTIVATION_SIGNATURE_DOMAIN: &[u8] = b"idunn.runtime-activation.signature.v1\0"; + +/// One provider-owned capability claim inside a runtime-presence statement. +/// The enclosing vector is canonical only when these records are strictly +/// increasing by `(capability, schema, compatibility)`. Capacity describes +/// that one claim and does not create a second identity for the same claim. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GameCultRuntimeCapability { + pub capability: String, + pub schema: String, + pub compatibility: String, + pub capacity: u32, +} + +impl GameCultRuntimeCapability { + fn identity(&self) -> (&str, &str, &str) { + (&self.capability, &self.schema, &self.compatibility) + } +} + +/// Idunn's admitted requirement for one provider-owned runtime capability. +/// The provider reports actual capacity in `GameCultRuntimeCapability`; this +/// record states only the minimum needed for this incarnation to be Ready. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IdunnExpectedCapability { + pub capability: String, + pub schema: String, + pub compatibility: String, + pub minimum_capacity: u32, +} + +impl IdunnExpectedCapability { + fn identity(&self) -> (&str, &str, &str) { + (&self.capability, &self.schema, &self.compatibility) + } +} + +/// Dual-proved claim from one exact runtime launch. The stable provider key and +/// Idunn's activation-scoped ephemeral key both sign `canonical_proof_payload`: +/// the canonical positional encoding of this complete record with both proof +/// byte fields empty. Present authority requires a matching Idunn-published +/// current activation; neither proof can establish admission by itself. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "gamecult.runtime_presence_health", + schema = "gamecult.runtime_presence_health.v2" +)] +pub struct GameCultRuntimePresenceHealthRecord { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub target: String, + #[cultcache(key = 2)] + pub expected_projection_sha256: String, + #[cultcache(key = 3)] + pub plan_id: String, + #[cultcache(key = 4)] + pub incarnation_id: String, + #[cultcache(key = 5)] + pub sealed_release_id: String, + #[cultcache(key = 6)] + pub activation_witness_sha256: String, + #[cultcache(key = 7)] + pub state_schema_generation: Option, + #[cultcache(key = 8)] + pub state_contract_sha256: Option, + #[cultcache(key = 9)] + pub runtime_id: String, + #[cultcache(key = 10)] + pub runtime_instance_id: String, + #[cultcache(key = 11)] + pub bound_endpoint: Option, + #[cultcache(key = 12)] + pub capabilities: Vec, + #[cultcache(key = 13)] + pub health_contract: String, + #[cultcache(key = 14)] + pub state: String, + #[cultcache(key = 15)] + pub detail: String, + #[cultcache(key = 16)] + pub write_lease_sha256: Option, + #[cultcache(key = 17)] + pub signer_identity_id: String, + #[cultcache(key = 18)] + pub publisher_sequence: u64, + #[cultcache(key = 19)] + pub observed_at_unix_millis: u64, + #[cultcache(key = 20)] + pub signature_algorithm: String, + #[cultcache(key = 21, bytes)] + pub signature: Vec, + #[cultcache(key = 22)] + pub activation_signer_identity_id: String, + #[cultcache(key = 23, bytes)] + pub activation_signature: Vec, +} + +impl GameCultRuntimePresenceHealthRecord { + /// Return the one canonical payload covered by both the stable provider + /// signature and the activation-scoped proof. Both signature fields are + /// empty in these bytes, avoiding circular signatures while binding every + /// authority-bearing statement field and both signer identities. + pub fn canonical_proof_payload(&self) -> Result> { + self.validate_shape(true)?; + let mut unsigned = self.clone(); + unsigned.signature.clear(); + unsigned.activation_signature.clear(); + Ok(rmp_serde::to_vec(&unsigned)?) + } + + /// Digest the complete signed presence record for an Odin correlation + /// receipt. An unsigned construction value is deliberately not digestible. + pub fn canonical_sha256(&self) -> Result { + self.validate()?; + Ok(prefixed_sha256(&rmp_serde::to_vec(self)?)) + } + + pub fn validate(&self) -> Result<()> { + self.validate_shape(false) + } + + fn validate_shape(&self, allow_unsigned: bool) -> Result<()> { + if self.schema_version != GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA { + bail!("runtime presence schema is unsupported"); + } + validate_authority_identifier(&self.target, "target")?; + validate_required_sha256( + &self.expected_projection_sha256, + "expected projection sha256", + )?; + validate_required_sha256(&self.plan_id, "plan id")?; + validate_authority_identifier(&self.incarnation_id, "incarnation id")?; + validate_required_sha256(&self.sealed_release_id, "sealed release id")?; + validate_required_sha256(&self.activation_witness_sha256, "activation witness sha256")?; + match (&self.state_schema_generation, &self.state_contract_sha256) { + (Some(generation), Some(contract_sha256)) => { + validate_authority_identifier(generation, "state schema generation")?; + validate_required_sha256(contract_sha256, "state contract sha256")?; + } + (None, None) => {} + _ => bail!("runtime presence state lineage is partial"), + } + validate_authority_identifier(&self.runtime_id, "runtime id")?; + validate_required_sha256(&self.runtime_instance_id, "runtime instance id")?; + validate_optional_endpoint(&self.bound_endpoint)?; + validate_runtime_capabilities(&self.capabilities)?; + validate_authority_identifier(&self.health_contract, "health contract")?; + validate_authority_identifier(&self.signer_identity_id, "signer identity id")?; + validate_authority_identifier( + &self.activation_signer_identity_id, + "activation signer identity id", + )?; + validate_optional_sha256(&self.write_lease_sha256, "write lease sha256")?; + + let signature_is_valid = + self.signature.len() == 64 || (allow_unsigned && self.signature.is_empty()); + let activation_signature_is_valid = self.activation_signature.len() == 64 + || (allow_unsigned && self.activation_signature.is_empty()); + if self.publisher_sequence == 0 + || self.observed_at_unix_millis == 0 + || !matches!( + self.state.as_str(), + "warming" | "active" | "degraded" | "failed" + ) + || self.detail.len() > 512 + || self.detail.chars().any(char::is_control) + || self.signature_algorithm != "ed25519" + || !signature_is_valid + || !activation_signature_is_valid + { + bail!("runtime presence state, sequence, detail, or signature is invalid"); + } + if self.state == "warming" && self.write_lease_sha256.is_some() { + bail!("warming runtime presence cannot claim a process write lease"); + } + if self.state_schema_generation.is_none() && self.write_lease_sha256.is_some() { + bail!("stateless runtime presence cannot claim a process write lease"); + } + Ok(()) + } +} + +/// Public route facts admitted for one incarnation. These are service-facing +/// endpoints only; actuator paths, unit files, container sockets, and runner +/// credentials do not belong in the Expected projection. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IdunnExpectedRoute { + pub route_id: String, + pub transport: String, + pub stable_endpoint: String, + pub candidate_endpoint: String, +} + +impl IdunnExpectedRoute { + fn validate(&self) -> Result<()> { + validate_authority_identifier(&self.route_id, "route id")?; + validate_endpoint(&self.stable_endpoint, "stable route endpoint")?; + validate_endpoint(&self.candidate_endpoint, "candidate route endpoint")?; + if self.stable_endpoint == self.candidate_endpoint { + bail!("stable and candidate route endpoints are identical"); + } + match self.transport.as_str() { + "http" => { + if !(self.stable_endpoint.starts_with("http://") + || self.stable_endpoint.starts_with("https://")) + || !self.candidate_endpoint.starts_with("http://") + { + bail!("HTTP route endpoints use an unsupported scheme"); + } + } + "tcp" => { + if !self.stable_endpoint.starts_with("tcp://") + || !self.candidate_endpoint.starts_with("tcp://") + { + bail!("TCP route endpoints use an unsupported scheme"); + } + } + "rudp" => { + if !self.stable_endpoint.starts_with("rudp://") + || !self.candidate_endpoint.starts_with("rudp://") + { + bail!("RUDP route endpoints use an unsupported scheme"); + } + } + _ => bail!("route transport is unsupported"), + } + Ok(()) + } +} + +/// One capability requirement and Idunn's exact admitted provider selection. +/// Provider fields are all absent while unresolved. Managed providers bind an +/// Expected digest; external operator bindings instead bind an endpoint. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IdunnExpectedDependency { + pub kind: String, + pub capability: String, + pub schema: String, + pub compatibility: String, + pub minimum_capacity: u32, + pub startup: String, + pub provider_id: Option, + pub provider_authority: Option, + pub provider_expected_projection_sha256: Option, + pub provider_endpoint: Option, +} + +impl IdunnExpectedDependency { + fn identity(&self) -> (&str, &str, &str) { + (&self.capability, &self.schema, &self.compatibility) + } + + fn validate(&self) -> Result<()> { + if !matches!( + self.kind.as_str(), + "bootstrap" + | "required" + | "optional" + | "shared-infrastructure" + | "private" + | "external-operator-binding" + ) { + bail!("dependency kind is unsupported"); + } + validate_authority_identifier(&self.capability, "dependency capability")?; + validate_authority_identifier(&self.schema, "dependency schema")?; + validate_authority_identifier(&self.compatibility, "dependency compatibility")?; + if self.minimum_capacity == 0 + || !matches!(self.startup.as_str(), "before-start" | "before-promotion") + { + bail!("dependency capacity or startup condition is invalid"); + } + if self.kind == "bootstrap" && self.startup != "before-start" { + bail!("bootstrap dependency must be satisfied before start"); + } + validate_optional_identifier(&self.provider_id, "dependency provider id")?; + validate_optional_identifier(&self.provider_authority, "dependency provider authority")?; + validate_optional_sha256( + &self.provider_expected_projection_sha256, + "dependency provider expected projection sha256", + )?; + validate_optional_endpoint_labeled( + &self.provider_endpoint, + "dependency provider endpoint", + )?; + + match ( + &self.provider_id, + &self.provider_authority, + &self.provider_expected_projection_sha256, + &self.provider_endpoint, + ) { + (None, None, None, None) => {} + (Some(_), Some(authority), Some(_), _) if authority == "managed-incarnation" => { + if self.kind == "external-operator-binding" { + bail!("external operator dependency cannot select a managed incarnation"); + } + } + (Some(_), Some(authority), None, Some(_)) + if authority == "external-operator-binding" + && self.kind == "external-operator-binding" => {} + _ => bail!("dependency provider binding is partial or authority-incoherent"), + } + Ok(()) + } +} + +/// Idunn-owned desired deployment identity published into CultMesh before +/// promotion. It describes the admitted incarnation and its public contracts, +/// never the private details needed to execute it on a particular host. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "idunn.expected_incarnation", + schema = "idunn.expected_incarnation.v2" +)] +pub struct IdunnExpectedIncarnationRecord { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub target: String, + #[cultcache(key = 2)] + pub plan_id: String, + #[cultcache(key = 3)] + pub incarnation_id: String, + #[cultcache(key = 4)] + pub sealed_release_id: String, + #[cultcache(key = 5)] + pub source_repository: String, + #[cultcache(key = 6)] + pub source_revision: String, + #[cultcache(key = 7)] + pub recipe_sha256: String, + #[cultcache(key = 8)] + pub runtime_id: String, + #[cultcache(key = 9)] + pub expected_signer_identity_id: String, + #[cultcache(key = 10)] + pub health_contract: String, + #[cultcache(key = 11)] + pub artifact_sha256: String, + #[cultcache(key = 12)] + pub state_schema_generation: Option, + #[cultcache(key = 13)] + pub state_contract_sha256: Option, + #[cultcache(key = 14)] + pub write_lease_required: bool, + #[cultcache(key = 15)] + pub route: Option, + #[cultcache(key = 16)] + pub capabilities: Vec, + #[cultcache(key = 17)] + pub dependencies: Vec, +} + +impl IdunnExpectedIncarnationRecord { + pub fn decode_canonical(payload: &[u8]) -> Result { + if messagepack_array_len(payload) != Some(18) { + bail!("expected incarnation is not the 18-field positional contract"); + } + let expected: Self = + rmp_serde::from_slice(payload).context("decoding expected incarnation")?; + if rmp_serde::to_vec(&expected)? != payload { + bail!("expected incarnation is not canonical positional MessagePack"); + } + expected.validate()?; + Ok(expected) + } + + pub fn canonical_bytes(&self) -> Result> { + self.validate()?; + Ok(rmp_serde::to_vec(self)?) + } + + pub fn canonical_sha256(&self) -> Result { + Ok(prefixed_sha256(&self.canonical_bytes()?)) + } + + pub fn validate(&self) -> Result<()> { + if self.schema_version != IDUNN_EXPECTED_INCARNATION_SCHEMA { + bail!("expected incarnation schema is unsupported"); + } + validate_authority_identifier(&self.target, "target")?; + validate_required_sha256(&self.plan_id, "plan id")?; + validate_authority_identifier(&self.incarnation_id, "incarnation id")?; + validate_required_sha256(&self.sealed_release_id, "sealed release id")?; + validate_source_repository(&self.source_repository)?; + if !is_lower_hex(&self.source_revision, 40) { + bail!("source revision is not a lowercase Git object id"); + } + validate_required_sha256(&self.recipe_sha256, "recipe sha256")?; + validate_authority_identifier(&self.runtime_id, "runtime id")?; + validate_authority_identifier( + &self.expected_signer_identity_id, + "expected signer identity id", + )?; + validate_authority_identifier(&self.health_contract, "health contract")?; + validate_required_sha256(&self.artifact_sha256, "artifact sha256")?; + match (&self.state_schema_generation, &self.state_contract_sha256) { + (Some(generation), Some(contract_sha256)) => { + validate_authority_identifier(generation, "state schema generation")?; + validate_required_sha256(contract_sha256, "state contract sha256")?; + } + (None, None) => {} + _ => bail!("expected incarnation state lineage is partial"), + } + if self.write_lease_required && self.state_schema_generation.is_none() { + bail!("stateless incarnation cannot require a process write lease"); + } + if let Some(route) = &self.route { + route.validate()?; + } + validate_expected_capabilities(&self.capabilities)?; + validate_expected_dependencies(&self.dependencies)?; + Ok(()) + } +} + +/// Odin's observation of one dependency selected in the bound Expected +/// projection. Evidence digests identify the signed/provider-owned facts Odin +/// used; they do not copy those facts into Odin authority. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OdinRuntimeDependencyEvidence { + pub kind: String, + pub capability: String, + pub schema: String, + pub compatibility: String, + pub provider_id: Option, + pub provider_authority: Option, + pub provider_expected_projection_sha256: Option, + pub provider_endpoint: Option, + pub observed_capacity: Option, + pub provider_evidence_sha256: Option, + pub ready: bool, +} + +impl OdinRuntimeDependencyEvidence { + fn identity(&self) -> (&str, &str, &str) { + (&self.capability, &self.schema, &self.compatibility) + } + + fn validate(&self) -> Result<()> { + if !matches!( + self.kind.as_str(), + "bootstrap" + | "required" + | "optional" + | "shared-infrastructure" + | "private" + | "external-operator-binding" + ) { + bail!("dependency evidence kind is unsupported"); + } + validate_authority_identifier(&self.kind, "dependency evidence kind")?; + validate_authority_identifier(&self.capability, "dependency evidence capability")?; + validate_authority_identifier(&self.schema, "dependency evidence schema")?; + validate_authority_identifier(&self.compatibility, "dependency evidence compatibility")?; + validate_optional_identifier(&self.provider_id, "dependency evidence provider id")?; + validate_optional_identifier( + &self.provider_authority, + "dependency evidence provider authority", + )?; + validate_optional_sha256( + &self.provider_expected_projection_sha256, + "dependency evidence provider expected projection sha256", + )?; + validate_optional_endpoint_labeled( + &self.provider_endpoint, + "dependency evidence provider endpoint", + )?; + validate_optional_sha256( + &self.provider_evidence_sha256, + "dependency provider evidence sha256", + )?; + if self.observed_capacity == Some(0) { + bail!("dependency evidence capacity is zero"); + } + if self.provider_id.is_none() + && (self.provider_authority.is_some() + || self.provider_expected_projection_sha256.is_some() + || self.provider_endpoint.is_some() + || self.observed_capacity.is_some() + || self.provider_evidence_sha256.is_some() + || self.ready) + { + bail!("dependency evidence has facts without a provider"); + } + if self.provider_id.is_some() && self.provider_authority.is_none() { + bail!("dependency evidence provider authority is missing"); + } + match ( + &self.provider_id, + &self.provider_authority, + &self.provider_expected_projection_sha256, + &self.provider_endpoint, + ) { + (None, None, None, None) => {} + (Some(_), Some(authority), Some(_), _) if authority == "managed-incarnation" => { + if self.kind == "external-operator-binding" { + bail!("external dependency evidence names a managed incarnation"); + } + } + (Some(_), Some(authority), None, Some(_)) + if authority == "external-operator-binding" + && self.kind == "external-operator-binding" => {} + _ => bail!("dependency evidence provider binding is authority-incoherent"), + } + if self.ready + && (self.observed_capacity.is_none() || self.provider_evidence_sha256.is_none()) + { + bail!("ready dependency lacks capacity or signed evidence"); + } + Ok(()) + } +} + +/// One explicit Expected/observed disagreement. Codes are unique and sorted so +/// the signed receipt has one canonical explanation for each disagreement. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OdinTopologyDisagreement { + pub code: String, + pub expected: Option, + pub observed: Option, +} + +impl OdinTopologyDisagreement { + fn validate(&self) -> Result<()> { + validate_authority_identifier(&self.code, "topology disagreement code")?; + validate_optional_detail(&self.expected, "topology disagreement expected value")?; + validate_optional_detail(&self.observed, "topology disagreement observed value")?; + if self.expected.is_none() && self.observed.is_none() { + bail!("topology disagreement carries no expected or observed value"); + } + Ok(()) + } +} + +/// Odin-owned signed correlation of Idunn's exact Expected projection with +/// current Idunn activation and provider-signed presence. Presence state and +/// write-lease observation remain explicit so an opaque Ready bit cannot cross +/// Idunn's warming/fencing boundary. The signature covers the canonical +/// positional encoding with `signature` empty. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "odin.runtime_topology_correlation", + schema = "odin.runtime_topology_correlation.v2" +)] +pub struct OdinRuntimeTopologyCorrelationRecord { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub target: String, + #[cultcache(key = 2)] + pub expected_projection_sha256: String, + #[cultcache(key = 3)] + pub expected: bool, + #[cultcache(key = 4)] + pub current_activation_sha256: Option, + #[cultcache(key = 5)] + pub signed_presence_sha256: Option, + #[cultcache(key = 6)] + pub observed_presence_state: Option, + #[cultcache(key = 7)] + pub observed_presence_publisher_sequence: Option, + #[cultcache(key = 8)] + pub observed_write_lease_sha256: Option, + #[cultcache(key = 9)] + pub observed_capabilities: Vec, + #[cultcache(key = 10)] + pub runtime_id: String, + #[cultcache(key = 11)] + pub runtime_instance_id: Option, + #[cultcache(key = 12)] + pub present: bool, + #[cultcache(key = 13)] + pub ready: bool, + #[cultcache(key = 14)] + pub dependencies: Vec, + #[cultcache(key = 15)] + pub disagreements: Vec, + #[cultcache(key = 16)] + pub signer_identity_id: String, + #[cultcache(key = 17)] + pub publisher_sequence: u64, + #[cultcache(key = 18)] + pub observed_at_unix_millis: u64, + #[cultcache(key = 19)] + pub signature_algorithm: String, + #[cultcache(key = 20, bytes)] + pub signature: Vec, +} + +impl OdinRuntimeTopologyCorrelationRecord { + pub fn decode_canonical_signed_payload(payload: &[u8]) -> Result<(Self, Vec)> { + if messagepack_array_len(payload) != Some(21) { + bail!("topology correlation is not the 21-field positional contract"); + } + let receipt: Self = + rmp_serde::from_slice(payload).context("decoding runtime topology correlation")?; + if rmp_serde::to_vec(&receipt)? != payload { + bail!("topology correlation is not canonical positional MessagePack"); + } + receipt.validate()?; + let unsigned = receipt.unsigned_signature_payload()?; + Ok((receipt, unsigned)) + } + + pub fn unsigned_signature_payload(&self) -> Result> { + self.validate_shape(true)?; + let mut unsigned = self.clone(); + unsigned.signature.clear(); + Ok(rmp_serde::to_vec(&unsigned)?) + } + + pub fn canonical_bytes(&self) -> Result> { + self.validate()?; + Ok(rmp_serde::to_vec(self)?) + } + + pub fn canonical_sha256(&self) -> Result { + Ok(prefixed_sha256(&self.canonical_bytes()?)) + } + + pub fn validate(&self) -> Result<()> { + self.validate_shape(false) + } + + pub fn validate_against_expected( + &self, + expected: &IdunnExpectedIncarnationRecord, + current_write_lease_sha256: Option<&str>, + ) -> Result<()> { + self.validate()?; + expected.validate()?; + if self.expected_projection_sha256 != expected.canonical_sha256()? + || self.target != expected.target + || self.runtime_id != expected.runtime_id + || self.dependencies.len() != expected.dependencies.len() + { + bail!("topology correlation substitutes or omits Expected authority"); + } + for (evidence, requirement) in self.dependencies.iter().zip(&expected.dependencies) { + if evidence.identity() != requirement.identity() + || evidence.kind != requirement.kind + || evidence.provider_id != requirement.provider_id + || evidence.provider_authority != requirement.provider_authority + || evidence.provider_expected_projection_sha256 + != requirement.provider_expected_projection_sha256 + || evidence.provider_endpoint != requirement.provider_endpoint + { + bail!("topology dependency evidence does not bind the Expected dependency"); + } + if evidence.ready + && evidence + .observed_capacity + .is_none_or(|capacity| capacity < requirement.minimum_capacity) + { + bail!("ready dependency does not meet Expected capacity"); + } + } + let mut capability_disagreements = Vec::new(); + correlate_capabilities( + &mut capability_disagreements, + &expected.capabilities, + &self.observed_capabilities, + ); + for disagreement in &capability_disagreements { + if !self.disagreements.contains(disagreement) { + bail!("topology correlation omits an observed capability disagreement"); + } + } + if let Some(current_write_lease_sha256) = current_write_lease_sha256 { + validate_required_sha256(current_write_lease_sha256, "current write lease sha256")?; + } + if expected.write_lease_required { + if self + .observed_write_lease_sha256 + .as_deref() + .is_some_and(|observed| Some(observed) != current_write_lease_sha256) + { + bail!("observed write lease is not the current Idunn grant"); + } + if self.ready + && (self.observed_write_lease_sha256.is_none() + || current_write_lease_sha256.is_none()) + { + bail!("Ready stateful topology lacks the exact current write lease"); + } + } else if self.observed_write_lease_sha256.is_some() || current_write_lease_sha256.is_some() + { + bail!("stateless Expected topology cannot carry a process write lease"); + } + Ok(()) + } + + fn validate_shape(&self, allow_unsigned: bool) -> Result<()> { + if self.schema_version != ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA { + bail!("topology correlation schema is unsupported"); + } + validate_authority_identifier(&self.target, "target")?; + validate_required_sha256( + &self.expected_projection_sha256, + "correlation expected projection sha256", + )?; + if !self.expected { + bail!("topology correlation cannot deny the Expected record it binds"); + } + validate_optional_sha256(&self.current_activation_sha256, "current activation sha256")?; + validate_optional_sha256(&self.signed_presence_sha256, "signed presence sha256")?; + validate_optional_identifier(&self.observed_presence_state, "observed presence state")?; + if self.observed_presence_publisher_sequence == Some(0) { + bail!("observed presence publisher sequence is zero"); + } + validate_optional_sha256( + &self.observed_write_lease_sha256, + "observed write lease sha256", + )?; + validate_runtime_capabilities(&self.observed_capabilities)?; + if self.signed_presence_sha256.is_none() && !self.observed_capabilities.is_empty() { + bail!("topology correlation has capabilities without signed presence"); + } + match ( + &self.signed_presence_sha256, + &self.observed_presence_state, + self.observed_presence_publisher_sequence, + &self.observed_write_lease_sha256, + ) { + (None, None, None, None) => {} + (Some(_), Some(state), Some(_), write_lease) + if matches!(state.as_str(), "warming" | "active" | "degraded" | "failed") => + { + if state == "warming" && write_lease.is_some() { + bail!("warming topology presence cannot carry a process write lease"); + } + } + _ => bail!("signed presence state, sequence, or write-lease observation is partial"), + } + validate_authority_identifier(&self.runtime_id, "runtime id")?; + validate_optional_sha256(&self.runtime_instance_id, "runtime instance id")?; + let has_runtime_evidence = + self.current_activation_sha256.is_some() || self.signed_presence_sha256.is_some(); + if self.runtime_instance_id.is_some() != has_runtime_evidence { + bail!("runtime instance identity and observation evidence are partial"); + } + if self.present + && (self.current_activation_sha256.is_none() + || self.signed_presence_sha256.is_none() + || self.observed_presence_publisher_sequence.is_none() + || self.runtime_instance_id.is_none()) + { + bail!("Present topology state lacks an authenticated runtime session"); + } + validate_dependency_evidence(&self.dependencies)?; + validate_topology_disagreements(&self.disagreements)?; + let has_activation = self.current_activation_sha256.is_some(); + let has_presence = self.signed_presence_sha256.is_some(); + if has_activation && has_presence && !self.present { + bail!("authenticated runtime session cannot be projected as not Present"); + } + if has_activation != has_presence && self.disagreements.is_empty() { + bail!("partial or rejected runtime evidence lacks an explicit disagreement"); + } + if self.ready + && (!self.present + || self.observed_presence_state.as_deref() != Some("active") + || !self.disagreements.is_empty() + || self + .dependencies + .iter() + .any(|dependency| dependency.kind != "optional" && !dependency.ready)) + { + bail!("Ready topology state is not supported by correlated evidence"); + } + validate_authority_identifier(&self.signer_identity_id, "signer identity id")?; + let signature_is_valid = + self.signature.len() == 64 || (allow_unsigned && self.signature.is_empty()); + if self.publisher_sequence == 0 + || self.observed_at_unix_millis == 0 + || self.signature_algorithm != "ed25519" + || !signature_is_valid + { + bail!("topology correlation sequence, time, or signature is invalid"); + } + Ok(()) + } +} + +/// An in-memory activation-scoped signer reconstructed by the launched service +/// from its protected systemd credential. The stable service identity never +/// receives this key, and the public activation record never contains it. +pub struct IdunnRuntimeActivationSigner { + signing_key: SigningKey, +} + +impl IdunnRuntimeActivationSigner { + fn generate() -> Self { + let mut seed = Zeroizing::new([0_u8; 32]); + rand::rng().fill_bytes(seed.as_mut()); + Self { + signing_key: SigningKey::from_bytes(&*seed), + } + } + + /// Open the exact raw 32-byte Ed25519 seed supplied through Idunn's systemd + /// credential without retaining an ordinary heap copy. No persistent key + /// schema exists: this authority lives for one activation and is replaced + /// on every launch. + pub fn from_credential_reader(mut credential: impl Read) -> Result { + let mut seed = Zeroizing::new([0_u8; 32]); + credential + .read_exact(seed.as_mut()) + .context("reading runtime activation credential")?; + let mut extra = [0_u8; 1]; + if credential.read(&mut extra)? != 0 { + bail!("runtime activation credential is longer than 32 bytes"); + } + Ok(Self { + signing_key: SigningKey::from_bytes(&*seed), + }) + } + + fn write_credential(&self, mut destination: impl Write) -> Result<()> { + let seed = Zeroizing::new(self.signing_key.to_bytes()); + destination + .write_all(seed.as_ref()) + .context("writing runtime activation credential") + } + + pub fn public_key(&self) -> Vec { + self.signing_key.verifying_key().to_bytes().to_vec() + } + + pub fn identity_id(&self) -> String { + derive_idunn_runtime_activation_identity_id(&self.public_key()) + .expect("an Ed25519 activation signer always has a 32-byte public key") + } + + pub fn sign_presence_proof( + &self, + presence: &GameCultRuntimePresenceHealthRecord, + ) -> Result> { + if presence.activation_signer_identity_id != self.identity_id() { + bail!("runtime presence names a different activation signer"); + } + let payload = presence.canonical_proof_payload()?; + Ok(self + .signing_key + .sign(&runtime_activation_signing_message(&payload)) + .to_bytes() + .to_vec()) + } +} + +/// Idunn's one-shot launch material. Issuance always creates a fresh key, and +/// writing the protected credential consumes the only Idunn-side signer. +pub struct IdunnRuntimeActivationLaunch { + activation: IdunnRuntimeActivationRecord, + signer: IdunnRuntimeActivationSigner, +} + +impl IdunnRuntimeActivationLaunch { + pub fn issue( + expected: &IdunnExpectedIncarnationRecord, + runtime_instance_id: String, + issued_at_unix_millis: u64, + idunn_signer: &crate::ServiceIdentitySigner, + ) -> Result { + let signer = IdunnRuntimeActivationSigner::generate(); + let activation = IdunnRuntimeActivationRecord::issue_with_signer( + expected, + runtime_instance_id, + &signer, + issued_at_unix_millis, + idunn_signer, + )?; + Ok(Self { activation, signer }) + } + + pub fn activation(&self) -> &IdunnRuntimeActivationRecord { + &self.activation + } + + pub fn write_credential(self, destination: impl Write) -> Result { + self.signer.write_credential(destination)?; + Ok(self.activation) + } +} + +/// Derive the activation-scoped identity from the Idunn-issued public key. The +/// domain is dedicated to runtime activation and cannot be substituted for a +/// stable provider, Idunn, or Odin service identity. +pub fn derive_idunn_runtime_activation_identity_id(public_key: &[u8]) -> Result { + if public_key.len() != 32 { + bail!("runtime activation public key has invalid length"); + } + Ok(format!( + "{:x}", + Sha256::digest([IDUNN_RUNTIME_ACTIVATION_ID_DOMAIN, public_key].concat()) + )) +} + +/// Current Idunn authority for one launch. Idunn's stable signature binds the +/// complete Expected digest and activation key; Expected in turn pins the +/// provider signer identity. The provider public key is lookup material, not a +/// second authority: its profile-derived identity must equal Idunn's signed +/// selection exactly. +#[derive(Clone, Debug)] +pub struct VerifiedRuntimeAuthority { + expected: IdunnExpectedIncarnationRecord, + activation: IdunnRuntimeActivationRecord, + provider_signer_public_key: Vec, + expected_sha256: String, + activation_sha256: String, +} + +impl VerifiedRuntimeAuthority { + pub fn expected(&self) -> &IdunnExpectedIncarnationRecord { + &self.expected + } + + pub fn activation(&self) -> &IdunnRuntimeActivationRecord { + &self.activation + } + + pub fn expected_sha256(&self) -> &str { + &self.expected_sha256 + } + + pub fn activation_sha256(&self) -> &str { + &self.activation_sha256 + } +} + +pub fn verify_runtime_authority( + expected: &IdunnExpectedIncarnationRecord, + activation: &IdunnRuntimeActivationRecord, + idunn_anchor: &crate::ServiceIdentityTrustAnchor, + provider_signer_public_key: &[u8], +) -> Result { + expected.validate()?; + activation.verify_for_expected(expected, idunn_anchor)?; + if crate::derive_service_identity_id::( + provider_signer_public_key, + )? != expected.expected_signer_identity_id + { + bail!("provider public key is not Idunn's Expected signer selection"); + } + Ok(VerifiedRuntimeAuthority { + expected: expected.clone(), + activation: activation.clone(), + provider_signer_public_key: provider_signer_public_key.to_vec(), + expected_sha256: expected.canonical_sha256()?, + activation_sha256: activation.canonical_sha256()?, + }) +} + +#[derive(Clone, Copy, Debug)] +pub struct RuntimePresenceAuthenticationContext { + pub trusted_received_at_unix_millis: u64, + pub maximum_age_millis: u64, + pub maximum_future_skew_millis: u64, +} + +/// Canonical runtime bytes authenticated by both the Expected provider key and +/// the current activation key. No semantic claim has yet been accepted as +/// Present; Odin must correlate this observation against current authority. +#[derive(Clone, Debug)] +pub struct AuthenticatedRuntimePresenceClaim { + record: GameCultRuntimePresenceHealthRecord, + canonical_bytes: Vec, + signed_presence_sha256: String, + received_at_unix_millis: u64, +} + +impl AuthenticatedRuntimePresenceClaim { + pub fn record(&self) -> &GameCultRuntimePresenceHealthRecord { + &self.record + } + + pub fn canonical_bytes(&self) -> &[u8] { + &self.canonical_bytes + } + + pub fn signed_presence_sha256(&self) -> &str { + &self.signed_presence_sha256 + } + + pub fn received_at_unix_millis(&self) -> u64 { + self.received_at_unix_millis + } +} + +/// A signed Odin receipt authenticated against the exact current Expected, +/// activation, write lease, Idunn-admitted topology key, and trusted receipt +/// time. This is evidence, not a replay cursor or route-promotion grant. The +/// caller must source the key from Idunn's admitted Odin binding. Idunn's +/// durable admission owner must atomically check and advance Odin's publisher +/// sequence before treating the receipt as current. +#[derive(Clone, Debug)] +pub struct AuthenticatedOdinRuntimeTopologyCorrelation { + record: OdinRuntimeTopologyCorrelationRecord, + canonical_bytes: Vec, +} + +impl AuthenticatedOdinRuntimeTopologyCorrelation { + pub fn record(&self) -> &OdinRuntimeTopologyCorrelationRecord { + &self.record + } + + pub fn canonical_bytes(&self) -> &[u8] { + &self.canonical_bytes + } +} + +#[derive(Clone, Copy, Debug)] +pub struct OdinTopologyAuthenticationContext { + pub trusted_received_at_unix_millis: u64, + pub maximum_age_millis: u64, + pub maximum_future_skew_millis: u64, +} + +pub fn authenticate_odin_runtime_topology_correlation( + canonical_correlation: &[u8], + authority: &VerifiedRuntimeAuthority, + current_write_lease_sha256: Option<&str>, + admitted_odin_signer_public_key: &[u8], + context: OdinTopologyAuthenticationContext, +) -> Result { + ensure!( + context.trusted_received_at_unix_millis > 0 && context.maximum_age_millis > 0, + "Odin topology trusted time and maximum age must be positive" + ); + let (record, unsigned) = OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload( + canonical_correlation, + )?; + record.validate_against_expected(authority.expected(), current_write_lease_sha256)?; + if record.current_activation_sha256.as_deref() != Some(authority.activation_sha256()) + || record.runtime_instance_id.as_deref() + != Some(authority.activation.runtime_instance_id.as_str()) + { + bail!("topology correlation does not bind the current activation"); + } + let latest_trusted_timestamp = context + .trusted_received_at_unix_millis + .saturating_add(context.maximum_future_skew_millis); + if record.observed_at_unix_millis > latest_trusted_timestamp + || context + .trusted_received_at_unix_millis + .saturating_sub(record.observed_at_unix_millis) + > context.maximum_age_millis + { + bail!("Odin topology correlation is outside the trusted observation window"); + } + crate::verify_service_identity_signature_with_public_key::< + crate::OdinTopologyIdentity, + crate::OdinRuntimeTopologyCorrelationPurpose, + >( + admitted_odin_signer_public_key, + &unsigned, + &crate::ServiceIdentitySignature { + identity_id: record.signer_identity_id.clone(), + signature: record.signature.clone(), + }, + )?; + Ok(AuthenticatedOdinRuntimeTopologyCorrelation { + record, + canonical_bytes: canonical_correlation.to_vec(), + }) +} + +/// Odin's deterministic interpretation of an authenticated provider claim. +/// Authentication of the stable provider and current activation establishes +/// Present. Correlation disagreements remain explicit and deny Ready; they do +/// not erase the observed session that produced them. +#[derive(Clone, Debug)] +pub struct RuntimePresenceCorrelation { + presence: VerifiedRuntimePresence, + disagreements: Vec, +} + +impl RuntimePresenceCorrelation { + pub fn claim(&self) -> &AuthenticatedRuntimePresenceClaim { + &self.presence.claim + } + + pub fn disagreements(&self) -> &[OdinTopologyDisagreement] { + &self.disagreements + } + + pub fn into_present(self) -> VerifiedRuntimePresence { + self.presence + } + + pub fn into_undisputed_present(self) -> Result { + if !self.disagreements.is_empty() { + bail!("runtime presence disagrees with current Expected projection"); + } + Ok(self.presence) + } +} + +/// A dual-authenticated claim that matches current authority. This is the only +/// value that represents semantic Present. Replay admission deliberately does +/// not live here: Odin's durable store must atomically check and advance every +/// authenticated provider sequence, including disagreement-bearing claims, +/// before it signs a topology correlation. +#[derive(Clone, Debug)] +pub struct VerifiedRuntimePresence { + claim: AuthenticatedRuntimePresenceClaim, +} + +impl VerifiedRuntimePresence { + pub fn record(&self) -> &GameCultRuntimePresenceHealthRecord { + self.claim.record() + } + + pub fn canonical_bytes(&self) -> &[u8] { + self.claim.canonical_bytes() + } + + pub fn signed_presence_sha256(&self) -> &str { + self.claim.signed_presence_sha256() + } + + pub fn accepted_at_unix_millis(&self) -> u64 { + self.claim.received_at_unix_millis() + } +} + +pub fn authenticate_runtime_presence_claim( + canonical_presence: &[u8], + authority: &VerifiedRuntimeAuthority, + context: RuntimePresenceAuthenticationContext, +) -> Result { + ensure!( + context.trusted_received_at_unix_millis > 0 && context.maximum_age_millis > 0, + "runtime presence trusted time and maximum age must be positive" + ); + if messagepack_array_len(canonical_presence) != Some(24) { + bail!("runtime presence payload is not the 24-field positional contract"); + } + let presence: GameCultRuntimePresenceHealthRecord = + rmp_serde::from_slice(canonical_presence).context("decoding runtime presence health")?; + if rmp_serde::to_vec(&presence)? != canonical_presence { + bail!("runtime presence payload is not canonical positional MessagePack"); + } + presence.validate()?; + let latest_trusted_timestamp = context + .trusted_received_at_unix_millis + .saturating_add(context.maximum_future_skew_millis); + if presence.observed_at_unix_millis > latest_trusted_timestamp + || authority.activation.issued_at_unix_millis > latest_trusted_timestamp + || context + .trusted_received_at_unix_millis + .saturating_sub(presence.observed_at_unix_millis) + > context.maximum_age_millis + { + bail!("runtime presence is outside the trusted observation window"); + } + if presence.signer_identity_id != authority.expected.expected_signer_identity_id { + bail!("runtime presence signer is not Idunn's Expected signer selection"); + } + + let proof_payload = presence.canonical_proof_payload()?; + crate::verify_service_identity_signature_with_public_key::< + crate::GameCultProviderHealthIdentity, + crate::GameCultRuntimePresenceHealthPurpose, + >( + &authority.provider_signer_public_key, + &proof_payload, + &crate::ServiceIdentitySignature { + identity_id: presence.signer_identity_id.clone(), + signature: presence.signature.clone(), + }, + )?; + let activation_public_key: [u8; 32] = authority + .activation + .activation_signer_public_key + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("runtime activation public key has invalid length"))?; + let activation_signature = Signature::from_slice(&presence.activation_signature) + .map_err(|_| anyhow::anyhow!("runtime activation signature has invalid length"))?; + VerifyingKey::from_bytes(&activation_public_key)? + .verify( + &runtime_activation_signing_message(&proof_payload), + &activation_signature, + ) + .map_err(|_| anyhow::anyhow!("runtime activation proof verification failed"))?; + + Ok(AuthenticatedRuntimePresenceClaim { + record: presence, + canonical_bytes: canonical_presence.to_vec(), + signed_presence_sha256: prefixed_sha256(canonical_presence), + received_at_unix_millis: context.trusted_received_at_unix_millis, + }) +} + +pub fn correlate_runtime_presence_claim( + claim: AuthenticatedRuntimePresenceClaim, + authority: &VerifiedRuntimeAuthority, +) -> Result { + let expected = authority.expected(); + let activation = authority.activation(); + let presence = claim.record(); + let expected_endpoint = expected + .route + .as_ref() + .map(|route| route.candidate_endpoint.clone()); + let mut disagreements = Vec::new(); + push_disagreement( + &mut disagreements, + "activation-signer-identity", + Some(&activation.activation_signer_identity_id), + Some(&presence.activation_signer_identity_id), + ); + push_disagreement( + &mut disagreements, + "activation-witness", + Some(authority.activation_sha256()), + Some(&presence.activation_witness_sha256), + ); + push_disagreement( + &mut disagreements, + "bound-endpoint", + expected_endpoint.as_deref(), + presence.bound_endpoint.as_deref(), + ); + push_disagreement( + &mut disagreements, + "expected-projection", + Some(authority.expected_sha256()), + Some(&presence.expected_projection_sha256), + ); + push_disagreement( + &mut disagreements, + "health-contract", + Some(&expected.health_contract), + Some(&presence.health_contract), + ); + push_disagreement( + &mut disagreements, + "incarnation-id", + Some(&expected.incarnation_id), + Some(&presence.incarnation_id), + ); + push_disagreement( + &mut disagreements, + "plan-id", + Some(&expected.plan_id), + Some(&presence.plan_id), + ); + push_disagreement( + &mut disagreements, + "runtime-id", + Some(&expected.runtime_id), + Some(&presence.runtime_id), + ); + push_disagreement( + &mut disagreements, + "runtime-instance-id", + Some(&activation.runtime_instance_id), + Some(&presence.runtime_instance_id), + ); + push_disagreement( + &mut disagreements, + "sealed-release-id", + Some(&expected.sealed_release_id), + Some(&presence.sealed_release_id), + ); + push_disagreement( + &mut disagreements, + "signer-identity", + Some(&expected.expected_signer_identity_id), + Some(&presence.signer_identity_id), + ); + push_disagreement( + &mut disagreements, + "state-contract", + expected.state_contract_sha256.as_deref(), + presence.state_contract_sha256.as_deref(), + ); + push_disagreement( + &mut disagreements, + "state-schema-generation", + expected.state_schema_generation.as_deref(), + presence.state_schema_generation.as_deref(), + ); + push_disagreement( + &mut disagreements, + "target", + Some(&expected.target), + Some(&presence.target), + ); + if activation.issued_at_unix_millis > presence.observed_at_unix_millis { + disagreements.push(OdinTopologyDisagreement { + code: "activation-issued-after-presence".into(), + expected: Some(format!("at-most:{}", presence.observed_at_unix_millis)), + observed: Some(activation.issued_at_unix_millis.to_string()), + }); + } + correlate_capabilities( + &mut disagreements, + &expected.capabilities, + &presence.capabilities, + ); + disagreements.sort_by(|left, right| left.code.cmp(&right.code)); + validate_topology_disagreements(&disagreements)?; + Ok(RuntimePresenceCorrelation { + presence: VerifiedRuntimePresence { claim }, + disagreements, + }) +} + +fn push_disagreement( + disagreements: &mut Vec, + code: &str, + expected: Option<&str>, + observed: Option<&str>, +) { + if expected != observed { + disagreements.push(OdinTopologyDisagreement { + code: code.into(), + expected: expected.map(str::to_string), + observed: observed.map(str::to_string), + }); + } +} + +fn correlate_capabilities( + disagreements: &mut Vec, + expected: &[IdunnExpectedCapability], + observed: &[GameCultRuntimeCapability], +) { + for (index, required) in expected.iter().enumerate() { + let actual = observed + .iter() + .find(|actual| actual.identity() == required.identity()); + let requirement = format!( + "{}/{}/{} capacity>={}", + required.capability, required.schema, required.compatibility, required.minimum_capacity + ); + match actual { + None => disagreements.push(OdinTopologyDisagreement { + code: format!("expected-capability-{index:03}-missing"), + expected: Some(requirement), + observed: None, + }), + Some(actual) if actual.capacity < required.minimum_capacity => { + disagreements.push(OdinTopologyDisagreement { + code: format!("expected-capability-{index:03}-capacity"), + expected: Some(requirement), + observed: Some(format!("capacity={}", actual.capacity)), + }); + } + Some(_) => {} + } + } +} + +fn runtime_activation_signing_message(payload: &[u8]) -> Vec { + let purpose = GAMECULT_RUNTIME_ACTIVATION_PROOF_SIGNING_PURPOSE.as_bytes(); + let mut out = Vec::with_capacity( + IDUNN_RUNTIME_ACTIVATION_SIGNATURE_DOMAIN.len() + purpose.len() + payload.len() + 16, + ); + out.extend_from_slice(IDUNN_RUNTIME_ACTIVATION_SIGNATURE_DOMAIN); + out.extend_from_slice(&(purpose.len() as u64).to_be_bytes()); + out.extend_from_slice(purpose); + out.extend_from_slice(&(payload.len() as u64).to_be_bytes()); + out.extend_from_slice(payload); + out +} + +/// Idunn-issued launch identity. Idunn's stable service identity signs the +/// complete activation, including the ephemeral public key, before the private +/// credential is passed to one workload alongside Expected. Odin still treats +/// it as current only after Idunn publishes an observed native process. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "idunn.runtime_activation", + schema = "idunn.runtime_activation.v2" +)] +pub struct IdunnRuntimeActivationRecord { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub expected_projection_sha256: String, + #[cultcache(key = 2)] + pub runtime_id: String, + #[cultcache(key = 3)] + pub runtime_instance_id: String, + #[cultcache(key = 4)] + pub activation_signer_identity_id: String, + #[cultcache(key = 5, bytes)] + pub activation_signer_public_key: Vec, + #[cultcache(key = 6)] + pub issued_at_unix_millis: u64, + #[cultcache(key = 7)] + pub idunn_signer_identity_id: String, + #[cultcache(key = 8)] + pub signature_algorithm: String, + #[cultcache(key = 9, bytes)] + pub signature: Vec, +} + +impl IdunnRuntimeActivationRecord { + fn issue_with_signer( + expected: &IdunnExpectedIncarnationRecord, + runtime_instance_id: String, + activation_signer: &IdunnRuntimeActivationSigner, + issued_at_unix_millis: u64, + idunn_signer: &crate::ServiceIdentitySigner, + ) -> Result { + expected.validate()?; + let mut activation = Self { + schema_version: IDUNN_RUNTIME_ACTIVATION_SCHEMA.into(), + expected_projection_sha256: expected.canonical_sha256()?, + runtime_id: expected.runtime_id.clone(), + runtime_instance_id, + activation_signer_identity_id: activation_signer.identity_id(), + activation_signer_public_key: activation_signer.public_key(), + issued_at_unix_millis, + idunn_signer_identity_id: idunn_signer.entry().identity_id.clone(), + signature_algorithm: "ed25519".into(), + signature: Vec::new(), + }; + let proof = idunn_signer.sign::( + &activation.unsigned_signature_payload()?, + ); + activation.signature = proof.signature; + activation.validate()?; + Ok(activation) + } + + pub fn decode_canonical(payload: &[u8]) -> Result { + if messagepack_array_len(payload) != Some(10) { + bail!("runtime activation is not the 10-field positional contract"); + } + let activation: Self = + rmp_serde::from_slice(payload).context("decoding runtime activation")?; + if rmp_serde::to_vec(&activation)? != payload { + bail!("runtime activation is not canonical positional MessagePack"); + } + activation.validate()?; + Ok(activation) + } + + pub fn canonical_bytes(&self) -> Result> { + self.validate()?; + Ok(rmp_serde::to_vec(self)?) + } + + pub fn canonical_sha256(&self) -> Result { + Ok(prefixed_sha256(&self.canonical_bytes()?)) + } + + pub fn unsigned_signature_payload(&self) -> Result> { + self.validate_shape(true)?; + let mut unsigned = self.clone(); + unsigned.signature.clear(); + Ok(rmp_serde::to_vec(&unsigned)?) + } + + pub fn verify_for_expected( + &self, + expected: &IdunnExpectedIncarnationRecord, + idunn_anchor: &crate::ServiceIdentityTrustAnchor, + ) -> Result<()> { + self.validate()?; + expected.validate()?; + if self.expected_projection_sha256 != expected.canonical_sha256()? + || self.runtime_id != expected.runtime_id + { + bail!("runtime activation does not bind the current Expected incarnation"); + } + crate::verify_service_identity_signature::< + crate::IdunnServiceIdentity, + crate::IdunnRuntimeActivationPurpose, + >( + idunn_anchor, + &self.unsigned_signature_payload()?, + &crate::ServiceIdentitySignature { + identity_id: self.idunn_signer_identity_id.clone(), + signature: self.signature.clone(), + }, + ) + .context("verifying Idunn runtime activation signature") + } + + pub fn validate(&self) -> Result<()> { + self.validate_shape(false) + } + + fn validate_shape(&self, allow_unsigned: bool) -> Result<()> { + if self.schema_version != IDUNN_RUNTIME_ACTIVATION_SCHEMA { + bail!("runtime activation schema is unsupported"); + } + validate_required_sha256( + &self.expected_projection_sha256, + "activation expected projection sha256", + )?; + validate_authority_identifier(&self.runtime_id, "activation runtime id")?; + validate_required_sha256(&self.runtime_instance_id, "runtime instance id")?; + validate_authority_identifier( + &self.activation_signer_identity_id, + "activation signer identity id", + )?; + if self.activation_signer_identity_id + != derive_idunn_runtime_activation_identity_id(&self.activation_signer_public_key)? + { + bail!("runtime activation signer identity does not match its public key"); + } + validate_authority_identifier( + &self.idunn_signer_identity_id, + "Idunn activation signer identity id", + )?; + let signature_is_valid = + self.signature.len() == 64 || (allow_unsigned && self.signature.is_empty()); + if self.issued_at_unix_millis == 0 + || self.signature_algorithm != "ed25519" + || !signature_is_valid + { + bail!("runtime activation issue time or signature is invalid"); + } + Ok(()) + } +} + +/// Unsigned Idunn-owned local file that grants one exact process incarnation +/// state-write authority. It is authoritative only when read through the +/// root-owned admitted path and lock protocol; its bytes carry no route or +/// traffic authority. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "idunn.process_write_lease", + schema = "idunn.process_write_lease.v1" +)] +pub struct IdunnProcessWriteLeaseRecord { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub target: String, + #[cultcache(key = 2)] + pub expected_projection_sha256: String, + #[cultcache(key = 3)] + pub plan_id: String, + #[cultcache(key = 4)] + pub incarnation_id: String, + #[cultcache(key = 5)] + pub sealed_release_id: String, + #[cultcache(key = 6)] + pub activation_witness_sha256: String, + #[cultcache(key = 7)] + pub state_schema_generation: String, + #[cultcache(key = 8)] + pub state_contract_sha256: String, + #[cultcache(key = 9)] + pub runtime_id: String, + #[cultcache(key = 10)] + pub runtime_instance_id: String, + #[cultcache(key = 11)] + pub warming_presence_sha256: String, + #[cultcache(key = 12)] + pub lease_epoch: u64, + #[cultcache(key = 13)] + pub issued_at_unix_millis: u64, +} + +impl IdunnProcessWriteLeaseRecord { + pub fn decode_canonical(payload: &[u8]) -> Result { + if messagepack_array_len(payload) != Some(14) { + bail!("process write lease is not the 14-field positional contract"); + } + let lease: Self = rmp_serde::from_slice(payload).context("decoding process write lease")?; + if rmp_serde::to_vec(&lease)? != payload { + bail!("process write lease is not canonical positional MessagePack"); + } + lease.validate()?; + Ok(lease) + } + + pub fn canonical_bytes(&self) -> Result> { + self.validate()?; + Ok(rmp_serde::to_vec(self)?) + } + + pub fn canonical_sha256(&self) -> Result { + Ok(prefixed_sha256(&self.canonical_bytes()?)) + } + + pub fn validate(&self) -> Result<()> { + if self.schema_version != IDUNN_PROCESS_WRITE_LEASE_SCHEMA { + bail!("process write lease schema is unsupported"); + } + validate_authority_identifier(&self.target, "target")?; + validate_required_sha256( + &self.expected_projection_sha256, + "lease expected projection sha256", + )?; + validate_required_sha256(&self.plan_id, "plan id")?; + validate_authority_identifier(&self.incarnation_id, "incarnation id")?; + validate_required_sha256(&self.sealed_release_id, "sealed release id")?; + validate_required_sha256(&self.activation_witness_sha256, "activation witness sha256")?; + validate_authority_identifier(&self.state_schema_generation, "state schema generation")?; + validate_required_sha256(&self.state_contract_sha256, "state contract sha256")?; + validate_authority_identifier(&self.runtime_id, "runtime id")?; + validate_required_sha256(&self.runtime_instance_id, "runtime instance id")?; + validate_required_sha256(&self.warming_presence_sha256, "warming presence sha256")?; + if self.lease_epoch == 0 || self.issued_at_unix_millis == 0 { + bail!("process write lease epoch or issue time is invalid"); + } + Ok(()) + } +} + +fn messagepack_array_len(payload: &[u8]) -> Option { + match *payload.first()? { + marker @ 0x90..=0x9f => Some(usize::from(marker & 0x0f)), + 0xdc => Some(usize::from(u16::from_be_bytes([ + *payload.get(1)?, + *payload.get(2)?, + ]))), + 0xdd => usize::try_from(u32::from_be_bytes([ + *payload.get(1)?, + *payload.get(2)?, + *payload.get(3)?, + *payload.get(4)?, + ])) + .ok(), + _ => None, + } +} + +fn validate_authority_identifier(value: &str, label: &str) -> Result<()> { + if value.is_empty() + || value.len() > 256 + || value.trim() != value + || value.chars().any(char::is_control) + { + bail!("{label} is empty, oversized, padded, or contains control characters"); + } + Ok(()) +} + +fn validate_optional_identifier(value: &Option, label: &str) -> Result<()> { + if let Some(value) = value { + validate_authority_identifier(value, label)?; + } + Ok(()) +} + +fn validate_source_repository(value: &str) -> Result<()> { + validate_authority_identifier(value, "source repository")?; + let segments: Vec<&str> = value.split('/').collect(); + if segments.len() < 3 + || !segments[0].contains('.') + || segments.iter().any(|segment| { + segment.is_empty() + || matches!(*segment, "." | "..") + || !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + }) + { + bail!("source repository is not a canonical host/owner/repository identity"); + } + Ok(()) +} + +fn validate_endpoint(value: &str, label: &str) -> Result<()> { + if value.is_empty() + || value.len() > 2048 + || value.trim() != value + || value.chars().any(char::is_control) + { + bail!("{label} is empty, oversized, padded, or contains control characters"); + } + Ok(()) +} + +fn validate_optional_endpoint_labeled(value: &Option, label: &str) -> Result<()> { + if let Some(endpoint) = value { + validate_endpoint(endpoint, label)?; + } + Ok(()) +} + +fn validate_optional_endpoint(value: &Option) -> Result<()> { + validate_optional_endpoint_labeled(value, "bound endpoint") +} + +fn validate_runtime_capabilities(capabilities: &[GameCultRuntimeCapability]) -> Result<()> { + if capabilities.len() > MAX_RUNTIME_CAPABILITIES { + bail!("runtime presence capability count exceeds its contract bound"); + } + let mut previous: Option<(&str, &str, &str)> = None; + for capability in capabilities { + validate_authority_identifier(&capability.capability, "capability")?; + validate_authority_identifier(&capability.schema, "capability schema")?; + validate_authority_identifier(&capability.compatibility, "capability compatibility")?; + if capability.capacity == 0 { + bail!("runtime capability capacity is zero"); + } + let identity = ( + capability.capability.as_str(), + capability.schema.as_str(), + capability.compatibility.as_str(), + ); + if previous.is_some_and(|previous| previous >= identity) { + bail!("runtime capabilities are not strictly sorted and unique"); + } + previous = Some(identity); + } + Ok(()) +} + +fn validate_expected_capabilities(capabilities: &[IdunnExpectedCapability]) -> Result<()> { + if capabilities.len() > MAX_RUNTIME_CAPABILITIES { + bail!("Expected capability count exceeds its contract bound"); + } + let mut previous: Option<(&str, &str, &str)> = None; + for capability in capabilities { + validate_authority_identifier(&capability.capability, "Expected capability")?; + validate_authority_identifier(&capability.schema, "Expected capability schema")?; + validate_authority_identifier( + &capability.compatibility, + "Expected capability compatibility", + )?; + if capability.minimum_capacity == 0 { + bail!("Expected capability minimum capacity is zero"); + } + let identity = capability.identity(); + if previous.is_some_and(|previous| previous >= identity) { + bail!("Expected capabilities are not strictly sorted and unique"); + } + previous = Some(identity); + } + Ok(()) +} + +fn validate_expected_dependencies(dependencies: &[IdunnExpectedDependency]) -> Result<()> { + if dependencies.len() > MAX_RUNTIME_DEPENDENCIES { + bail!("Expected dependency count exceeds its contract bound"); + } + let mut previous: Option<(&str, &str, &str)> = None; + for dependency in dependencies { + dependency.validate()?; + let identity = dependency.identity(); + if previous.is_some_and(|previous| previous >= identity) { + bail!("Expected dependencies are not strictly sorted and unique"); + } + previous = Some(identity); + } + Ok(()) +} + +fn validate_dependency_evidence(dependencies: &[OdinRuntimeDependencyEvidence]) -> Result<()> { + if dependencies.len() > MAX_RUNTIME_DEPENDENCIES { + bail!("dependency evidence count exceeds its contract bound"); + } + let mut previous: Option<(&str, &str, &str)> = None; + for dependency in dependencies { + dependency.validate()?; + let identity = dependency.identity(); + if previous.is_some_and(|previous| previous >= identity) { + bail!("dependency evidence is not strictly sorted and unique"); + } + previous = Some(identity); + } + Ok(()) +} + +fn validate_topology_disagreements(disagreements: &[OdinTopologyDisagreement]) -> Result<()> { + if disagreements.len() > MAX_TOPOLOGY_DISAGREEMENTS { + bail!("topology disagreement count exceeds its contract bound"); + } + let mut previous: Option<&str> = None; + for disagreement in disagreements { + disagreement.validate()?; + if previous.is_some_and(|previous| previous >= disagreement.code.as_str()) { + bail!("topology disagreements are not strictly sorted and unique by code"); + } + previous = Some(&disagreement.code); + } + Ok(()) +} + +fn validate_optional_detail(value: &Option, label: &str) -> Result<()> { + if value.as_deref().is_some_and(|value| { + value.is_empty() + || value.len() > 1024 + || value.trim() != value + || value.chars().any(char::is_control) + }) { + bail!("{label} is empty, oversized, padded, or contains control characters"); + } + Ok(()) +} + +fn validate_required_sha256(value: &str, label: &str) -> Result<()> { + if !is_sha256(value) { + bail!("{label} is malformed"); + } + Ok(()) +} + +fn validate_optional_sha256(value: &Option, label: &str) -> Result<()> { + if value.as_deref().is_some_and(|value| !is_sha256(value)) { + bail!("{label} is malformed"); + } + Ok(()) +} + +fn prefixed_sha256(bytes: &[u8]) -> String { + format!("sha256-{:x}", Sha256::digest(bytes)) +} + +fn is_sha256(value: &str) -> bool { + value + .strip_prefix("sha256-") + .is_some_and(|digest| is_lower_hex(digest, 64)) +} + +fn is_lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA, GameCultProviderHealthIdentity, + GameCultRuntimePresenceHealthPurpose, GameCultServiceTrustAnchorRecord, + IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA, IdunnServiceIdentity, + OdinRuntimeTopologyCorrelationPurpose, OdinTopologyIdentity, ServiceIdentitySignature, + ServiceIdentitySigner, ServiceSignaturePurpose, derive_service_identity_id, + enroll_service_identity_at, verify_service_identity_signature, + }; + + struct WrongOdinTopologyPurpose; + + impl ServiceSignaturePurpose for WrongOdinTopologyPurpose { + const PURPOSE: &'static [u8] = b"odin.not-runtime-topology-correlation.v1"; + } + + fn digest(byte: char) -> String { + format!("sha256-{}", byte.to_string().repeat(64)) + } + + fn runtime_capability(name: &str) -> GameCultRuntimeCapability { + GameCultRuntimeCapability { + capability: name.into(), + schema: format!("{name}.v1"), + compatibility: "v1".into(), + capacity: 1, + } + } + + fn expected_capability(name: &str) -> IdunnExpectedCapability { + IdunnExpectedCapability { + capability: name.into(), + schema: format!("{name}.v1"), + compatibility: "v1".into(), + minimum_capacity: 1, + } + } + + fn runtime_presence() -> GameCultRuntimePresenceHealthRecord { + GameCultRuntimePresenceHealthRecord { + schema_version: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), + target: "ghostlight".into(), + expected_projection_sha256: digest('9'), + plan_id: digest('e'), + incarnation_id: "ghostlight/2026-09-02/1".into(), + sealed_release_id: digest('f'), + activation_witness_sha256: digest('a'), + state_schema_generation: Some("world-v2".into()), + state_contract_sha256: Some(digest('b')), + runtime_id: "ghostlight-yggdrasil-1".into(), + runtime_instance_id: digest('8'), + bound_endpoint: Some("http://127.0.0.1:14103".into()), + capabilities: vec![ + runtime_capability("conversation"), + runtime_capability("state"), + ], + health_contract: "ghostlight.runtime-health.v1".into(), + state: "active".into(), + detail: "ready".into(), + write_lease_sha256: Some(digest('c')), + signer_identity_id: "provider-signing-key".into(), + publisher_sequence: 1, + observed_at_unix_millis: 100, + signature_algorithm: "ed25519".into(), + signature: vec![6; 64], + activation_signer_identity_id: "activation-signing-key".into(), + activation_signature: vec![7; 64], + } + } + + fn runtime_activation() -> IdunnRuntimeActivationRecord { + let signer = IdunnRuntimeActivationSigner::from_credential_reader(&[8; 32][..]).unwrap(); + IdunnRuntimeActivationRecord { + schema_version: IDUNN_RUNTIME_ACTIVATION_SCHEMA.into(), + expected_projection_sha256: digest('9'), + runtime_id: "ghostlight-yggdrasil-1".into(), + runtime_instance_id: digest('8'), + activation_signer_identity_id: signer.identity_id(), + activation_signer_public_key: signer.public_key(), + issued_at_unix_millis: 99, + idunn_signer_identity_id: "idunn-signing-key".into(), + signature_algorithm: "ed25519".into(), + signature: vec![5; 64], + } + } + + fn process_write_lease() -> IdunnProcessWriteLeaseRecord { + IdunnProcessWriteLeaseRecord { + schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), + target: "ghostlight".into(), + expected_projection_sha256: digest('9'), + plan_id: digest('e'), + incarnation_id: "ghostlight/2026-09-02/1".into(), + sealed_release_id: digest('f'), + activation_witness_sha256: digest('a'), + state_schema_generation: "world-v2".into(), + state_contract_sha256: digest('b'), + runtime_id: "ghostlight-yggdrasil-1".into(), + runtime_instance_id: digest('8'), + warming_presence_sha256: digest('d'), + lease_epoch: 1, + issued_at_unix_millis: 101, + } + } + + fn expected_dependency( + kind: &str, + capability: &str, + provider_id: Option<&str>, + ) -> IdunnExpectedDependency { + IdunnExpectedDependency { + kind: kind.into(), + capability: capability.into(), + schema: format!("{capability}.v1"), + compatibility: "v1".into(), + minimum_capacity: 1, + startup: if kind == "bootstrap" { + "before-start".into() + } else { + "before-promotion".into() + }, + provider_id: provider_id.map(str::to_string), + provider_authority: provider_id.map(|_| "managed-incarnation".into()), + provider_expected_projection_sha256: provider_id.map(|_| digest('1')), + provider_endpoint: provider_id.map(|_| "tcp://127.0.0.1:14100".into()), + } + } + + fn expected_incarnation() -> IdunnExpectedIncarnationRecord { + IdunnExpectedIncarnationRecord { + schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), + target: "ghostlight".into(), + plan_id: digest('2'), + incarnation_id: "ghostlight/2026-09-02/1".into(), + sealed_release_id: digest('3'), + source_repository: "github.com/GameCult/Ghostlight".into(), + source_revision: "4".repeat(40), + recipe_sha256: digest('5'), + runtime_id: "ghostlight-yggdrasil-1".into(), + expected_signer_identity_id: "ghostlight-provider-signing-key".into(), + health_contract: "ghostlight.runtime-health.v1".into(), + artifact_sha256: digest('6'), + state_schema_generation: Some("world-v2".into()), + state_contract_sha256: Some(digest('7')), + write_lease_required: true, + route: Some(IdunnExpectedRoute { + route_id: "ghostlight-public".into(), + transport: "http".into(), + stable_endpoint: "https://ghostlight.gamecult.net".into(), + candidate_endpoint: "http://127.0.0.1:14103".into(), + }), + capabilities: vec![ + expected_capability("conversation"), + expected_capability("state"), + ], + dependencies: vec![ + expected_dependency("shared-infrastructure", "rendezvous", Some("odin-ygg-1")), + expected_dependency("optional", "telemetry", None), + ], + } + } + + fn dependency_evidence(requirement: &IdunnExpectedDependency) -> OdinRuntimeDependencyEvidence { + let selected = requirement.provider_id.is_some(); + OdinRuntimeDependencyEvidence { + kind: requirement.kind.clone(), + capability: requirement.capability.clone(), + schema: requirement.schema.clone(), + compatibility: requirement.compatibility.clone(), + provider_id: requirement.provider_id.clone(), + provider_authority: requirement.provider_authority.clone(), + provider_expected_projection_sha256: requirement + .provider_expected_projection_sha256 + .clone(), + provider_endpoint: requirement.provider_endpoint.clone(), + observed_capacity: selected.then_some(1), + provider_evidence_sha256: selected.then(|| digest('8')), + ready: selected, + } + } + + fn topology_correlation( + expected: &IdunnExpectedIncarnationRecord, + ) -> OdinRuntimeTopologyCorrelationRecord { + OdinRuntimeTopologyCorrelationRecord { + schema_version: ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA.into(), + target: expected.target.clone(), + expected_projection_sha256: expected.canonical_sha256().unwrap(), + expected: true, + current_activation_sha256: Some(digest('9')), + signed_presence_sha256: Some(digest('a')), + observed_presence_state: Some("active".into()), + observed_presence_publisher_sequence: Some(1), + observed_write_lease_sha256: Some(digest('c')), + observed_capabilities: expected + .capabilities + .iter() + .map(|capability| GameCultRuntimeCapability { + capability: capability.capability.clone(), + schema: capability.schema.clone(), + compatibility: capability.compatibility.clone(), + capacity: capability.minimum_capacity, + }) + .collect(), + runtime_id: expected.runtime_id.clone(), + runtime_instance_id: Some(digest('b')), + present: true, + ready: true, + dependencies: expected + .dependencies + .iter() + .map(dependency_evidence) + .collect(), + disagreements: vec![], + signer_identity_id: "odin-topology-signing-key".into(), + publisher_sequence: 1, + observed_at_unix_millis: 102, + signature_algorithm: "ed25519".into(), + signature: vec![7; 64], + } + } + + fn validate_against_current_lease( + receipt: &OdinRuntimeTopologyCorrelationRecord, + expected: &IdunnExpectedIncarnationRecord, + ) -> Result<()> { + let current_lease = digest('c'); + receipt.validate_against_expected(expected, Some(¤t_lease)) + } + + fn topology_anchor() -> GameCultServiceTrustAnchorRecord { + let public_key = vec![9; 32]; + GameCultServiceTrustAnchorRecord { + schema_version: GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into(), + trust_anchor_id: "root/odin/runtime-topology".into(), + service_id: "odin".into(), + runtime_id: "odin-yggdrasil-1".into(), + signer_identity_id: derive_service_identity_id::(&public_key) + .unwrap(), + signer_public_key: public_key, + signature_algorithm: "ed25519".into(), + signing_purpose: ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE.into(), + signed_schema: ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA.into(), + binding_authority: "root".into(), + bound_at_unix_millis: 100, + expires_at_unix_millis: Some(200), + private_state_exposed: false, + } + } + + fn runtime_presence_anchor() -> GameCultServiceTrustAnchorRecord { + let public_key = vec![7; 32]; + GameCultServiceTrustAnchorRecord { + schema_version: GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into(), + trust_anchor_id: "root/ghostlight/runtime-presence".into(), + service_id: "ghostlight".into(), + runtime_id: "ghostlight-yggdrasil-1".into(), + signer_identity_id: derive_service_identity_id::( + &public_key, + ) + .unwrap(), + signer_public_key: public_key, + signature_algorithm: "ed25519".into(), + signing_purpose: GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE.into(), + signed_schema: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), + binding_authority: "root".into(), + bound_at_unix_millis: 100, + expires_at_unix_millis: Some(200), + private_state_exposed: false, + } + } + + struct DualProofFixture { + stable_signer: ServiceIdentitySigner, + activation_signer: IdunnRuntimeActivationSigner, + odin_signer: ServiceIdentitySigner, + idunn_anchor: crate::ServiceIdentityTrustAnchor, + stable_public_key: Vec, + odin_anchor: GameCultServiceTrustAnchorRecord, + expected: IdunnExpectedIncarnationRecord, + activation: IdunnRuntimeActivationRecord, + presence: GameCultRuntimePresenceHealthRecord, + } + + fn dual_proof_fixture(identity_path: &std::path::Path) -> Result { + let stable_signer = + enroll_service_identity_at::(identity_path)?; + let stable_public = stable_signer.trust_anchor()?; + let idunn_signer = enroll_service_identity_at::( + &identity_path.with_extension("idunn.cc"), + )?; + let idunn_anchor = idunn_signer.trust_anchor()?; + let odin_signer = enroll_service_identity_at::( + &identity_path.with_extension("odin.cc"), + )?; + let odin_public = odin_signer.trust_anchor()?; + let mut expected = expected_incarnation(); + expected.expected_signer_identity_id = stable_public.identity_id.clone(); + expected.capabilities[0].minimum_capacity = 2; + let launch = + IdunnRuntimeActivationLaunch::issue(&expected, digest('8'), 99, &idunn_signer)?; + let activation = launch.activation().clone(); + let mut activation_credential = Vec::new(); + assert_eq!( + launch.write_credential(&mut activation_credential)?, + activation + ); + let activation_signer = + IdunnRuntimeActivationSigner::from_credential_reader(&activation_credential[..])?; + + let stable_public_key = stable_public.public_key.clone(); + let odin_anchor = GameCultServiceTrustAnchorRecord { + schema_version: GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into(), + trust_anchor_id: "root/odin/runtime-topology".into(), + service_id: "odin".into(), + runtime_id: "odin-yggdrasil-1".into(), + signer_identity_id: odin_public.identity_id, + signer_public_key: odin_public.public_key, + signature_algorithm: "ed25519".into(), + signing_purpose: ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE.into(), + signed_schema: ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA.into(), + binding_authority: "root".into(), + bound_at_unix_millis: 1, + expires_at_unix_millis: Some(1_000), + private_state_exposed: false, + }; + + let mut presence = runtime_presence(); + presence.target = expected.target.clone(); + presence.plan_id = expected.plan_id.clone(); + presence.incarnation_id = expected.incarnation_id.clone(); + presence.sealed_release_id = expected.sealed_release_id.clone(); + presence.state_schema_generation = expected.state_schema_generation.clone(); + presence.state_contract_sha256 = expected.state_contract_sha256.clone(); + presence.bound_endpoint = expected + .route + .as_ref() + .map(|route| route.candidate_endpoint.clone()); + presence.capabilities = expected + .capabilities + .iter() + .map(|capability| GameCultRuntimeCapability { + capability: capability.capability.clone(), + schema: capability.schema.clone(), + compatibility: capability.compatibility.clone(), + capacity: capability.minimum_capacity, + }) + .collect(); + presence.health_contract = expected.health_contract.clone(); + presence.runtime_id = activation.runtime_id.clone(); + presence.runtime_instance_id = activation.runtime_instance_id.clone(); + presence.expected_projection_sha256 = activation.expected_projection_sha256.clone(); + presence.activation_witness_sha256 = activation.canonical_sha256()?; + presence.signer_identity_id = stable_public.identity_id; + presence.activation_signer_identity_id = activation_signer.identity_id(); + presence.observed_at_unix_millis = 110; + presence.signature.clear(); + presence.activation_signature.clear(); + let proof_payload = presence.canonical_proof_payload()?; + presence.signature = stable_signer + .sign::(&proof_payload) + .signature; + presence.activation_signature = activation_signer.sign_presence_proof(&presence)?; + + Ok(DualProofFixture { + stable_signer, + activation_signer, + odin_signer, + idunn_anchor, + stable_public_key, + odin_anchor, + expected, + activation, + presence, + }) + } + + fn resign_presence(fixture: &mut DualProofFixture) -> Result> { + fixture.presence.signature.clear(); + fixture.presence.activation_signature.clear(); + let proof_payload = fixture.presence.canonical_proof_payload()?; + fixture.presence.signature = fixture + .stable_signer + .sign::(&proof_payload) + .signature; + fixture.presence.activation_signature = fixture + .activation_signer + .sign_presence_proof(&fixture.presence)?; + Ok(rmp_serde::to_vec(&fixture.presence)?) + } + + fn fixture_authority(fixture: &DualProofFixture) -> Result { + verify_runtime_authority( + &fixture.expected, + &fixture.activation, + &fixture.idunn_anchor, + &fixture.stable_public_key, + ) + } + + fn authenticate_fixture( + fixture: &DualProofFixture, + ) -> Result { + authenticate_runtime_presence_claim( + &rmp_serde::to_vec(&fixture.presence)?, + &fixture_authority(fixture)?, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: 120, + maximum_age_millis: 30, + maximum_future_skew_millis: 5, + }, + ) + } + + fn correlate_fixture(fixture: &DualProofFixture) -> Result { + correlate_runtime_presence_claim( + authenticate_fixture(fixture)?, + &fixture_authority(fixture)?, + ) + } + + fn verify_fixture(fixture: &DualProofFixture) -> Result { + correlate_fixture(fixture)?.into_undisputed_present() + } + + fn authenticated_topology_receipt( + fixture: &DualProofFixture, + presence: &VerifiedRuntimePresence, + present: bool, + disagreements: Vec, + ) -> Result { + let authority = fixture_authority(fixture)?; + let mut receipt = topology_correlation(&fixture.expected); + receipt.current_activation_sha256 = Some(authority.activation_sha256().into()); + receipt.signed_presence_sha256 = Some(presence.signed_presence_sha256().into()); + receipt.observed_presence_state = Some(presence.record().state.clone()); + receipt.observed_presence_publisher_sequence = Some(presence.record().publisher_sequence); + receipt.observed_write_lease_sha256 = presence.record().write_lease_sha256.clone(); + receipt.observed_capabilities = presence.record().capabilities.clone(); + receipt.runtime_instance_id = Some(fixture.activation.runtime_instance_id.clone()); + receipt.present = present; + receipt.ready = present && presence.record().state == "active"; + receipt.disagreements = disagreements; + receipt.signer_identity_id = fixture.odin_anchor.signer_identity_id.clone(); + receipt.signature.clear(); + receipt.signature = fixture + .odin_signer + .sign::(&receipt.unsigned_signature_payload()?) + .signature; + let encoded = receipt.canonical_bytes()?; + authenticate_odin_runtime_topology_correlation( + &encoded, + &authority, + presence.record().write_lease_sha256.as_deref(), + &fixture.odin_anchor.signer_public_key, + OdinTopologyAuthenticationContext { + trusted_received_at_unix_millis: 120, + maximum_age_millis: 30, + maximum_future_skew_millis: 5, + }, + ) + } + + #[test] + fn runtime_authority_contracts_round_trip_as_canonical_positional_records() -> Result<()> { + let presence = runtime_presence(); + presence.validate()?; + let encoded_presence = rmp_serde::to_vec(&presence)?; + assert_eq!(messagepack_array_len(&encoded_presence), Some(24)); + let decoded_presence: GameCultRuntimePresenceHealthRecord = + rmp_serde::from_slice(&encoded_presence)?; + assert_eq!(decoded_presence, presence); + let unsigned = presence.canonical_proof_payload()?; + let unsigned_presence: GameCultRuntimePresenceHealthRecord = + rmp_serde::from_slice(&unsigned)?; + assert!(unsigned_presence.signature.is_empty()); + assert!(unsigned_presence.activation_signature.is_empty()); + + let activation = runtime_activation(); + let encoded_activation = activation.canonical_bytes()?; + assert_eq!(messagepack_array_len(&encoded_activation), Some(10)); + assert_eq!( + IdunnRuntimeActivationRecord::decode_canonical(&encoded_activation)?, + activation + ); + assert_eq!( + activation.canonical_sha256()?, + prefixed_sha256(&encoded_activation) + ); + + let lease = process_write_lease(); + lease.validate()?; + let encoded_lease = lease.canonical_bytes()?; + assert_eq!(messagepack_array_len(&encoded_lease), Some(14)); + assert_eq!( + IdunnProcessWriteLeaseRecord::decode_canonical(&encoded_lease)?, + lease + ); + assert_eq!(lease.canonical_sha256()?, prefixed_sha256(&encoded_lease)); + Ok(()) + } + + #[test] + fn exact_stable_and_activation_proofs_admit_runtime_presence() -> Result<()> { + let temp = tempfile::tempdir()?; + let fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; + assert_eq!(fixture.activation.activation_signer_public_key.len(), 32); + let verified = verify_fixture(&fixture)?; + assert_eq!(verified.record(), &fixture.presence); + Ok(()) + } + + #[test] + fn authenticated_expected_disagreements_remain_typed_present_and_not_ready() -> Result<()> { + type Mutation = fn(&mut GameCultRuntimePresenceHealthRecord); + let cases: &[(&str, &str, Mutation)] = &[ + ("target", "target", |value| value.target = "epiphany".into()), + ("projection", "expected-projection", |value| { + value.expected_projection_sha256 = digest('0') + }), + ("plan", "plan-id", |value| value.plan_id = digest('0')), + ("incarnation", "incarnation-id", |value| { + value.incarnation_id = "ghostlight/other/1".into() + }), + ("release", "sealed-release-id", |value| { + value.sealed_release_id = digest('0') + }), + ("runtime", "runtime-id", |value| { + value.runtime_id = "ghostlight-other-runtime".into() + }), + ("process", "runtime-instance-id", |value| { + value.runtime_instance_id = digest('0') + }), + ("endpoint", "bound-endpoint", |value| { + value.bound_endpoint = Some("http://127.0.0.1:14104".into()) + }), + ("health", "health-contract", |value| { + value.health_contract = "ghostlight.other-health.v1".into() + }), + ("state-generation", "state-schema-generation", |value| { + value.state_schema_generation = Some("world-v3".into()) + }), + ("state-contract", "state-contract", |value| { + value.state_contract_sha256 = Some(digest('0')) + }), + ("activation", "activation-witness", |value| { + value.activation_witness_sha256 = digest('0') + }), + ]; + + let temp = tempfile::tempdir()?; + for (name, expected_code, mutate) in cases { + let mut fixture = dual_proof_fixture(&temp.path().join(format!("{name}.cc")))?; + mutate(&mut fixture.presence); + resign_presence(&mut fixture)?; + let correlation = correlate_fixture(&fixture)?; + assert!( + correlation + .disagreements() + .iter() + .any(|disagreement| disagreement.code == *expected_code), + "missing {expected_code} disagreement for {name}" + ); + assert_eq!( + correlation.claim().record().runtime_instance_id, + fixture.presence.runtime_instance_id + ); + assert!(correlation.into_undisputed_present().is_err()); + } + Ok(()) + } + + #[test] + fn provider_owns_observed_capacity_while_expected_owns_only_the_minimum() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; + fixture.presence.capabilities[0].capacity = 50; + fixture + .presence + .capabilities + .push(runtime_capability("telemetry")); + resign_presence(&mut fixture)?; + verify_fixture(&fixture)?; + + fixture.presence.capabilities[0].capacity = 1; + fixture + .presence + .capabilities + .retain(|capability| capability.capability != "state"); + resign_presence(&mut fixture)?; + let correlation = correlate_fixture(&fixture)?; + let codes = correlation + .disagreements() + .iter() + .map(|disagreement| disagreement.code.as_str()) + .collect::>(); + assert!(codes.contains(&"expected-capability-000-capacity")); + assert!(codes.contains(&"expected-capability-001-missing")); + assert!(!codes.iter().any(|code| code.contains("unexpected"))); + Ok(()) + } + + #[test] + fn stable_proof_and_provider_key_substitution_fail_closed() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; + fixture.presence.detail = "tampered after signing".into(); + assert!(authenticate_fixture(&fixture).is_err()); + + let other_idunn = enroll_service_identity_at::( + &temp.path().join("other-idunn.cc"), + )?; + assert!( + verify_runtime_authority( + &fixture.expected, + &fixture.activation, + &other_idunn.trust_anchor()?, + &fixture.stable_public_key, + ) + .is_err() + ); + + fixture.stable_public_key = vec![9; 32]; + assert!(fixture_authority(&fixture).is_err()); + Ok(()) + } + + #[test] + fn incumbent_stable_key_without_activation_key_cannot_claim_presence() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; + fixture.presence.activation_signature = vec![0; 64]; + let proof_payload = fixture.presence.canonical_proof_payload()?; + fixture.presence.signature = fixture + .stable_signer + .sign::(&proof_payload) + .signature; + + let error = verify_fixture(&fixture).unwrap_err(); + assert!(error.to_string().contains("activation proof verification")); + Ok(()) + } + + #[test] + fn activation_cannot_substitute_another_expected_runtime_id() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; + fixture.activation.runtime_id = "ghostlight-yggdrasil-2".into(); + fixture.presence.runtime_id = fixture.activation.runtime_id.clone(); + fixture.presence.activation_witness_sha256 = fixture.activation.canonical_sha256()?; + + // The stable service key can re-sign the altered canonical statement, + // but the activation proof still came from the other runtime id. + let proof_payload = fixture.presence.canonical_proof_payload()?; + fixture.presence.signature = fixture + .stable_signer + .sign::(&proof_payload) + .signature; + let error = fixture_authority(&fixture).unwrap_err(); + assert!(error.to_string().contains("current Expected")); + Ok(()) + } + + #[test] + fn idunn_signature_prevents_provider_minted_activation() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; + fixture.activation.issued_at_unix_millis += 1; + fixture.presence.activation_witness_sha256 = fixture.activation.canonical_sha256()?; + resign_presence(&mut fixture)?; + let error = fixture_authority(&fixture).unwrap_err(); + assert!( + error + .to_string() + .contains("Idunn runtime activation signature") + ); + Ok(()) + } + + #[test] + fn authenticated_odin_receipt_is_fresh_evidence_not_replay_authority() -> Result<()> { + let temp = tempfile::tempdir()?; + let fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; + let authority = fixture_authority(&fixture)?; + let presence = verify_fixture(&fixture)?; + let accepted = authenticated_topology_receipt(&fixture, &presence, true, Vec::new())?; + assert_eq!( + accepted.record().observed_presence_publisher_sequence, + Some(fixture.presence.publisher_sequence) + ); + assert_eq!( + accepted.record().signed_presence_sha256.as_deref(), + Some(presence.signed_presence_sha256()) + ); + + let mut tampered = accepted.record().clone(); + tampered.signature = vec![0; 64]; + assert!( + authenticate_odin_runtime_topology_correlation( + &tampered.canonical_bytes()?, + &authority, + presence.record().write_lease_sha256.as_deref(), + &fixture.odin_anchor.signer_public_key, + OdinTopologyAuthenticationContext { + trusted_received_at_unix_millis: 120, + maximum_age_millis: 30, + maximum_future_skew_millis: 5, + }, + ) + .is_err() + ); + assert!( + authenticate_odin_runtime_topology_correlation( + accepted.canonical_bytes(), + &authority, + presence.record().write_lease_sha256.as_deref(), + &fixture.odin_anchor.signer_public_key, + OdinTopologyAuthenticationContext { + trusted_received_at_unix_millis: 200, + maximum_age_millis: 30, + maximum_future_skew_millis: 5, + }, + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn separate_launches_receive_distinct_activation_identities() -> Result<()> { + let temp = tempfile::tempdir()?; + let idunn = + enroll_service_identity_at::(&temp.path().join("idunn.cc"))?; + let expected = expected_incarnation(); + let first = IdunnRuntimeActivationLaunch::issue(&expected, digest('8'), 99, &idunn)?; + let second = IdunnRuntimeActivationLaunch::issue(&expected, digest('9'), 100, &idunn)?; + assert_ne!( + first.activation().activation_signer_identity_id, + second.activation().activation_signer_identity_id + ); + Ok(()) + } + + #[test] + fn trusted_time_rejects_stale_and_future_presence() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; + let authority = fixture_authority(&fixture)?; + let canonical = rmp_serde::to_vec(&fixture.presence)?; + assert!( + authenticate_runtime_presence_claim( + &canonical, + &authority, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: 200, + maximum_age_millis: 10, + maximum_future_skew_millis: 5, + }, + ) + .is_err() + ); + + fixture.presence.observed_at_unix_millis = 126; + resign_presence(&mut fixture)?; + assert!( + authenticate_runtime_presence_claim( + &rmp_serde::to_vec(&fixture.presence)?, + &authority, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: 120, + maximum_age_millis: 30, + maximum_future_skew_millis: 5, + }, + ) + .is_err() + ); + + Ok(()) + } + + #[test] + fn runtime_authority_decoders_reject_malformed_and_noncanonical_bytes() -> Result<()> { + let temp = tempfile::tempdir()?; + let fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; + assert!( + authenticate_runtime_presence_claim( + &[0x91, 0xc0], + &fixture_authority(&fixture)?, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: 120, + maximum_age_millis: 30, + maximum_future_skew_millis: 5, + }, + ) + .is_err() + ); + assert!(IdunnRuntimeActivationRecord::decode_canonical(&[0x91, 0xc0]).is_err()); + assert!(IdunnProcessWriteLeaseRecord::decode_canonical(&[0x91, 0xc0]).is_err()); + + let mut legacy_presence = fixture.presence.clone(); + legacy_presence.schema_version = "gamecult.runtime_presence_health.v1".into(); + assert!( + authenticate_runtime_presence_claim( + &rmp_serde::to_vec(&legacy_presence)?, + &fixture_authority(&fixture)?, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: 120, + maximum_age_millis: 30, + maximum_future_skew_millis: 5, + }, + ) + .is_err() + ); + let mut legacy_activation = fixture.activation.clone(); + legacy_activation.schema_version = "idunn.runtime_activation.v1".into(); + assert!( + IdunnRuntimeActivationRecord::decode_canonical(&rmp_serde::to_vec(&legacy_activation)?) + .is_err() + ); + let mut legacy_expected = fixture.expected.clone(); + legacy_expected.schema_version = "idunn.expected_incarnation.v1".into(); + assert!( + IdunnExpectedIncarnationRecord::decode_canonical(&rmp_serde::to_vec(&legacy_expected)?) + .is_err() + ); + let mut legacy_topology = topology_correlation(&fixture.expected); + legacy_topology.schema_version = "odin.runtime_topology_correlation.v1".into(); + assert!( + OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload( + &rmp_serde::to_vec(&legacy_topology)?, + ) + .is_err() + ); + + let presence = rmp_serde::to_vec(&runtime_presence())?; + assert_eq!(&presence[..3], &[0xdc, 0, 24]); + let mut noncanonical_presence = vec![0xdd, 0, 0, 0, 24]; + noncanonical_presence.extend_from_slice(&presence[3..]); + assert!( + authenticate_runtime_presence_claim( + &noncanonical_presence, + &fixture_authority(&fixture)?, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: 120, + maximum_age_millis: 30, + maximum_future_skew_millis: 5, + }, + ) + .is_err() + ); + + let activation = runtime_activation().canonical_bytes()?; + assert_eq!(activation[0], 0x9a); + let mut noncanonical_activation = vec![0xdc, 0, 10]; + noncanonical_activation.extend_from_slice(&activation[1..]); + assert!(IdunnRuntimeActivationRecord::decode_canonical(&noncanonical_activation).is_err()); + + let lease = process_write_lease().canonical_bytes()?; + assert_eq!(lease[0], 0x9e); + let mut noncanonical_lease = vec![0xdc, 0, 14]; + noncanonical_lease.extend_from_slice(&lease[1..]); + assert!(IdunnProcessWriteLeaseRecord::decode_canonical(&noncanonical_lease).is_err()); + Ok(()) + } + + #[test] + fn runtime_presence_refuses_capability_and_warming_lease_ambiguity() { + let mut value = runtime_presence(); + value.capabilities.reverse(); + assert!(value.validate().is_err()); + + let mut value = runtime_presence(); + let mut duplicate = runtime_capability("state"); + duplicate.capacity = 2; + value.capabilities.push(duplicate); + assert!(value.validate().is_err()); + + let mut value = runtime_presence(); + value.capabilities[0].capacity = 0; + assert!(value.validate().is_err()); + + let mut value = runtime_presence(); + value.state = "warming".into(); + assert!(value.validate().is_err()); + value.write_lease_sha256 = None; + assert!(value.validate().is_ok()); + + let mut value = runtime_presence(); + value.state_schema_generation = None; + assert!(value.validate().is_err()); + value.state_contract_sha256 = None; + assert!(value.validate().is_err()); + value.write_lease_sha256 = None; + assert!(value.validate().is_ok()); + } + + #[test] + fn runtime_authority_refuses_malformed_digest_process_and_epoch_bindings() { + let mut value = runtime_presence(); + value.activation_witness_sha256 = digest('A'); + assert!(value.validate().is_err()); + + let mut value = runtime_presence(); + value.plan_id = "plan-1".into(); + assert!(value.validate().is_err()); + + let mut value = runtime_presence(); + value.sealed_release_id = "release-1".into(); + assert!(value.validate().is_err()); + + let mut value = runtime_presence(); + value.runtime_instance_id = "systemd-invocation-1".into(); + assert!(value.validate().is_err()); + + let mut value = runtime_presence(); + value.expected_projection_sha256 = "expected-1".into(); + assert!(value.validate().is_err()); + + let mut value = runtime_presence(); + value.write_lease_sha256 = Some("lease-1".into()); + assert!(value.validate().is_err()); + + let mut value = process_write_lease(); + value.warming_presence_sha256 = digest('D'); + assert!(value.validate().is_err()); + + let mut value = process_write_lease(); + value.plan_id = "plan-1".into(); + assert!(value.validate().is_err()); + + let mut value = process_write_lease(); + value.runtime_instance_id = "pid-4242".into(); + assert!(value.validate().is_err()); + + let mut value = process_write_lease(); + value.lease_epoch = 0; + assert!(value.validate().is_err()); + + let mut value = runtime_activation(); + value.runtime_instance_id = digest('A'); + assert!(value.validate().is_err()); + + let mut value = runtime_activation(); + value.expected_projection_sha256 = "expected-1".into(); + assert!(value.validate().is_err()); + } + + #[test] + fn runtime_presence_trust_anchor_is_provider_profile_and_purpose_bound() -> Result<()> { + let anchor = runtime_presence_anchor(); + anchor.validate()?; + + let mut value = anchor.clone(); + value.signing_purpose = "idunn.signed_daemon_health.v1".into(); + assert!(value.validate().is_err()); + + let mut value = anchor.clone(); + value.signed_schema = IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA.into(); + assert!(value.validate().is_err()); + + let mut value = anchor.clone(); + value.signing_purpose = "gamecult.runtime_presence_health.v1".into(); + value.signed_schema = "gamecult.runtime_presence_health.v1".into(); + assert!(value.validate().is_err()); + + let mut value = anchor; + value.signer_identity_id = + derive_service_identity_id::(&value.signer_public_key)?; + assert!(value.validate().is_err()); + Ok(()) + } + + #[test] + fn process_write_lease_cannot_encode_route_or_traffic_authority() -> Result<()> { + let canonical = process_write_lease().canonical_bytes()?; + assert_eq!(messagepack_array_len(&canonical), Some(14)); + + for forbidden in ["route", "traffic"] { + let mut with_extra_authority = vec![0xdc, 0, 15]; + with_extra_authority.extend_from_slice(&canonical[1..]); + with_extra_authority.extend_from_slice(&rmp_serde::to_vec(&forbidden)?); + assert!(IdunnProcessWriteLeaseRecord::decode_canonical(&with_extra_authority).is_err()); + } + Ok(()) + } + + #[test] + fn expected_and_topology_correlation_round_trip_canonically() -> Result<()> { + let expected = expected_incarnation(); + let expected_bytes = expected.canonical_bytes()?; + assert_eq!(&expected_bytes[..3], &[0xdc, 0, 18]); + assert_eq!( + IdunnExpectedIncarnationRecord::decode_canonical(&expected_bytes)?, + expected + ); + assert_eq!( + expected.canonical_sha256()?, + prefixed_sha256(&expected_bytes) + ); + + let receipt = topology_correlation(&expected); + validate_against_current_lease(&receipt, &expected)?; + let receipt_bytes = receipt.canonical_bytes()?; + assert_eq!(&receipt_bytes[..3], &[0xdc, 0, 21]); + let (decoded, unsigned) = + OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload(&receipt_bytes)?; + assert_eq!(decoded, receipt); + let unsigned_receipt: OdinRuntimeTopologyCorrelationRecord = + rmp_serde::from_slice(&unsigned)?; + assert!(unsigned_receipt.signature.is_empty()); + assert_eq!(unsigned, receipt.unsigned_signature_payload()?); + assert_eq!(receipt.canonical_sha256()?, prefixed_sha256(&receipt_bytes)); + Ok(()) + } + + #[test] + fn expected_and_topology_decoders_reject_noncanonical_or_extended_records() -> Result<()> { + assert!(IdunnExpectedIncarnationRecord::decode_canonical(&[0x91, 0xc0]).is_err()); + assert!( + OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload(&[0x91, 0xc0]) + .is_err() + ); + + let expected = expected_incarnation().canonical_bytes()?; + let mut noncanonical_expected = vec![0xdd, 0, 0, 0, 18]; + noncanonical_expected.extend_from_slice(&expected[3..]); + assert!(IdunnExpectedIncarnationRecord::decode_canonical(&noncanonical_expected).is_err()); + + let receipt = topology_correlation(&expected_incarnation()).canonical_bytes()?; + let mut noncanonical_receipt = vec![0xdd, 0, 0, 0, 21]; + noncanonical_receipt.extend_from_slice(&receipt[3..]); + assert!( + OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload( + &noncanonical_receipt, + ) + .is_err() + ); + + for forbidden in ["private-host-path", "runner-secret"] { + let mut extended = vec![0xdc, 0, 19]; + extended.extend_from_slice(&expected[3..]); + extended.extend_from_slice(&rmp_serde::to_vec(&forbidden)?); + assert!(IdunnExpectedIncarnationRecord::decode_canonical(&extended).is_err()); + } + Ok(()) + } + + #[test] + fn expected_refuses_partial_state_and_noncanonical_claim_sets() { + let mut value = expected_incarnation(); + value.state_contract_sha256 = None; + assert!(value.validate().is_err()); + + let mut value = expected_incarnation(); + value.state_schema_generation = None; + assert!(value.validate().is_err()); + + let mut value = expected_incarnation(); + value.state_schema_generation = None; + value.state_contract_sha256 = None; + assert!(value.validate().is_err()); + value.write_lease_required = false; + assert!(value.validate().is_ok()); + + let mut value = expected_incarnation(); + value.capabilities.reverse(); + assert!(value.validate().is_err()); + + let mut value = expected_incarnation(); + value.capabilities.push(expected_capability("state")); + assert!(value.validate().is_err()); + + let mut value = expected_incarnation(); + value.dependencies.reverse(); + assert!(value.validate().is_err()); + + let mut value = expected_incarnation(); + value.dependencies.push(value.dependencies[1].clone()); + assert!(value.validate().is_err()); + + let mut value = expected_incarnation(); + let mut duplicate = value.dependencies[1].clone(); + duplicate.kind = "required".into(); + value.dependencies.push(duplicate); + assert!(value.validate().is_err()); + + let mut value = expected_incarnation(); + value.dependencies[0].provider_authority = None; + assert!(value.validate().is_err()); + + let mut value = expected_incarnation(); + value.source_revision = "A".repeat(40); + assert!(value.validate().is_err()); + + for private_source in [ + "F:\\Projects\\Ghostlight", + "/srv/build/Ghostlight", + "https://token@github.com/GameCult/Ghostlight", + ] { + let mut value = expected_incarnation(); + value.source_repository = private_source.into(); + assert!(value.validate().is_err()); + } + + let mut value = expected_incarnation(); + let stable_endpoint = value.route.as_ref().unwrap().stable_endpoint.clone(); + value.route.as_mut().unwrap().candidate_endpoint = stable_endpoint; + assert!(value.validate().is_err()); + } + + #[test] + fn expected_route_keeps_rudp_distinct_and_scheme_exact() { + let mut value = expected_incarnation(); + value.route = Some(IdunnExpectedRoute { + route_id: "odin-rendezvous".into(), + transport: "rudp".into(), + stable_endpoint: "rudp://10.77.0.1:17871".into(), + candidate_endpoint: "rudp://127.0.0.1:24171".into(), + }); + assert!(value.validate().is_ok()); + + value.route.as_mut().unwrap().candidate_endpoint = "tcp://127.0.0.1:24171".into(); + assert!(value.validate().is_err()); + + value.route.as_mut().unwrap().candidate_endpoint = "udp://127.0.0.1:24171".into(); + assert!(value.validate().is_err()); + } + + #[test] + fn topology_correlation_refuses_expected_substitution_and_dependency_omission() { + let expected = expected_incarnation(); + + let mut value = topology_correlation(&expected); + value.target = "epiphany".into(); + assert!(value.validate().is_ok()); + assert!(validate_against_current_lease(&value, &expected).is_err()); + + let mut value = topology_correlation(&expected); + value.runtime_id = "ghostlight-other-runtime".into(); + assert!(validate_against_current_lease(&value, &expected).is_err()); + + let mut value = topology_correlation(&expected); + value.expected_projection_sha256 = digest('c'); + assert!(validate_against_current_lease(&value, &expected).is_err()); + + let mut value = topology_correlation(&expected); + value.dependencies.pop(); + assert!(value.validate().is_ok()); + assert!(validate_against_current_lease(&value, &expected).is_err()); + + let mut value = topology_correlation(&expected); + value.dependencies[0].provider_id = Some("odin-substitute".into()); + assert!(value.validate().is_ok()); + assert!(validate_against_current_lease(&value, &expected).is_err()); + + let mut value = topology_correlation(&expected); + value.dependencies[0].kind = "required".into(); + assert!(value.validate().is_ok()); + assert!(validate_against_current_lease(&value, &expected).is_err()); + + let mut value = topology_correlation(&expected); + value.dependencies[0].observed_capacity = Some(0); + assert!(value.validate().is_err()); + + let mut value = topology_correlation(&expected); + value.dependencies[0].observed_capacity = Some(1); + let mut stricter = expected.clone(); + stricter.dependencies[0].minimum_capacity = 2; + value.expected_projection_sha256 = stricter.canonical_sha256().unwrap(); + assert!(validate_against_current_lease(&value, &stricter).is_err()); + } + + #[test] + fn topology_correlation_requires_explicit_coherent_partial_states() { + let expected = expected_incarnation(); + let mut value = topology_correlation(&expected); + value.signed_presence_sha256 = None; + value.observed_presence_state = None; + value.observed_presence_publisher_sequence = None; + value.observed_write_lease_sha256 = None; + value.observed_capabilities.clear(); + value.present = false; + value.ready = false; + assert!(value.validate().is_err()); + + value.disagreements.push(OdinTopologyDisagreement { + code: "presence-missing".into(), + expected: Some("signed-runtime-presence".into()), + observed: None, + }); + assert!(value.validate().is_ok()); + + let mut value = topology_correlation(&expected); + value.runtime_instance_id = None; + assert!(value.validate().is_err()); + + let mut value = topology_correlation(&expected); + value.current_activation_sha256 = None; + value.present = true; + value.ready = false; + value.disagreements.push(OdinTopologyDisagreement { + code: "activation-missing".into(), + expected: Some("current-activation".into()), + observed: None, + }); + assert!(value.validate().is_err()); + + let mut value = topology_correlation(&expected); + value.expected = false; + value.present = false; + value.ready = false; + assert!(value.validate().is_err()); + } + + #[test] + fn topology_correlation_refuses_unsorted_or_duplicate_claims() { + let expected = expected_incarnation(); + let mut value = topology_correlation(&expected); + value.dependencies.reverse(); + assert!(value.validate().is_err()); + + let mut value = topology_correlation(&expected); + value.dependencies.push(value.dependencies[1].clone()); + assert!(value.validate().is_err()); + + let mut value = topology_correlation(&expected); + value.ready = false; + value.disagreements = vec![ + OdinTopologyDisagreement { + code: "schema-mismatch".into(), + expected: Some("state.v2".into()), + observed: Some("state.v1".into()), + }, + OdinTopologyDisagreement { + code: "endpoint-mismatch".into(), + expected: Some("route-a".into()), + observed: Some("route-b".into()), + }, + ]; + assert!(value.validate().is_err()); + + value.disagreements[1].code = "schema-mismatch".into(); + assert!(value.validate().is_err()); + } + + #[test] + fn topology_correlation_preserves_warming_and_exact_write_lease_authority() { + let expected = expected_incarnation(); + let value = topology_correlation(&expected); + validate_against_current_lease(&value, &expected).unwrap(); + assert!( + value + .validate_against_expected(&expected, Some(&digest('d'))) + .is_err() + ); + assert!(value.validate_against_expected(&expected, None).is_err()); + + let mut digest_only_ready = topology_correlation(&expected); + digest_only_ready.observed_write_lease_sha256 = None; + digest_only_ready.validate().unwrap(); + assert!( + digest_only_ready + .validate_against_expected(&expected, Some(&digest('c'))) + .is_err() + ); + + let mut warming = topology_correlation(&expected); + warming.observed_presence_state = Some("warming".into()); + warming.ready = false; + assert!(warming.validate().is_err()); + warming.observed_write_lease_sha256 = None; + warming.validate().unwrap(); + warming.validate_against_expected(&expected, None).unwrap(); + warming.ready = true; + assert!(warming.validate().is_err()); + + let mut partial = topology_correlation(&expected); + partial.observed_presence_state = None; + assert!(partial.validate().is_err()); + let mut partial = topology_correlation(&expected); + partial.signed_presence_sha256 = None; + assert!(partial.validate().is_err()); + + let mut stateless = expected.clone(); + stateless.state_schema_generation = None; + stateless.state_contract_sha256 = None; + stateless.write_lease_required = false; + let mut stateless_receipt = topology_correlation(&stateless); + stateless_receipt.observed_write_lease_sha256 = None; + stateless_receipt + .validate_against_expected(&stateless, None) + .unwrap(); + stateless_receipt.observed_write_lease_sha256 = Some(digest('c')); + assert!( + stateless_receipt + .validate_against_expected(&stateless, Some(&digest('c'))) + .is_err() + ); + } + + #[test] + fn topology_correlation_cannot_manufacture_ready() { + let expected = expected_incarnation(); + let mut value = topology_correlation(&expected); + value.dependencies[0].ready = false; + assert!(value.validate().is_err()); + + let mut value = topology_correlation(&expected); + value.disagreements.push(OdinTopologyDisagreement { + code: "capability-mismatch".into(), + expected: Some("conversation.v1".into()), + observed: Some("conversation.v0".into()), + }); + assert!(value.validate().is_err()); + + let mut value = topology_correlation(&expected); + value.present = false; + value.disagreements.push(OdinTopologyDisagreement { + code: "identity-mismatch".into(), + expected: Some("expected-runtime".into()), + observed: Some("other-runtime".into()), + }); + assert!(value.validate().is_err()); + + let mut value = topology_correlation(&expected); + value.dependencies[0].provider_evidence_sha256 = None; + assert!(value.validate().is_err()); + + let mut missing_capability = topology_correlation(&expected); + missing_capability.observed_capabilities.pop(); + assert!(missing_capability.validate().is_ok()); + assert!( + missing_capability + .validate_against_expected(&expected, Some(&digest('c'))) + .is_err() + ); + missing_capability.present = true; + missing_capability.ready = false; + missing_capability.disagreements = vec![OdinTopologyDisagreement { + code: "generic-mismatch".into(), + expected: Some("expected".into()), + observed: Some("observed".into()), + }]; + assert!( + missing_capability + .validate_against_expected(&expected, Some(&digest('c'))) + .is_err() + ); + correlate_capabilities( + &mut missing_capability.disagreements, + &expected.capabilities, + &missing_capability.observed_capabilities, + ); + missing_capability + .disagreements + .sort_by(|left, right| left.code.cmp(&right.code)); + missing_capability + .validate_against_expected(&expected, Some(&digest('c'))) + .unwrap(); + + let mut additional_capability = topology_correlation(&expected); + additional_capability + .observed_capabilities + .push(runtime_capability("telemetry")); + additional_capability + .validate_against_expected(&expected, Some(&digest('c'))) + .unwrap(); + } + + #[test] + fn topology_signature_is_exact_and_purpose_bound() -> Result<()> { + let temp = tempfile::tempdir()?; + let signer = enroll_service_identity_at::( + &temp.path().join("odin-topology.cc"), + )?; + let anchor = signer.trust_anchor()?; + let expected = expected_incarnation(); + let mut receipt = topology_correlation(&expected); + receipt.signer_identity_id = anchor.identity_id.clone(); + receipt.signature.clear(); + let unsigned = receipt.unsigned_signature_payload()?; + let proof = signer.sign::(&unsigned); + receipt.signature = proof.signature.clone(); + + let encoded = receipt.canonical_bytes()?; + let (decoded, decoded_unsigned) = + OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload(&encoded)?; + verify_service_identity_signature::< + OdinTopologyIdentity, + OdinRuntimeTopologyCorrelationPurpose, + >( + &anchor, + &decoded_unsigned, + &ServiceIdentitySignature { + identity_id: decoded.signer_identity_id, + signature: decoded.signature, + }, + )?; + + let mut substituted = decoded_unsigned.clone(); + substituted.push(0); + assert!( + verify_service_identity_signature::< + OdinTopologyIdentity, + OdinRuntimeTopologyCorrelationPurpose, + >(&anchor, &substituted, &proof) + .is_err() + ); + + let wrong_purpose = signer.sign::(&decoded_unsigned); + assert!( + verify_service_identity_signature::< + OdinTopologyIdentity, + OdinRuntimeTopologyCorrelationPurpose, + >(&anchor, &decoded_unsigned, &wrong_purpose) + .is_err() + ); + Ok(()) + } + + #[test] + fn topology_trust_anchor_is_odin_profile_and_purpose_bound() -> Result<()> { + let anchor = topology_anchor(); + anchor.validate()?; + + let mut value = anchor.clone(); + value.service_id = "idunn".into(); + assert!(value.validate().is_err()); + + let mut value = anchor.clone(); + value.signing_purpose = GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE.into(); + assert!(value.validate().is_err()); + + let mut value = anchor.clone(); + value.signed_schema = GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(); + assert!(value.validate().is_err()); + + let mut value = anchor; + value.signer_identity_id = + derive_service_identity_id::(&value.signer_public_key)?; + assert!(value.validate().is_err()); + Ok(()) + } +} diff --git a/packages/cultnet-rs/src/service_identity.rs b/packages/cultnet-rs/src/service_identity.rs new file mode 100644 index 00000000..6f3e58d8 --- /dev/null +++ b/packages/cultnet-rs/src/service_identity.rs @@ -0,0 +1,959 @@ +use anyhow::{Context, Result, anyhow, bail}; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, SingleFileMessagePackBackingStore}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::io::{Read, Write}; +use std::marker::PhantomData; +use std::path::Path; +#[cfg(windows)] +use zeroize::Zeroize; +use zeroize::Zeroizing; + +pub const WINDOWS_SERVICE_IDENTITY_ASSURANCE: &str = "os_user_installation_bound_best_effort"; +pub const LINUX_SERVICE_IDENTITY_ASSURANCE: &str = "os_installation_file_bound_cloneable_baseline"; + +/// Fixes every authority-bearing name at the call site. A service cannot open +/// another service's identity by supplying a convenient runtime string. +pub trait ServiceIdentityProfile: Send + Sync + 'static { + const PRIVATE_TYPE: &'static str; + const PRIVATE_SCHEMA: &'static str; + const PRIVATE_KEY: &'static str; + const TRUST_ANCHOR_TYPE: &'static str; + const TRUST_ANCHOR_SCHEMA: &'static str; + const TRUST_ANCHOR_KEY: &'static str; + const ID_DOMAIN: &'static [u8]; + const SIGNATURE_DOMAIN: &'static [u8]; + const PROTECTOR_CONTEXT: &'static str; +} + +/// A purpose is a type, not caller-provided prose. Protocol implementations +/// define one zero-sized type per signed statement kind. +pub trait ServiceSignaturePurpose: Send + Sync + 'static { + const PURPOSE: &'static [u8]; +} + +pub enum IdunnServiceIdentity {} + +/// Odin-owned identity dedicated to signed Expected/Present/Ready topology +/// correlation. It is not a provider-health key and cannot issue Idunn +/// admission or lifecycle authority. +pub enum OdinTopologyIdentity {} + +pub struct OdinRuntimeTopologyCorrelationPurpose; + +impl ServiceSignaturePurpose for OdinRuntimeTopologyCorrelationPurpose { + const PURPOSE: &'static [u8] = b"odin.runtime_topology_correlation.v2"; +} + +impl ServiceIdentityProfile for OdinTopologyIdentity { + const PRIVATE_TYPE: &'static str = "odin.topology_identity.private.v1"; + const PRIVATE_SCHEMA: &'static str = "odin.topology_identity.private.v1"; + const PRIVATE_KEY: &'static str = "odin-topology-identity"; + const TRUST_ANCHOR_TYPE: &'static str = "odin.topology_identity.trust_anchor.v1"; + const TRUST_ANCHOR_SCHEMA: &'static str = "odin.topology_identity.trust_anchor.v1"; + const TRUST_ANCHOR_KEY: &'static str = "odin-topology-identity-public"; + const ID_DOMAIN: &'static [u8] = b"odin.topology-identity.id.v1\0"; + const SIGNATURE_DOMAIN: &'static [u8] = b"odin.topology-identity.signature.v1\0"; + const PROTECTOR_CONTEXT: &'static str = "odin-topology-identity-v1"; +} + +/// Root-operated identity dedicated to deployment-brake release grants. The +/// Idunn daemon receives only this profile's public trust anchor; opening the +/// private store is a provisioning operation, never daemon startup work. +pub enum IdunnDeploymentBrakeOperatorIdentity {} + +pub struct IdunnDeploymentBrakeReleasePurpose; + +impl ServiceSignaturePurpose + for IdunnDeploymentBrakeReleasePurpose +{ + const PURPOSE: &'static [u8] = b"idunn.deployment-release.v1"; +} + +impl ServiceIdentityProfile for IdunnDeploymentBrakeOperatorIdentity { + const PRIVATE_TYPE: &'static str = "idunn.deployment_brake_operator.private.v1"; + const PRIVATE_SCHEMA: &'static str = "idunn.deployment_brake_operator.private.v1"; + const PRIVATE_KEY: &'static str = "idunn-deployment-brake-operator"; + const TRUST_ANCHOR_TYPE: &'static str = "idunn.deployment_brake_operator.trust_anchor.v1"; + const TRUST_ANCHOR_SCHEMA: &'static str = "idunn.deployment_brake_operator.trust_anchor.v1"; + const TRUST_ANCHOR_KEY: &'static str = "idunn-deployment-brake-operator-public"; + const ID_DOMAIN: &'static [u8] = b"idunn.deployment-brake-operator.id.v1\0"; + const SIGNATURE_DOMAIN: &'static [u8] = b"idunn.deployment-brake-operator.signature.v1\0"; + const PROTECTOR_CONTEXT: &'static str = "idunn-deployment-brake-operator-v1"; +} + +/// Dedicated provider-owned identity for generic daemon-health statements. +/// It is intentionally independent of any one provider runtime or repository. +pub enum GameCultProviderHealthIdentity {} + +/// The only generic health statement purpose accepted from provider-health +/// identities by Idunn admission. +pub struct IdunnSignedDaemonHealthPurpose; + +impl ServiceSignaturePurpose for IdunnSignedDaemonHealthPurpose { + const PURPOSE: &'static [u8] = b"idunn.signed_daemon_health.v1"; +} + +/// Provider-owned runtime presence and health. This is deliberately distinct +/// from the legacy daemon-health purpose so an old signature cannot acquire +/// deployment-incarnation authority by substitution. +pub struct GameCultRuntimePresenceHealthPurpose; + +impl ServiceSignaturePurpose + for GameCultRuntimePresenceHealthPurpose +{ + const PURPOSE: &'static [u8] = b"gamecult.runtime_presence_health.v2"; +} + +impl ServiceIdentityProfile for GameCultProviderHealthIdentity { + const PRIVATE_TYPE: &'static str = "gamecult.provider_health_identity.private.v1"; + const PRIVATE_SCHEMA: &'static str = "gamecult.provider_health_identity.private.v1"; + const PRIVATE_KEY: &'static str = "gamecult-provider-health-identity"; + const TRUST_ANCHOR_TYPE: &'static str = "gamecult.provider_health_identity.trust_anchor.v1"; + const TRUST_ANCHOR_SCHEMA: &'static str = "gamecult.provider_health_identity.trust_anchor.v1"; + const TRUST_ANCHOR_KEY: &'static str = "gamecult-provider-health-identity-public"; + const ID_DOMAIN: &'static [u8] = b"gamecult.provider-health.identity.v1\0"; + const SIGNATURE_DOMAIN: &'static [u8] = b"gamecult.provider-health.signature.v1\0"; + const PROTECTOR_CONTEXT: &'static str = "gamecult-provider-health-identity-v1"; +} + +/// The only signing purpose accepted for Idunn's public projection of a +/// provider-authenticated health admission. Keeping this profile here makes +/// purpose selection a compile-time protocol choice rather than caller text. +pub struct IdunnAuthenticatedProviderHealthProjectionPurpose; + +impl ServiceSignaturePurpose + for IdunnAuthenticatedProviderHealthProjectionPurpose +{ + const PURPOSE: &'static [u8] = b"idunn.authenticated-provider-health-projection.v1"; +} + +/// Idunn's stable identity signs every activation before its ephemeral key is +/// handed to a workload. This makes the launch witness portable without +/// allowing a configuration file or provider to mint its own activation. +pub struct IdunnRuntimeActivationPurpose; + +impl ServiceSignaturePurpose for IdunnRuntimeActivationPurpose { + const PURPOSE: &'static [u8] = b"idunn.runtime_activation.v2"; +} + +impl ServiceIdentityProfile for IdunnServiceIdentity { + const PRIVATE_TYPE: &'static str = "idunn.service_identity.private.v1"; + const PRIVATE_SCHEMA: &'static str = "idunn.service_identity.private.v1"; + const PRIVATE_KEY: &'static str = "idunn-service-identity"; + const TRUST_ANCHOR_TYPE: &'static str = "idunn.service_identity.trust_anchor.v1"; + const TRUST_ANCHOR_SCHEMA: &'static str = "idunn.service_identity.trust_anchor.v1"; + const TRUST_ANCHOR_KEY: &'static str = "idunn-service-identity-public"; + const ID_DOMAIN: &'static [u8] = b"idunn.service-identity.id.v1\0"; + const SIGNATURE_DOMAIN: &'static [u8] = b"idunn.service-identity.signature.v1\0"; + const PROTECTOR_CONTEXT: &'static str = "idunn-service-identity-v1"; +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ServiceIdentityPrivateEntry { + pub schema_version: String, + pub identity_id: String, + pub public_key: Vec, + pub protected_private_seed: Vec, + pub protector_kind: String, + pub protector_binding: String, + pub protector_version: String, + pub assurance: String, + pub created_at: String, + pub enrollment_nonce: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceIdentityTrustAnchor { + pub schema_version: String, + pub identity_id: String, + pub public_key: Vec, + pub assurance: String, + pub identity_created_at: String, + pub source_identity_record_sha256: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServiceIdentitySignature { + pub identity_id: String, + pub signature: Vec, +} + +/// The only value holding unprotected key material. It exposes signing, never +/// seed export or access to the underlying signing key. +pub struct ServiceIdentitySigner { + entry: ServiceIdentityPrivateEntry, + signing_key: SigningKey, + profile: PhantomData

, +} + +impl ServiceIdentitySigner

{ + pub fn entry(&self) -> &ServiceIdentityPrivateEntry { + &self.entry + } + + pub fn trust_anchor(&self) -> Result { + Ok(ServiceIdentityTrustAnchor { + schema_version: P::TRUST_ANCHOR_SCHEMA.into(), + identity_id: self.entry.identity_id.clone(), + public_key: self.entry.public_key.clone(), + assurance: self.entry.assurance.clone(), + identity_created_at: self.entry.created_at.clone(), + source_identity_record_sha256: format!( + "sha256-{}", + hex(&Sha256::digest(rmp_serde::to_vec(&self.entry)?)) + ), + }) + } + + pub fn sign>(&self, payload: &[u8]) -> ServiceIdentitySignature { + ServiceIdentitySignature { + identity_id: self.entry.identity_id.clone(), + signature: self + .signing_key + .sign(&signing_message::(payload)) + .to_bytes() + .to_vec(), + } + } +} + +pub fn enroll_service_identity_at( + path: &Path, +) -> Result> { + if path.exists() { + bail!( + "service identity store {} already exists; enrollment is immutable", + path.display() + ); + } + prepare_private_parent(path)?; + let mut seed = Zeroizing::new([0u8; 32]); + rand::rng().fill_bytes(seed.as_mut()); + let signing_key = SigningKey::from_bytes(&*seed); + let mut nonce = [0u8; 32]; + rand::rng().fill_bytes(&mut nonce); + let public_key = signing_key.verifying_key().to_bytes(); + let binding = platform_binding::

()?; + let entry = ServiceIdentityPrivateEntry { + schema_version: P::PRIVATE_SCHEMA.into(), + identity_id: identity_id::

(&public_key), + public_key: public_key.to_vec(), + protected_private_seed: protect_seed::

(&*seed, &binding)?, + protector_kind: platform_protector_kind().into(), + protector_binding: binding, + protector_version: "v1".into(), + assurance: platform_assurance().into(), + created_at: chrono::Utc::now().to_rfc3339(), + enrollment_nonce: nonce.to_vec(), + }; + validate_private::

(&entry)?; + let envelope = private_envelope::

(&entry)?; + atomic_create_private_store(path, &envelope)?; + harden_store_permissions(path)?; + open_service_identity_at::

(path) +} + +pub fn open_service_identity_at( + path: &Path, +) -> Result> { + if !path.is_file() { + bail!("service identity store {} does not exist", path.display()); + } + let entries = SingleFileMessagePackBackingStore::new(path).pull_all()?; + open_service_identity_envelopes::

(&entries) +} + +/// Opens an immutable service-identity credential from a read-only stream. +/// +/// Unlike [`open_service_identity_at`], this path has no sibling-lock or +/// writable-filesystem requirement. It is intended for immutable credential +/// copies such as systemd `LoadCredential` files. +pub fn open_service_identity_credential_reader( + mut reader: impl Read, +) -> Result> { + let mut bytes = Zeroizing::new(Vec::new()); + reader + .read_to_end(&mut bytes) + .context("failed to read immutable service identity credential")?; + open_service_identity_credential_bytes::

(&bytes) +} + +/// Opens an immutable service-identity credential from its exact bytes. +pub fn open_service_identity_credential_bytes( + bytes: &[u8], +) -> Result> { + let mut decoder = rmp_serde::Deserializer::new(std::io::Cursor::new(bytes)); + let entries = Vec::::deserialize(&mut decoder) + .context("immutable service identity credential is malformed MessagePack")?; + if decoder.position() != bytes.len() as u64 { + bail!("immutable service identity credential contains trailing data"); + } + open_service_identity_envelopes::

(&entries) +} + +fn open_service_identity_envelopes( + entries: &[CultCacheEnvelope], +) -> Result> { + let [envelope] = entries else { + bail!("service identity store must contain exactly one immutable envelope"); + }; + if envelope.r#type != P::PRIVATE_TYPE + || envelope.key != P::PRIVATE_KEY + || envelope.schema_id.as_deref() != Some(P::PRIVATE_SCHEMA) + { + bail!("service identity store belongs to a different profile or schema"); + } + let entry: ServiceIdentityPrivateEntry = rmp_serde::from_slice(&envelope.payload) + .context("service identity private payload is malformed MessagePack")?; + validate_private::

(&entry)?; + if entry.protector_kind != platform_protector_kind() || entry.assurance != platform_assurance() + { + bail!("service identity protector does not belong to this platform implementation"); + } + let binding = platform_binding::

()?; + if entry.protector_binding != binding { + bail!("service identity protector binding does not match this OS installation or profile"); + } + let seed = unprotect_seed::

(&entry.protected_private_seed, &binding)?; + let seed: &[u8; 32] = seed + .as_slice() + .try_into() + .map_err(|_| anyhow!("unprotected service identity seed has invalid length"))?; + let signing_key = SigningKey::from_bytes(seed); + if signing_key.verifying_key().to_bytes().as_slice() != entry.public_key.as_slice() { + bail!("service identity private seed does not match enrolled public key"); + } + Ok(ServiceIdentitySigner { + entry, + signing_key, + profile: PhantomData, + }) +} + +pub fn export_service_identity_trust_anchor( + signer: &ServiceIdentitySigner

, + path: &Path, +) -> Result { + let anchor = signer.trust_anchor()?; + ensure_parent(path)?; + let envelope = CultCacheEnvelope { + key: P::TRUST_ANCHOR_KEY.into(), + r#type: P::TRUST_ANCHOR_TYPE.into(), + payload: rmp_serde::to_vec(&anchor)?, + stored_at: anchor.identity_created_at.clone(), + schema_id: Some(P::TRUST_ANCHOR_SCHEMA.into()), + }; + let mut backing = SingleFileMessagePackBackingStore::new(path); + match backing.pull_all()?.as_slice() { + [] => backing.push(&envelope)?, + [current] if current == &envelope => {} + _ => bail!("service identity trust anchor output already contains different state"), + } + Ok(anchor) +} + +pub fn verify_service_identity_signature( + anchor: &ServiceIdentityTrustAnchor, + payload: &[u8], + proof: &ServiceIdentitySignature, +) -> Result<()> +where + P: ServiceIdentityProfile, + S: ServiceSignaturePurpose

, +{ + validate_anchor::

(anchor)?; + if proof.identity_id != anchor.identity_id { + bail!("service identity signature names a different identity"); + } + let key: [u8; 32] = anchor + .public_key + .as_slice() + .try_into() + .map_err(|_| anyhow!("service identity public key has invalid length"))?; + let signature = Signature::from_slice(&proof.signature) + .map_err(|_| anyhow!("service identity signature has invalid length"))?; + VerifyingKey::from_bytes(&key)? + .verify(&signing_message::(payload), &signature) + .map_err(|_| anyhow!("service identity signature verification failed")) +} + +/// Verify against a public key already admitted by an owning trust store. +/// The profile derives the identity, so callers cannot substitute a convenient +/// identity string while retaining the same signature bytes. +pub fn verify_service_identity_signature_with_public_key( + public_key: &[u8], + payload: &[u8], + proof: &ServiceIdentitySignature, +) -> Result<()> +where + P: ServiceIdentityProfile, + S: ServiceSignaturePurpose

, +{ + if proof.identity_id != derive_service_identity_id::

(public_key)? { + bail!("service identity signature names a different identity"); + } + let key: [u8; 32] = public_key + .try_into() + .map_err(|_| anyhow!("service identity public key has invalid length"))?; + let signature = Signature::from_slice(&proof.signature) + .map_err(|_| anyhow!("service identity signature has invalid length"))?; + VerifyingKey::from_bytes(&key)? + .verify(&signing_message::(payload), &signature) + .map_err(|_| anyhow!("service identity signature verification failed")) +} + +fn validate_private(entry: &ServiceIdentityPrivateEntry) -> Result<()> { + if entry.schema_version != P::PRIVATE_SCHEMA + || entry.public_key.len() != 32 + || entry.enrollment_nonce.len() != 32 + || entry.protected_private_seed.is_empty() + || entry.protector_version != "v1" + { + bail!("service identity private entry violates its profile schema"); + } + chrono::DateTime::parse_from_rfc3339(&entry.created_at) + .map_err(|_| anyhow!("service identity created_at is not RFC3339"))?; + if identity_id::

(&entry.public_key) != entry.identity_id { + bail!("service identity id does not match public key"); + } + Ok(()) +} + +fn validate_anchor(anchor: &ServiceIdentityTrustAnchor) -> Result<()> { + if anchor.schema_version != P::TRUST_ANCHOR_SCHEMA + || anchor.public_key.len() != 32 + || derive_service_identity_id::

(&anchor.public_key)? != anchor.identity_id + { + bail!("service identity trust anchor violates its profile schema"); + } + Ok(()) +} + +/// Derives the profile-bound identity id from an exact Ed25519 public key. +/// Consumers use this instead of accepting a caller-supplied identity string. +pub fn derive_service_identity_id(public_key: &[u8]) -> Result { + if public_key.len() != 32 { + bail!("service identity public key has invalid length"); + } + Ok(hex(&Sha256::digest([P::ID_DOMAIN, public_key].concat()))) +} + +fn identity_id(key: &[u8]) -> String { + derive_service_identity_id::

(key).expect("internal service identity key is Ed25519") +} + +fn signing_message>( + payload: &[u8], +) -> Vec { + let mut out = + Vec::with_capacity(P::SIGNATURE_DOMAIN.len() + S::PURPOSE.len() + payload.len() + 16); + out.extend_from_slice(P::SIGNATURE_DOMAIN); + out.extend_from_slice(&(S::PURPOSE.len() as u64).to_be_bytes()); + out.extend_from_slice(S::PURPOSE); + out.extend_from_slice(&(payload.len() as u64).to_be_bytes()); + out.extend_from_slice(payload); + out +} + +fn private_envelope( + entry: &ServiceIdentityPrivateEntry, +) -> Result { + Ok(CultCacheEnvelope { + key: P::PRIVATE_KEY.into(), + r#type: P::PRIVATE_TYPE.into(), + payload: rmp_serde::to_vec(entry)?, + stored_at: entry.created_at.clone(), + schema_id: Some(P::PRIVATE_SCHEMA.into()), + }) +} + +fn ensure_parent(path: &Path) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("service identity path has no parent"))?; + std::fs::create_dir_all(parent)?; + Ok(()) +} + +/// Like [`ensure_parent`], but also locks the directory down to owner-only +/// access. Reserved for paths that hold private key material: a public trust +/// anchor is routinely exported into a directory another service already +/// owns and reads from, and forcing that directory to `0700` would lock the +/// legitimate reader out rather than protect anything. +fn prepare_private_parent(path: &Path) -> Result<()> { + ensure_parent(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let parent = path + .parent() + .ok_or_else(|| anyhow!("service identity path has no parent"))?; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +/// Enrollment is a one-shot authority transition. `create_new` makes two +/// simultaneous enrollments unable to overwrite one another, while the encoded +/// envelope vector remains a supported CultCache single-file representation. +fn atomic_create_private_store(path: &Path, envelope: &CultCacheEnvelope) -> Result<()> { + let bytes = rmp_serde::to_vec(&vec![envelope.clone()]) + .context("failed to encode immutable service identity store")?; + let mut file = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .with_context(|| { + format!( + "service identity store {} already exists or cannot be created; enrollment is immutable", + path.display() + ) + })?; + if let Err(error) = file.write_all(&bytes).and_then(|()| file.sync_all()) { + drop(file); + let _ = std::fs::remove_file(path); + return Err(error).context("failed to persist immutable service identity store"); + } + Ok(()) +} +fn harden_store_permissions(_path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +#[cfg(windows)] +fn platform_protector_kind() -> &'static str { + "windows_dpapi_current_user" +} +#[cfg(target_os = "linux")] +fn platform_protector_kind() -> &'static str { + "linux_file_mode_machine_id_binding" +} +#[cfg(windows)] +fn platform_assurance() -> &'static str { + WINDOWS_SERVICE_IDENTITY_ASSURANCE +} +#[cfg(target_os = "linux")] +fn platform_assurance() -> &'static str { + LINUX_SERVICE_IDENTITY_ASSURANCE +} + +#[cfg(windows)] +fn platform_binding() -> Result { + Ok(format!("dpapi-current-user:{}", P::PROTECTOR_CONTEXT)) +} +#[cfg(target_os = "linux")] +fn platform_binding() -> Result { + let raw = std::fs::read_to_string("/etc/machine-id") + .or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id")) + .context("Linux machine-id is unavailable")?; + let id = raw.trim(); + if id.is_empty() { + bail!("Linux machine-id is empty"); + } + Ok(format!( + "{}:machine-id-sha256:{}", + P::PROTECTOR_CONTEXT, + hex(&Sha256::digest(id.as_bytes())) + )) +} + +#[cfg(windows)] +fn protect_seed(seed: &[u8; 32], binding: &str) -> Result> { + Ok(dpapi::

(seed, binding, true)?.to_vec()) +} +#[cfg(windows)] +fn unprotect_seed( + seed: &[u8], + binding: &str, +) -> Result>> { + dpapi::

(seed, binding, false) +} +#[cfg(windows)] +fn dpapi( + input: &[u8], + binding: &str, + protect: bool, +) -> Result>> { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Cryptography::{ + CRYPT_INTEGER_BLOB, CRYPTPROTECT_UI_FORBIDDEN, CryptProtectData, CryptUnprotectData, + }; + let mut input = Zeroizing::new(input.to_vec()); + let mut entropy = Zeroizing::new( + Sha256::digest( + [ + b"gamecult-service-identity-dpapi-v1\0".as_slice(), + P::PROTECTOR_CONTEXT.as_bytes(), + binding.as_bytes(), + ] + .concat(), + ) + .to_vec(), + ); + let ib = CRYPT_INTEGER_BLOB { + cbData: input.len() as u32, + pbData: input.as_mut_ptr(), + }; + let eb = CRYPT_INTEGER_BLOB { + cbData: entropy.len() as u32, + pbData: entropy.as_mut_ptr(), + }; + let mut output = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + }; + let ok = unsafe { + if protect { + CryptProtectData( + &ib, + std::ptr::null(), + &eb, + std::ptr::null(), + std::ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + } else { + CryptUnprotectData( + &ib, + std::ptr::null_mut(), + &eb, + std::ptr::null(), + std::ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + } + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()) + .context("DPAPI service identity operation failed"); + } + if output.pbData.is_null() { + bail!("DPAPI service identity operation returned a null output"); + } + let output = unsafe { std::slice::from_raw_parts_mut(output.pbData, output.cbData as usize) }; + let bytes = Zeroizing::new(output.to_vec()); + output.zeroize(); + unsafe { + LocalFree(output.as_mut_ptr().cast()); + } + Ok(bytes) +} + +#[cfg(target_os = "linux")] +fn protect_seed(seed: &[u8; 32], binding: &str) -> Result> { + xor_linux::

(seed, binding) +} +#[cfg(target_os = "linux")] +fn unprotect_seed( + seed: &[u8], + binding: &str, +) -> Result>> { + if seed.len() != 32 { + bail!("protected Linux service seed has invalid length"); + } + let mask = Sha256::digest( + [ + b"gamecult-linux-service-seed-v1\0".as_slice(), + P::PROTECTOR_CONTEXT.as_bytes(), + binding.as_bytes(), + ] + .concat(), + ); + let mut clear_seed = Zeroizing::new(Vec::with_capacity(seed.len())); + clear_seed.extend(seed.iter().zip(mask).map(|(a, b)| a ^ b)); + Ok(clear_seed) +} +#[cfg(target_os = "linux")] +fn xor_linux(seed: &[u8], binding: &str) -> Result> { + let mask = Sha256::digest( + [ + b"gamecult-linux-service-seed-v1\0".as_slice(), + P::PROTECTOR_CONTEXT.as_bytes(), + binding.as_bytes(), + ] + .concat(), + ); + Ok(seed.iter().zip(mask).map(|(a, b)| a ^ b).collect()) +} + +fn hex(bytes: &[u8]) -> String { + const D: &[u8; 16] = b"0123456789abcdef"; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(D[(b >> 4) as usize] as char); + s.push(D[(b & 15) as usize] as char); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + enum OtherPurpose {} + impl ServiceSignaturePurpose for OtherPurpose { + const PURPOSE: &'static [u8] = b"idunn.other.v1"; + } + + #[test] + fn immutable_enrollment_reopens_and_is_purpose_and_payload_bound() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("idunn.ccmp"); + let signer = enroll_service_identity_at::(&path)?; + let proof = signer.sign::(b"healthy"); + let anchor = signer.trust_anchor()?; + verify_service_identity_signature::< + IdunnServiceIdentity, + IdunnAuthenticatedProviderHealthProjectionPurpose, + >(&anchor, b"healthy", &proof)?; + assert!( + verify_service_identity_signature::< + IdunnServiceIdentity, + IdunnAuthenticatedProviderHealthProjectionPurpose, + >(&anchor, b"unhealthy", &proof) + .is_err() + ); + assert!( + verify_service_identity_signature::( + &anchor, b"healthy", &proof + ) + .is_err() + ); + assert!(enroll_service_identity_at::(&path).is_err()); + assert_eq!( + derive_service_identity_id::(&anchor.public_key)?, + anchor.identity_id + ); + assert!(derive_service_identity_id::(&[0; 31]).is_err()); + assert_eq!( + open_service_identity_at::(&path)?.entry(), + signer.entry() + ); + Ok(()) + } + + #[test] + fn immutable_credential_reader_opens_without_a_path_and_retains_signing_authority() -> Result<()> + { + let temp = tempfile::tempdir()?; + let path = temp.path().join("provider-health.cc"); + let enrolled = enroll_service_identity_at::(&path)?; + let bytes = std::fs::read(path)?; + + let from_reader = open_service_identity_credential_reader::( + std::io::Cursor::new(bytes.clone()), + )?; + let from_bytes = + open_service_identity_credential_bytes::(&bytes)?; + assert_eq!(from_reader.entry(), enrolled.entry()); + assert_eq!(from_bytes.entry(), enrolled.entry()); + + let payload = b"runtime-presence"; + let proof = from_reader.sign::(payload); + verify_service_identity_signature::< + GameCultProviderHealthIdentity, + GameCultRuntimePresenceHealthPurpose, + >(&enrolled.trust_anchor()?, payload, &proof)?; + Ok(()) + } + + #[test] + fn immutable_credential_reader_rejects_ambiguous_profile_or_machine_state() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("idunn.ccmp"); + enroll_service_identity_at::(&path)?; + let bytes = std::fs::read(path)?; + + assert!( + open_service_identity_credential_bytes::(&bytes) + .is_err() + ); + + let mut envelopes: Vec = rmp_serde::from_slice(&bytes)?; + let mut duplicated = envelopes.clone(); + duplicated.push(envelopes[0].clone()); + assert!( + open_service_identity_credential_bytes::(&rmp_serde::to_vec( + &duplicated + )?) + .is_err() + ); + + let mut trailing = bytes.clone(); + trailing.push(0xc0); + assert!(open_service_identity_credential_bytes::(&trailing).is_err()); + + let mut wrong_seed_envelopes = envelopes.clone(); + let mut wrong_seed: ServiceIdentityPrivateEntry = + rmp_serde::from_slice(&wrong_seed_envelopes[0].payload)?; + wrong_seed.protected_private_seed[0] ^= 1; + wrong_seed_envelopes[0].payload = rmp_serde::to_vec(&wrong_seed)?; + assert!( + open_service_identity_credential_bytes::(&rmp_serde::to_vec( + &wrong_seed_envelopes + )?) + .is_err() + ); + + let mut entry: ServiceIdentityPrivateEntry = rmp_serde::from_slice(&envelopes[0].payload)?; + entry.protector_binding.push_str(":another-machine"); + envelopes[0].payload = rmp_serde::to_vec(&entry)?; + assert!( + open_service_identity_credential_bytes::(&rmp_serde::to_vec( + &envelopes + )?) + .is_err() + ); + Ok(()) + } + + #[test] + fn malformed_or_profile_substituted_state_fails_closed_without_replacement() -> Result<()> { + enum Alien {} + impl ServiceIdentityProfile for Alien { + const PRIVATE_TYPE: &'static str = "alien.private.v1"; + const PRIVATE_SCHEMA: &'static str = "alien.private.v1"; + const PRIVATE_KEY: &'static str = "alien"; + const TRUST_ANCHOR_TYPE: &'static str = "alien.anchor.v1"; + const TRUST_ANCHOR_SCHEMA: &'static str = "alien.anchor.v1"; + const TRUST_ANCHOR_KEY: &'static str = "alien-public"; + const ID_DOMAIN: &'static [u8] = b"alien.id\0"; + const SIGNATURE_DOMAIN: &'static [u8] = b"alien.sig\0"; + const PROTECTOR_CONTEXT: &'static str = "alien-v1"; + } + let temp = tempfile::tempdir()?; + let malformed = temp.path().join("bad.ccmp"); + std::fs::write(&malformed, b"rot")?; + let before = std::fs::read(&malformed)?; + assert!(open_service_identity_at::(&malformed).is_err()); + assert!(enroll_service_identity_at::(&malformed).is_err()); + assert_eq!(before, std::fs::read(&malformed)?); + let good = temp.path().join("good.ccmp"); + enroll_service_identity_at::(&good)?; + assert!(open_service_identity_at::(&good).is_err()); + Ok(()) + } + + #[test] + fn exporting_a_public_trust_anchor_does_not_narrow_its_parent_permissions() -> Result<()> { + let temp = tempfile::tempdir()?; + let signer = + enroll_service_identity_at::(&temp.path().join("private.ccmp"))?; + let public_parent = temp.path().join("public"); + std::fs::create_dir(&public_parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&public_parent, std::fs::Permissions::from_mode(0o750))?; + } + let output = public_parent.join("public.ccmp"); + export_service_identity_trust_anchor(&signer, &output)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&public_parent)?.permissions().mode() & 0o777, + 0o750, + "exporting a public trust anchor must not reassign its parent directory's permissions" + ); + } + Ok(()) + } + + #[test] + fn exported_anchor_is_public_only_immutable_and_rejects_identity_substitution() -> Result<()> { + let temp = tempfile::tempdir()?; + let signer = + enroll_service_identity_at::(&temp.path().join("private.ccmp"))?; + let output = temp.path().join("public.ccmp"); + let anchor = export_service_identity_trust_anchor(&signer, &output)?; + let bytes = std::fs::read(&output)?; + assert!( + !bytes + .windows(signer.entry().protected_private_seed.len()) + .any(|w| w == signer.entry().protected_private_seed) + ); + export_service_identity_trust_anchor(&signer, &output)?; + assert_eq!(bytes, std::fs::read(&output)?); + let other = + enroll_service_identity_at::(&temp.path().join("other.ccmp"))?; + let proof = other.sign::(b"healthy"); + assert!( + verify_service_identity_signature::< + IdunnServiceIdentity, + IdunnAuthenticatedProviderHealthProjectionPurpose, + >(&anchor, b"healthy", &proof) + .is_err() + ); + Ok(()) + } + + #[test] + fn provider_health_profile_is_domain_separated_and_purpose_bound() -> Result<()> { + let temp = tempfile::tempdir()?; + let signer = enroll_service_identity_at::( + &temp.path().join("provider-health.cc"), + )?; + let proof = signer.sign::(b"signed-health-record"); + let anchor = signer.trust_anchor()?; + verify_service_identity_signature::< + GameCultProviderHealthIdentity, + IdunnSignedDaemonHealthPurpose, + >(&anchor, b"signed-health-record", &proof)?; + verify_service_identity_signature_with_public_key::< + GameCultProviderHealthIdentity, + IdunnSignedDaemonHealthPurpose, + >(&anchor.public_key, b"signed-health-record", &proof)?; + assert_eq!( + anchor.schema_version, + "gamecult.provider_health_identity.trust_anchor.v1" + ); + assert_ne!( + derive_service_identity_id::(&anchor.public_key)?, + derive_service_identity_id::(&anchor.public_key)? + ); + Ok(()) + } + + #[test] + fn runtime_presence_and_legacy_health_purposes_cannot_be_substituted() -> Result<()> { + let temp = tempfile::tempdir()?; + let signer = enroll_service_identity_at::( + &temp.path().join("provider-runtime-presence.cc"), + )?; + let anchor = signer.trust_anchor()?; + let payload = b"runtime-presence"; + + let presence = signer.sign::(payload); + verify_service_identity_signature::< + GameCultProviderHealthIdentity, + GameCultRuntimePresenceHealthPurpose, + >(&anchor, payload, &presence)?; + assert!( + verify_service_identity_signature::< + GameCultProviderHealthIdentity, + IdunnSignedDaemonHealthPurpose, + >(&anchor, payload, &presence) + .is_err() + ); + + let legacy = signer.sign::(payload); + assert!( + verify_service_identity_signature::< + GameCultProviderHealthIdentity, + GameCultRuntimePresenceHealthPurpose, + >(&anchor, payload, &legacy) + .is_err() + ); + Ok(()) + } +} diff --git a/packages/cultnet-rs/src/snapshot_query.rs b/packages/cultnet-rs/src/snapshot_query.rs new file mode 100644 index 00000000..f6150d1d --- /dev/null +++ b/packages/cultnet-rs/src/snapshot_query.rs @@ -0,0 +1,339 @@ +use anyhow::{Result, anyhow}; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::{CultNetDocumentRegistry, CultNetMessage, CultNetRawDocumentRecord}; + +/// Read-only backing surface for a CultNet snapshot server. +/// +/// Records returned here are already the stored wire records. The snapshot +/// server selects and clones them; it never decodes payloads, rewrites source +/// metadata, or applies them to another cache. +pub trait CultNetRawSnapshotSource { + fn raw_snapshot(&self) -> Result>; +} + +impl CultNetRawSnapshotSource for Vec { + fn raw_snapshot(&self) -> Result> { + Ok(self.clone()) + } +} + +impl CultNetRawSnapshotSource for F +where + F: Fn() -> Result>, +{ + fn raw_snapshot(&self) -> Result> { + self() + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CultNetReadOnlySnapshotPolicy { + allowed_records: BTreeSet<(String, String)>, +} + +impl CultNetReadOnlySnapshotPolicy { + pub fn new() -> Self { + Self::default() + } + + pub fn allow( + &mut self, + schema_id: impl Into, + record_key: impl Into, + ) -> Result<&mut Self> { + let schema_id = schema_id.into(); + let record_key = record_key.into(); + require_non_empty(&schema_id, "schema_id")?; + require_non_empty(&record_key, "record_key")?; + self.allowed_records.insert((schema_id, record_key)); + Ok(self) + } + + fn allows(&self, schema_id: &str, record_key: &str) -> bool { + self.allowed_records + .contains(&(schema_id.to_string(), record_key.to_string())) + } +} + +/// Serve one raw snapshot request from an explicitly bounded, read-only view. +/// +/// The document registry proves that every exposed schema belongs to the +/// runtime. The policy grants individual `(schema_id, record_key)` pairs. The +/// source owns the bytes and metadata; this function only selects records. +pub fn serve_read_only_raw_snapshot( + document_registry: &CultNetDocumentRegistry, + policy: &CultNetReadOnlySnapshotPolicy, + source: &S, + request: &CultNetMessage, +) -> Result { + let CultNetMessage::SnapshotRequest { + message_id, + schema_ids, + record_keys, + } = request + else { + return Err(anyhow!("expected cultnet.snapshot_request.v0")); + }; + require_non_empty(message_id, "message_id")?; + reject_duplicates(schema_ids.as_deref(), "requested schema id")?; + reject_duplicates(record_keys.as_deref(), "requested record key")?; + + let requested_schemas = schema_ids + .as_ref() + .map(|values| values.iter().map(String::as_str).collect::>()); + let requested_keys = record_keys + .as_ref() + .map(|values| values.iter().map(String::as_str).collect::>()); + let mut selected = Vec::new(); + let mut seen = BTreeSet::new(); + + for document in source.raw_snapshot()? { + let identity = (document.schema_id.clone(), document.record_key.clone()); + if !seen.insert(identity.clone()) { + return Err(anyhow!( + "snapshot source contains duplicate record {:?}/{:?}", + identity.0, + identity.1 + )); + } + if document_registry + .binding_by_schema_id(&document.schema_id) + .is_none() + { + return Err(anyhow!( + "snapshot source record {:?}/{:?} uses an unregistered schema", + document.schema_id, + document.record_key + )); + } + if !policy.allows(&document.schema_id, &document.record_key) { + continue; + } + if requested_schemas + .as_ref() + .is_some_and(|schemas| !schemas.contains(document.schema_id.as_str())) + { + continue; + } + if requested_keys + .as_ref() + .is_some_and(|keys| !keys.contains(document.record_key.as_str())) + { + continue; + } + selected.push(document); + } + + Ok(CultNetMessage::SnapshotResponseRaw { + message_id: message_id.clone(), + documents: selected, + }) +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CultNetSnapshotSourceExpectation { + pub runtime_id: Option, + pub agent_id: Option, + pub role: Option, + pub tags: Option>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultNetSnapshotRecordExpectation { + pub schema_id: String, + pub record_key: String, + pub source: CultNetSnapshotSourceExpectation, +} + +impl CultNetSnapshotRecordExpectation { + pub fn new(schema_id: impl Into, record_key: impl Into) -> Self { + Self { + schema_id: schema_id.into(), + record_key: record_key.into(), + source: CultNetSnapshotSourceExpectation::default(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultNetRawSnapshotQuery { + message_id: String, + expectations: BTreeMap<(String, String), CultNetSnapshotSourceExpectation>, +} + +impl CultNetRawSnapshotQuery { + pub fn new( + message_id: impl Into, + records: Vec, + ) -> Result { + let message_id = message_id.into(); + require_non_empty(&message_id, "message_id")?; + if records.is_empty() { + return Err(anyhow!("snapshot query must request at least one record")); + } + let mut expectations = BTreeMap::new(); + for record in records { + require_non_empty(&record.schema_id, "schema_id")?; + require_non_empty(&record.record_key, "record_key")?; + let identity = (record.schema_id, record.record_key); + if expectations + .insert(identity.clone(), record.source) + .is_some() + { + return Err(anyhow!( + "snapshot query contains duplicate requested record {:?}/{:?}", + identity.0, + identity.1 + )); + } + } + Ok(Self { + message_id, + expectations, + }) + } + + pub fn request(&self) -> CultNetMessage { + CultNetMessage::SnapshotRequest { + message_id: self.message_id.clone(), + schema_ids: Some( + self.expectations + .keys() + .map(|(schema_id, _)| schema_id.clone()) + .collect::>() + .into_iter() + .collect(), + ), + record_keys: Some( + self.expectations + .keys() + .map(|(_, record_key)| record_key.clone()) + .collect::>() + .into_iter() + .collect(), + ), + } + } + + /// Validate a response and return records without mutating any local cache. + pub fn accept_response( + &self, + response: CultNetMessage, + ) -> Result> { + let CultNetMessage::SnapshotResponseRaw { + message_id, + documents, + } = response + else { + return Err(anyhow!("expected cultnet.snapshot_response_raw.v0")); + }; + if message_id != self.message_id { + return Err(anyhow!( + "snapshot response message id {:?} does not match request {:?}", + message_id, + self.message_id + )); + } + + let mut seen = BTreeSet::new(); + for document in &documents { + let identity = (document.schema_id.clone(), document.record_key.clone()); + if !seen.insert(identity.clone()) { + return Err(anyhow!( + "snapshot response contains duplicate record {:?}/{:?}", + identity.0, + identity.1 + )); + } + let expected = self.expectations.get(&identity).ok_or_else(|| { + anyhow!( + "snapshot response contains unexpected record {:?}/{:?}", + identity.0, + identity.1 + ) + })?; + require_expected_metadata( + "source_runtime_id", + expected.runtime_id.as_deref(), + document.source_runtime_id.as_deref(), + &identity, + )?; + require_expected_metadata( + "source_agent_id", + expected.agent_id.as_deref(), + document.source_agent_id.as_deref(), + &identity, + )?; + require_expected_metadata( + "source_role", + expected.role.as_deref(), + document.source_role.as_deref(), + &identity, + )?; + if let Some(expected_tags) = expected.tags.as_ref() + && document.tags.as_ref() != Some(expected_tags) + { + return Err(anyhow!( + "snapshot response record {:?}/{:?} has unexpected tags: expected {:?}, got {:?}", + identity.0, + identity.1, + expected_tags, + document.tags + )); + } + } + Ok(documents) + } +} + +pub fn query_read_only_raw_snapshot( + query: &CultNetRawSnapshotQuery, + exchange: F, +) -> Result> +where + F: FnOnce(CultNetMessage) -> Result, +{ + query.accept_response(exchange(query.request())?) +} + +fn require_expected_metadata( + field: &str, + expected: Option<&str>, + actual: Option<&str>, + identity: &(String, String), +) -> Result<()> { + if let Some(expected) = expected + && actual != Some(expected) + { + return Err(anyhow!( + "snapshot response record {:?}/{:?} has unexpected {field}: expected {:?}, got {:?}", + identity.0, + identity.1, + expected, + actual + )); + } + Ok(()) +} + +fn reject_duplicates(values: Option<&[String]>, label: &str) -> Result<()> { + let Some(values) = values else { + return Ok(()); + }; + let mut seen = BTreeSet::new(); + for value in values { + require_non_empty(value, label)?; + if !seen.insert(value) { + return Err(anyhow!("duplicate {label} {value:?}")); + } + } + Ok(()) +} + +fn require_non_empty(value: &str, field: &str) -> Result<()> { + if value.trim().is_empty() { + return Err(anyhow!("{field} must be non-empty")); + } + Ok(()) +} diff --git a/packages/cultnet-rs/src/transport.rs b/packages/cultnet-rs/src/transport.rs index d7aad261..d097aa3c 100644 --- a/packages/cultnet-rs/src/transport.rs +++ b/packages/cultnet-rs/src/transport.rs @@ -19,6 +19,13 @@ pub struct CultNetTransportStats { pub bytes_sent: u64, pub frames_received: u64, pub frames_sent: u64, + /// Reliable packets dropped by a send-side expiry deadline instead of being + /// retransmitted. Always zero for transports without reliable expiry. + pub reliable_packets_expired: u64, + /// Incomplete fragment sets discarded to admit newer ones. Each is one + /// payload that lost a fragment on the wire. Zero for non-fragmenting + /// transports. + pub fragment_sets_evicted: u64, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -201,6 +208,7 @@ pub fn create_tcp_framed_transport_profile( max_payload_bytes: options.max_payload_bytes, max_fragment_bytes: options.max_fragment_bytes, max_pending_reliable_packets: None, + reliable_expire_after_ms: None, }], }], } @@ -258,6 +266,27 @@ where let mut header = [0_u8; FRAME_HEADER_BYTES]; self.stream.read_exact(&mut header)?; let payload_len = u32::from_be_bytes(header) as usize; + // Refuse before allocating. The length is the peer's claim, so honouring + // it without checking the advertised maximum lets one header reserve as + // much memory as the peer cares to name. + if let Some(max_payload_bytes) = self + .profile + .transports + .iter() + .find(|transport| transport.protocol == CultNetTransportProtocol::TcpFramed) + .and_then(|transport| { + transport + .channels + .iter() + .find(|channel| channel.channel_id == "schema") + }) + .and_then(|channel| channel.max_payload_bytes) + && payload_len > max_payload_bytes as usize + { + return Err(anyhow!( + "tcp_framed payload length {payload_len} exceeds advertised maximum {max_payload_bytes}" + )); + } let mut payload = vec![0_u8; payload_len]; self.stream.read_exact(&mut payload)?; self.stats.bytes_received += (FRAME_HEADER_BYTES + payload_len) as u64; diff --git a/packages/cultnet-rs/tests/byte_slot_migration.rs b/packages/cultnet-rs/tests/byte_slot_migration.rs new file mode 100644 index 00000000..0acdcd4d --- /dev/null +++ b/packages/cultnet-rs/tests/byte_slot_migration.rs @@ -0,0 +1,113 @@ +//! Does adding `#[cultcache(key = N, bytes)]` to a field break reading the +//! records already written without it? +//! +//! The attribute changes a slot from a MessagePack array-of-integers (serde's +//! default for `Vec`) to a real `bin`. Six of cultnet-rs's byte fields +//! already carry it and four do not, so converting the remaining four is a +//! stored-data question before it is a signature question: every record +//! written before the flip holds an array in that slot. +//! +//! Both directions are tested, because a migration has to survive a rollback +//! as well as a roll-forward. + +use cultcache_rs::DatabaseEntry; +use serde::Serialize; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "test.byte_slot", schema = "test.byte_slot.v1")] +struct ArrayEncoded { + #[cultcache(key = 0)] + schema_version: String, + #[cultcache(key = 1)] + payload: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "test.byte_slot", schema = "test.byte_slot.v1")] +struct BinEncoded { + #[cultcache(key = 0)] + schema_version: String, + #[cultcache(key = 1, bytes)] + payload: Vec, +} + +fn sample() -> Vec { + (0u8..=255).collect() +} + +#[test] +fn the_attribute_is_what_switches_the_wire_shape() { + let array = rmp_serde::to_vec(&ArrayEncoded { + schema_version: "test.byte_slot.v1".into(), + payload: sample(), + }) + .expect("array encodes"); + let bin = rmp_serde::to_vec(&BinEncoded { + schema_version: "test.byte_slot.v1".into(), + payload: sample(), + }) + .expect("bin encodes"); + + // 256 bytes as `bin` is a 3-byte header plus the payload. As an array it + // is one or two bytes per element, because every value above 0x7f needs a + // u8 marker of its own. + println!("array-encoded {} bytes, bin-encoded {} bytes", array.len(), bin.len()); + assert!( + array.len() > bin.len(), + "array {} should exceed bin {}", + array.len(), + bin.len() + ); +} + +#[test] +fn a_bytes_reader_still_reads_records_written_before_the_attribute() { + let written_before = rmp_serde::to_vec(&ArrayEncoded { + schema_version: "test.byte_slot.v1".into(), + payload: sample(), + }) + .expect("array encodes"); + + // serde_bytes::ByteBuf accepts an array marker as well as bin, so the + // flip is NOT a hard read break. This was worth measuring rather than + // assuming: the opposite would have forced a dual-read path or a rewrite + // migration onto every field with persisted records. + let read_after = + rmp_serde::from_slice::(&written_before).expect("bytes reader reads an array"); + assert_eq!(read_after.payload, sample()); +} + +#[test] +fn a_pre_attribute_reader_still_reads_records_written_after_it() { + let written_after = rmp_serde::to_vec(&BinEncoded { + schema_version: "test.byte_slot.v1".into(), + payload: sample(), + }) + .expect("bin encodes"); + + // The rollback direction, so reverting the binary is survivable. + let read_by_old = + rmp_serde::from_slice::(&written_after).expect("plain reader reads bin"); + assert_eq!(read_by_old.payload, sample()); +} + +#[test] +fn re_encoding_an_old_record_under_the_attribute_does_not_reproduce_its_bytes() { + // Where the cost actually lands. Reading is compatible; reproducing is + // not. Idunn byte-exact-checks its control store by re-encoding what it + // decoded, and canonical_sha256 hashes the same serialization -- so a + // record written before the flip can be read, but can never re-encode to + // the bytes it was stored as, and its canonical digest changes. + let written_before = rmp_serde::to_vec(&ArrayEncoded { + schema_version: "test.byte_slot.v1".into(), + payload: sample(), + }) + .expect("array encodes"); + let decoded = rmp_serde::from_slice::(&written_before).expect("reads"); + let re_encoded = rmp_serde::to_vec(&decoded).expect("re-encodes"); + + assert_ne!( + written_before, re_encoded, + "identical bytes would mean the attribute changed nothing" + ); +} diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index 2803db51..0b76990f 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -4,6 +4,7 @@ use chrono::Utc; use cultcache_rs::CultCache; use cultcache_rs::DatabaseEntry; use cultcache_rs::SingleFileMessagePackBackingStore; +use cultnet_rs::CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS; use cultnet_rs::CultMesh; use cultnet_rs::CultMeshAuthorityLease; use cultnet_rs::CultMeshPeerCard; @@ -17,6 +18,7 @@ use cultnet_rs::CultNetDocumentPutOptions; use cultnet_rs::CultNetDocumentRegistry; use cultnet_rs::CultNetMessage; use cultnet_rs::CultNetMutationAuthority; +use cultnet_rs::CultNetReactiveDocumentOptions; use cultnet_rs::CultNetReconnectController; use cultnet_rs::CultNetReconnectPolicyOptions; use cultnet_rs::CultNetRudpPacket; @@ -59,12 +61,14 @@ use cultnet_rs::encode_frame; use cultnet_rs::encode_rudp_packet; use pretty_assertions::assert_eq; use std::cell::RefCell; +use std::collections::VecDeque; use std::net::UdpSocket; use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration as StdDuration; +use std::time::Instant as StdInstant; const TS_HELLO_FRAME: &[u8] = include_bytes!("fixtures/cultnet-ts-hello.frame"); const TS_LEGACY_LOGIN_FRAME: &[u8] = include_bytes!("fixtures/cultnet-ts-legacy-login.frame"); @@ -149,6 +153,44 @@ struct GhostlightAgentStateFixture { display_name: String, } +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "ghostlight.agent-state.ui", + schema = "GhostlightAgentStateUiFixture" +)] +struct GhostlightAgentStateUiFixture { + #[cultcache(key = 0)] + schema_version: String, + #[cultcache(key = 1)] + agent_id: String, + #[cultcache(key = 2)] + display_name: String, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "ghostlight.reactive-note", + schema = "GhostlightReactiveNoteFixture" +)] +struct GhostlightReactiveNoteFixture { + #[cultcache(key = 0)] + body: String, + #[cultcache(key = 1)] + revision: i32, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "ghostlight.reactive-note.ui", + schema = "GhostlightReactiveNoteUiFixture" +)] +struct GhostlightReactiveNoteUiFixture { + #[cultcache(key = 0)] + body: String, + #[cultcache(key = 1)] + revision: i32, +} + #[test] fn security_helpers_round_trip_encrypted_strings_and_validate_sessions() -> Result<()> { let server_security = CultNetServerSecurityOptions::development(); @@ -216,6 +258,7 @@ fn cultnet_schema_messages_round_trip_through_messagepack_frames() -> Result<()> max_payload_bytes: None, max_fragment_bytes: None, max_pending_reliable_packets: None, + reliable_expire_after_ms: None, }], }], }]), @@ -316,6 +359,7 @@ fn rudp_transport_profile_advertises_state_and_realtime_channels() { let profile = create_rudp_transport_profile( "rust-rudp", cultnet_rs::RudpTransportProfileOptions { + media_delivery: None, transport_id: Some("public-rudp".to_string()), host: Some("127.0.0.1".to_string()), port: Some(7777), @@ -323,6 +367,7 @@ fn rudp_transport_profile_advertises_state_and_realtime_channels() { max_fragment_bytes: Some(1000), max_pending_reliable_packets: Some(64), reconnect_policy: None, + media_reliable_expire_after_ms: None, }, ); @@ -383,6 +428,12 @@ fn rudp_transport_profile_advertises_state_and_realtime_channels() { CultNetTransportOrdering::Unordered, Some(64) ), + ( + "audio", + CultNetTransportDelivery::Reliable, + CultNetTransportOrdering::Unordered, + Some(64) + ), ] ); } @@ -715,6 +766,133 @@ fn rudp_session_pings_and_detects_receive_timeout() -> Result<()> { Ok(()) } +#[test] +fn rudp_lossy_packets_cannot_create_reliable_ordered_gaps() -> Result<()> { + let mut client = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 198, + initial_sequence: 1, + ..CultNetRudpSessionOptions::default() + }); + let mut server = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 198, + initial_sequence: 100, + ..CultNetRudpSessionOptions::default() + }); + let connect = client.create_connect(0, Vec::new())?; + let accept = server.accept_connect(&connect, 1, Vec::new())?; + client.receive(&accept, 2)?; + + let ping = client.create_ping(b"pulse".to_vec()); + let pong = server + .receive(&ping, 3)? + .reply + .expect("ping should produce pong"); + let realtime = server.send( + "realtime", + b"discarded realtime".to_vec(), + CultNetRudpSendOptions::default(), + )?; + let latest = server.send( + "latest", + b"discarded latest state".to_vec(), + CultNetRudpSendOptions { + sequenced: true, + ..CultNetRudpSendOptions::default() + }, + )?; + let schema = server.send( + "schema", + b"committed response".to_vec(), + CultNetRudpSendOptions { + reliable: true, + ordered: true, + ..CultNetRudpSendOptions::default() + }, + )?; + + assert_eq!(ping.sequence, 0); + assert_eq!(pong.sequence, 0); + assert_eq!(realtime.sequence, 0); + assert_eq!(latest.sequence, 1); + assert_eq!(schema.sequence, accept.sequence + 1); + assert_eq!( + client.receive(&schema, 4)?.delivered[0].payload, + b"committed response" + ); + Ok(()) +} + +#[test] +fn rudp_unreliable_sequenced_delivery_is_scoped_to_its_channel() -> Result<()> { + let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 197, + initial_sequence: 50, + ..CultNetRudpSessionOptions::default() + }); + let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 197, + initial_sequence: 100, + ..CultNetRudpSessionOptions::default() + }); + let connect = sender.create_connect(0, Vec::new())?; + let accept = receiver.accept_connect(&connect, 1, Vec::new())?; + sender.receive(&accept, 2)?; + let options = CultNetRudpSendOptions { + sequenced: true, + ..CultNetRudpSendOptions::default() + }; + let older = sender.send("latest", b"older".to_vec(), options.clone())?; + let newer = sender.send("latest", b"newer".to_vec(), options)?; + + assert_eq!(older.sequence, 1); + assert_eq!(newer.sequence, 2); + assert_eq!(receiver.receive(&newer, 3)?.delivered[0].payload, b"newer"); + assert!(receiver.receive(&older, 4)?.delivered.is_empty()); + Ok(()) +} + +#[test] +fn rudp_rejects_unreliable_ordered_delivery() -> Result<()> { + let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 196, + ..CultNetRudpSessionOptions::default() + }); + session.receive( + &CultNetRudpPacket { + packet_type: CultNetRudpPacketType::Accept, + connection_id: 196, + sequence: 50, + ack: 0, + ack_mask: 0, + channel_id: "control".to_string(), + reliable: false, + ordered: false, + sequenced: false, + fragment_id: 0, + fragment_index: 0, + fragment_count: 0, + payload: Vec::new(), + }, + 0, + )?; + let error = session + .send( + "schema", + b"cannot order what will not retransmit".to_vec(), + CultNetRudpSendOptions { + ordered: true, + ..CultNetRudpSendOptions::default() + }, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("ordered delivery requires reliability") + ); + Ok(()) +} + #[test] fn rudp_session_bounds_pending_reliable_packets_before_enqueue() -> Result<()> { let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { @@ -953,6 +1131,8 @@ fn rudp_session_skips_control_packets_while_ordering_schema_payloads() -> Result let first = sender.send("schema", b"first".to_vec(), options.clone())?; let control = sender.create_ack(); let second = sender.send("schema", b"second".to_vec(), options)?; + assert_eq!(control.sequence, 0); + assert_eq!(second.sequence, first.sequence + 1); assert_eq!( receiver @@ -1075,6 +1255,7 @@ fn rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames() let connection_id = 0x1020_3040; let mut server = CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { + media_delivery: None, runtime_id: "rust-rudp-server".to_string(), socket: server_socket, mode: CultNetRudpSocketMode::Server, @@ -1087,9 +1268,11 @@ fn rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames() max_fragment_bytes: None, max_pending_reliable_packets: None, reconnect_policy: None, + media_reliable_expire_after_ms: None, })?; let mut client = CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { + media_delivery: None, runtime_id: "rust-rudp-client".to_string(), socket: client_socket, mode: CultNetRudpSocketMode::Client, @@ -1102,6 +1285,7 @@ fn rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames() max_fragment_bytes: None, max_pending_reliable_packets: None, reconnect_policy: None, + media_reliable_expire_after_ms: None, })?; client.connect(b"join".to_vec())?; @@ -1165,6 +1349,7 @@ fn rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames() -> let connection_id = 0x1020_3041; let mut server = CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { + media_delivery: None, runtime_id: "rust-rudp-fragment-server".to_string(), socket: server_socket, mode: CultNetRudpSocketMode::Server, @@ -1177,9 +1362,11 @@ fn rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames() -> max_fragment_bytes: Some(8), max_pending_reliable_packets: None, reconnect_policy: None, + media_reliable_expire_after_ms: None, })?; let mut client = CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { + media_delivery: None, runtime_id: "rust-rudp-fragment-client".to_string(), socket: client_socket, mode: CultNetRudpSocketMode::Client, @@ -1192,6 +1379,7 @@ fn rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames() -> max_fragment_bytes: Some(8), max_pending_reliable_packets: None, reconnect_policy: None, + media_reliable_expire_after_ms: None, })?; client.connect(b"join".to_vec())?; @@ -1207,6 +1395,164 @@ fn rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames() -> Ok(()) } +#[test] +fn rudp_session_advances_large_fragment_sets_through_a_bounded_reliable_window() -> Result<()> { + let connection_id = 0x1020_3042; + let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id, + initial_sequence: 1, + resend_delay_ms: 25, + max_pending_reliable_packets: None, + }); + let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id, + initial_sequence: 100, + resend_delay_ms: 25, + max_pending_reliable_packets: None, + }); + for session in [&mut sender, &mut receiver] { + session.receive( + &CultNetRudpPacket { + packet_type: CultNetRudpPacketType::Accept, + connection_id, + sequence: 0, + ack: 0, + ack_mask: 0, + channel_id: "control".into(), + reliable: false, + ordered: false, + sequenced: false, + fragment_id: 0, + fragment_index: 0, + fragment_count: 0, + payload: Vec::new(), + }, + 0, + )?; + } + + let fragment_count = CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 17; + let payload = (0..fragment_count * 8) + .map(|index| (index % 251) as u8) + .collect::>(); + let initial = sender.send_many( + "schema", + payload.clone(), + CultNetRudpSendOptions { + reliable: true, + ordered: true, + now_ms: 1, + ..CultNetRudpSendOptions::default() + }, + Some(8), + )?; + assert_eq!(initial.len(), CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS); + let oldest_sequence = initial[0].sequence; + assert_eq!(sender.pending_reliable_sequences().len(), initial.len()); + assert_eq!(sender.queued_reliable_packet_count(), 17); + + let mut wire = VecDeque::from(initial); + let mut delivered = Vec::new(); + while !wire.is_empty() { + let admitted = wire.len(); + for _ in 0..admitted { + let packet = wire.pop_front().expect("admitted packet remains queued"); + let received = receiver.receive(&packet, 2)?; + delivered.extend(received.delivered); + } + let acknowledged = sender.receive(&receiver.create_ack(), 3)?; + wire.extend(acknowledged.ready_to_send); + } + + assert_eq!(sender.outstanding_reliable_packet_count(), 0); + assert_eq!(delivered.len(), 1); + assert_eq!(delivered[0].payload, payload); + let old_ack = receiver.create_ack_for_received(oldest_sequence); + assert_eq!(old_ack.ack, oldest_sequence); + assert_eq!(old_ack.ack_mask, 0); + Ok(()) +} + +#[test] +fn rudp_socket_flush_waits_for_large_fragment_delivery() -> Result<()> { + let server_socket = bind_udp_socket()?; + let client_socket = bind_udp_socket()?; + let server_addr = server_socket.local_addr()?; + let connection_id = 0x1020_3043; + let mut server = + CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { + media_delivery: None, + runtime_id: "rust-rudp-window-server".into(), + socket: server_socket, + mode: CultNetRudpSocketMode::Server, + remote_addr: None, + connection_id, + initial_sequence: 100, + resend_delay_ms: 25, + transport_id: None, + max_payload_bytes: None, + max_fragment_bytes: Some(8), + max_pending_reliable_packets: None, + reconnect_policy: None, + media_reliable_expire_after_ms: None, + })?; + let mut client = + CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { + media_delivery: None, + runtime_id: "rust-rudp-window-client".into(), + socket: client_socket, + mode: CultNetRudpSocketMode::Client, + remote_addr: Some(server_addr), + connection_id, + initial_sequence: 1, + resend_delay_ms: 25, + transport_id: None, + max_payload_bytes: None, + max_fragment_bytes: Some(8), + max_pending_reliable_packets: None, + reconnect_policy: None, + media_reliable_expire_after_ms: None, + })?; + client.connect(b"join".to_vec())?; + pump_rudp_handshake(&mut client, &mut server)?; + + let payload = vec![0x5a; (CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 17) * 8]; + client.send("schema", payload.clone())?; + assert_eq!( + client.pending_reliable_packet_count(), + CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + ); + assert_eq!( + client.outstanding_reliable_packet_count(), + CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 17 + ); + + let server_worker = thread::spawn(move || -> Result { + let delivery_deadline = StdInstant::now() + StdDuration::from_secs(5); + let delivered = loop { + if let Some(frame) = server.receive_once()? { + break frame; + } + if StdInstant::now() >= delivery_deadline { + anyhow::bail!("RUDP large socket frame was not delivered"); + } + }; + let linger_deadline = StdInstant::now() + StdDuration::from_millis(250); + while StdInstant::now() < linger_deadline { + let _ = server.receive_once()?; + server.poll_resends()?; + } + Ok(delivered) + }); + client.flush_reliable(StdDuration::from_secs(5))?; + let delivered = server_worker + .join() + .map_err(|_| anyhow::anyhow!("RUDP server worker panicked"))??; + assert_eq!(delivered.payload, payload); + assert_eq!(client.outstanding_reliable_packet_count(), 0); + Ok(()) +} + #[test] fn cultmesh_facade_creates_rudp_client_from_peer_endpoint() -> Result<()> { let connection_id = 0x2030_4050; @@ -1604,7 +1950,7 @@ fn document_registry_replicates_typed_cultcache_state() -> Result<()> { let mut origin = CultCache::new(); origin.register_entry_type::()?; - origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store)); + origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?; origin.pull_all_backing_stores()?; origin.put("epiphany.persona", &payload)?; @@ -1612,11 +1958,17 @@ fn document_registry_replicates_typed_cultcache_state() -> Result<()> { let mut target = CultCache::new(); target.register_entry_type::()?; - target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store)); + target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; target.pull_all_backing_stores()?; let applied = registry.apply_snapshot_response::(&mut target, &snapshot)?; + let synced = registry.sync_document_from_snapshot_response::( + &mut target, + &snapshot, + "epiphany.persona", + )?; assert_eq!(applied, vec![payload.clone()]); + assert_eq!(synced, payload); assert_eq!( target.get_required::("epiphany.persona")?, payload @@ -1655,7 +2007,7 @@ fn raw_snapshot_replication_preserves_messagepack_payload_bytes() -> Result<()> let mut origin = CultCache::new(); origin.register_entry_type::()?; - origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store)); + origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?; origin.pull_all_backing_stores()?; origin.put("epiphany.persona", &payload)?; @@ -1666,7 +2018,7 @@ fn raw_snapshot_replication_preserves_messagepack_payload_bytes() -> Result<()> let mut target = CultCache::new(); target.register_entry_type::()?; - target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store)); + target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; target.pull_all_backing_stores()?; let applied = registry .apply_raw_snapshot_response::(&mut target, &raw_snapshot)?; @@ -1682,6 +2034,213 @@ fn raw_snapshot_replication_preserves_messagepack_payload_bytes() -> Result<()> Ok(()) } +#[test] +fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> { + let temp = tempfile::tempdir()?; + let origin_store = temp.path().join("origin-alias.msgpack"); + let target_store = temp.path().join("target-alias.msgpack"); + let canonical = GhostlightAgentStateFixture { + schema_version: "ghostlight.agent_state.v0".to_string(), + agent_id: "epiphany.persona".to_string(), + display_name: "Persona".to_string(), + }; + + let mut origin_registry = CultNetDocumentRegistry::new(); + origin_registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< + GhostlightAgentStateFixture, + >( + "ghostlight.agent_state.v0".to_string(), + "ghostlight.agent_state.v0".to_string(), + )); + let mut origin = CultCache::new(); + origin.register_entry_type::()?; + origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?; + origin.pull_all_backing_stores()?; + origin.put("epiphany.persona", &canonical)?; + let raw_snapshot = origin_registry.create_raw_snapshot_response( + &origin, + "raw-snapshot-alias", + Some(&["ghostlight.agent_state.v0".to_string()]), + Some(&["epiphany.persona".to_string()]), + )?; + + let mut alias_registry = CultNetDocumentRegistry::new(); + alias_registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< + GhostlightAgentStateUiFixture, + >( + "ghostlight.agent_state.v0".to_string(), + "ghostlight.agent_state.v0".to_string(), + )); + let mut target = CultCache::new(); + target.register_entry_type::()?; + target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; + target.pull_all_backing_stores()?; + + let applied = alias_registry + .sync_raw_document_from_snapshot_response::( + &mut target, + &raw_snapshot, + "epiphany.persona", + )?; + + assert_eq!( + applied, + GhostlightAgentStateUiFixture { + schema_version: "ghostlight.agent_state.v0".to_string(), + agent_id: "epiphany.persona".to_string(), + display_name: "Persona".to_string(), + } + ); + assert_eq!( + target.get_required::("epiphany.persona")?, + applied + ); + Ok(()) +} + +#[test] +fn reactive_document_coalesces_direct_same_schema_alias_member_writes() -> Result<()> { + let temp = tempfile::tempdir()?; + let target_store = temp.path().join("target-reactive-alias.msgpack"); + let mut registry = CultNetDocumentRegistry::new(); + registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< + GhostlightReactiveNoteUiFixture, + >( + "ghostlight.reactive_note.v0".to_string(), + "ghostlight.reactive_note.v0".to_string(), + )); + let cache = Arc::new(Mutex::new(CultCache::new())); + { + let mut cache = cache.lock().expect("cache mutex"); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; + cache.pull_all_backing_stores()?; + cache.put( + "note:reactive", + &GhostlightReactiveNoteUiFixture { + body: "initial".to_string(), + revision: 1, + }, + )?; + } + + let reactive = registry.reactive_document::( + Arc::clone(&cache), + "note:reactive", + CultNetReactiveDocumentOptions { + flush_delay: StdDuration::from_millis(5), + ..CultNetReactiveDocumentOptions::default() + }, + )?; + { + let current = reactive.current(); + let mut current = current.lock().expect("reactive current mutex"); + current.body = "first-local-edit".to_string(); + current.body = "second-local-edit".to_string(); + current.revision = 2; + } + + for _ in 0..30 { + let body = cache + .lock() + .expect("cache mutex") + .get_required::("note:reactive")? + .body; + if body == "second-local-edit" { + break; + } + thread::sleep(StdDuration::from_millis(5)); + } + + let stored = cache + .lock() + .expect("cache mutex") + .get_required::("note:reactive")?; + assert_eq!(stored.body, "second-local-edit"); + assert_eq!(stored.revision, 2); + assert!(!reactive.is_dirty()); + assert!(reactive.last_error().is_none()); + Ok(()) +} + +#[test] +fn reactive_document_tracks_canonical_reconciliation_delta() -> Result<()> { + let temp = tempfile::tempdir()?; + let target_store = temp.path().join("target-reactive-reconcile.msgpack"); + let mut registry = CultNetDocumentRegistry::new(); + registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< + GhostlightReactiveNoteFixture, + >( + "ghostlight.reactive_note.v0".to_string(), + "ghostlight.reactive_note.v0".to_string(), + )); + let cache = Arc::new(Mutex::new(CultCache::new())); + { + let mut cache = cache.lock().expect("cache mutex"); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; + cache.pull_all_backing_stores()?; + cache.put( + "note:reconcile", + &GhostlightReactiveNoteFixture { + body: "initial".to_string(), + revision: 1, + }, + )?; + } + + let reactive = registry.reactive_document::( + Arc::clone(&cache), + "note:reconcile", + CultNetReactiveDocumentOptions { + flush_delay: StdDuration::from_secs(60), + detect_local_changes: false, + ..CultNetReactiveDocumentOptions::default() + }, + )?; + reactive.update(|note| { + note.body = "local-prediction".to_string(); + note.revision = 2; + })?; + let authoritative = registry.create_document_put_message( + "put-authoritative", + "note:reconcile", + &GhostlightReactiveNoteFixture { + body: "authoritative".to_string(), + revision: 7, + }, + CultNetDocumentPutOptions::default(), + )?; + + reactive.apply_document_put_message(&authoritative)?; + + let current = reactive.current(); + let current = current.lock().expect("reactive current mutex").clone(); + assert_eq!(current.body, "local-prediction"); + let reconciliation = reactive + .reconciliation() + .expect("dirty canonical apply records reconciliation"); + assert_eq!(reconciliation.canonical.body, "authoritative"); + assert_eq!(reconciliation.predicted.body, "local-prediction"); + assert_eq!( + reconciliation.delta.get("0"), + Some(&serde_json::json!("local-prediction")) + ); + assert_eq!( + reconciliation.delta.get("1"), + Some(&serde_json::json!(-5.0)) + ); + + reactive.flush()?; + let stored = cache + .lock() + .expect("cache mutex") + .get_required::("note:reconcile")?; + assert_eq!(stored.body, "local-prediction"); + assert!(reactive.reconciliation().is_none()); + Ok(()) +} + #[test] fn client_security_keeps_the_connection_key_visible_without_exposing_cipher_logic() -> Result<()> { let options = CultNetClientSecurityOptions::development(); @@ -1879,3 +2438,514 @@ fn schema_discovery_round_trips_over_legacy_gamecult_contract_when_inline_schema ); Ok(()) } + +#[test] +fn rudp_sequenced_channel_tracking_is_bounded() -> Result<()> { + let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 401, + initial_sequence: 1, + ..CultNetRudpSessionOptions::default() + }); + let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 401, + initial_sequence: 100, + ..CultNetRudpSessionOptions::default() + }); + let connect = sender.create_connect(0, Vec::new())?; + let accept = receiver.accept_connect(&connect, 1, Vec::new())?; + sender.receive(&accept, 2)?; + + // A peer picks its own channel ids, so the sequenced map is attacker-sized. + let mut error = None; + for channel in 0..256u32 { + let packet = sender.send( + &format!("channel-{channel}"), + b"state".to_vec(), + CultNetRudpSendOptions { + sequenced: true, + ..CultNetRudpSendOptions::default() + }, + )?; + if let Err(refused) = receiver.receive(&packet, 3) { + error = Some(refused); + break; + } + } + let error = error.expect("unbounded sequenced channel tracking"); + assert!( + error.to_string().contains("too many sequenced channels"), + "unexpected refusal: {error}" + ); + Ok(()) +} + +#[test] +fn rudp_ordered_channel_tracking_is_bounded() -> Result<()> { + let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 402, + initial_sequence: 1, + ..CultNetRudpSessionOptions::default() + }); + let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 402, + initial_sequence: 100, + ..CultNetRudpSessionOptions::default() + }); + let connect = sender.create_connect(0, Vec::new())?; + let accept = receiver.accept_connect(&connect, 1, Vec::new())?; + sender.receive(&accept, 2)?; + + let mut error = None; + for channel in 0..256u32 { + let packet = sender.send( + &format!("ordered-{channel}"), + b"state".to_vec(), + CultNetRudpSendOptions { + reliable: true, + ordered: true, + ..CultNetRudpSendOptions::default() + }, + )?; + match receiver.receive(&packet, 3) { + Ok(_) => { + sender.receive(&receiver.create_ack_for(packet.sequence), 3)?; + } + Err(refused) => { + error = Some(refused); + break; + } + } + } + let error = error.expect("unbounded ordered channel tracking"); + assert!( + error.to_string().contains("too many ordered channels"), + "unexpected refusal: {error}" + ); + Ok(()) +} + +#[test] +fn rudp_ordered_hold_buffer_is_bounded() -> Result<()> { + let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 403, + initial_sequence: 1, + ..CultNetRudpSessionOptions::default() + }); + let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 403, + initial_sequence: 100, + ..CultNetRudpSessionOptions::default() + }); + let connect = sender.create_connect(0, Vec::new())?; + let accept = receiver.accept_connect(&connect, 1, Vec::new())?; + sender.receive(&accept, 2)?; + + let options = CultNetRudpSendOptions { + reliable: true, + ordered: true, + ..CultNetRudpSendOptions::default() + }; + // Withhold the first frame so every later one has to be held. + let withheld = sender.send("schema", b"first".to_vec(), options.clone())?; + sender.receive(&receiver.create_ack_for(withheld.sequence), 3)?; + + let mut error = None; + for step in 0..4_096u32 { + let packet = sender.send("schema", vec![(step % 251) as u8], options.clone())?; + match receiver.receive(&packet, 4) { + Ok(result) => { + assert!(result.delivered.is_empty(), "gap should hold every frame"); + sender.receive(&receiver.create_ack_for(packet.sequence), 4)?; + } + Err(refused) => { + error = Some(refused); + break; + } + } + } + let error = error.expect("unbounded ordered hold buffer"); + assert!( + error.to_string().contains("ordered hold buffer is full"), + "unexpected refusal: {error}" + ); + Ok(()) +} + +#[test] +fn rudp_far_ahead_sequence_cannot_starve_the_session() -> Result<()> { + let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 404, + initial_sequence: 1, + ..CultNetRudpSessionOptions::default() + }); + let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 404, + initial_sequence: 100, + ..CultNetRudpSessionOptions::default() + }); + let connect = sender.create_connect(0, Vec::new())?; + let accept = receiver.accept_connect(&connect, 1, Vec::new())?; + sender.receive(&accept, 2)?; + + let options = CultNetRudpSendOptions { + reliable: true, + ..CultNetRudpSendOptions::default() + }; + let first = sender.send("schema", b"before".to_vec(), options.clone())?; + assert_eq!(receiver.receive(&first, 3)?.delivered.len(), 1); + sender.receive(&receiver.create_ack_for(first.sequence), 3)?; + + // A forged packet far past the current highest. Recording it would push the + // receive state beyond every sequence still in flight. + let forged = CultNetRudpPacket { + packet_type: CultNetRudpPacketType::Data, + connection_id: 404, + sequence: first.sequence.saturating_add(500_000), + ack: 0, + ack_mask: 0, + channel_id: "schema".to_string(), + reliable: true, + ordered: false, + sequenced: false, + fragment_id: 0, + fragment_index: 0, + fragment_count: 0, + payload: b"hostile".to_vec(), + }; + assert!(receiver.receive(&forged, 4)?.delivered.is_empty()); + + // The session must still accept the next legitimate packet. + let second = sender.send("schema", b"after".to_vec(), options)?; + assert_eq!( + receiver.receive(&second, 5)?.delivered[0].payload, + b"after".to_vec() + ); + Ok(()) +} + +#[test] +fn rudp_reliable_sequence_exhaustion_is_reported_not_fatal() -> Result<()> { + let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 405, + // One sequence left: the handshake takes it, so the next send is the + // first allocation with nowhere to go. + initial_sequence: u32::MAX - 1, + ..CultNetRudpSessionOptions::default() + }); + let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 405, + initial_sequence: 100, + ..CultNetRudpSessionOptions::default() + }); + let connect = sender.create_connect(0, Vec::new())?; + let accept = receiver.accept_connect(&connect, 1, Vec::new())?; + sender.receive(&accept, 2)?; + + let error = sender + .send( + "schema", + b"one past the end".to_vec(), + CultNetRudpSendOptions { + reliable: true, + ..CultNetRudpSendOptions::default() + }, + ) + .unwrap_err(); + assert!( + error.to_string().contains("sequence space is exhausted"), + "unexpected error: {error}" + ); + Ok(()) +} + +#[test] +fn tcp_framed_receive_refuses_a_length_beyond_the_advertised_maximum() -> Result<()> { + let profile = create_tcp_framed_transport_profile( + "rust-transport", + TcpFramedTransportProfileOptions { + transport_id: Some("bounded-tcp".to_string()), + max_payload_bytes: Some(64), + ..TcpFramedTransportProfileOptions::default() + }, + ); + let advertised = profile + .transports + .iter() + .find(|transport| transport.protocol == CultNetTransportProtocol::TcpFramed) + .and_then(|transport| { + transport + .channels + .iter() + .find(|channel| channel.channel_id == "schema") + }) + .and_then(|channel| channel.max_payload_bytes) + .expect("tcp_framed schema channel advertises a maximum payload"); + + // Only a header, declaring more than the profile allows. Honouring it would + // reserve the peer's number before a single payload byte arrives. + let mut header = ((advertised as u64 + 1) as u32).to_be_bytes().to_vec(); + header.extend_from_slice(b"short"); + let mut receiver = TcpFramedTransportConnection::new(std::io::Cursor::new(header), profile); + let error = receiver.receive().unwrap_err(); + assert!( + error.to_string().contains("exceeds advertised maximum"), + "unexpected error: {error}" + ); + Ok(()) +} + +#[test] +fn rudp_reliable_expiry_drops_late_packets_and_leaves_durable_sends_alone() -> Result<()> { + let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 7, + initial_sequence: 10, + resend_delay_ms: 100, + ..CultNetRudpSessionOptions::default() + }); + accept_rudp_session(&mut sender, 7)?; + + let expiring = sender.send( + "media", + b"late".to_vec(), + CultNetRudpSendOptions { + reliable: true, + now_ms: 0, + reliable_expire_after_ms: Some(50), + ..CultNetRudpSendOptions::default() + }, + )?; + let durable = sender.send( + "schema", + b"state".to_vec(), + CultNetRudpSendOptions { + reliable: true, + now_ms: 0, + ..CultNetRudpSendOptions::default() + }, + )?; + assert_eq!(sender.pending_reliable_sequences().len(), 2); + assert_eq!(sender.reliable_packets_expired(), 0); + + // One millisecond before the deadline nothing is dropped: the boundary is + // inclusive, so a packet is never discarded early. + sender.send( + "schema", + b"tick".to_vec(), + CultNetRudpSendOptions { + reliable: false, + now_ms: 50, + ..CultNetRudpSendOptions::default() + }, + )?; + assert_eq!(sender.reliable_packets_expired(), 0); + + sender.send( + "schema", + b"tick".to_vec(), + CultNetRudpSendOptions { + reliable: false, + now_ms: 51, + ..CultNetRudpSendOptions::default() + }, + )?; + assert_eq!(sender.reliable_packets_expired(), 1); + assert_eq!( + sender.pending_reliable_sequences(), + vec![durable.sequence], + "a send without an expiry deadline is never dropped" + ); + assert_ne!(expiring.sequence, durable.sequence); + Ok(()) +} + +#[test] +fn rudp_reliable_expiry_also_reclaims_the_send_backlog() -> Result<()> { + let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 9, + initial_sequence: 10, + resend_delay_ms: 100, + ..CultNetRudpSessionOptions::default() + }); + accept_rudp_session(&mut sender, 9)?; + + let fragment_count = CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 5; + let payload = (0..fragment_count * 8) + .map(|index| (index % 251) as u8) + .collect::>(); + sender.send_many( + "media", + payload, + CultNetRudpSendOptions { + reliable: true, + now_ms: 0, + reliable_expire_after_ms: Some(30), + ..CultNetRudpSendOptions::default() + }, + Some(8), + )?; + assert_eq!( + sender.pending_reliable_sequences().len(), + CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + ); + assert_eq!(sender.queued_reliable_packet_count(), 5); + + // The deadline is fixed at admission, so waiting in the backlog spends it + // exactly as waiting for an acknowledgement does. A queued packet promoted + // after its moment has passed would be as useless as a retransmitted one. + sender.send( + "schema", + b"tick".to_vec(), + CultNetRudpSendOptions { + reliable: false, + now_ms: 31, + ..CultNetRudpSendOptions::default() + }, + )?; + assert_eq!(sender.pending_reliable_sequences().len(), 0); + assert_eq!(sender.queued_reliable_packet_count(), 0); + assert_eq!( + sender.reliable_packets_expired(), + fragment_count as u64, + "both the in-flight window and the backlog are counted" + ); + Ok(()) +} + +fn accept_rudp_session(session: &mut CultNetRudpSession, connection_id: u32) -> Result<()> { + session.receive( + &CultNetRudpPacket { + packet_type: CultNetRudpPacketType::Accept, + connection_id, + sequence: 0, + ack: 0, + ack_mask: 0, + channel_id: "control".into(), + reliable: false, + ordered: false, + sequenced: false, + fragment_id: 0, + fragment_index: 0, + fragment_count: 0, + payload: Vec::new(), + }, + 0, + )?; + Ok(()) +} + +#[test] +fn rudp_profile_expires_only_the_media_channels() -> Result<()> { + let socket = std::net::UdpSocket::bind("127.0.0.1:0")?; + let peer = socket.local_addr()?; + let options = CultNetRudpSocketTransportOptions::client("expiry-profile", socket, peer, 11); + let connection = CultNetRudpSocketTransportConnection::new(options)?; + + let channels = connection + .profile + .transports + .iter() + .flat_map(|transport| transport.channels.iter()) + .map(|channel| (channel.channel_id.as_str(), channel.reliable_expire_after_ms)) + .collect::>(); + + for media_channel in ["media", "audio"] { + assert!( + channels.contains(&(media_channel, Some(75))), + "the {media_channel} channel advertises its expiry so a peer can see it: {channels:?}" + ); + } + for (channel_id, expiry) in &channels { + if !matches!(*channel_id, "media" | "audio") { + assert_eq!( + *expiry, None, + "state channel {channel_id} must never expire a reliable send" + ); + } + } + Ok(()) +} + +fn connected_rudp_pair() -> Result<(CultNetRudpSession, CultNetRudpSession)> { + let mut client = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 311, + initial_sequence: 1, + ..CultNetRudpSessionOptions::default() + }); + let mut server = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 311, + initial_sequence: 100, + ..CultNetRudpSessionOptions::default() + }); + let connect = client.create_connect(0, Vec::new())?; + let accept = server.accept_connect(&connect, 1, Vec::new())?; + client.receive(&accept, 2)?; + Ok((client, server)) +} + +#[test] +fn rudp_evicts_stalest_incomplete_fragment_set_instead_of_dying() -> Result<()> { + let (mut client, mut server) = connected_rudp_pair()?; + client.set_max_pending_fragment_sets(4)?; + let fragmented = |server: &mut CultNetRudpSession, fill: u8| { + server.send_many( + "media", + vec![fill; 2500], + CultNetRudpSendOptions::default(), + Some(1000), + ) + }; + + // Eight payloads each lose their last fragment on the wire. Before, the + // fifth new set errored the receiver out and every later fragmented + // payload with it. + let mut stranded = Vec::new(); + for fill in 1..=8u8 { + let packets = fragmented(&mut server, fill)?; + assert_eq!(packets.len(), 3); + for packet in &packets[..2] { + assert!(client.receive(packet, 10)?.delivered.is_empty()); + } + stranded.push(packets[2].clone()); + } + assert_eq!(client.fragment_sets_evicted(), 4); + + // A complete payload still lands. + let complete = fragmented(&mut server, 9)?; + let mut delivered = Vec::new(); + for packet in &complete { + delivered.extend(client.receive(packet, 11)?.delivered); + } + assert_eq!(delivered.len(), 1); + assert_eq!(delivered[0].payload, vec![9u8; 2500]); + assert_eq!(client.fragment_sets_evicted(), 5); + + // The eviction order is stalest-first: set 8 is still pending and completes + // when its late fragment shows up; set 1 was evicted long ago, so its late + // fragment opens a fresh set and delivers nothing. + assert_eq!( + client.receive(&stranded[7], 12)?.delivered[0].payload, + vec![8u8; 2500] + ); + assert!(client.receive(&stranded[0], 13)?.delivered.is_empty()); + Ok(()) +} + +#[test] +fn rudp_fragment_ids_wrap_instead_of_saturating() -> Result<()> { + let (_client, mut server) = connected_rudp_pair()?; + let mut ids = Vec::new(); + for _ in 0..(u16::MAX as usize + 1) { + let packets = server.send_many( + "media", + vec![0u8; 1500], + CultNetRudpSendOptions::default(), + Some(1000), + )?; + ids.push(packets[0].fragment_id); + } + assert_eq!(ids[0], 1); + assert_eq!(ids[u16::MAX as usize - 1], u16::MAX); + // The 65,536th set must not share an id with the 65,535th. + assert_eq!(ids[u16::MAX as usize], 1); + Ok(()) +} diff --git a/packages/cultnet-rs/tests/media_channel_delivery.rs b/packages/cultnet-rs/tests/media_channel_delivery.rs new file mode 100644 index 00000000..499be0d3 --- /dev/null +++ b/packages/cultnet-rs/tests/media_channel_delivery.rs @@ -0,0 +1,92 @@ +//! Media delivery is the caller's choice, and the advertised profile is where +//! that choice lives. +//! +//! Before this, `media` was hardcoded `Reliable` in two places — the profile +//! builder and the per-channel send options — so no caller could say otherwise. +//! A producer asking for an unreliable media lane got a reliable one that +//! retransmits under loss, which adds load exactly when the link has least to +//! give. Muninn asked for that lane by passing no expiry, meaning "unreliable, +//! so expiry is moot", and got reliable-with-no-expiry instead: retransmit +//! forever. +//! +//! The default stays `Reliable` so every existing consumer is untouched. + +use std::net::UdpSocket; + +use cultnet_rs::{ + CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, + CultNetTransportDelivery, +}; + +fn media_channel_delivery( + media_delivery: Option, +) -> (CultNetTransportDelivery, Option) { + let socket = UdpSocket::bind("127.0.0.1:0").expect("binds"); + let endpoint = "127.0.0.1:5204".parse().expect("parses"); + let mut options = CultNetRudpSocketTransportOptions::client("test-media", socket, endpoint, 7); + options.media_delivery = media_delivery; + options.media_reliable_expire_after_ms = None; + + let transport = CultNetRudpSocketTransportConnection::new(options).expect("opens"); + let channel = transport + .profile + .transports + .first() + .expect("a transport") + .channels + .iter() + .find(|channel| channel.channel_id == "media") + .expect("a media channel"); + (channel.delivery, channel.reliable_expire_after_ms) +} + +#[test] +fn media_defaults_to_reliable_so_existing_consumers_do_not_move() { + let (delivery, _) = media_channel_delivery(None); + assert_eq!(delivery, CultNetTransportDelivery::Reliable); +} + +#[test] +fn a_caller_can_ask_for_an_unreliable_media_lane() { + let (delivery, expiry) = media_channel_delivery(Some(CultNetTransportDelivery::Unreliable)); + assert_eq!(delivery, CultNetTransportDelivery::Unreliable); + assert_eq!( + expiry, None, + "an unreliable channel has nothing to expire; the pairing should stay honest" + ); +} + +#[test] +fn asking_for_reliable_explicitly_matches_the_default() { + let (explicit, _) = media_channel_delivery(Some(CultNetTransportDelivery::Reliable)); + let (defaulted, _) = media_channel_delivery(None); + assert_eq!(explicit, defaulted); +} + +/// The channels whose semantics are fixed by design must not drift with this. +#[test] +fn the_other_channels_keep_their_fixed_delivery() { + let socket = UdpSocket::bind("127.0.0.1:0").expect("binds"); + let endpoint = "127.0.0.1:5204".parse().expect("parses"); + let mut options = CultNetRudpSocketTransportOptions::client("test-media", socket, endpoint, 8); + options.media_delivery = Some(CultNetTransportDelivery::Unreliable); + + let transport = CultNetRudpSocketTransportConnection::new(options).expect("opens"); + let channels = &transport + .profile + .transports + .first() + .expect("a transport") + .channels; + + let delivery_of = |id: &str| { + channels + .iter() + .find(|channel| channel.channel_id == id) + .unwrap_or_else(|| panic!("channel {id}")) + .delivery + }; + + assert_eq!(delivery_of("schema"), CultNetTransportDelivery::Reliable); + assert_eq!(delivery_of("realtime"), CultNetTransportDelivery::Unreliable); +} diff --git a/packages/cultnet-rs/tests/media_stream_contracts.rs b/packages/cultnet-rs/tests/media_stream_contracts.rs new file mode 100644 index 00000000..963d78a2 --- /dev/null +++ b/packages/cultnet-rs/tests/media_stream_contracts.rs @@ -0,0 +1,217 @@ +//! The CultMesh Media Stream records are a wire contract shared by producers and +//! consumers that never link each other. These tests pin the properties a +//! consumer is entitled to rely on: positional layout, declared field order, and +//! tolerance of records written before an appended field existed. + +use cultcache_rs::DatabaseEntry; +use cultnet_rs::{ + GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA, GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA, + GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA, GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA, + GameCultMediaAudioPacketRecord, GameCultMediaReceiverFeedbackRecord, + GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord, +}; +use rmpv::Value; + +fn access_unit() -> GameCultMediaVideoAccessUnitRecord { + GameCultMediaVideoAccessUnitRecord { + stream_id: "raven-primary-av".to_string(), + session_id: "session-1".to_string(), + frame_id: 4242, + codec: "h264".to_string(), + pts_ticks: 90_000, + duration_ticks: 3_000, + timebase_num: 1, + timebase_den: 90_000, + keyframe: true, + dependency_frame_id: None, + deadline_ticks: 108_000, + chunk_index: 2, + chunk_count: 5, + payload: vec![0xDE, 0xAD, 0xBE, 0xEF], + } +} + +fn audio_packet() -> GameCultMediaAudioPacketRecord { + GameCultMediaAudioPacketRecord { + stream_id: "raven-primary-av".to_string(), + session_id: "session-1".to_string(), + packet_id: 77, + codec: "opus".to_string(), + pts_ticks: 48_000, + duration_ticks: 960, + timebase_num: 1, + timebase_den: 48_000, + deadline_ticks: 96_000, + payload: vec![1, 2, 3], + } +} + +fn decode_as_value(bytes: &[u8]) -> Value { + rmp_serde::from_slice(bytes).expect("records decode as generic MessagePack") +} + +/// The declared `key` indices are the wire layout, not documentation. A consumer +/// in another language reads position 2 for `frame_id` and position 13 for +/// `payload`; reordering the struct would silently break it. +#[test] +fn video_access_unit_encodes_as_a_positional_array() { + let encoded = rmp_serde::to_vec(&access_unit()).expect("encodes"); + let Value::Array(slots) = decode_as_value(&encoded) else { + panic!("access units must encode as a positional array"); + }; + + assert_eq!(slots.len(), 14, "field count is wire contract"); + assert_eq!(slots[0].as_str(), Some("raven-primary-av")); + assert_eq!(slots[2].as_u64(), Some(4242), "slot 2 is frame_id"); + assert_eq!(slots[3].as_str(), Some("h264")); + assert_eq!(slots[8].as_bool(), Some(true), "slot 8 is keyframe"); + assert!(slots[9].is_nil(), "an absent dependency stays nil, not 0"); + assert_eq!(slots[12].as_u64(), Some(5), "slot 12 is chunk_count"); + assert_eq!( + slots[13].as_slice(), + Some([0xDE, 0xAD, 0xBE, 0xEF].as_slice()), + "slot 13 is the payload, carried as bin" + ); +} + +/// Payloads must reach the wire as MessagePack `bin`, not as an array of +/// integers. The C#, TypeScript and Python runtimes all read byte fields as +/// `bin`, so a payload serialized as a sequence is unreadable by the reference +/// implementation, and costs 1.45x besides: 848 bytes encode to 1234 as an +/// integer array against 851 as `bin`. +/// +/// This is what the derive's `bytes` attribute buys. `#[serde(with = +/// "serde_bytes")]` will not do it — `DatabaseEntry` emits its own `Serialize` +/// and reads only its own `cultcache` attribute namespace. +#[test] +fn payload_should_encode_as_messagepack_bin() { + let encoded = rmp_serde::to_vec(&access_unit()).expect("encodes"); + let Value::Array(slots) = decode_as_value(&encoded) else { + panic!("access units must encode as a positional array"); + }; + + assert_eq!( + slots[13].as_slice(), + Some([0xDE, 0xAD, 0xBE, 0xEF].as_slice()), + "payloads belong on the wire as bin, not as an array of integers" + ); +} + +#[test] +fn audio_packets_are_whole_and_carry_their_own_timebase() { + let encoded = rmp_serde::to_vec(&audio_packet()).expect("encodes"); + let Value::Array(slots) = decode_as_value(&encoded) else { + panic!("audio packets must encode as a positional array"); + }; + + assert_eq!(slots.len(), 10, "field count is wire contract"); + assert_eq!(slots[2].as_u64(), Some(77), "slot 2 is packet_id"); + assert_eq!(slots[6].as_u64(), Some(1), "slot 6 is timebase_num"); + assert_eq!(slots[7].as_u64(), Some(48_000), "slot 7 is timebase_den"); + + let decoded: GameCultMediaAudioPacketRecord = + rmp_serde::from_slice(&encoded).expect("round-trips"); + assert_eq!(decoded, audio_packet()); +} + +#[test] +fn parity_shards_carry_the_geometry_recovery_needs() { + let shard = GameCultMediaVideoParityShardRecord { + stream_id: "raven-primary-av".to_string(), + session_id: "session-1".to_string(), + frame_id: 4242, + codec: "h264".to_string(), + pts_ticks: 90_000, + duration_ticks: 3_000, + timebase_num: 1, + timebase_den: 90_000, + keyframe: false, + dependency_frame_id: Some(4241), + deadline_ticks: 108_000, + chunk_count: 5, + parity_index: 1, + parity_count: 2, + chunk_payload_bytes: 1024, + last_chunk_payload_bytes: 512, + payload: vec![9, 9, 9], + }; + + let encoded = rmp_serde::to_vec(&shard).expect("encodes"); + let Value::Array(slots) = decode_as_value(&encoded) else { + panic!("parity shards must encode as a positional array"); + }; + + assert_eq!(slots.len(), 17, "field count is wire contract"); + assert_eq!(slots[12].as_u64(), Some(1), "slot 12 is parity_index"); + assert_eq!(slots[13].as_u64(), Some(2), "slot 13 is parity_count"); + assert_eq!( + slots[15].as_u64(), + Some(512), + "slot 15 is last_chunk_payload_bytes, which recovery needs to size the tail" + ); + + let decoded: GameCultMediaVideoParityShardRecord = + rmp_serde::from_slice(&encoded).expect("round-trips"); + assert_eq!(decoded, shard); +} + +/// `missing_video_chunk_keys` was appended after producers were already writing +/// feedback. A record written without it must still decode, or the append was a +/// breaking change wearing a `default` attribute. +#[test] +fn receiver_feedback_decodes_records_written_before_its_last_field() { + let without_appended_field = Value::Array(vec![ + Value::from("raven-primary-av"), + Value::from("session-1"), + Value::from("starfire-obs"), + Value::from(4200u64), + Value::Array(vec![Value::from(4198u64)]), + Value::Array(vec![Value::from(4199u64)]), + Value::from(true), + Value::from(1_500i64), + Value::from(9_000i64), + Value::from("2026-09-09T00:00:00Z"), + ]); + + let mut bytes = Vec::new(); + rmpv::encode::write_value(&mut bytes, &without_appended_field).expect("writes legacy record"); + + let decoded: GameCultMediaReceiverFeedbackRecord = + rmp_serde::from_slice(&bytes).expect("a pre-append record must still decode"); + + assert_eq!(decoded.receiver_id, "starfire-obs"); + assert_eq!(decoded.highest_decodable_frame_id, Some(4200)); + assert!(decoded.requested_keyframe); + assert!( + decoded.missing_video_chunk_keys.is_empty(), + "the appended field defaults rather than failing the decode" + ); +} + +/// The published constants and the derived type identity must not drift apart; +/// a consumer resolving by schema id and a producer registering by type would +/// then disagree without either failing to compile. +#[test] +fn published_schema_constants_match_the_derived_identity() { + assert_eq!( + GameCultMediaVideoAccessUnitRecord::SCHEMA_NAME, + GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA + ); + assert_eq!( + GameCultMediaVideoParityShardRecord::SCHEMA_NAME, + GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA + ); + assert_eq!( + GameCultMediaAudioPacketRecord::SCHEMA_NAME, + GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA + ); + assert_eq!( + GameCultMediaReceiverFeedbackRecord::SCHEMA_NAME, + GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA + ); + + assert_eq!( + GameCultMediaAudioPacketRecord::TYPE, + "gamecult.media_audio_packet" + ); +} diff --git a/packages/cultnet-rs/tests/media_stream_wire.rs b/packages/cultnet-rs/tests/media_stream_wire.rs new file mode 100644 index 00000000..cee31a04 --- /dev/null +++ b/packages/cultnet-rs/tests/media_stream_wire.rs @@ -0,0 +1,217 @@ +//! The envelope, and one proof that removing Muninn's shadow structs changed +//! nothing on the wire. + +use cultnet_rs::{ + GAMECULT_MEDIA_CHANNEL, GameCultMediaAudioPacketRecord, GameCultMediaReceiverFeedbackRecord, + GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord, + GameCultMediaWireRecord, MediaWireProvenance, decode_media_wire_record, + encode_media_wire_record, +}; +use serde::Serialize; + +fn provenance() -> MediaWireProvenance<'static> { + MediaWireProvenance { + stored_at: "unix:1000", + runtime_id: "raven", + role: "muninn.media", + producer: "muninn", + } +} + +fn access_unit() -> GameCultMediaVideoAccessUnitRecord { + GameCultMediaVideoAccessUnitRecord { + stream_id: "raven-primary-av".to_string(), + session_id: "session-1".to_string(), + frame_id: 4242, + codec: "h264".to_string(), + pts_ticks: 90_000, + duration_ticks: 3_000, + timebase_num: 1, + timebase_den: 90_000, + keyframe: true, + dependency_frame_id: None, + deadline_ticks: 108_000, + chunk_index: 2, + chunk_count: 5, + payload: (0u8..=255).collect(), + } +} + +/// Muninn's `VideoAccessUnitWirePayload`, reproduced exactly as it was. +/// +/// It existed only so `#[serde(with = "serde_bytes")]` could reach the payload, +/// because the `DatabaseEntry` derive ignores serde attributes. Every other +/// field was restated by hand, in order, and had to stay in step with the record +/// by discipline alone. +#[derive(Serialize)] +struct LegacyVideoAccessUnitWirePayload<'a>( + &'a str, + &'a str, + u64, + &'a str, + i64, + u32, + u32, + u32, + bool, + Option, + i64, + u16, + u16, + #[serde(with = "serde_bytes")] &'a [u8], +); + +/// If these differ, deleting the shadow structs was a wire break rather than a +/// simplification, and every existing consumer would need to move in step. +#[test] +fn the_derive_reproduces_the_hand_written_wire_payload_byte_for_byte() { + let record = access_unit(); + let legacy = LegacyVideoAccessUnitWirePayload( + &record.stream_id, + &record.session_id, + record.frame_id, + &record.codec, + record.pts_ticks, + record.duration_ticks, + record.timebase_num, + record.timebase_den, + record.keyframe, + record.dependency_frame_id, + record.deadline_ticks, + record.chunk_index, + record.chunk_count, + &record.payload, + ); + + let from_derive = rmp_serde::to_vec(&record).expect("record encodes"); + let from_shadow = rmp_serde::to_vec(&legacy).expect("shadow struct encodes"); + + assert_eq!( + from_derive, from_shadow, + "the `bytes` attribute must reproduce what serde_bytes produced by hand" + ); +} + +#[test] +fn a_video_record_round_trips_through_the_envelope() { + let record = GameCultMediaWireRecord::Video(access_unit()); + let wire = encode_media_wire_record(&record, provenance()).expect("encodes"); + assert_eq!(decode_media_wire_record(&wire).expect("decodes"), record); +} + +#[test] +fn every_variant_round_trips() { + let variants = vec![ + GameCultMediaWireRecord::Video(access_unit()), + GameCultMediaWireRecord::VideoParity(GameCultMediaVideoParityShardRecord { + stream_id: "s".to_string(), + session_id: "x".to_string(), + frame_id: 1, + codec: "h264".to_string(), + pts_ticks: 0, + duration_ticks: 1, + timebase_num: 1, + timebase_den: 90_000, + keyframe: false, + dependency_frame_id: Some(0), + deadline_ticks: 1, + chunk_count: 4, + parity_index: 1, + parity_count: 2, + chunk_payload_bytes: 848, + last_chunk_payload_bytes: 400, + payload: vec![1, 2, 3], + }), + GameCultMediaWireRecord::Audio(GameCultMediaAudioPacketRecord { + stream_id: "s".to_string(), + session_id: "x".to_string(), + packet_id: 9, + codec: "opus".to_string(), + pts_ticks: 0, + duration_ticks: 960, + timebase_num: 1, + timebase_den: 48_000, + deadline_ticks: 1, + payload: vec![4, 5, 6], + }), + GameCultMediaWireRecord::Feedback(GameCultMediaReceiverFeedbackRecord { + stream_id: "s".to_string(), + session_id: "x".to_string(), + receiver_id: "starfire-obs".to_string(), + highest_decodable_frame_id: Some(41), + missing_frame_ids: vec![42], + late_frame_ids: vec![43], + requested_keyframe: true, + jitter_us: 500, + decode_queue_us: 2_000, + observed_at: "unix:1000".to_string(), + missing_video_chunk_keys: vec!["42:1".to_string()], + }), + ]; + + for record in variants { + let wire = encode_media_wire_record(&record, provenance()).expect("encodes"); + assert_eq!( + decode_media_wire_record(&wire).expect("decodes"), + record, + "variant {} did not survive the envelope", + record.schema_id() + ); + } +} + +/// Record keys address one piece of media within a session. A consumer parses +/// them, so their shape is contract. +#[test] +fn record_keys_distinguish_chunks_parity_and_senders() { + let mut second_chunk = access_unit(); + second_chunk.chunk_index = 3; + + let first = GameCultMediaWireRecord::Video(access_unit()).record_key(); + let second = GameCultMediaWireRecord::Video(second_chunk).record_key(); + assert_ne!(first, second, "chunks of one frame must not share a key"); + assert!(first.contains(":video:"), "got {first}"); +} + +/// A shared envelope must not label another producer's traffic as Muninn's. +#[test] +fn the_producer_namespace_is_the_callers_not_the_envelopes() { + let record = GameCultMediaWireRecord::Video(access_unit()); + let theirs = MediaWireProvenance { + stored_at: "unix:1000", + runtime_id: "nightwing", + role: "brokkr.media", + producer: "brokkr", + }; + + let wire = encode_media_wire_record(&record, theirs).expect("encodes"); + let text = String::from_utf8_lossy(&wire); + assert!(text.contains("brokkr-media:"), "message id names the producer"); + assert!(text.contains("brokkr.media"), "tag names the producer"); + assert!( + !text.contains("muninn"), + "the envelope must not name a producer the caller did not supply" + ); +} + +#[test] +fn provenance_must_be_complete() { + let record = GameCultMediaWireRecord::Video(access_unit()); + for blank in ["stored_at", "runtime_id", "role", "producer"] { + let mut incomplete = provenance(); + match blank { + "stored_at" => incomplete.stored_at = "", + "runtime_id" => incomplete.runtime_id = "", + "role" => incomplete.role = "", + _ => incomplete.producer = "", + } + let error = encode_media_wire_record(&record, incomplete) + .expect_err("empty provenance must be refused"); + assert!(error.to_string().contains(blank), "got {error}"); + } +} + +#[test] +fn the_media_channel_is_named_once() { + assert_eq!(GAMECULT_MEDIA_CHANNEL, "media"); +} diff --git a/packages/cultnet-rs/tests/provider_session.rs b/packages/cultnet-rs/tests/provider_session.rs new file mode 100644 index 00000000..4ad7c23a --- /dev/null +++ b/packages/cultnet-rs/tests/provider_session.rs @@ -0,0 +1,365 @@ +use anyhow::Result; +use cultnet_rs::*; + +const REGISTRATION_GOLDEN: &str = "hqpwcm92aWRlcklkqGFldGhlcmlhsXNlcnZpY2VJbnN0YW5jZUlkq2FldGhlcmlhLTQyqmVuZHBvaW50SWSvYWV0aGVyaWEtcHVibGljp3ZlcnNlSWSmcHVibGljuHJlcXVlc3RlZExlYXNlRHVyYXRpb25Nc811MLBhdXRob3JpdHlMZWFzZUlkq2F1dGhvcml0eS03"; +const CONNECT_EVIDENCE_GOLDEN: &str = + "gq9jbGllbnRTZXNzaW9uSWSyYWV0aGVyaWEtY2xpZW50LTQyrHNlc3Npb25Ub2tlbrJvZGluLXNlc3Npb24tdG9rZW4="; +const TOKENLESS_CONNECT_EVIDENCE_GOLDEN: &str = + "gq9jbGllbnRTZXNzaW9uSWSwYW5vbnltb3VzLWNsaWVudKxzZXNzaW9uVG9rZW7A"; + +fn registration() -> CultMeshProviderRegistrationWire { + CultMeshProviderRegistrationWire { + provider_id: "aetheria".to_string(), + service_instance_id: "aetheria-42".to_string(), + endpoint_id: "aetheria-public".to_string(), + verse_id: "public".to_string(), + requested_lease_duration_ms: 30_000, + authority_lease_id: Some("authority-7".to_string()), + } +} + +#[test] +fn registration_matches_csharp_and_typescript_golden_payload() -> Result<()> { + let encoded = encode_provider_session_payload(®istration())?; + assert_eq!(encoded, REGISTRATION_GOLDEN); + assert_eq!( + decode_provider_session_payload::(&encoded)?, + registration() + ); + Ok(()) +} + +#[test] +fn connect_evidence_is_a_validated_named_messagepack_map() -> Result<()> { + let evidence = CultMeshProviderConnectEvidenceWire { + client_session_id: "aetheria-client-42".to_string(), + session_token: Some("odin-session-token".to_string()), + }; + let encoded = encode_provider_connect_evidence(&evidence)?; + assert_eq!( + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &encoded), + CONNECT_EVIDENCE_GOLDEN + ); + assert_eq!(decode_provider_connect_evidence(&encoded)?, evidence); + let value: rmpv::Value = rmp_serde::from_slice(&encoded)?; + let map = value.as_map().expect("Connect evidence is a named map"); + assert_eq!( + map.iter() + .find_map(|(key, value)| (key.as_str() == Some("clientSessionId")).then_some(value)) + .and_then(rmpv::Value::as_str), + Some("aetheria-client-42") + ); + assert_eq!( + map.iter() + .find_map(|(key, value)| (key.as_str() == Some("sessionToken")).then_some(value)) + .and_then(rmpv::Value::as_str), + Some("odin-session-token") + ); + + assert!( + encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire { + client_session_id: String::new(), + session_token: None, + }) + .is_err() + ); + assert!( + encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire { + client_session_id: "client".to_string(), + session_token: Some(" ".to_string()), + }) + .is_err() + ); + assert!(decode_provider_connect_evidence(&[]).is_err()); + let tokenless = CultMeshProviderConnectEvidenceWire { + client_session_id: "anonymous-client".to_string(), + session_token: None, + }; + let tokenless_encoded = encode_provider_connect_evidence(&tokenless)?; + assert_eq!( + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &tokenless_encoded + ), + TOKENLESS_CONNECT_EVIDENCE_GOLDEN + ); + assert_eq!( + decode_provider_connect_evidence(&tokenless_encoded)?, + tokenless + ); + Ok(()) +} + +#[test] +fn registration_round_trips_inside_canonical_operation_envelope() -> Result<()> { + let request = create_provider_session_request( + "register-1", + CULTMESH_PROVIDER_REGISTER_OPERATION, + CULTMESH_PROVIDER_REGISTRATION_SCHEMA, + ®istration(), + Some("aetheria-42".to_string()), + Some("odin".to_string()), + )?; + let bytes = encode_cultnet_message_to_vec(&request, CultNetWireContract::CultNetSchemaV0)?; + let decoded_request = + decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)?; + let decoded: CultMeshProviderRegistrationWire = decode_provider_session_request( + &decoded_request, + CULTMESH_PROVIDER_REGISTER_OPERATION, + CULTMESH_PROVIDER_REGISTRATION_SCHEMA, + )?; + assert_eq!(decoded, registration()); + + let lease = CultMeshProviderLeaseWire { + provider_id: "aetheria".to_string(), + service_instance_id: "aetheria-42".to_string(), + endpoint_id: "aetheria-public".to_string(), + verse_id: "public".to_string(), + lease_id: "lease-2".to_string(), + valid_from_utc: "2026-07-14T12:00:00Z".to_string(), + expires_at_utc: "2026-07-14T12:00:10Z".to_string(), + }; + let response = create_provider_session_response( + &decoded_request, + CultMeshProviderOperationStatus::Ok, + CULTMESH_PROVIDER_LEASE_SCHEMA, + &lease, + Some("odin".to_string()), + )?; + let (status, decoded_lease) = decode_provider_session_response::( + &response, + CULTMESH_PROVIDER_REGISTER_OPERATION, + CULTMESH_PROVIDER_LEASE_SCHEMA, + )?; + assert_eq!(status, CultMeshProviderOperationStatus::Ok); + assert_eq!(decoded_lease, lease); + Ok(()) +} + +#[test] +fn publication_and_command_preserve_typed_messagepack_values() -> Result<()> { + let publication = CultMeshProviderPublicationPutWire { + lease_id: "lease-1".to_string(), + publication_id: "surface:pilot".to_string(), + document: CultNetRawDocumentRecord { + schema_id: "gamecult.eve.surface.v1".to_string(), + record_key: "eve:surface:pilot".to_string(), + stored_at: String::new(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: vec![0x81, 0xa2, 0x69, 0x64, 0x01], + source_runtime_id: Some("aetheria-42".to_string()), + source_agent_id: None, + source_role: None, + tags: None, + }, + }; + let publication_encoded = encode_provider_session_payload(&publication)?; + assert_eq!( + decode_provider_session_payload::( + &publication_encoded + )?, + publication + ); + + let command = CultMeshProviderCommandWire { + command_id: "cmd-7".to_string(), + command_kind: "pilot.thrust".to_string(), + provider_id: "aetheria".to_string(), + service_instance_id: "aetheria-42".to_string(), + payload: rmpv::Value::Map(vec![(rmpv::Value::from("axis"), rmpv::Value::F64(0.75))]), + }; + let command_bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + encode_provider_session_payload(&command)?, + )?; + let decoded_command = decode_provider_command_document(&CultNetRawDocumentRecord { + schema_id: CULTMESH_PROVIDER_COMMAND_SCHEMA.to_string(), + record_key: "provider-command:aetheria:aetheria-42:cmd-7".to_string(), + stored_at: String::new(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: command_bytes, + source_runtime_id: Some("odin".to_string()), + source_agent_id: None, + source_role: Some("provider-session-broker".to_string()), + tags: None, + })?; + assert_eq!(decoded_command, command); + Ok(()) +} + +#[test] +fn lifecycle_validation_rejects_split_or_malformed_authority() { + let mismatched = create_provider_session_request( + "bad-schema", + CULTMESH_PROVIDER_REGISTER_OPERATION, + CULTMESH_PROVIDER_PUBLICATION_PUT_SCHEMA, + ®istration(), + None, + None, + ); + assert!(mismatched.is_err()); + + let malformed_lease = CultMeshProviderLeaseWire { + provider_id: "provider".to_string(), + service_instance_id: "instance".to_string(), + endpoint_id: "endpoint".to_string(), + verse_id: "verse".to_string(), + lease_id: "lease".to_string(), + valid_from_utc: "2026-07-14T12:00:10Z".to_string(), + expires_at_utc: "2026-07-14T12:00:00Z".to_string(), + }; + assert!(encode_provider_session_payload(&malformed_lease).is_err()); + + let malformed_request = CultNetMessage::OperationRequest { + message_id: String::new(), + service_id: CULTMESH_PROVIDER_SESSION_SERVICE_ID.to_string(), + operation: String::new(), + payload_schema: CULTMESH_PROVIDER_REGISTRATION_SCHEMA.to_string(), + payload_encoding: CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING.to_string(), + payload: REGISTRATION_GOLDEN.to_string(), + source_runtime_id: None, + target_runtime_id: None, + }; + assert!( + create_provider_session_response( + &malformed_request, + CultMeshProviderOperationStatus::Invalid, + CULTMESH_PROVIDER_MUTATION_ACCEPTANCE_SCHEMA, + &CultMeshProviderMutationAcceptanceWire { + accepted_at_utc: "2026-07-14T12:00:00Z".to_string(), + lease_id: None, + publication_id: None, + command_id: None, + receipt_id: None, + }, + None, + ) + .is_err() + ); +} + +#[test] +fn remaining_lifecycle_payloads_round_trip_as_named_maps() -> Result<()> { + let renewal = CultMeshProviderLeaseRenewalWire { + lease_id: "lease-1".to_string(), + requested_lease_duration_ms: 30_000, + }; + assert_eq!( + decode_provider_session_payload::( + &encode_provider_session_payload(&renewal)? + )?, + renewal + ); + + let deletion = CultMeshProviderPublicationDeleteWire { + lease_id: "lease-1".to_string(), + publication_id: "surface:pilot".to_string(), + schema_id: "gamecult.eve.surface.v1".to_string(), + record_key: "eve:surface:pilot".to_string(), + }; + assert_eq!( + decode_provider_session_payload::( + &encode_provider_session_payload(&deletion)? + )?, + deletion + ); + + let receipt = CultMeshProviderReceiptPutWire { + lease_id: "lease-1".to_string(), + receipt: CultMeshProviderCommandReceiptWire { + receipt_id: "receipt-7".to_string(), + command_id: "cmd-7".to_string(), + command_kind: "pilot.thrust".to_string(), + provider_id: "aetheria".to_string(), + service_instance_id: "aetheria-42".to_string(), + state: CultMeshProviderReceiptStateWire::Applied, + completed_at_utc: "2026-07-14T12:01:02.345Z".to_string(), + result: Some(rmpv::Value::Map(vec![( + rmpv::Value::from("accepted"), + rmpv::Value::Boolean(true), + )])), + error: None, + }, + }; + assert_eq!( + decode_provider_session_payload::( + &encode_provider_session_payload(&receipt)? + )?, + receipt + ); + + let withdrawal = CultMeshProviderWithdrawalWire { + lease_id: "lease-1".to_string(), + }; + assert_eq!( + decode_provider_session_payload::( + &encode_provider_session_payload(&withdrawal)? + )?, + withdrawal + ); + + let acceptance = CultMeshProviderMutationAcceptanceWire { + accepted_at_utc: "2026-07-14T12:01:03Z".to_string(), + lease_id: Some("lease-1".to_string()), + publication_id: Some("surface:pilot".to_string()), + command_id: None, + receipt_id: None, + }; + assert_eq!( + decode_provider_session_payload::( + &encode_provider_session_payload(&acceptance)? + )?, + acceptance + ); + Ok(()) +} + +#[test] +fn non_ok_response_uses_correlated_empty_map_and_diagnostics() -> Result<()> { + let request = create_provider_session_request( + "register-denied", + CULTMESH_PROVIDER_REGISTER_OPERATION, + CULTMESH_PROVIDER_REGISTRATION_SCHEMA, + ®istration(), + Some("aetheria-42".to_string()), + Some("odin".to_string()), + )?; + let response = create_provider_session_error_response( + &request, + CultMeshProviderOperationStatus::Denied, + vec!["registration authority rejected the session".to_string()], + Some("odin".to_string()), + )?; + let CultNetMessage::OperationResponse { + message_id, + operation, + status, + payload_schema, + payload, + diagnostics, + .. + } = response + else { + panic!("expected operation response"); + }; + assert_eq!(message_id, "register-denied"); + assert_eq!(operation, CULTMESH_PROVIDER_REGISTER_OPERATION); + assert_eq!(status, "denied"); + assert_eq!(payload_schema, CULTMESH_PROVIDER_MUTATION_ACCEPTANCE_SCHEMA); + assert_eq!(diagnostics.len(), 1); + let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, payload)?; + assert_eq!( + rmp_serde::from_slice::(&bytes)?, + rmpv::Value::Map(Vec::new()) + ); + assert!( + create_provider_session_error_response( + &request, + CultMeshProviderOperationStatus::Ok, + vec!["not an error".to_string()], + None, + ) + .is_err() + ); + Ok(()) +} diff --git a/packages/cultnet-rs/tests/rudp_server_hub.rs b/packages/cultnet-rs/tests/rudp_server_hub.rs new file mode 100644 index 00000000..201cf3b4 --- /dev/null +++ b/packages/cultnet-rs/tests/rudp_server_hub.rs @@ -0,0 +1,574 @@ +use std::collections::VecDeque; +use std::net::UdpSocket; +use std::time::Duration; + +use anyhow::{Result, anyhow}; +use cultnet_rs::*; + +const CONNECTION_ID: u32 = 0x4355_4c54; + +fn socket() -> Result { + let socket = UdpSocket::bind("127.0.0.1:0")?; + socket.set_read_timeout(Some(Duration::from_millis(20)))?; + Ok(socket) +} + +fn client(remote: std::net::SocketAddr) -> Result { + let mut options = + CultNetRudpSocketTransportOptions::client("provider", socket()?, remote, CONNECTION_ID); + options.max_fragment_bytes = Some(2_048); + CultNetRudpSocketTransportConnection::new(options) +} + +fn connect( + hub: &mut CultNetRudpServerHub, + client: &mut CultNetRudpSocketTransportConnection, + evidence: &[u8], +) -> Result { + client.connect(evidence.to_vec())?; + let mut connected = None; + for _ in 0..20 { + if let Some(CultNetRudpServerEvent::Connected { session }) = hub.receive_event_once()? { + connected = Some(session); + break; + } + } + let session = connected.ok_or_else(|| anyhow!("hub did not receive Connect"))?; + let _ = client.receive_once()?; + assert!(client.connected()); + Ok(session) +} + +fn receive_frame(hub: &mut CultNetRudpServerHub) -> Result { + for _ in 0..20 { + if let Some(event @ CultNetRudpServerEvent::Frame { .. }) = hub.receive_event_once()? { + return Ok(event); + } + } + Err(anyhow!("hub did not receive a frame")) +} + +fn receive_message(client: &mut CultNetRudpSocketTransportConnection) -> Result { + for _ in 0..20 { + if let Some(message) = client.receive_schema_message_once()? { + return Ok(message); + } + } + Err(anyhow!("client did not receive a schema message")) +} + +#[test] +fn hub_keeps_independent_peer_sessions_and_exposes_connect_evidence() -> Result<()> { + let server_socket = socket()?; + let server_addr = server_socket.local_addr()?; + let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new( + "odin", + server_socket, + CONNECTION_ID, + ))?; + let mut first = client(server_addr)?; + let mut second = client(server_addr)?; + + let first_session = connect(&mut hub, &mut first, b"session-token-one")?; + let second_session = connect(&mut hub, &mut second, b"session-token-two")?; + assert_ne!(first_session.remote_addr, second_session.remote_addr); + assert_eq!(first_session.connect_payload, b"session-token-one"); + assert_eq!(second_session.connect_payload, b"session-token-two"); + assert_eq!(hub.sessions().len(), 2); + + let first_request = create_provider_session_request( + "register-first", + CULTMESH_PROVIDER_REGISTER_OPERATION, + CULTMESH_PROVIDER_REGISTRATION_SCHEMA, + &CultMeshProviderRegistrationWire { + provider_id: "first".to_string(), + service_instance_id: "first-1".to_string(), + endpoint_id: "first-public".to_string(), + verse_id: "public".to_string(), + requested_lease_duration_ms: 30_000, + authority_lease_id: None, + }, + Some("first-1".to_string()), + Some("odin".to_string()), + )?; + let second_request = create_provider_session_request( + "register-second", + CULTMESH_PROVIDER_REGISTER_OPERATION, + CULTMESH_PROVIDER_REGISTRATION_SCHEMA, + &CultMeshProviderRegistrationWire { + provider_id: "second".to_string(), + service_instance_id: "second-1".to_string(), + endpoint_id: "second-public".to_string(), + verse_id: "public".to_string(), + requested_lease_duration_ms: 30_000, + authority_lease_id: None, + }, + Some("second-1".to_string()), + Some("odin".to_string()), + )?; + first.send_schema_message(&first_request)?; + second.send_schema_message(&second_request)?; + + for _ in 0..2 { + let CultNetRudpServerEvent::Frame { session, frame } = receive_frame(&mut hub)? else { + unreachable!(); + }; + assert_eq!(frame.channel_id, "schema"); + let request = decode_cultnet_message_from_slice( + &frame.payload, + CultNetWireContract::CultNetSchemaV0, + )?; + let CultNetMessage::OperationRequest { message_id, .. } = &request else { + return Err(anyhow!("expected operation request")); + }; + let provider_id = if message_id == "register-first" { + "first" + } else { + "second" + }; + let lease = CultMeshProviderLeaseWire { + provider_id: provider_id.to_string(), + service_instance_id: format!("{provider_id}-1"), + endpoint_id: format!("{provider_id}-public"), + verse_id: "public".to_string(), + lease_id: format!("lease-{provider_id}"), + valid_from_utc: "2026-07-14T12:00:00Z".to_string(), + expires_at_utc: "2026-07-14T12:00:30Z".to_string(), + }; + let response = create_provider_session_response( + &request, + CultMeshProviderOperationStatus::Ok, + CULTMESH_PROVIDER_LEASE_SCHEMA, + &lease, + Some("odin".to_string()), + )?; + hub.send_schema_message(&session, &response)?; + } + + let first_response = receive_message(&mut first)?; + let second_response = receive_message(&mut second)?; + let (_, first_lease) = decode_provider_session_response::( + &first_response, + CULTMESH_PROVIDER_REGISTER_OPERATION, + CULTMESH_PROVIDER_LEASE_SCHEMA, + )?; + let (_, second_lease) = decode_provider_session_response::( + &second_response, + CULTMESH_PROVIDER_REGISTER_OPERATION, + CULTMESH_PROVIDER_LEASE_SCHEMA, + )?; + assert_eq!(first_lease.provider_id, "first"); + assert_eq!(second_lease.provider_id, "second"); + assert_eq!(hub.session(first_session.remote_addr), Some(&first_session)); + assert_eq!( + hub.session(second_session.remote_addr), + Some(&second_session) + ); + Ok(()) +} + +#[test] +fn hub_fences_replaced_generations_and_does_not_replace_connect_retransmits() -> Result<()> { + let server_socket = socket()?; + let server_addr = server_socket.local_addr()?; + let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new( + "odin", + server_socket, + CONNECTION_ID, + ))?; + let mut provider = client(server_addr)?; + let original_evidence = + encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire { + client_session_id: "provider-client-1".to_string(), + session_token: Some("shared-session-token".to_string()), + })?; + let replacement_evidence = + encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire { + client_session_id: "provider-client-2".to_string(), + session_token: Some("shared-session-token".to_string()), + })?; + let original = connect(&mut hub, &mut provider, &original_evidence)?; + + provider.connect(original_evidence.clone())?; + for _ in 0..3 { + assert!(hub.receive_event_once()?.is_none()); + } + assert_eq!(hub.session(original.remote_addr), Some(&original)); + + provider.connect(replacement_evidence.clone())?; + let mut disconnected = None; + for _ in 0..20 { + if let Some(event @ CultNetRudpServerEvent::Disconnected { .. }) = + hub.receive_event_once()? + { + disconnected = Some(event); + break; + } + } + let disconnected = + disconnected.ok_or_else(|| anyhow!("replacement did not disconnect old generation"))?; + let CultNetRudpServerEvent::Disconnected { session, .. } = disconnected else { + return Err(anyhow!("expected old generation Disconnected event")); + }; + assert_eq!(session, original); + let connected = hub + .receive_event_once()? + .ok_or_else(|| anyhow!("replacement did not connect new generation"))?; + let CultNetRudpServerEvent::Connected { + session: replacement, + } = connected + else { + return Err(anyhow!("expected replacement Connected event")); + }; + assert_ne!(replacement.session_generation, original.session_generation); + let original_decoded = decode_provider_connect_evidence(&original.connect_payload)?; + let replacement_decoded = decode_provider_connect_evidence(&replacement.connect_payload)?; + assert_eq!( + original_decoded.session_token, + replacement_decoded.session_token + ); + assert_ne!( + original_decoded.client_session_id, + replacement_decoded.client_session_id + ); + assert!( + hub.send(&original, "schema", vec![0x80]) + .unwrap_err() + .to_string() + .contains("no longer active") + ); + assert_eq!(hub.session(replacement.remote_addr), Some(&replacement)); + Ok(()) +} + +#[test] +fn identical_connect_retransmit_reuses_pending_accept_after_loss() -> Result<()> { + let server_socket = socket()?; + let server_addr = server_socket.local_addr()?; + let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new( + "odin", + server_socket, + CONNECTION_ID, + ))?; + let client_socket = socket()?; + let mut client_session = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: CONNECTION_ID, + initial_sequence: 1, + resend_delay_ms: 250, + max_pending_reliable_packets: None, + }); + let evidence = encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire { + client_session_id: "provider-client-lost-accept".to_string(), + session_token: Some("shared-session-token".to_string()), + })?; + let connect_packet = client_session.create_connect(0, evidence)?; + let connect_wire = encode_rudp_packet(&connect_packet)?; + + client_socket.send_to(&connect_wire, server_addr)?; + let CultNetRudpServerEvent::Connected { session } = hub + .receive_event_once()? + .ok_or_else(|| anyhow!("hub did not accept first Connect"))? + else { + return Err(anyhow!("hub did not emit Connected")); + }; + let mut buffer = vec![0_u8; 65_535]; + let (first_len, _) = client_socket.recv_from(&mut buffer)?; + let lost_accept = decode_rudp_packet(&buffer[..first_len])?; + assert_eq!(lost_accept.packet_type, CultNetRudpPacketType::Accept); + + client_socket.send_to(&connect_wire, server_addr)?; + assert!(hub.receive_event_once()?.is_none()); + let (resent_len, _) = client_socket.recv_from(&mut buffer)?; + let resent_accept = decode_rudp_packet(&buffer[..resent_len])?; + assert_eq!(resent_accept, lost_accept); + assert_eq!(hub.session(session.remote_addr), Some(&session)); + + client_session.receive(&resent_accept, 1)?; + assert!(client_session.connected()); + Ok(()) +} + +#[test] +fn exact_reliable_acks_clear_more_than_one_ack_window_of_fragments() -> Result<()> { + let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: CONNECTION_ID, + initial_sequence: 1, + resend_delay_ms: 250, + max_pending_reliable_packets: None, + }); + let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: CONNECTION_ID, + initial_sequence: 1, + resend_delay_ms: 250, + max_pending_reliable_packets: None, + }); + let connect_packet = sender.create_connect(0, Vec::new())?; + let accept = receiver.accept_connect(&connect_packet, 0, Vec::new())?; + sender.receive(&accept, 0)?; + receiver.receive(&sender.create_ack_for(accept.sequence), 0)?; + + let packets = sender.send_many( + "schema", + vec![0x5a; 40], + CultNetRudpSendOptions { + reliable: true, + ordered: true, + sequenced: false, + now_ms: 1, + reliable_expire_after_ms: None, + }, + Some(1), + )?; + // Forty one-byte fragments cannot leave inside one reliable send window. The + // window's worth goes out now and the rest queue until acknowledgements free + // space, which is the crossing this test exists to prove. + assert_eq!(packets.len(), CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS); + assert_eq!( + sender.queued_reliable_packet_count(), + 40 - CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + ); + + let mut wire = VecDeque::from(packets); + let mut delivered = Vec::new(); + while !wire.is_empty() { + let admitted = wire.len(); + for _ in 0..admitted { + let packet = wire.pop_front().expect("admitted packet remains queued"); + let received = receiver.receive(&packet, 2)?; + delivered.extend(received.delivered); + } + let acknowledged = sender.receive(&receiver.create_ack(), 3)?; + wire.extend(acknowledged.ready_to_send); + } + + assert_eq!(delivered.len(), 1); + assert_eq!(delivered[0].payload, vec![0x5a; 40]); + assert!(sender.pending_reliable_sequences().is_empty()); + assert_eq!(sender.outstanding_reliable_packet_count(), 0); + Ok(()) +} + +#[test] +fn session_and_hub_reject_configured_memory_bounds() -> Result<()> { + let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: CONNECTION_ID, + initial_sequence: 1, + resend_delay_ms: 250, + max_pending_reliable_packets: None, + }); + receiver.set_max_payload_bytes(Some(4)); + receiver.set_max_pending_fragment_sets(1)?; + let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: CONNECTION_ID, + initial_sequence: 1, + resend_delay_ms: 250, + max_pending_reliable_packets: None, + }); + let connect_packet = sender.create_connect(0, Vec::new())?; + let accept = receiver.accept_connect(&connect_packet, 0, Vec::new())?; + sender.receive(&accept, 0)?; + + let oversized = sender.send_many( + "schema", + vec![1, 2, 3, 4, 5], + CultNetRudpSendOptions { + reliable: true, + ordered: true, + sequenced: false, + now_ms: 1, + reliable_expire_after_ms: None, + }, + Some(2), + )?; + receiver.receive(&oversized[0], 1)?; + receiver.receive(&oversized[1], 1)?; + assert!(receiver.receive(&oversized[2], 1).is_err()); + + let fragment_options = CultNetRudpSendOptions { + reliable: true, + ordered: true, + sequenced: false, + now_ms: 2, + reliable_expire_after_ms: None, + }; + let first_set = sender.send_many( + "schema", + vec![1, 2, 3, 4], + fragment_options.clone(), + Some(2), + )?; + let second_set = sender.send_many("schema", vec![5, 6, 7, 8], fragment_options, Some(2))?; + receiver.receive(&first_set[0], 2)?; + // The fragment-set bound evicts the stalest incomplete set rather than + // refusing the packet: a receiver that dies at its bound under loss is a + // fuse, not a limit (measured 2026-09-10, see tests/cultnet.rs). + assert_eq!(receiver.fragment_sets_evicted(), 0); + receiver.receive(&second_set[0], 2)?; + assert_eq!(receiver.fragment_sets_evicted(), 1, "the first set was evicted for the second"); + + let server_socket = socket()?; + let server_addr = server_socket.local_addr()?; + let mut options = CultNetRudpServerHubOptions::new("odin", server_socket, CONNECTION_ID); + options.max_peers = 1; + let mut hub = CultNetRudpServerHub::new(options)?; + let mut first = client(server_addr)?; + let mut second = client(server_addr)?; + connect(&mut hub, &mut first, b"first")?; + second.connect(b"second".to_vec())?; + for _ in 0..20 { + match hub.receive_event_once() { + Err(error) => { + assert!(error.to_string().contains("peer limit")); + return Ok(()); + } + Ok(_) => continue, + } + } + Err(anyhow!("hub did not enforce peer limit")) +} + +#[test] +fn replay_history_is_bounded_and_idle_hub_sessions_expire() -> Result<()> { + let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: CONNECTION_ID, + initial_sequence: 1, + resend_delay_ms: 250, + max_pending_reliable_packets: None, + }); + let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: CONNECTION_ID, + initial_sequence: 1, + resend_delay_ms: 250, + max_pending_reliable_packets: None, + }); + let connect_packet = sender.create_connect(0, Vec::new())?; + let accept = receiver.accept_connect(&connect_packet, 0, Vec::new())?; + sender.receive(&accept, 0)?; + let mut oldest = None; + for sequence in 0..4_100 { + let packet = sender.send( + "realtime", + vec![(sequence % 251) as u8], + CultNetRudpSendOptions { + reliable: false, + ordered: false, + sequenced: false, + now_ms: sequence, + reliable_expire_after_ms: None, + }, + )?; + if oldest.is_none() { + oldest = Some(packet.clone()); + } + assert_eq!(receiver.receive(&packet, sequence)?.delivered.len(), 1); + } + // Separating the sequence domains moved duplicate suppression onto the + // reliable domain, so a replayed lossy datagram is delivered again rather + // than silently dropped. Lossy traffic is remembered nowhere, which is what + // bounds its history. + assert_eq!( + receiver + .receive(&oldest.expect("oldest packet"), 4_101)? + .delivered + .len(), + 1 + ); + + // The bounded replay window still guards the reliable domain, and nothing + // else asserts it once the lossy path stopped being remembered. + let mut oldest_reliable = None; + for step in 0..4_100u64 { + let packet = sender.send( + "schema", + vec![(step % 251) as u8], + CultNetRudpSendOptions { + reliable: true, + ordered: false, + sequenced: false, + now_ms: step, + reliable_expire_after_ms: None, + }, + )?; + if oldest_reliable.is_none() { + oldest_reliable = Some(packet.clone()); + } + assert_eq!(receiver.receive(&packet, step)?.delivered.len(), 1); + sender.receive(&receiver.create_ack_for(packet.sequence), step)?; + } + assert!( + receiver + .receive(&oldest_reliable.expect("oldest reliable packet"), 4_101)? + .delivered + .is_empty() + ); + + let server_socket = socket()?; + let server_addr = server_socket.local_addr()?; + let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new( + "odin", + server_socket, + CONNECTION_ID, + ))?; + let mut provider = client(server_addr)?; + let context = connect(&mut hub, &mut provider, b"expiring-session")?; + std::thread::sleep(Duration::from_millis(2)); + assert_eq!(hub.remove_timed_out_sessions(0), vec![context]); + assert!(hub.sessions().is_empty()); + Ok(()) +} + +/// A packet that carries no event — an acknowledgement, a ping — must not +/// make the reader report "nothing waiting" while frames sit behind it. +/// A caller that polls once per send fell behind its peer's acknowledgements +/// this way and a reliable channel resent everything. +#[test] +fn a_reader_does_not_stop_at_a_packet_that_carries_no_event() -> Result<()> { + let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new( + "hub", + socket()?, + CONNECTION_ID, + ))?; + let mut peer = client(hub.local_addr()?)?; + let session = connect(&mut hub, &mut peer, b"evidence")?; + + // Hub side: a ping (reply, no event) ahead of a reliable frame. + peer.ping(b"keepalive".to_vec())?; + peer.send("schema", b"after the ping".to_vec())?; + std::thread::sleep(Duration::from_millis(20)); + match hub.receive_event_once()? { + Some(CultNetRudpServerEvent::Frame { frame, .. }) => { + assert_eq!(frame.payload, b"after the ping"); + } + other => panic!("the first read must reach the frame behind the ping, got {other:?}"), + } + + // Client side: the hub's acknowledgement of that frame is queued ahead of + // two unreliable frames; the client must reach both without a "nothing" + // in between, and must not acknowledge unreliable frames back. + let mut unreliable_hub_options = + CultNetRudpServerHubOptions::new("hub-unreliable", socket()?, CONNECTION_ID); + unreliable_hub_options.media_delivery = Some(CultNetTransportDelivery::Unreliable); + let mut media_hub = CultNetRudpServerHub::new(unreliable_hub_options)?; + let mut media_client = client(media_hub.local_addr()?)?; + let media_session = connect(&mut media_hub, &mut media_client, b"media")?; + media_client.send("schema", b"reliable, will be acked".to_vec())?; + std::thread::sleep(Duration::from_millis(20)); + while media_hub.receive_event_once()?.is_some() {} + media_hub.send(&media_session, "media", b"one".to_vec())?; + media_hub.send(&media_session, "media", b"two".to_vec())?; + std::thread::sleep(Duration::from_millis(20)); + let before = media_hub.stats().bytes_received; + let first = media_client.receive_once()?.expect("the first read reaches the frame behind the ack"); + let second = media_client.receive_once()?.expect("the second frame follows"); + assert_eq!((first.payload, second.payload), (b"one".to_vec(), b"two".to_vec())); + assert!(media_client.receive_once()?.is_none()); + std::thread::sleep(Duration::from_millis(20)); + while media_hub.receive_event_once()?.is_some() {} + assert_eq!( + media_hub.stats().bytes_received, + before, + "unreliable frames are not acknowledged; the hub read nothing back" + ); + let _ = session; + Ok(()) +} diff --git a/packages/cultnet-ts/README.md b/packages/cultnet-ts/README.md index 5ff66e7e..75e77b88 100644 --- a/packages/cultnet-ts/README.md +++ b/packages/cultnet-ts/README.md @@ -195,7 +195,7 @@ the helper path. ## Quick Example ```ts -import { CultCache, defineDocumentType } from "cultcache-ts"; +import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts"; import { CultNetDocumentRegistry, createTcpFramedCultNetPeer, diff --git a/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json b/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json new file mode 100644 index 00000000..aca1ff2b --- /dev/null +++ b/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-accepted.schema.json", + "title": "CultMesh Session Accepted", + "type": "object", + "required": ["schemaVersion", "messageId", "accepted", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce", "providerKeyId", "providerSignature"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultmesh.session_accepted.v2" }, + "messageId": { "type": "string", "minLength": 1 }, + "accepted": { "type": "boolean" }, + "verseId": { "type": "string", "minLength": 1 }, + "authorityRuntimeId": { "type": "string", "minLength": 1 }, + "protocolId": { "type": "string", "minLength": 1 }, + "routeGeneration": { "type": "string" }, + "clientNonce": { "type": "string" }, + "providerKeyId": { "type": "string" }, + "providerSignature": { "type": "string" }, + "error": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json b/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json new file mode 100644 index 00000000..937f3e28 --- /dev/null +++ b/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-open.schema.json", + "title": "CultMesh Session Open", + "type": "object", + "required": ["schemaVersion", "messageId", "sourceRuntimeId", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultmesh.session_open.v2" }, + "messageId": { "type": "string", "minLength": 1 }, + "sourceRuntimeId": { "type": "string", "minLength": 1 }, + "verseId": { "type": "string", "minLength": 1 }, + "authorityRuntimeId": { "type": "string", "minLength": 1 }, + "protocolId": { "type": "string", "minLength": 1 }, + "routeGeneration": { "type": "string" }, + "clientNonce": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json new file mode 100644 index 00000000..e99b9dea --- /dev/null +++ b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json", + "title": "CultMesh Verse Catalog Request", + "type": "object", + "required": ["schemaVersion", "messageId"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultmesh.verse_catalog_request.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "verseIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "transportVersion": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json new file mode 100644 index 00000000..464908ad --- /dev/null +++ b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json", + "title": "CultMesh Verse Catalog Response", + "type": "object", + "required": ["schemaVersion", "messageId", "verses"], + "additionalProperties": false, + "$defs": { + "compatibility": { + "type": "object", + "required": ["transportVersion", "rulesHash", "compatibleVerseIds", "requiredPluginIds", "optionalPluginIds"], + "additionalProperties": false, + "properties": { + "transportVersion": { "type": "string", "minLength": 1 }, + "rulesHash": { "type": "string", "minLength": 1 }, + "compatibleVerseIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "requiredPluginIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "optionalPluginIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } + } + }, + "authorityRoute": { + "type": "object", + "required": ["authorityRuntimeId", "endpoint", "protocolIds", "priority", "generation"], + "additionalProperties": false, + "properties": { + "authorityRuntimeId": { "type": "string", "minLength": 1 }, + "endpoint": { "type": "string", "minLength": 1 }, + "protocolIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "priority": { "type": "integer", "minimum": 0 }, + "generation": { "type": "string", "minLength": 1 }, + "certificate": { + "anyOf": [{ "$ref": "#/$defs/routeCertificate" }, { "type": "null" }] + } + } + }, + "routeCertificate": { + "type": "object", + "required": ["providerKeyId", "providerPublicKeyX", "providerPublicKeyY", "odinKeyId", "issuedAtUnixMilliseconds", "expiresAtUnixMilliseconds", "signature"], + "additionalProperties": false, + "properties": { + "providerKeyId": { "type": "string", "minLength": 1 }, + "providerPublicKeyX": { "type": "string", "minLength": 1 }, + "providerPublicKeyY": { "type": "string", "minLength": 1 }, + "odinKeyId": { "type": "string", "minLength": 1 }, + "issuedAtUnixMilliseconds": { "type": "integer", "minimum": 0 }, + "expiresAtUnixMilliseconds": { "type": "integer", "minimum": 1 }, + "signature": { "type": "string", "minLength": 1 } + } + }, + "verse": { + "type": "object", + "required": ["verseId", "displayName", "authorityModel", "compatibility", "discoveryEndpoints", "authorityRuntimeIds"], + "additionalProperties": false, + "properties": { + "verseId": { "type": "string", "minLength": 1 }, + "displayName": { "type": "string", "minLength": 1 }, + "authorityModel": { "type": "string", "minLength": 1 }, + "compatibility": { "$ref": "#/$defs/compatibility" }, + "discoveryEndpoints": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "authorityRuntimeIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "authorityRoutes": { "type": "array", "items": { "$ref": "#/$defs/authorityRoute" } }, + "parentVerseId": { "type": "string", "minLength": 1 }, + "description": { "type": "string" } + } + } + }, + "properties": { + "schemaVersion": { "const": "cultmesh.verse_catalog_response.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "verses": { "type": "array", "items": { "$ref": "#/$defs/verse" } } + } +} diff --git a/packages/cultnet-ts/contracts/cultnet.database-change-raw.schema.json b/packages/cultnet-ts/contracts/cultnet.database-change-raw.schema.json new file mode 100644 index 00000000..dd650fa0 --- /dev/null +++ b/packages/cultnet-ts/contracts/cultnet.database-change-raw.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-change-raw.schema.json", + "title": "CultNet Raw Database Change Message", + "type": "object", + "required": ["schemaVersion", "messageId", "subscriptionId", "changeKind"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.database_change_raw.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "subscriptionId": { "type": "string", "minLength": 1 }, + "changeKind": { "enum": ["added", "updated", "removed"] }, + "document": { "$ref": "./cultnet.raw-document-record.schema.json" }, + "recordKey": { "type": "string", "minLength": 1 }, + "schemaId": { "type": "string", "minLength": 1 } + }, + "allOf": [ + { + "if": { "properties": { "changeKind": { "const": "removed" } } }, + "then": { "required": ["recordKey", "schemaId"] }, + "else": { "required": ["document"] } + } + ] +} diff --git a/packages/cultnet-ts/contracts/cultnet.database-subscribe.schema.json b/packages/cultnet-ts/contracts/cultnet.database-subscribe.schema.json new file mode 100644 index 00000000..08304199 --- /dev/null +++ b/packages/cultnet-ts/contracts/cultnet.database-subscribe.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-subscribe.schema.json", + "title": "CultNet Database Subscribe Message", + "type": "object", + "required": ["schemaVersion", "messageId", "subscriptionId", "includeSnapshot"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.database_subscribe.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "subscriptionId": { "type": "string", "minLength": 1 }, + "schemaIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "recordKeys": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "includeSnapshot": { "type": "boolean" }, + "consumerRuntimeId": { "type": "string", "minLength": 1 }, + "bodyIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "supportedBodyTransports": { "type": "array", "items": { "type": "string", "minLength": 1 } } + } +} diff --git a/packages/cultnet-ts/contracts/cultnet.database-unsubscribe.schema.json b/packages/cultnet-ts/contracts/cultnet.database-unsubscribe.schema.json new file mode 100644 index 00000000..7d4b0855 --- /dev/null +++ b/packages/cultnet-ts/contracts/cultnet.database-unsubscribe.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-unsubscribe.schema.json", + "title": "CultNet Database Unsubscribe Message", + "type": "object", + "required": ["schemaVersion", "messageId", "subscriptionId"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.database_unsubscribe.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "subscriptionId": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-ts/contracts/cultnet.operation-request.schema.json b/packages/cultnet-ts/contracts/cultnet.operation-request.schema.json new file mode 100644 index 00000000..07cd9d8d --- /dev/null +++ b/packages/cultnet-ts/contracts/cultnet.operation-request.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.operation-request.schema.json", + "title": "CultNet Operation Request", + "type": "object", + "required": ["schemaVersion", "messageId", "serviceId", "operation", "payloadSchema", "payload"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.operation_request.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "serviceId": { "type": "string", "minLength": 1 }, + "operation": { "type": "string", "minLength": 1 }, + "payloadSchema": { "type": "string", "minLength": 1 }, + "payloadEncoding": { "const": "messagepack-base64" }, + "payload": { "type": "string", "minLength": 1, "contentEncoding": "base64" }, + "sourceRuntimeId": { "type": "string", "minLength": 1 }, + "targetRuntimeId": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-ts/contracts/cultnet.operation-response.schema.json b/packages/cultnet-ts/contracts/cultnet.operation-response.schema.json new file mode 100644 index 00000000..c3be917d --- /dev/null +++ b/packages/cultnet-ts/contracts/cultnet.operation-response.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.operation-response.schema.json", + "title": "CultNet Operation Response", + "type": "object", + "required": ["schemaVersion", "messageId", "serviceId", "operation", "status", "payloadSchema", "payload"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.operation_response.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "serviceId": { "type": "string", "minLength": 1 }, + "operation": { "type": "string", "minLength": 1 }, + "status": { "type": "string", "minLength": 1 }, + "payloadSchema": { "type": "string", "minLength": 1 }, + "payloadEncoding": { "const": "messagepack-base64" }, + "payload": { "type": "string", "minLength": 1, "contentEncoding": "base64" }, + "diagnostics": { "type": "array", "items": { "type": "string" } }, + "sourceRuntimeId": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-ts/package-lock.json b/packages/cultnet-ts/package-lock.json deleted file mode 100644 index 349dfe87..00000000 --- a/packages/cultnet-ts/package-lock.json +++ /dev/null @@ -1,322 +0,0 @@ -{ - "name": "cultnet-ts", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "cultnet-ts", - "version": "0.1.0", - "license": "MIT", - "dependencies": { - "@msgpack/msgpack": "^3.1.3", - "ajv": "^8.17.1", - "cultcache-ts": "github:GameCult/cultcache-ts" - }, - "devDependencies": { - "@types/node": "^24.0.0", - "json-schema-to-typescript": "^15.0.4", - "typescript": "^5.9.0", - "zod": "^3.25.76" - } - }, - "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "11.9.3", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", - "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.15", - "js-yaml": "^4.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/philsturgeon" - } - }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@msgpack/msgpack": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", - "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", - "license": "ISC", - "engines": { - "node": ">= 18" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/cultcache-ts": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/GameCult/cultcache-ts.git#6c2e1e3691146f99c44c098371cc8c787b4a8d0c", - "license": "MIT", - "dependencies": { - "@msgpack/msgpack": "^3.1.3", - "zod": "^3.25.76" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-to-typescript": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", - "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@apidevtools/json-schema-ref-parser": "^11.5.5", - "@types/json-schema": "^7.0.15", - "@types/lodash": "^4.17.7", - "is-glob": "^4.0.3", - "js-yaml": "^4.1.0", - "lodash": "^4.17.21", - "minimist": "^1.2.8", - "prettier": "^3.2.5", - "tinyglobby": "^0.2.9" - }, - "bin": { - "json2ts": "dist/src/cli.js" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index b4c17ade..2f4b74f1 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -1,19 +1,32 @@ { "name": "cultnet-ts", - "version": "0.1.0", + "version": "0.2.0", "description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "default": "./dist/index.js" + }, + "./contracts": { + "types": "./dist/contracts.d.ts", + "require": "./dist/contracts.js", + "default": "./dist/contracts.js" + } + }, "files": [ "dist", "contracts" ], "scripts": { "contracts:generate": "node tools/generate-swarm-contracts.mjs", - "build": "npm run contracts:generate && tsc -p tsconfig.json", - "typecheck": "npm run contracts:generate && tsc -p tsconfig.json --pretty false", - "test": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js", - "test:interop": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/interop/*.test.js" + "build": "node tools/generate-swarm-contracts.mjs && node ../../node_modules/typescript/bin/tsc -p tsconfig.json", + "prepack": "node tools/generate-swarm-contracts.mjs && node ../../node_modules/typescript/bin/tsc -p tsconfig.json", + "typecheck": "node tools/generate-swarm-contracts.mjs && node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false", + "test": "node tools/generate-swarm-contracts.mjs && node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js", + "test:interop": "node tools/generate-swarm-contracts.mjs && node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test --test-force-exit dist-test/test/interop/*.test.js" }, "keywords": [ "messagepack", @@ -25,10 +38,22 @@ ], "author": "metacratic", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/GameCult/CultLib.git", + "directory": "packages/cultnet-ts" + }, + "bugs": { + "url": "https://github.com/GameCult/CultLib/issues" + }, + "homepage": "https://github.com/GameCult/CultLib/tree/main/packages/cultnet-ts#readme", + "engines": { + "node": ">=18" + }, "dependencies": { "@msgpack/msgpack": "^3.1.3", "ajv": "^8.17.1", - "cultcache-ts": "file:../cultcache-ts" + "@gamecult/cultcache-ts": "^0.14.0" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index 2e89c025..c71cdcc1 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -18,11 +18,20 @@ import transportProfileSchema from "../contracts/cultnet.transport-profile.schem import snapshotRequestSchema from "../contracts/cultnet.snapshot-request.schema.json"; import snapshotResponseSchema from "../contracts/cultnet.snapshot-response.schema.json"; import snapshotResponseRawSchema from "../contracts/cultnet.snapshot-response-raw.schema.json"; +import databaseSubscribeSchema from "../contracts/cultnet.database-subscribe.schema.json"; +import databaseUnsubscribeSchema from "../contracts/cultnet.database-unsubscribe.schema.json"; +import databaseChangeRawSchema from "../contracts/cultnet.database-change-raw.schema.json"; import schemaDescriptorSchema from "../contracts/cultnet.schema-descriptor.schema.json"; import schemaCatalogRequestSchema from "../contracts/cultnet.schema-catalog-request.schema.json"; import schemaCatalogResponseSchema from "../contracts/cultnet.schema-catalog-response.schema.json"; import shardCatalogRequestSchema from "../contracts/cultnet.shard-catalog-request.schema.json"; import shardCatalogResponseSchema from "../contracts/cultnet.shard-catalog-response.schema.json"; +import operationRequestSchema from "../contracts/cultnet.operation-request.schema.json"; +import operationResponseSchema from "../contracts/cultnet.operation-response.schema.json"; +import verseCatalogRequestSchema from "../contracts/cultmesh.verse-catalog-request.schema.json"; +import verseCatalogResponseSchema from "../contracts/cultmesh.verse-catalog-response.schema.json"; +import sessionOpenSchema from "../contracts/cultmesh.session-open.schema.json"; +import sessionAcceptedSchema from "../contracts/cultmesh.session-accepted.schema.json"; import ghostlightAgentStateSchema from "../contracts/ghostlight.agent-state.schema.json"; export type CultNetWireContract = "cultnet.schema.v0" | "gamecult.networking.v0"; @@ -42,10 +51,19 @@ export type CultNetSchemaVersion = | "cultnet.snapshot_request.v0" | "cultnet.snapshot_response.v0" | "cultnet.snapshot_response_raw.v0" + | "cultnet.database_subscribe.v0" + | "cultnet.database_unsubscribe.v0" + | "cultnet.database_change_raw.v0" | "cultnet.schema_catalog_request.v0" | "cultnet.schema_catalog_response.v0" | "cultnet.shard_catalog_request.v0" - | "cultnet.shard_catalog_response.v0"; + | "cultnet.shard_catalog_response.v0" + | "cultnet.operation_request.v0" + | "cultnet.operation_response.v0" + | "cultmesh.verse_catalog_request.v0" + | "cultmesh.verse_catalog_response.v0" + | "cultmesh.session_open.v2" + | "cultmesh.session_accepted.v2"; export type CultNetSchemaKind = "wire_message" | "document_payload" | "shared_contract"; export type CultNetRawPayloadEncoding = "messagepack"; @@ -176,6 +194,9 @@ export interface CultNetDocumentDeleteMessage { export interface CultNetRawDocumentRecord { schemaId: string; + schemaName?: string; + schemaVersion?: string; + schemaContentHash?: string; recordKey: string; storedAt: string; payloadEncoding: CultNetRawPayloadEncoding; @@ -213,6 +234,34 @@ export interface CultNetSnapshotResponseRawMessage { documents: CultNetRawDocumentRecord[]; } +export interface CultNetDatabaseSubscribeMessage { + schemaVersion: "cultnet.database_subscribe.v0"; + messageId: string; + subscriptionId: string; + schemaIds?: string[]; + recordKeys?: string[]; + includeSnapshot: boolean; + consumerRuntimeId?: string; + bodyIds?: string[]; + supportedBodyTransports?: string[]; +} + +export interface CultNetDatabaseUnsubscribeMessage { + schemaVersion: "cultnet.database_unsubscribe.v0"; + messageId: string; + subscriptionId: string; +} + +export interface CultNetDatabaseChangeRawMessage { + schemaVersion: "cultnet.database_change_raw.v0"; + messageId: string; + subscriptionId: string; + changeKind: "added" | "updated" | "removed"; + document?: CultNetRawDocumentRecord; + recordKey?: string; + schemaId?: string; +} + export interface CultNetSchemaDescriptor { schemaId: string; kind: CultNetSchemaKind; @@ -265,6 +314,108 @@ export interface CultNetShardCatalogResponseMessage { shards: CultNetShardDescriptor[]; } +export interface CultNetOperationRequestMessage { + schemaVersion: "cultnet.operation_request.v0"; + messageId: string; + serviceId: string; + operation: string; + payloadSchema: string; + payloadEncoding?: "messagepack-base64"; + payload: string; + sourceRuntimeId?: string; + targetRuntimeId?: string; +} + +export interface CultNetOperationResponseMessage { + schemaVersion: "cultnet.operation_response.v0"; + messageId: string; + serviceId: string; + operation: string; + status: string; + payloadSchema: string; + payloadEncoding?: "messagepack-base64"; + payload: string; + diagnostics?: string[]; + sourceRuntimeId?: string; +} + +export interface CultMeshVerseCompatibilityMessage { + transportVersion: string; + rulesHash: string; + compatibleVerseIds: string[]; + requiredPluginIds: string[]; + optionalPluginIds: string[]; +} + +export interface CultMeshVerseDescriptorMessage { + verseId: string; + displayName: string; + authorityModel: string; + compatibility: CultMeshVerseCompatibilityMessage; + discoveryEndpoints: string[]; + authorityRuntimeIds: string[]; + authorityRoutes?: CultMeshAuthorityRouteMessage[]; + parentVerseId?: string; + description?: string; +} + +export interface CultMeshAuthorityRouteMessage { + authorityRuntimeId: string; + endpoint: string; + protocolIds: string[]; + priority: number; + generation: string; + certificate?: CultMeshRouteCertificateMessage; +} + +export interface CultMeshRouteCertificateMessage { + providerKeyId: string; + providerPublicKeyX: string; + providerPublicKeyY: string; + odinKeyId: string; + issuedAtUnixMilliseconds: number; + expiresAtUnixMilliseconds: number; + signature: string; +} + +export interface CultMeshSessionOpenMessage { + schemaVersion: "cultmesh.session_open.v2"; + messageId: string; + sourceRuntimeId: string; + verseId: string; + authorityRuntimeId: string; + protocolId: string; + routeGeneration: string; + clientNonce: string; +} + +export interface CultMeshSessionAcceptedMessage { + schemaVersion: "cultmesh.session_accepted.v2"; + messageId: string; + accepted: boolean; + verseId: string; + authorityRuntimeId: string; + protocolId: string; + routeGeneration: string; + clientNonce: string; + providerKeyId: string; + providerSignature: string; + error?: string; +} + +export interface CultMeshVerseCatalogRequestMessage { + schemaVersion: "cultmesh.verse_catalog_request.v0"; + messageId: string; + verseIds?: string[]; + transportVersion?: string; +} + +export interface CultMeshVerseCatalogResponseMessage { + schemaVersion: "cultmesh.verse_catalog_response.v0"; + messageId: string; + verses: CultMeshVerseDescriptorMessage[]; +} + export interface GhostlightPressure { pressure_id: string; label: string; @@ -506,10 +657,19 @@ export type CultNetMessage = | CultNetSnapshotRequestMessage | CultNetSnapshotResponseMessage | CultNetSnapshotResponseRawMessage + | CultNetDatabaseSubscribeMessage + | CultNetDatabaseUnsubscribeMessage + | CultNetDatabaseChangeRawMessage | CultNetSchemaCatalogRequestMessage | CultNetSchemaCatalogResponseMessage | CultNetShardCatalogRequestMessage - | CultNetShardCatalogResponseMessage; + | CultNetShardCatalogResponseMessage + | CultNetOperationRequestMessage + | CultNetOperationResponseMessage + | CultMeshVerseCatalogRequestMessage + | CultMeshVerseCatalogResponseMessage + | CultMeshSessionOpenMessage + | CultMeshSessionAcceptedMessage; const ajv = new Ajv2020({ allErrors: true, @@ -531,10 +691,19 @@ const CULTNET_MESSAGE_SCHEMAS = [ snapshotRequestSchema, snapshotResponseSchema, snapshotResponseRawSchema, + databaseSubscribeSchema, + databaseUnsubscribeSchema, + databaseChangeRawSchema, schemaCatalogRequestSchema, schemaCatalogResponseSchema, shardCatalogRequestSchema, shardCatalogResponseSchema, + operationRequestSchema, + operationResponseSchema, + verseCatalogRequestSchema, + verseCatalogResponseSchema, + sessionOpenSchema, + sessionAcceptedSchema, ] as const; const cultNetValidators = new Map(); @@ -587,6 +756,11 @@ export function parseCultNetMessage(input: unknown, wireContract: CultNetWireCon return input as CultNetSnapshotResponseRawMessage; } + if (schemaVersion === "cultnet.database_change_raw.v0") { + validateDatabaseChangeRawMessage(input); + return input as CultNetDatabaseChangeRawMessage; + } + if (!validator(input)) { const keys = Object.keys(input as Record).join(","); throw new Error(`${renderValidationErrors(schemaVersion, validator)}; keys=${keys}`); @@ -873,8 +1047,11 @@ function encodeLegacySchemaDescriptor(schema: CultNetSchemaDescriptor): Record character.charCodeAt(0)); } function validateDocumentPutRawMessage(input: unknown): asserts input is CultNetDocumentPutRawMessage { @@ -918,9 +1101,39 @@ function validateSnapshotResponseRawMessage( }); } +function validateDatabaseChangeRawMessage( + input: unknown, +): asserts input is CultNetDatabaseChangeRawMessage { + const candidate = validateBinaryMessageRoot(input, "cultnet.database_change_raw.v0"); + requireNonEmptyString(candidate.subscriptionId, "/subscriptionId"); + if (candidate.changeKind !== "added" && + candidate.changeKind !== "updated" && + candidate.changeKind !== "removed") { + throw new Error( + "Validation failed for cultnet.database_change_raw.v0: /changeKind: must be added, updated, or removed", + ); + } + + if (candidate.changeKind === "removed") { + requireNonEmptyString(candidate.recordKey, "/recordKey"); + requireNonEmptyString(candidate.schemaId, "/schemaId"); + return; + } + + if (!("document" in candidate)) { + throw new Error( + "Validation failed for cultnet.database_change_raw.v0: /document: is required for added or updated changes", + ); + } + validateRawDocumentRecord(candidate.document, "/document"); +} + function validateBinaryMessageRoot( input: unknown, - schemaVersion: "cultnet.document_put_raw.v0" | "cultnet.snapshot_response_raw.v0", + schemaVersion: + | "cultnet.document_put_raw.v0" + | "cultnet.snapshot_response_raw.v0" + | "cultnet.database_change_raw.v0", ): Record { if (!input || typeof input !== "object") { throw new Error(`Validation failed for ${schemaVersion}: /: must be an object`); @@ -947,6 +1160,9 @@ function validateRawDocumentRecord( const candidate = input as Record; requireNonEmptyString(candidate.schemaId, `${instancePath}/schemaId`); + requireOptionalNonEmptyString(candidate.schemaName, `${instancePath}/schemaName`); + requireOptionalNonEmptyString(candidate.schemaVersion, `${instancePath}/schemaVersion`); + requireOptionalNonEmptyString(candidate.schemaContentHash, `${instancePath}/schemaContentHash`); requireNonEmptyString(candidate.recordKey, `${instancePath}/recordKey`); requireNonEmptyString(candidate.storedAt, `${instancePath}/storedAt`); if (candidate.payloadEncoding !== "messagepack") { @@ -1010,6 +1226,34 @@ function normalizeCultNetOptionalNulls( acceptLegacyPropertyName(candidate, "ShardEpoch", "shardEpoch"); stripNullProperties(candidate, ["schemaIds", "recordKeys", "shardId", "shardEpoch"]); return; + case "cultnet.database_subscribe.v0": + stripNullProperties(candidate, [ + "schemaIds", + "recordKeys", + "consumerRuntimeId", + "bodyIds", + "supportedBodyTransports", + ]); + return; + case "cultnet.operation_request.v0": + stripNullProperties(candidate, ["payloadEncoding", "sourceRuntimeId", "targetRuntimeId"]); + return; + case "cultnet.operation_response.v0": + stripNullProperties(candidate, ["payloadEncoding", "diagnostics", "sourceRuntimeId"]); + return; + case "cultmesh.verse_catalog_request.v0": + stripNullProperties(candidate, ["verseIds", "transportVersion"]); + return; + case "cultmesh.verse_catalog_response.v0": + for (const verse of Array.isArray(candidate.verses) ? candidate.verses : []) { + if (verse && typeof verse === "object") { + stripNullProperties(verse as Record, ["parentVerseId", "description"]); + } + } + return; + case "cultmesh.session_accepted.v2": + stripNullProperties(candidate, ["error"]); + return; case "cultnet.schema_catalog_request.v0": stripNullProperties(candidate, ["schemaIds", "kinds"]); return; @@ -1059,10 +1303,15 @@ export const cultNetSchemas = { snapshotRequestSchema, snapshotResponseSchema, snapshotResponseRawSchema, + databaseSubscribeSchema, + databaseUnsubscribeSchema, + databaseChangeRawSchema, schemaDescriptorSchema, schemaCatalogRequestSchema, schemaCatalogResponseSchema, shardCatalogRequestSchema, shardCatalogResponseSchema, + operationRequestSchema, + operationResponseSchema, ghostlightAgentStateSchema, } as const; diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 5d0ecf99..140526c5 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -8,12 +8,17 @@ export { type CultNetDocumentPutMessage, type CultNetDocumentPutRawMessage, type CultNetDocumentRecord, + type CultNetDatabaseSubscribeMessage, + type CultNetDatabaseUnsubscribeMessage, + type CultNetDatabaseChangeRawMessage, type CultNetRawDocumentRecord, type CultNetRawPayloadEncoding, type CultNetErrorMessage, type CultNetHelloMessage, type CultNetLoginMessage, type CultNetLoginSuccessMessage, + type CultNetOperationRequestMessage, + type CultNetOperationResponseMessage, type CultNetMessage, type CultNetRegisterMessage, type CultNetSampleChangeNameMessage, @@ -78,6 +83,7 @@ export { createRudpTransportProfile, decodeRudpPacket, encodeRudpPacket, + CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS, CultNetRudpReconnectLoop, CultNetRudpSession, CultNetRudpSocketTransportConnection, @@ -91,6 +97,13 @@ export { type CultNetRudpSessionOptions, type RudpTransportProfileOptions, } from "./rudp"; +export { + invokeCultNetOperation, + startCultNetOperationServer, + type CultNetOperationClientOptions, + type CultNetOperationServer, + type CultNetOperationServerOptions, +} from "./operation-service"; export { defineJsonSchemaContract, type JsonSchemaContract, @@ -131,3 +144,33 @@ export { validateGhostlightAgentStateGenerated, type GhostlightAgentStateShape, } from "./generated/swarm-contracts.generated"; +export { + CULTNET_RUDP_PROTOCOL_ID, + createSignedDaemonHealthPublisher, + parseEndpoint as parseDaemonHealthEndpoint, + publishSignedDaemonHealth, + signedDaemonHealthPayload, + signedDaemonHealthSigningMessage, + type DaemonHealth, + type SignedDaemonHealthContract, + type SignedDaemonHealthPublisher, + type SignedDaemonHealthPublisherOptions, +} from "./signed-daemon-health"; + +export { + GAMECULT_RUNTIME_ACTIVATION_PROOF_SIGNING_PURPOSE, + GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, + GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE, + GAMECULT_RUNTIME_PRESENCE_IDENTITY_NAME, + IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, + RUNTIME_PRESENCE_FIELD_COUNT, + RUNTIME_PRESENCE_SLOT, + encodeRuntimePresenceHealth, + runtimePresenceActivationSigningMessage, + runtimePresenceProofPayload, + runtimePresenceProviderSigningMessage, + validateRuntimePresence, + type GameCultRuntimeCapability, + type RuntimePresenceHealth, + type RuntimePresenceState, +} from "./runtime-presence-health"; diff --git a/packages/cultnet-ts/src/json-schema-contract.ts b/packages/cultnet-ts/src/json-schema-contract.ts index b97a6762..4d8fb85d 100644 --- a/packages/cultnet-ts/src/json-schema-contract.ts +++ b/packages/cultnet-ts/src/json-schema-contract.ts @@ -1,5 +1,5 @@ import Ajv2020, { type ErrorObject, type ValidateFunction } from "ajv/dist/2020"; -import type { CultCacheSchema } from "cultcache-ts"; +import type { CultCacheSchema } from "@gamecult/cultcache-ts"; export interface JsonSchemaContract extends CultCacheSchema { readonly schemaId: string; diff --git a/packages/cultnet-ts/src/operation-service.ts b/packages/cultnet-ts/src/operation-service.ts new file mode 100644 index 00000000..ee13457f --- /dev/null +++ b/packages/cultnet-ts/src/operation-service.ts @@ -0,0 +1,202 @@ +import { createSocket, type RemoteInfo, type Socket } from "node:dgram"; +import { decode, encode } from "@msgpack/msgpack"; + +import { + encodeCultNetMessageForWire, + parseCultNetMessage, + type CultNetOperationRequestMessage, + type CultNetOperationResponseMessage, +} from "./contracts"; +import { + CultNetRudpSession, + CultNetRudpSocketTransportConnection, + decodeRudpPacket, + encodeRudpPacket, + type CultNetRudpPacket, +} from "./rudp"; +import { CultNetPeer } from "./peer"; + +const DEFAULT_CONNECTION_ID = 0x43554c54; + +export interface CultNetOperationServerOptions { + runtimeId: string; + host?: string; + port?: number; + connectionId?: number; + maxFragmentBytes?: number; + handler: (request: CultNetOperationRequestMessage) => + CultNetOperationResponseMessage | Promise; +} + +export interface CultNetOperationServer { + readonly endpoint: string; + close(): Promise; +} + +export interface CultNetOperationClientOptions { + runtimeId: string; + connectionId?: number; + timeoutMs?: number; + maxFragmentBytes?: number; +} + +interface RemoteSession { + session: CultNetRudpSession; + remote: RemoteInfo; +} + +export async function startCultNetOperationServer( + options: CultNetOperationServerOptions, +): Promise { + if (!options.runtimeId) throw new Error("CultNet operation server requires runtimeId."); + if (!options.handler) throw new Error("CultNet operation server requires a handler."); + const socket = createSocket("udp4"); + const connectionId = options.connectionId ?? DEFAULT_CONNECTION_ID; + const sessions = new Map(); + const sendPacket = (remote: RemoteInfo, packet: CultNetRudpPacket): void => { + const wire = encodeRudpPacket(packet); + socket.send(wire, remote.port, remote.address); + }; + socket.on("message", (wire, remote) => { + void handleServerDatagram(socket, sessions, connectionId, options, wire, remote, sendPacket); + }); + await bindSocket(socket, options.port ?? 0, options.host ?? "127.0.0.1"); + const resendTimer = setInterval(() => { + for (const peer of sessions.values()) { + for (const packet of peer.session.dueResends(Date.now())) sendPacket(peer.remote, packet); + } + }, 25); + resendTimer.unref?.(); + const address = socket.address(); + const endpoint = `rudp://${address.address}:${address.port}`; + return { + endpoint, + close: async () => { + clearInterval(resendTimer); + await closeSocket(socket); + }, + }; +} + +export async function invokeCultNetOperation( + endpoint: string, + request: CultNetOperationRequestMessage, + options: CultNetOperationClientOptions, +): Promise { + const target = parseRudpEndpoint(endpoint); + const socket = createSocket("udp4"); + await bindSocket(socket, 0, "127.0.0.1"); + const transport = new CultNetRudpSocketTransportConnection({ + mode: "client", + runtimeId: options.runtimeId, + transportId: `${options.runtimeId}.operations`, + socket, + remoteHost: target.host, + remotePort: target.port, + connectionId: options.connectionId ?? DEFAULT_CONNECTION_ID, + maxFragmentBytes: options.maxFragmentBytes ?? 2048, + }); + const peer = new CultNetPeer(transport, { wireContract: "cultnet.schema.v0" }); + const timeoutMs = options.timeoutMs ?? 10_000; + try { + transport.connect(); + await waitUntil(() => transport.connected, timeoutMs, "CultNet operation connection timed out."); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("CultNet operation response timed out.")), timeoutMs); + const onMessage = (message: unknown): void => { + const candidate = message as CultNetOperationResponseMessage; + if (candidate.schemaVersion !== "cultnet.operation_response.v0" || candidate.messageId !== request.messageId) return; + clearTimeout(timer); + peer.off("message", onMessage); + resolve(candidate); + }; + peer.on("message", onMessage); + peer.send(request); + }); + } finally { + transport.close(); + } +} + +async function handleServerDatagram( + socket: Socket, + sessions: Map, + connectionId: number, + options: CultNetOperationServerOptions, + wire: Buffer, + remote: RemoteInfo, + sendPacket: (remote: RemoteInfo, packet: CultNetRudpPacket) => void, +): Promise { + let packet: CultNetRudpPacket; + try { + packet = decodeRudpPacket(wire); + } catch { + return; + } + if (packet.connectionId !== connectionId) return; + const key = `${remote.address}:${remote.port}`; + let peer = sessions.get(key); + if (packet.packetType === "connect") { + peer = { session: new CultNetRudpSession({ connectionId, resendDelayMs: 25 }), remote }; + sessions.set(key, peer); + sendPacket(remote, peer.session.acceptConnect(packet, Date.now(), encode("cultnet-operation-service"))); + return; + } + if (!peer) return; + const result = peer.session.receive(packet, Date.now()); + if (result.reply) sendPacket(remote, result.reply); + for (const ready of result.readyToSend ?? []) sendPacket(remote, ready); + if (result.disconnected) { + sessions.delete(key); + return; + } + for (const frame of result.delivered) { + if (frame.channelId !== "schema") continue; + const message = parseCultNetMessage(decode(frame.payload)); + if (message.schemaVersion !== "cultnet.operation_request.v0") continue; + const response = await options.handler(message); + const payload = encode(encodeCultNetMessageForWire(response, "cultnet.schema.v0")); + for (const responsePacket of peer.session.sendMany("schema", payload, { + reliable: true, + ordered: true, + nowMs: Date.now(), + maxFragmentBytes: options.maxFragmentBytes ?? 2048, + })) sendPacket(remote, responsePacket); + } + if (packet.packetType === "data" || result.delivered.length > 0) { + sendPacket(remote, peer.session.createAckForReceived(packet.sequence)); + } +} + +function parseRudpEndpoint(endpoint: string): { host: string; port: number } { + const url = new URL(endpoint); + if (url.protocol !== "rudp:") throw new Error(`CultNet operation endpoint must use rudp://: ${endpoint}`); + const port = Number(url.port); + if (!url.hostname || !Number.isInteger(port) || port <= 0) throw new Error(`Invalid CultNet operation endpoint: ${endpoint}`); + return { host: url.hostname, port }; +} + +function bindSocket(socket: Socket, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind(port, host, () => { + socket.off("error", reject); + resolve(); + }); + }); +} + +function closeSocket(socket: Socket): Promise { + return new Promise(resolve => { + if (!socket.address()) return resolve(); + socket.close(() => resolve()); + }); +} + +async function waitUntil(check: () => boolean, timeoutMs: number, message: string): Promise { + const deadline = Date.now() + timeoutMs; + while (!check()) { + if (Date.now() >= deadline) throw new Error(message); + await new Promise(resolve => setTimeout(resolve, 5)); + } +} diff --git a/packages/cultnet-ts/src/peer.ts b/packages/cultnet-ts/src/peer.ts index 816fcfd6..0e54ee58 100644 --- a/packages/cultnet-ts/src/peer.ts +++ b/packages/cultnet-ts/src/peer.ts @@ -106,6 +106,10 @@ export class CultNetPeer extends EventEmitter { this.#stream?.write(encodeFrame(payload)); } + async flush(timeoutMs?: number): Promise { + await this.#transport?.flush?.(timeoutMs); + } + sendHello(message: CultNetHelloMessage): void { this.send(message); } diff --git a/packages/cultnet-ts/src/replication.ts b/packages/cultnet-ts/src/replication.ts index 600a21e3..77dab70f 100644 --- a/packages/cultnet-ts/src/replication.ts +++ b/packages/cultnet-ts/src/replication.ts @@ -6,7 +6,7 @@ import type { CultCacheDocumentFormatter, CultCacheDocumentValue, CultCacheEnvelope, -} from "cultcache-ts"; +} from "@gamecult/cultcache-ts"; import { type CultNetDocumentDeleteMessage, @@ -57,7 +57,15 @@ export class CultNetDocumentRegistry { } getBySchemaId(schemaId: string): CultNetDocumentBinding | undefined { - return this.#schemaBindings.get(schemaId); + const exact = this.#schemaBindings.get(schemaId); + if (exact) { + return exact; + } + + const requestedSchemaIds = new Set([schemaId]); + return this.#uniqueBindings().find(binding => + schemaMatchesBinding(schemaIdForBinding(binding), binding, requestedSchemaIds), + ); } createDocumentPutMessage( @@ -159,7 +167,7 @@ export class CultNetDocumentRegistry { const binding = this.#requireBinding(envelope.type); const schemaId = schemaIdForEnvelope(envelope, binding); - if (requestedSchemaIds && !requestedSchemaIds.has(schemaId)) { + if (requestedSchemaIds && !schemaMatchesBinding(schemaId, binding, requestedSchemaIds)) { continue; } @@ -196,7 +204,7 @@ export class CultNetDocumentRegistry { const binding = this.#requireBinding(envelope.type); const schemaId = schemaIdForEnvelope(envelope, binding); - if (requestedSchemaIds && !requestedSchemaIds.has(schemaId)) { + if (requestedSchemaIds && !schemaMatchesBinding(schemaId, binding, requestedSchemaIds)) { continue; } @@ -238,11 +246,14 @@ export class CultNetDocumentRegistry { cache: CultCache, message: CultNetDocumentPutRawMessage, ): Promise { - const binding = this.#requireSchemaBinding(message.document.schemaId); + const resolution = this.#resolveRawDocumentBinding(message.document); + const { binding } = resolution; return cache.putEnvelope(binding.definition, { key: message.document.recordKey, type: binding.definition.type, - schemaId: message.document.schemaId, + schemaId: resolution.preserveIncomingSchemaId + ? message.document.schemaId + : schemaIdForBinding(binding), payload: new Uint8Array(message.document.payload), storedAt: message.document.storedAt, }); @@ -292,6 +303,35 @@ export class CultNetDocumentRegistry { return binding; } + #resolveRawDocumentBinding(document: CultNetRawDocumentRecord): { + binding: CultNetDocumentBinding; + preserveIncomingSchemaId: boolean; + } { + const exact = this.getBySchemaId(document.schemaId); + if (exact) { + return { binding: exact, preserveIncomingSchemaId: true }; + } + + const payload = new Uint8Array(document.payload); + for (const binding of this.#uniqueBindings()) { + try { + decodeDocumentValue(binding.definition, payload); + return { binding, preserveIncomingSchemaId: false }; + } catch { + // Keep looking; a foreign schema id is acceptable only when the payload validates locally. + } + } + + throw new Error(`No CultNet document binding is registered for schema "${document.schemaId}".`); + } + + #uniqueBindings(): CultNetDocumentBinding[] { + return [...new Set([ + ...this.#schemaBindings.values(), + ...this.#bindings.values(), + ])]; + } + #createRawDocumentRecord(envelope: CultCacheEnvelope): CultNetRawDocumentRecord { const binding = this.#requireBinding(envelope.type); return { @@ -308,6 +348,42 @@ function schemaIdForBinding(binding: CultNetDocumentBinding): string { return binding.definition.schemaId ?? binding.definition.type; } +function schemaMatchesBinding( + schemaId: string, + binding: CultNetDocumentBinding, + requestedSchemaIds: Set, +): boolean { + if (requestedSchemaIds.has(schemaId)) { + return true; + } + + const definition = binding.definition; + if (definition.schemaId && requestedSchemaIds.has(definition.schemaId)) { + return true; + } + if (definition.schemaName && requestedSchemaIds.has(definition.schemaName)) { + return true; + } + if (definition.schemaVersion && requestedSchemaIds.has(definition.schemaVersion)) { + return true; + } + if (definition.compatibleSchemaIds?.some(candidate => requestedSchemaIds.has(candidate))) { + return true; + } + + return Array.from(requestedSchemaIds) + .some(candidate => inferSchemaName(candidate) === definition.schemaName); +} + +function inferSchemaName(schemaId: string): string | undefined { + const marker = schemaId.lastIndexOf(".v"); + if (marker <= 0 || marker + 2 >= schemaId.length) { + return undefined; + } + const version = schemaId.slice(marker + 2); + return /^\d+$/u.test(version) ? schemaId.slice(0, marker) : undefined; +} + function schemaIdForEnvelope( envelope: CultCacheEnvelope, binding: CultNetDocumentBinding, diff --git a/packages/cultnet-ts/src/rudp.ts b/packages/cultnet-ts/src/rudp.ts index 54b85e83..56dc67fa 100644 --- a/packages/cultnet-ts/src/rudp.ts +++ b/packages/cultnet-ts/src/rudp.ts @@ -16,6 +16,8 @@ const RUDP_MAGIC = [0x43, 0x4e, 0x52, 0x30] as const; // CNR0 const RUDP_VERSION = 0; const RUDP_FIXED_HEADER_BYTES = 36; const MAX_CHANNEL_ID_BYTES = 255; +export const CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS = 32; +const RUDP_RECEIVED_SEQUENCE_WINDOW = 4_096; export type CultNetRudpPacketType = | "connect" @@ -57,6 +59,7 @@ export interface CultNetRudpSessionOptions { export interface CultNetRudpReceiveResult { delivered: CultNetRudpDeliveredFrame[]; + readyToSend?: CultNetRudpPacket[]; reply?: CultNetRudpPacket; pong?: boolean; pongPayload?: Uint8Array; @@ -144,16 +147,25 @@ export class CultNetRudpSession { readonly connectionId: number; readonly resendDelayMs: number; #nextSequence: number; + readonly #nextSequencedByChannel = new Map(); #nextFragmentId = 1; #connected = false; readonly #maxPendingReliablePackets: number | undefined; #lastReceivedAtMs: number | undefined; #highestReceivedSequence: number | undefined; readonly #receivedSequences = new Set(); + readonly #latestSequencedByChannel = new Map(); readonly #pendingReliable = new Map(); + readonly #queuedReliable: CultNetRudpPacket[] = []; readonly #orderedNextSequenceByChannel = new Map(); readonly #orderedBuffers = new Map>(); readonly #fragmentBuffers = new Map(); + /// Matches cultnet-rs's `max_pending_fragment_sets`. A fragment set is only + /// removed on successful reassembly, so a set that loses one fragment is + /// stranded for the life of the session. Without a bound the map grows + /// forever under any loss on a fragmenting channel. + #maxPendingFragmentSets = 64; + #fragmentSetsEvicted = 0; constructor(options: CultNetRudpSessionOptions) { this.connectionId = toUint32(options.connectionId, "connectionId"); @@ -173,10 +185,44 @@ export class CultNetRudpSession { return [...this.#pendingReliable.keys()].sort((left, right) => left - right); } + get queuedReliablePacketCount(): number { + return this.#queuedReliable.length; + } + + get outstandingReliablePacketCount(): number { + return this.#pendingReliable.size + this.#queuedReliable.length; + } + + /// Incomplete fragment sets dropped to stay within the pending bound. + /// Non-zero means the peer is losing fragments: the receiver is still + /// serving, and something upstream is not. + get fragmentSetsEvicted(): number { + return this.#fragmentSetsEvicted; + } + + get pendingFragmentSetCount(): number { + return this.#fragmentBuffers.size; + } + get lastReceivedAtMs(): number | undefined { return this.#lastReceivedAtMs; } + resetPeerState(): void { + this.#connected = false; + this.#lastReceivedAtMs = undefined; + this.#highestReceivedSequence = undefined; + this.#receivedSequences.clear(); + this.#nextSequencedByChannel.clear(); + this.#latestSequencedByChannel.clear(); + this.#pendingReliable.clear(); + this.#queuedReliable.splice(0); + this.#orderedNextSequenceByChannel.clear(); + this.#orderedBuffers.clear(); + this.#fragmentBuffers.clear(); + this.#fragmentSetsEvicted = 0; + } + createConnect(nowMs = 0, payload = new Uint8Array()): CultNetRudpPacket { this.#ensureReliableCapacity(1); const packet = this.#createPacket({ @@ -196,8 +242,20 @@ export class CultNetRudpSession { throw new Error(`Expected RUDP connect packet, got ${packet.packetType}.`); } + const pendingAccept = [...this.#pendingReliable.values()] + .find(pending => pending.packet.packetType === "accept"); + if (pendingAccept) { + this.#rememberReceived(packet.sequence); + this.#lastReceivedAtMs = nowMs; + pendingAccept.lastSentAtMs = nowMs; + return { + ...pendingAccept.packet, + payload: new Uint8Array(pendingAccept.packet.payload ?? new Uint8Array()), + }; + } this.#ensureReliableCapacity(1); this.#rememberReceived(packet.sequence); + this.#lastReceivedAtMs = nowMs; this.#connected = true; const response = this.#createPacket({ packetType: "accept", @@ -215,6 +273,9 @@ export class CultNetRudpSession { payload: Uint8Array, options: { reliable?: boolean; ordered?: boolean; sequenced?: boolean; nowMs?: number } = {}, ): CultNetRudpPacket { + if (options.reliable && this.#pendingReliable.size >= CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS) { + throw new Error("RUDP reliable send window is full; receive acknowledgements before sending."); + } return this.sendMany(channelId, payload, options)[0]!; } @@ -232,6 +293,9 @@ export class CultNetRudpSession { if (!this.#connected) { throw new Error("Cannot send RUDP data before the session is connected."); } + if (options.ordered && !options.reliable) { + throw new Error("RUDP ordered delivery requires reliability."); + } const maxFragmentBytes = options.maxFragmentBytes; if (maxFragmentBytes !== undefined && maxFragmentBytes <= 0) { @@ -248,10 +312,9 @@ export class CultNetRudpSession { ordered: options.ordered, sequenced: options.sequenced, }); - if (packet.reliable) { - this.#trackReliable(packet, options.nowMs ?? 0); - } - return [packet]; + return packet.reliable + ? this.#admitReliablePackets([packet], options.nowMs ?? 0) + : [packet]; } const fragmentCount = Math.ceil(payload.byteLength / maxFragmentBytes); @@ -275,17 +338,17 @@ export class CultNetRudpSession { fragmentIndex: index, fragmentCount, }); - if (packet.reliable) { - this.#trackReliable(packet, options.nowMs ?? 0); - } packets.push(packet); } - return packets; + return options.reliable + ? this.#admitReliablePackets(packets, options.nowMs ?? 0) + : packets; } receive(packet: CultNetRudpPacket, nowMs = 0): CultNetRudpReceiveResult { this.#requireConnection(packet); this.#applyAcknowledgements(packet); + const readyToSend = this.#promoteQueuedReliable(nowMs); this.#lastReceivedAtMs = nowMs; const expectedSequenceIfUninitialized = this.#highestReceivedSequence === undefined ? packet.sequence @@ -294,13 +357,13 @@ export class CultNetRudpSession { if (packet.packetType === "accept") { this.#rememberReceived(packet.sequence); this.#connected = true; - return { delivered: [] }; + return { delivered: [], readyToSend }; } if (packet.packetType === "ping") { - this.#rememberReceived(packet.sequence); return { delivered: [], + readyToSend, reply: this.#createPacket({ packetType: "pong", channelId: "control", @@ -310,51 +373,106 @@ export class CultNetRudpSession { } if (packet.packetType === "ack" || packet.packetType === "pong") { - this.#rememberReceived(packet.sequence); return { delivered: [], + readyToSend, pong: packet.packetType === "pong", pongPayload: packet.packetType === "pong" ? packet.payload ?? new Uint8Array() : undefined, }; } if (packet.packetType === "disconnect") { - this.#rememberReceived(packet.sequence); this.#connected = false; return { delivered: [], + readyToSend, disconnected: true, disconnectReason: packet.payload ?? new Uint8Array(), }; } if (packet.packetType !== "data") { - return { delivered: [] }; + return { delivered: [], readyToSend }; } - const isDuplicate = this.#receivedSequences.has(packet.sequence); - this.#rememberReceived(packet.sequence); + const isDuplicate = (packet.reliable ?? false) + && (this.#receivedSequences.has(packet.sequence) + || (this.#highestReceivedSequence !== undefined + && packet.sequence < this.#highestReceivedSequence + && this.#highestReceivedSequence - packet.sequence >= RUDP_RECEIVED_SEQUENCE_WINDOW)); + if (packet.reliable ?? false) this.#rememberReceived(packet.sequence); if (isDuplicate) { - return { delivered: [] }; + return { delivered: [], readyToSend }; } const reassembled = this.#reassemble(packet); if (!reassembled) { - return { delivered: [] }; + return { delivered: [], readyToSend }; + } + + if (!reassembled.ordered && (packet.sequenced ?? false)) { + const newestSequence = reassembled.nextSequence - 1; + const latestSequence = this.#latestSequencedByChannel.get(reassembled.frame.channelId); + if (latestSequence !== undefined && newestSequence <= latestSequence) { + return { delivered: [], readyToSend }; + } + this.#latestSequencedByChannel.set(reassembled.frame.channelId, newestSequence); + return { delivered: [reassembled.frame], readyToSend }; } if (!reassembled.ordered) { - return { delivered: [reassembled.frame] }; + return { delivered: [reassembled.frame], readyToSend }; } - return { delivered: this.#deliverOrdered(reassembled.frame, reassembled.nextSequence, expectedSequenceIfUninitialized) }; + return { + delivered: this.#deliverOrdered(reassembled.frame, reassembled.nextSequence, expectedSequenceIfUninitialized), + readyToSend, + }; } createAck(): CultNetRudpPacket { - return this.#createPacket({ + const { ack, ackMask } = this.#ackState(); + return { packetType: "ack", + connectionId: this.connectionId, + sequence: 0, + ack, + ackMask, channelId: "control", - }); + reliable: false, + ordered: false, + sequenced: false, + fragmentId: 0, + fragmentIndex: 0, + fragmentCount: 0, + payload: new Uint8Array(), + }; + } + + createAckFor(sequence: number): CultNetRudpPacket { + return { + packetType: "ack", + connectionId: this.connectionId, + sequence: 0, + ack: toUint32(sequence, "ack sequence"), + ackMask: 0, + channelId: "control", + reliable: false, + ordered: false, + sequenced: false, + fragmentId: 0, + fragmentIndex: 0, + fragmentCount: 0, + payload: new Uint8Array(), + }; + } + + createAckForReceived(sequence: number): CultNetRudpPacket { + const receivedSequence = toUint32(sequence, "received sequence"); + const { ack } = this.#ackState(); + return ack >= receivedSequence && ack - receivedSequence <= 32 + ? this.createAck() + : this.createAckFor(receivedSequence); } createPing(payload = new Uint8Array()): CultNetRudpPacket { @@ -407,8 +525,17 @@ export class CultNetRudpSession { fragmentIndex?: number; fragmentCount?: number; }): CultNetRudpPacket { - const sequence = this.#nextSequence; - this.#nextSequence = toUint32(this.#nextSequence + 1, "sequence"); + let sequence = 0; + if (packet.reliable ?? false) { + sequence = this.#nextSequence; + this.#nextSequence = toUint32(this.#nextSequence + 1, "reliable sequence"); + } else if (packet.sequenced ?? false) { + sequence = this.#nextSequencedByChannel.get(packet.channelId) ?? 1; + this.#nextSequencedByChannel.set( + packet.channelId, + toUint32(sequence + 1, "sequenced channel sequence"), + ); + } const { ack, ackMask } = this.#ackState(); return { packetType: packet.packetType, @@ -434,11 +561,26 @@ export class CultNetRudpSession { }); } + #admitReliablePackets(packets: CultNetRudpPacket[], nowMs: number): CultNetRudpPacket[] { + const available = Math.max(0, CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS - this.#pendingReliable.size); + const ready = packets.slice(0, available); + for (const packet of ready) this.#trackReliable(packet, nowMs); + this.#queuedReliable.push(...packets.slice(available)); + return ready; + } + + #promoteQueuedReliable(nowMs: number): CultNetRudpPacket[] { + const available = Math.max(0, CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS - this.#pendingReliable.size); + const ready = this.#queuedReliable.splice(0, available); + for (const packet of ready) this.#trackReliable(packet, nowMs); + return ready; + } + #ensureReliableCapacity(packetCount: number): void { if (packetCount === 0 || this.#maxPendingReliablePackets === undefined) { return; } - if (this.#pendingReliable.size + packetCount > this.#maxPendingReliablePackets) { + if (this.outstandingReliablePacketCount + packetCount > this.#maxPendingReliablePackets) { throw new Error("RUDP reliable send queue is full."); } } @@ -457,6 +599,12 @@ export class CultNetRudpSession { if (this.#highestReceivedSequence === undefined || sequence > this.#highestReceivedSequence) { this.#highestReceivedSequence = sequence; } + if (this.#receivedSequences.size > RUDP_RECEIVED_SEQUENCE_WINDOW) { + const keepFrom = Math.max(0, (this.#highestReceivedSequence ?? sequence) - RUDP_RECEIVED_SEQUENCE_WINDOW + 1); + for (const received of this.#receivedSequences) { + if (received < keepFrom) this.#receivedSequences.delete(received); + } + } } #ackState(): { ack: number; ackMask: number } { @@ -506,6 +654,20 @@ export class CultNetRudpSession { payloads: new Map(), sequences: new Map(), }; + // Evict the oldest stranded set rather than refusing the payload. A + // receiver that stops accepting fragmented traffic after a bounded + // number of losses is a denial of service delivered by the network; + // dropping the least recent incomplete set costs one payload that was + // already incomplete. Map iteration is insertion-ordered, so the first + // key is the oldest. + while (this.#fragmentBuffers.size >= this.#maxPendingFragmentSets) { + const oldest = this.#fragmentBuffers.keys().next(); + if (oldest.done) { + break; + } + this.#fragmentBuffers.delete(oldest.value); + this.#fragmentSetsEvicted += 1; + } this.#fragmentBuffers.set(key, buffer); } if (buffer.fragmentCount !== fragmentCount || buffer.ordered !== (packet.ordered ?? false)) { @@ -692,6 +854,10 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement return { ...this.#stats }; } + get outstandingReliablePacketCount(): number { + return this.#session.outstandingReliablePacketCount; + } + connect(payload = new Uint8Array()): void { if (this.#mode !== "client") { throw new Error("Only a client RUDP socket transport can initiate connect."); @@ -711,6 +877,21 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement this.#stats.framesSent += 1; } + async flush(timeoutMs = 30_000): Promise { + const deadline = Date.now() + Math.max(0, timeoutMs); + while (this.#session.outstandingReliablePacketCount > 0) { + if (this.#closed) { + throw new Error("RUDP transport closed before reliable packets were acknowledged."); + } + if (Date.now() >= deadline) { + throw new Error( + `RUDP reliable flush timed out with ${this.#session.outstandingReliablePacketCount} packets outstanding.`, + ); + } + await new Promise(resolve => setTimeout(resolve, 5)); + } + } + ping(payload = new Uint8Array()): void { this.#sendPacket(this.#session.createPing(payload)); } @@ -727,6 +908,13 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement close(): void { clearInterval(this.#resendTimer); if (!this.#closed) { + if (this.#session.connected && this.#remoteHost && this.#remotePort !== undefined) { + try { + this.#sendPacket(this.#session.createDisconnect()); + } catch { + // Best-effort shutdown: close must still release the UDP socket. + } + } this.#closed = true; this.#socket.close(); } @@ -746,11 +934,17 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement this.#remoteHost = remote.address; this.#remotePort = remote.port; } else if (remote.address !== this.#remoteHost || remote.port !== this.#remotePort) { - return; + if (this.#mode === "server" && packet.packetType === "connect") { + this.#remoteHost = remote.address; + this.#remotePort = remote.port; + } else { + return; + } } try { if (this.#mode === "server" && packet.packetType === "connect") { + this.#session.resetPeerState(); this.#sendPacket(this.#session.acceptConnect(packet, Date.now())); return; } @@ -759,6 +953,9 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement if (result.reply) { this.#sendPacket(result.reply); } + for (const ready of result.readyToSend ?? []) { + this.#sendPacket(ready); + } if (result.pong) { this.emit("pong", { payload: result.pongPayload ?? new Uint8Array() }); } @@ -774,8 +971,8 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement this.emit("close"); return; } - if (packet.packetType === "accept" || result.delivered.length > 0) { - this.#sendPacket(this.#session.createAck()); + if (packet.packetType === "accept" || packet.packetType === "data" || result.delivered.length > 0) { + this.#sendPacket(this.#session.createAckForReceived(packet.sequence)); } } catch (error) { this.emit("error", error instanceof Error ? error : new Error(String(error))); diff --git a/packages/cultnet-ts/src/runtime-presence-health.ts b/packages/cultnet-ts/src/runtime-presence-health.ts new file mode 100644 index 00000000..8d70da8e --- /dev/null +++ b/packages/cultnet-ts/src/runtime-presence-health.ts @@ -0,0 +1,256 @@ +// Runtime presence health: the dual-proved statement one launched incarnation +// makes about itself. +// +// This is the TypeScript half of `gamecult.runtime_presence_health.v2`, the +// contract `cultnet-rs` defines in `runtime_authority_contracts.rs`. Odin admits +// a document as runtime presence only when it carries this schema id and its +// record key equals the signed target; anything else it files away as an +// ordinary document, and no topology correlation is ever produced for it. +// +// Two proofs cover one payload. The stable provider key says "this service made +// this statement"; the activation key Idunn hands the process at launch says +// "and it is the incarnation Idunn started". Neither establishes admission +// alone, which is why both signature fields are empty in the signed bytes: it +// avoids a circular signature while binding every authority-bearing field and +// both signer identities. +// +// The wire shape is a positional tuple indexed by the same field slots the Rust +// derive emits. Field N here is field N there; that correspondence is the whole +// contract, and the constants below name the slots so it stays checkable. + +import { encode } from "@msgpack/msgpack"; + +export const GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA = "gamecult.runtime_presence_health.v2"; + +/** Purpose for the stable provider-health signature over the proof payload. */ +export const GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE = + "gamecult.runtime_presence_health.v2"; + +/** Purpose for the activation-scoped proof over the same payload. */ +export const GAMECULT_RUNTIME_ACTIVATION_PROOF_SIGNING_PURPOSE = + "gamecult.runtime_presence.activation-proof.v1"; + +/** systemd file-descriptor name Idunn uses for the activation signing key. */ +export const IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME = "gamecult-idunn-runtime-activation-key"; + +/** systemd file-descriptor name Idunn uses for the provider-health identity. */ +export const GAMECULT_RUNTIME_PRESENCE_IDENTITY_NAME = "gamecult-runtime-presence-identity"; + +const PROVIDER_HEALTH_SIGNATURE_DOMAIN = Buffer.from( + "gamecult.provider-health.signature.v1\0", + "utf8", +); + +const RUNTIME_ACTIVATION_SIGNATURE_DOMAIN = Buffer.from( + "idunn.runtime-activation.signature.v1\0", + "utf8", +); + +/** + * Positional slots, mirroring the `#[cultcache(key = N)]` attributes on + * `GameCultRuntimePresenceHealthRecord`. The tuple is always this long: the + * Rust derive serialises `max_slot + 1` elements regardless of which are set. + */ +export const RUNTIME_PRESENCE_SLOT = { + schemaVersion: 0, + target: 1, + expectedProjectionSha256: 2, + planId: 3, + incarnationId: 4, + sealedReleaseId: 5, + activationWitnessSha256: 6, + stateSchemaGeneration: 7, + stateContractSha256: 8, + runtimeId: 9, + runtimeInstanceId: 10, + boundEndpoint: 11, + capabilities: 12, + healthContract: 13, + state: 14, + detail: 15, + writeLeaseSha256: 16, + signerIdentityId: 17, + publisherSequence: 18, + observedAtUnixMillis: 19, + signatureAlgorithm: 20, + signature: 21, + activationSignerIdentityId: 22, + activationSignature: 23, +} as const; + +export const RUNTIME_PRESENCE_FIELD_COUNT = 24; + +/** A capability claim. Nested as its own positional tuple, not a map. */ +export interface GameCultRuntimeCapability { + capability: string; + schema: string; + compatibility: string; + capacity: number; +} + +/** The states the contract admits. */ +export type RuntimePresenceState = "warming" | "active" | "degraded" | "failed"; + +export interface RuntimePresenceHealth { + target: string; + expectedProjectionSha256: string; + planId: string; + incarnationId: string; + sealedReleaseId: string; + activationWitnessSha256: string; + stateSchemaGeneration: string | null; + stateContractSha256: string | null; + runtimeId: string; + runtimeInstanceId: string; + boundEndpoint: string | null; + capabilities: GameCultRuntimeCapability[]; + healthContract: string; + state: RuntimePresenceState; + detail: string; + writeLeaseSha256: string | null; + signerIdentityId: string; + publisherSequence: number; + observedAtUnixMillis: number; + activationSignerIdentityId: string; +} + +function encodeCapability(capability: GameCultRuntimeCapability): unknown[] { + // Plain serde struct on the Rust side, and the record is encoded with + // rmp_serde's compact form, so a capability is a positional tuple too. + return [capability.capability, capability.schema, capability.compatibility, capability.capacity]; +} + +/** + * Capabilities are canonical only when strictly increasing by + * `(capability, schema, compatibility)`. Sorting here rather than trusting the + * caller keeps a correct statement from being rejected over field order. + */ +function canonicalCapabilities(capabilities: GameCultRuntimeCapability[]): unknown[][] { + const sorted = [...capabilities].sort((left, right) => { + const leftKey = [left.capability, left.schema, left.compatibility]; + const rightKey = [right.capability, right.schema, right.compatibility]; + for (let index = 0; index < leftKey.length; index += 1) { + if (leftKey[index]! < rightKey[index]!) return -1; + if (leftKey[index]! > rightKey[index]!) return 1; + } + return 0; + }); + return sorted.map(encodeCapability); +} + +function presenceTuple( + presence: RuntimePresenceHealth, + signature: Uint8Array, + activationSignature: Uint8Array, +): unknown[] { + const fields = new Array(RUNTIME_PRESENCE_FIELD_COUNT); + fields[RUNTIME_PRESENCE_SLOT.schemaVersion] = GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA; + fields[RUNTIME_PRESENCE_SLOT.target] = presence.target; + fields[RUNTIME_PRESENCE_SLOT.expectedProjectionSha256] = presence.expectedProjectionSha256; + fields[RUNTIME_PRESENCE_SLOT.planId] = presence.planId; + fields[RUNTIME_PRESENCE_SLOT.incarnationId] = presence.incarnationId; + fields[RUNTIME_PRESENCE_SLOT.sealedReleaseId] = presence.sealedReleaseId; + fields[RUNTIME_PRESENCE_SLOT.activationWitnessSha256] = presence.activationWitnessSha256; + fields[RUNTIME_PRESENCE_SLOT.stateSchemaGeneration] = presence.stateSchemaGeneration; + fields[RUNTIME_PRESENCE_SLOT.stateContractSha256] = presence.stateContractSha256; + fields[RUNTIME_PRESENCE_SLOT.runtimeId] = presence.runtimeId; + fields[RUNTIME_PRESENCE_SLOT.runtimeInstanceId] = presence.runtimeInstanceId; + fields[RUNTIME_PRESENCE_SLOT.boundEndpoint] = presence.boundEndpoint; + fields[RUNTIME_PRESENCE_SLOT.capabilities] = canonicalCapabilities(presence.capabilities); + fields[RUNTIME_PRESENCE_SLOT.healthContract] = presence.healthContract; + fields[RUNTIME_PRESENCE_SLOT.state] = presence.state; + fields[RUNTIME_PRESENCE_SLOT.detail] = presence.detail; + fields[RUNTIME_PRESENCE_SLOT.writeLeaseSha256] = presence.writeLeaseSha256; + fields[RUNTIME_PRESENCE_SLOT.signerIdentityId] = presence.signerIdentityId; + fields[RUNTIME_PRESENCE_SLOT.publisherSequence] = presence.publisherSequence; + fields[RUNTIME_PRESENCE_SLOT.observedAtUnixMillis] = presence.observedAtUnixMillis; + fields[RUNTIME_PRESENCE_SLOT.signatureAlgorithm] = "ed25519"; + fields[RUNTIME_PRESENCE_SLOT.signature] = signature; + fields[RUNTIME_PRESENCE_SLOT.activationSignerIdentityId] = presence.activationSignerIdentityId; + fields[RUNTIME_PRESENCE_SLOT.activationSignature] = activationSignature; + return fields; +} + +/** + * Reject statements the contract forbids, before they are signed. + * + * These are the same conditions `validate_shape` enforces on the Rust side. A + * statement that fails here would be refused after a round trip and a signature; + * failing early names the actual problem instead. + */ +export function validateRuntimePresence(presence: RuntimePresenceHealth): void { + if (presence.publisherSequence <= 0 || !Number.isSafeInteger(presence.publisherSequence)) { + throw new Error("runtime presence publisher sequence must be a positive integer"); + } + if (presence.observedAtUnixMillis <= 0 || !Number.isSafeInteger(presence.observedAtUnixMillis)) { + throw new Error("runtime presence observation time must be a positive integer"); + } + if (presence.detail.length > 512 || /\p{Cc}/u.test(presence.detail)) { + throw new Error("runtime presence detail is too long or carries control characters"); + } + if (presence.state === "warming" && presence.writeLeaseSha256 !== null) { + throw new Error("warming runtime presence cannot claim a process write lease"); + } + if (presence.stateSchemaGeneration === null && presence.writeLeaseSha256 !== null) { + throw new Error("stateless runtime presence cannot claim a process write lease"); + } + const generationPresent = presence.stateSchemaGeneration !== null; + const contractPresent = presence.stateContractSha256 !== null; + if (generationPresent !== contractPresent) { + throw new Error("runtime presence state lineage is partial"); + } +} + +/** + * The one payload both proofs cover: the complete record with both signature + * fields empty. + */ +export function runtimePresenceProofPayload(presence: RuntimePresenceHealth): Uint8Array { + validateRuntimePresence(presence); + return encode(presenceTuple(presence, new Uint8Array(), new Uint8Array())); +} + +/** The complete signed record, as put on the wire. */ +export function encodeRuntimePresenceHealth( + presence: RuntimePresenceHealth, + signature: Uint8Array, + activationSignature: Uint8Array, +): Uint8Array { + if (signature.length !== 64 || activationSignature.length !== 64) { + throw new Error("runtime presence proofs must both be 64-byte ed25519 signatures"); + } + validateRuntimePresence(presence); + return encode(presenceTuple(presence, signature, activationSignature)); +} + +function domainSeparated(domain: Buffer, purpose: string, payload: Uint8Array): Buffer { + const purposeBytes = Buffer.from(purpose, "utf8"); + const lengths = Buffer.alloc(16); + lengths.writeBigUInt64BE(BigInt(purposeBytes.length), 0); + lengths.writeBigUInt64BE(BigInt(payload.length), 8); + return Buffer.concat([ + domain, + lengths.subarray(0, 8), + purposeBytes, + lengths.subarray(8, 16), + Buffer.from(payload), + ]); +} + +/** Message the stable provider-health key signs. */ +export function runtimePresenceProviderSigningMessage(payload: Uint8Array): Buffer { + return domainSeparated( + PROVIDER_HEALTH_SIGNATURE_DOMAIN, + GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE, + payload, + ); +} + +/** Message the activation key Idunn passed at launch signs. */ +export function runtimePresenceActivationSigningMessage(payload: Uint8Array): Buffer { + return domainSeparated( + RUNTIME_ACTIVATION_SIGNATURE_DOMAIN, + GAMECULT_RUNTIME_ACTIVATION_PROOF_SIGNING_PURPOSE, + payload, + ); +} diff --git a/packages/cultnet-ts/src/shard-catalog.ts b/packages/cultnet-ts/src/shard-catalog.ts index 38a5d1c3..f57e0bee 100644 --- a/packages/cultnet-ts/src/shard-catalog.ts +++ b/packages/cultnet-ts/src/shard-catalog.ts @@ -47,11 +47,11 @@ export class CultNetShardCatalog { } list(options: CultNetShardCatalogOptions = {}): CultNetShardDescriptor[] { - const requestedSchemaIds = options.schemaIds ? new Set(options.schemaIds) : null; + const requestedSchemaIds = options.schemaIds ?? []; const requestedRecordKeys = options.recordKeys ?? []; return this.shards.filter((shard) => { - if (requestedSchemaIds && !shard.schemaIds?.some((schemaId) => requestedSchemaIds.has(schemaId))) { + if (requestedSchemaIds.length > 0 && !schemaIdsMatchAny(shard.schemaIds ?? [], requestedSchemaIds)) { return false; } if (requestedRecordKeys.length > 0 && !requestedRecordKeys.some((recordKey) => shardServes(shard, { recordKey }))) { @@ -86,7 +86,12 @@ export function shardServes( shard: CultNetShardDescriptor, options: { schemaId?: string; recordKey?: string } = {}, ): boolean { - if (options.schemaId !== undefined && shard.schemaIds !== undefined && shard.schemaIds.length > 0 && !shard.schemaIds.includes(options.schemaId)) { + if ( + options.schemaId !== undefined && + shard.schemaIds !== undefined && + shard.schemaIds.length > 0 && + !schemaIdsMatchAny(shard.schemaIds, [options.schemaId]) + ) { return false; } if (options.recordKey !== undefined && shard.keyPrefix !== undefined && !options.recordKey.startsWith(shard.keyPrefix)) { @@ -95,6 +100,35 @@ export function shardServes( return true; } +function schemaIdsMatchAny( + advertisedSchemaIds: readonly string[], + requestedSchemaIds: readonly string[], +): boolean { + return advertisedSchemaIds.some(advertised => + requestedSchemaIds.some(requested => schemaIdsMatch(advertised, requested)), + ); +} + +function schemaIdsMatch(left: string, right: string): boolean { + if (left === right) { + return true; + } + + const leftName = inferSchemaName(left) ?? left; + const rightName = inferSchemaName(right) ?? right; + return leftName === rightName; +} + +function inferSchemaName(schemaId: string): string | undefined { + const marker = schemaId.lastIndexOf(".v"); + if (marker <= 0 || marker + 2 >= schemaId.length) { + return undefined; + } + + const version = schemaId.slice(marker + 2); + return /^\d+$/u.test(version) ? schemaId.slice(0, marker) : undefined; +} + function cloneShardDescriptor(descriptor: CultNetShardDescriptor): CultNetShardDescriptor { return { shardId: descriptor.shardId, diff --git a/packages/cultnet-ts/src/signed-daemon-health.ts b/packages/cultnet-ts/src/signed-daemon-health.ts new file mode 100644 index 00000000..d4f0f0ce --- /dev/null +++ b/packages/cultnet-ts/src/signed-daemon-health.ts @@ -0,0 +1,377 @@ +// Signed daemon-health publication over CultNet RUDP. +// +// This is the transport half of daemon health publication: bind a socket, shake +// hands, sign the statement, put the document, wait for the ack. It is generic +// across services. The receiving service owns the connection id and the schema +// names, and supplies them as a contract; nothing service-specific is hardcoded +// here. +// +// The signature and identity domains are deliberately not parameterised. They +// are the GameCult provider-health domains, shared by every publisher and +// verifier, and a caller that could vary them could silently produce statements +// nobody verifies. + +import dgram from "node:dgram"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import { encode } from "@msgpack/msgpack"; + +import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "./rudp"; +import type { CultNetRudpPacket } from "./rudp"; +import { encodeCultNetMessageForWire } from "./contracts"; + +export const CULTNET_RUDP_PROTOCOL_ID = "cultnet.transport.rudp.v0"; + +const SIGNATURE_DOMAIN = Buffer.from("gamecult.provider-health.signature.v1\0", "utf8"); +const ID_DOMAIN = Buffer.from("gamecult.provider-health.identity.v1\0", "utf8"); + +/** What the receiving service owns: where to connect and what to call the documents. */ +export interface SignedDaemonHealthContract { + /** RUDP connection id the receiving service listens on. */ + connectionId: number; + /** Schema id for a signed statement. Also the signing purpose. */ + signedSchemaId: string; + /** Schema id used when no signing key is configured. */ + unsignedSchemaId: string; + /** Prefix for generated message ids. Defaults to "daemon-health". */ + messageIdPrefix?: string; +} + +export interface SignedDaemonHealthPublisherOptions { + /** host:port, or [ipv6]:port. */ + endpoint: string; + daemonId: string; + healthContract: string; + sourceRuntimeId?: string; + /** Omit to publish unsigned. */ + privateKeyPath?: string; + contract: SignedDaemonHealthContract; +} + +export interface DaemonHealth { + state: string; + detail?: string; + observedAt?: string; +} + +export interface SignedDaemonHealthPublisher { + daemonId: string; + endpoint: { host: string; port: number }; + healthContract: string; + publisherIncarnationId: string; + publisherSequence: number; + sourceRuntimeId: string; + contract: SignedDaemonHealthContract; + privateKey?: crypto.KeyObject; + publicKey?: Buffer; + signerIdentityId?: string; +} + +export function createSignedDaemonHealthPublisher( + options: SignedDaemonHealthPublisherOptions | null | undefined, +): SignedDaemonHealthPublisher | null { + if (!options) return null; + if (!options.contract) { + throw new Error("Signed daemon health publisher requires a contract naming the connection id and schema ids."); + } + + const publisher: SignedDaemonHealthPublisher = { + daemonId: options.daemonId, + endpoint: parseEndpoint(options.endpoint), + healthContract: options.healthContract, + publisherIncarnationId: crypto.randomUUID(), + publisherSequence: 0, + sourceRuntimeId: options.sourceRuntimeId || "daemon-health-publisher", + contract: options.contract, + }; + + if (options.privateKeyPath) { + publisher.privateKey = crypto.createPrivateKey(fs.readFileSync(options.privateKeyPath)); + publisher.publicKey = rawEd25519PublicKey(publisher.privateKey); + publisher.signerIdentityId = crypto + .createHash("sha256") + .update(ID_DOMAIN) + .update(publisher.publicKey) + .digest("hex"); + } + return publisher; +} + +export async function publishSignedDaemonHealth( + publisher: SignedDaemonHealthPublisher | null, + health: DaemonHealth, +): Promise { + if (!publisher) return; + const { contract } = publisher; + + const socket = dgram.createSocket(endpointFamily(publisher.endpoint.host)); + await bindSocket(socket, publisher.endpoint); + const receiver = createPacketReceiver(socket); + const session = new CultNetRudpSession({ + connectionId: contract.connectionId, + initialSequence: 1, + resendDelayMs: 100, + }); + + try { + const connect = session.createConnect(Date.now(), new Uint8Array()); + await sendPacket(socket, publisher.endpoint, connect); + await receiveUntil(receiver, session, publisher.endpoint, (packet) => packet.packetType === "accept", 5000, "accept"); + + const observedAt = health.observedAt || new Date().toISOString(); + const signed = publisher.privateKey ? signedDaemonHealthPayload(publisher, health, observedAt) : null; + const payload = signed?.payload || encode([ + publisher.daemonId, + health.state, + String(health.detail || "").slice(0, 512), + observedAt, + publisher.healthContract, + "daemon-published", + CULTNET_RUDP_PROTOCOL_ID, + ]); + + const message = { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: `${contract.messageIdPrefix || "daemon-health"}:${publisher.daemonId}:${observedAt.replace(/[:.]/g, "-")}`, + document: { + schemaId: signed ? contract.signedSchemaId : contract.unsignedSchemaId, + recordKey: publisher.daemonId, + storedAt: observedAt, + payloadEncoding: "messagepack", + payload, + sourceRuntimeId: publisher.sourceRuntimeId, + sourceRole: "daemon-health-publisher", + tags: [CULTNET_RUDP_PROTOCOL_ID], + }, + }; + + const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); + const dataPackets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); + const ack = receiveUntil( + receiver, + session, + publisher.endpoint, + (packet) => packet.packetType === "ack", + 500, + "ack", + ).catch(() => undefined); + for (const packet of dataPackets) { + await sendPacket(socket, publisher.endpoint, packet); + } + await ack; + } finally { + receiver.close(); + socket.close(); + } +} + +export function signedDaemonHealthPayload( + publisher: SignedDaemonHealthPublisher, + health: DaemonHealth, + observedAt: string, +): { payload: Uint8Array; statement: unknown[]; unsignedPayload: Uint8Array } { + if (!publisher.privateKey) { + throw new Error("Signed daemon health requires a configured private key."); + } + publisher.publisherSequence += 1; + const observedAtUnixMillis = Date.parse(observedAt); + if (!Number.isSafeInteger(observedAtUnixMillis) || observedAtUnixMillis <= 0) { + throw new Error("Signed daemon health observation time is invalid."); + } + + const unsigned: unknown[] = [ + publisher.contract.signedSchemaId, + publisher.daemonId, + publisher.healthContract, + publisher.sourceRuntimeId, + health.state, + String(health.detail || "").slice(0, 512), + publisher.signerIdentityId, + publisher.publisherIncarnationId, + publisher.publisherSequence, + observedAtUnixMillis, + null, + null, + null, + null, + "ed25519", + new Uint8Array(), + false, + ]; + + const unsignedPayload = encode(unsigned); + const signature = crypto.sign( + null, + signedDaemonHealthSigningMessage(publisher.contract.signedSchemaId, unsignedPayload), + publisher.privateKey, + ); + const statement = unsigned.slice(); + statement[15] = new Uint8Array(signature); + return { payload: encode(statement), statement, unsignedPayload }; +} + +/** + * Domain-separated signing preimage. The signing purpose is the signed schema + * id, so a statement signed for one service's schema cannot verify as another's. + */ +export function signedDaemonHealthSigningMessage(signedSchemaId: string, payload: Uint8Array): Buffer { + const purpose = Buffer.from(signedSchemaId, "utf8"); + const purposeLength = Buffer.alloc(8); + purposeLength.writeBigUInt64BE(BigInt(purpose.length)); + const payloadLength = Buffer.alloc(8); + payloadLength.writeBigUInt64BE(BigInt(payload.length)); + return Buffer.concat([SIGNATURE_DOMAIN, purposeLength, purpose, payloadLength, Buffer.from(payload)]); +} + +function rawEd25519PublicKey(privateKey: crypto.KeyObject): Buffer { + const der = crypto.createPublicKey(privateKey).export({ type: "spki", format: "der" }) as Buffer; + if (der.length < 32) throw new Error("Ed25519 public key export is too short."); + return Buffer.from(der.subarray(der.length - 32)); +} + +async function bindSocket(socket: dgram.Socket, endpoint: { host: string }): Promise { + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => { + socket.off("error", reject); + resolve(); + }); + }); +} + +export function parseEndpoint(value: string): { host: string; port: number } { + const text = String(value || "").trim(); + const ipv6 = text.match(/^\[([^\]]+)\]:(\d+)$/); + if (ipv6) return { host: ipv6[1], port: parsePort(ipv6[2]) }; + const index = text.lastIndexOf(":"); + if (index <= 0) { + throw new Error(`Daemon health RUDP endpoint must be host:port, got "${value}".`); + } + return { host: text.slice(0, index), port: parsePort(text.slice(index + 1)) }; +} + +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Daemon health RUDP endpoint port is invalid: ${value}`); + } + return port; +} + +function endpointFamily(host: string): "udp4" | "udp6" { + return host.includes(":") ? "udp6" : "udp4"; +} + +interface PacketReceiver { + socket: dgram.Socket; + next(timeoutMs: number, label?: string): Promise; + close(): void; +} + +async function receiveUntil( + receiver: PacketReceiver, + session: CultNetRudpSession, + endpoint: { host: string; port: number }, + predicate: (packet: CultNetRudpPacket) => boolean, + timeoutMs: number, + label: string, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const packet = await receiver.next(Math.min(100, deadline - Date.now()), label); + const result = session.receive(packet, Date.now()); + if (result.reply) { + throw new Error("Daemon health publisher received an unexpected reply-required packet."); + } + if (predicate(packet)) return; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ETIMEDOUT") throw error; + } + for (const packet of session.dueResends(Date.now())) { + await sendPacket(receiver.socket, endpoint, packet); + } + } + throw new Error(`timed out waiting for daemon health RUDP ${label} response after ${timeoutMs}ms`); +} + +function createPacketReceiver(socket: dgram.Socket): PacketReceiver { + const packets: CultNetRudpPacket[] = []; + const waiters: Array<{ + resolve: (packet: CultNetRudpPacket) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; + }> = []; + const errors: Error[] = []; + + const resolveNext = () => { + while (waiters.length > 0 && (packets.length > 0 || errors.length > 0)) { + const waiter = waiters.shift()!; + clearTimeout(waiter.timer); + if (errors.length > 0) waiter.reject(errors.shift()!); + else waiter.resolve(packets.shift()!); + } + }; + const onMessage = (wire: Buffer) => { + try { + packets.push(decodeRudpPacket(wire)); + } catch (error) { + errors.push(error as Error); + } + resolveNext(); + }; + const onError = (error: Error) => { + errors.push(error); + resolveNext(); + }; + + socket.on("message", onMessage); + socket.on("error", onError); + + return { + socket, + next(timeoutMs: number, label = "packet") { + if (packets.length > 0) return Promise.resolve(packets.shift()!); + if (errors.length > 0) return Promise.reject(errors.shift()!); + return new Promise((resolve, reject) => { + const waiter = { + resolve, + reject, + timer: setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index >= 0) waiters.splice(index, 1); + const error = new Error(`timed out waiting for daemon health RUDP ${label}`) as NodeJS.ErrnoException; + error.code = "ETIMEDOUT"; + reject(error); + }, Math.max(1, timeoutMs)), + }; + waiters.push(waiter); + }); + }, + close() { + socket.off("message", onMessage); + socket.off("error", onError); + while (waiters.length > 0) { + const waiter = waiters.shift()!; + clearTimeout(waiter.timer); + const error = new Error("Daemon health publisher closed.") as NodeJS.ErrnoException; + error.code = "ECLOSED"; + waiter.reject(error); + } + }, + }; +} + +async function sendPacket( + socket: dgram.Socket, + endpoint: { host: string; port: number }, + packet: CultNetRudpPacket, +): Promise { + const wire = encodeRudpPacket(packet); + await new Promise((resolve, reject) => { + socket.send(wire, endpoint.port, endpoint.host, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} diff --git a/packages/cultnet-ts/src/transport.ts b/packages/cultnet-ts/src/transport.ts index 53f64127..a1b9b3ce 100644 --- a/packages/cultnet-ts/src/transport.ts +++ b/packages/cultnet-ts/src/transport.ts @@ -131,6 +131,7 @@ export interface CultNetTransportConnection { readonly profile: CultNetTransportProfile; readonly stats: CultNetTransportStats; send(channelId: string, payload: Uint8Array): void; + flush?(timeoutMs?: number): Promise; close(): void; on( eventName: EventName, diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index c2a8a8d8..2f540a86 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -12,7 +12,7 @@ import { CultCache, SingleFileMessagePackBackingStore, defineDocumentType, -} from "cultcache-ts"; +} from "@gamecult/cultcache-ts"; import { CultNetClientSecurityOptions, @@ -21,6 +21,7 @@ import { CultNetRudpReconnectLoop, CultNetRudpSession, CultNetRudpSocketTransportConnection, + CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS, CultNetSchemaCatalog, CultNetSchemaRegistry, CultNetSecret, @@ -41,6 +42,8 @@ import { encodeRudpPacket, ghostlightAgentStateGeneratedContract, parseCultNetMessage, + invokeCultNetOperation, + startCultNetOperationServer, validateGhostlightAgentStateGenerated, validateGhostlightAgentState, shardServes, @@ -56,6 +59,64 @@ import { type InteropNote, } from "./interop/cultnet-interop-shared"; +test("CultNet validates the canonical CultMesh Verse catalog messages", () => { + const request = parseCultNetMessage({ + schemaVersion: "cultmesh.verse_catalog_request.v0", + messageId: "catalog-1", + verseIds: ["sample.counter"], + transportVersion: "cultmesh.v1", + }); + assert.equal(request.schemaVersion, "cultmesh.verse_catalog_request.v0"); + + const response = parseCultNetMessage({ + schemaVersion: "cultmesh.verse_catalog_response.v0", + messageId: "catalog-1", + verses: [{ + verseId: "sample.counter", + displayName: "Counter", + authorityModel: "OperatorCluster", + compatibility: { + transportVersion: "cultmesh.v1", + rulesHash: "counter-v1", + compatibleVerseIds: [], + requiredPluginIds: [], + optionalPluginIds: [], + }, + discoveryEndpoints: ["wss://provider.example/cultmesh"], + authorityRuntimeIds: ["sample.counter-provider"], + authorityRoutes: [{ + authorityRuntimeId: "sample.counter-provider", + endpoint: "wss://provider.example/cultmesh", + protocolIds: ["cultmesh.documents.v1"], + priority: 0, + generation: "provider-route-1", + }], + }], + }); + assert.equal(response.schemaVersion, "cultmesh.verse_catalog_response.v0"); + assert.equal(response.verses[0].authorityRuntimeIds[0], "sample.counter-provider"); + assert.equal(response.verses[0].authorityRoutes![0].generation, "provider-route-1"); + + const session = parseCultNetMessage({ + schemaVersion: "cultmesh.session_accepted.v2", + messageId: "session-1", + accepted: true, + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-provider", + protocolId: "cultmesh.documents.v1", + routeGeneration: "provider-route-1", + clientNonce: "bm9uY2U=", + providerKeyId: "provider-key-1", + providerSignature: "c2lnbmF0dXJl", + }); + assert.equal(session.schemaVersion, "cultmesh.session_accepted.v2"); + + assert.throws(() => parseCultNetMessage({ + ...response, + verses: [{ ...response.verses[0], compatibility: { ...response.verses[0].compatibility, rulesHash: "" } }], + }), /rulesHash/); +}); + class FakeRudpReconnectTransport extends EventEmitter { public connectCalls = 0; public closeCalls = 0; @@ -413,11 +474,20 @@ test("rudp session handshake acks reliable connect and accept packets", () => { assert.equal(server.connected, true); assert.deepEqual(server.pendingReliableSequences, [100]); + const retransmittedAccept = server.acceptConnect(connect, 15, Buffer.from("ok", "utf8")); + assert.deepEqual( + encodeRudpPacket(retransmittedAccept), + encodeRudpPacket(accept), + "a retransmitted Connect reuses the pending Accept packet", + ); + assert.deepEqual(server.pendingReliableSequences, [100]); + client.receive(accept, 20); assert.equal(client.connected, true); assert.deepEqual(client.pendingReliableSequences, []); const ack = client.createAck(); + assert.equal(ack.sequence, 0); assert.equal(ack.ack, 100); server.receive(ack, 30); assert.deepEqual(server.pendingReliableSequences, []); @@ -483,6 +553,53 @@ test("rudp session pings and detects receive timeout", () => { assert.equal(client.connected, false); }); +test("rudp lossy packets cannot create reliable ordered gaps", () => { + const client = new CultNetRudpSession({ connectionId: 198, initialSequence: 1 }); + const server = new CultNetRudpSession({ connectionId: 198, initialSequence: 100 }); + const connect = client.createConnect(0); + const accept = server.acceptConnect(connect, 1); + client.receive(accept, 2); + + const ping = client.createPing(Buffer.from("pulse")); + const pong = server.receive(ping, 3).reply!; + const realtime = server.send("realtime", Buffer.from("discarded realtime")); + const latest = server.send("latest", Buffer.from("discarded latest state"), { sequenced: true }); + const schema = server.send("schema", Buffer.from("committed response"), { reliable: true, ordered: true }); + + assert.equal(ping.sequence, 0); + assert.equal(pong.sequence, 0); + assert.equal(realtime.sequence, 0); + assert.equal(latest.sequence, 1); + assert.equal(schema.sequence, accept.sequence + 1); + assert.deepEqual(client.receive(schema, 4).delivered.map(frame => Buffer.from(frame.payload).toString("utf8")), [ + "committed response", + ]); +}); + +test("rudp unreliable sequenced delivery is scoped to its channel", () => { + const sender = new CultNetRudpSession({ connectionId: 197, initialSequence: 50 }); + const receiver = new CultNetRudpSession({ connectionId: 197, initialSequence: 100 }); + const connect = sender.createConnect(0); + const accept = receiver.acceptConnect(connect, 1); + sender.receive(accept, 2); + const older = sender.send("latest", Buffer.from("older"), { sequenced: true }); + const newer = sender.send("latest", Buffer.from("newer"), { sequenced: true }); + + assert.equal(older.sequence, 1); + assert.equal(newer.sequence, 2); + assert.deepEqual(receiver.receive(newer, 3).delivered.map(frame => Buffer.from(frame.payload).toString("utf8")), ["newer"]); + assert.deepEqual(receiver.receive(older, 4).delivered, []); +}); + +test("rudp rejects unreliable ordered delivery", () => { + const session = new CultNetRudpSession({ connectionId: 196 }); + session.receive({ packetType: "accept", connectionId: 196, sequence: 50, ack: 0, ackMask: 0, channelId: "control" }); + assert.throws( + () => session.send("schema", Buffer.from("cannot order what will not retransmit"), { ordered: true }), + /ordered delivery requires reliability/, + ); +}); + test("rudp session bounds pending reliable packets before enqueue", () => { const session = new CultNetRudpSession({ connectionId: 102, initialSequence: 1, maxPendingReliablePackets: 2 }); session.receive({ packetType: "accept", connectionId: 102, sequence: 50, ack: 0, ackMask: 0, channelId: "control" }); @@ -533,6 +650,9 @@ test("rudp session skips received control packets while ordering schema payloads const control = sender.createAck(); const second = sender.send("schema", Buffer.from("second"), { reliable: true, ordered: true }); + assert.equal(control.sequence, 0); + assert.equal(second.sequence, first.sequence + 1); + assert.deepEqual(receiver.receive(first).delivered.map((frame) => Buffer.from(frame.payload).toString("utf8")), ["first"]); assert.deepEqual(receiver.receive(control).delivered, []); assert.deepEqual(receiver.receive(second).delivered.map((frame) => Buffer.from(frame.payload).toString("utf8")), ["second"]); @@ -807,9 +927,9 @@ test("CultNet can round-trip gamecult.networking.v0 auth messages through the ex assert.deepEqual(wireValue, [ 0, [ - Buffer.from("nonce", "utf8"), - Buffer.from("auth", "utf8"), - Buffer.from("password", "utf8"), + new TextEncoder().encode("nonce"), + new TextEncoder().encode("auth"), + new TextEncoder().encode("password"), ], ]); @@ -936,13 +1056,15 @@ test("CultNet shard catalog filters descriptors and answers catalog requests", ( assert.equal(watchedShardId, "profiles-a"); assert.equal(shardServes(catalog.get("notes-a")!, { schemaId: "note.v1", recordKey: "note:1" }), true); + assert.equal(shardServes(catalog.get("notes-a")!, { schemaId: "note", recordKey: "note:1" }), true); assert.equal(shardServes(catalog.get("notes-a")!, { schemaId: "profile.v1", recordKey: "note:1" }), false); assert.deepEqual(catalog.list({ schemaIds: ["note.v1"], recordKeys: ["note:1"] }).map((shard) => shard.shardId), ["notes-a"]); + assert.deepEqual(catalog.list({ schemaIds: ["note"], recordKeys: ["note:1"] }).map((shard) => shard.shardId), ["notes-a"]); const response = catalog.createCatalogResponse({ schemaVersion: "cultnet.shard_catalog_request.v0", messageId: "shards", - schemaIds: ["note.v1"], + schemaIds: ["note"], recordKeys: ["note:2"], }); assert.equal(response.schemaVersion, "cultnet.shard_catalog_response.v0"); @@ -987,7 +1109,7 @@ test("CultNet peer can request and sync shard catalogs by message id", async () const synced = new CultNetShardCatalog(); const applied = await requester.syncShardCatalog(synced, { messageId: "peer-shards", - schemaIds: ["note.v1"], + schemaIds: ["note"], recordKeys: ["note:local"], timeoutMs: 1_000, }); @@ -1215,6 +1337,361 @@ test("CultNet raw replication preserves CultCache payload bytes for bit-compatib } }); +test("operation envelopes preserve typed service routing and payload correlation", () => { + const request = parseCultNetMessage({ + schemaVersion: "cultnet.operation_request.v0", + messageId: "plugin-42", + serviceId: "sai.vn", + operation: "project", + payloadSchema: "gamecult.eve.plugin_abi.request.v1", + payloadEncoding: "messagepack-base64", + payload: "gaZzY2hlbWHEJ2dhbWVjdWx0LmV2ZS5wbHVnaW5fYWJpLnJlcXVlc3QudjE=", + sourceRuntimeId: "eve-unity", + }); + assert.equal(request.schemaVersion, "cultnet.operation_request.v0"); + assert.equal(request.messageId, "plugin-42"); + assert.equal(request.serviceId, "sai.vn"); + + const response = parseCultNetMessage({ + schemaVersion: "cultnet.operation_response.v0", + messageId: "plugin-42", + serviceId: "sai.vn", + operation: "project", + status: "ok", + payloadSchema: "gamecult.eve.plugin_abi.response.v1", + payloadEncoding: "messagepack-base64", + payload: "gaZzdGF0dXOkb2s=", + diagnostics: [], + }); + assert.equal(response.schemaVersion, "cultnet.operation_response.v0"); + assert.equal(response.messageId, request.messageId); +}); + +test("rudp session advances large fragment sets through a bounded reliable window", () => { + const connectionId = 457; + const sender = new CultNetRudpSession({ connectionId, initialSequence: 1, resendDelayMs: 25 }); + const receiver = new CultNetRudpSession({ connectionId, initialSequence: 100, resendDelayMs: 25 }); + sender.receive({ packetType: "accept", connectionId, sequence: 0, ack: 0, ackMask: 0, channelId: "control" }); + receiver.receive({ packetType: "accept", connectionId, sequence: 0, ack: 0, ackMask: 0, channelId: "control" }); + + const fragmentCount = CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 17; + const payload = Uint8Array.from( + { length: fragmentCount * 8 }, + (_, index) => index % 251, + ); + const wire = sender.sendMany("schema", payload, { + reliable: true, + ordered: true, + nowMs: 1, + maxFragmentBytes: 8, + }); + assert.equal(wire.length, CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS); + const oldestSequence = wire[0]!.sequence; + assert.equal(sender.pendingReliableSequences.length, wire.length); + assert.equal(sender.queuedReliablePacketCount, 17); + + const delivered = []; + while (wire.length > 0) { + const admitted = wire.length; + for (let index = 0; index < admitted; index += 1) { + const packet = wire.shift()!; + delivered.push(...receiver.receive(packet, 2).delivered); + } + const acknowledged = sender.receive(receiver.createAck(), 3); + wire.push(...(acknowledged.readyToSend ?? [])); + } + + assert.equal(sender.outstandingReliablePacketCount, 0); + assert.equal(delivered.length, 1); + assert.deepEqual(delivered[0]?.payload, payload); + const oldAck = receiver.createAckForReceived(oldestSequence); + assert.equal(oldAck.ack, oldestSequence); + assert.equal(oldAck.ackMask, 0); +}); + +test("CultNet contracts encode legacy bytes without Node Buffer authority", () => { + const originalBuffer = globalThis.Buffer; + try { + Object.defineProperty(globalThis, "Buffer", { configurable: true, value: undefined }); + const message = parseCultNetMessage({ + schemaVersion: "cultnet.login.v0", + nonce: "AQID", + auth: "BAUG", + password: "BwgJ", + }); + const wire = encodeCultNetMessageForWire(message, "gamecult.networking.v0") as [number, Uint8Array[]]; + assert.equal(wire[0], 0); + assert.deepEqual([...wire[1][0]], [1, 2, 3]); + } finally { + Object.defineProperty(globalThis, "Buffer", { configurable: true, value: originalBuffer }); + } +}); + +test("database subscription contracts match the C# live document lane", () => { + const subscribe = parseCultNetMessage({ + schemaVersion: "cultnet.database_subscribe.v0", + messageId: "subscribe-counter", + subscriptionId: "counter:browser-1", + schemaIds: ["sample.counter_state.v1"], + recordKeys: ["counter:main"], + includeSnapshot: true, + consumerRuntimeId: "browser-1", + }); + assert.equal(subscribe.schemaVersion, "cultnet.database_subscribe.v0"); + assert.equal(subscribe.subscriptionId, "counter:browser-1"); + + const added = parseCultNetMessage({ + schemaVersion: "cultnet.database_change_raw.v0", + messageId: "change-counter-1", + subscriptionId: "counter:browser-1", + changeKind: "added", + document: { + schemaId: "sample.counter_state.v1", + recordKey: "counter:main", + storedAt: "2026-08-17T00:00:00Z", + payloadEncoding: "messagepack", + payload: new Uint8Array([0x81, 0xa5, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x01]), + }, + }); + assert.equal(added.schemaVersion, "cultnet.database_change_raw.v0"); + assert.equal(added.document?.recordKey, "counter:main"); + + const removed = parseCultNetMessage({ + schemaVersion: "cultnet.database_change_raw.v0", + messageId: "change-counter-2", + subscriptionId: "counter:browser-1", + changeKind: "removed", + recordKey: "counter:main", + schemaId: "sample.counter_state.v1", + }); + assert.equal(removed.schemaVersion, "cultnet.database_change_raw.v0"); + assert.equal(removed.recordKey, "counter:main"); + + const unsubscribe = parseCultNetMessage({ + schemaVersion: "cultnet.database_unsubscribe.v0", + messageId: "unsubscribe-counter", + subscriptionId: "counter:browser-1", + }); + assert.equal(unsubscribe.schemaVersion, "cultnet.database_unsubscribe.v0"); + + assert.throws( + () => parseCultNetMessage({ + schemaVersion: "cultnet.database_change_raw.v0", + messageId: "invalid-change", + subscriptionId: "counter:browser-1", + changeKind: "updated", + }), + /document: is required/, + ); +}); + +test("RUDP operation service correlates typed payload envelopes", async () => { + const server = await startCultNetOperationServer({ + runtimeId: "sai-sidecar", + handler: request => ({ + schemaVersion: "cultnet.operation_response.v0", + messageId: request.messageId, + serviceId: request.serviceId, + operation: request.operation, + status: "ok", + payloadSchema: "gamecult.eve.plugin_abi.response.v1", + payloadEncoding: "messagepack-base64", + payload: request.payload, + diagnostics: [], + sourceRuntimeId: "sai-sidecar", + }), + }); + try { + const response = await invokeCultNetOperation(server.endpoint, { + schemaVersion: "cultnet.operation_request.v0", + messageId: "service-1", + serviceId: "sai.vn", + operation: "describe", + payloadSchema: "gamecult.eve.plugin_abi.request.v1", + payloadEncoding: "messagepack-base64", + payload: "gaZzY2hlbWHEJ2dhbWVjdWx0LmV2ZS5wbHVnaW5fYWJpLnJlcXVlc3QudjE=", + }, { runtimeId: "eve-test" }); + assert.equal(response.messageId, "service-1"); + assert.equal(response.serviceId, "sai.vn"); + assert.equal(response.status, "ok"); + } finally { + await server.close(); + } +}); + +test("rudp sequence-neutral acknowledgements interoperate with ordered receivers", () => { + const sender = new CultNetRudpSession({ connectionId: 125, initialSequence: 1 }); + const receiver = new CultNetRudpSession({ connectionId: 125, initialSequence: 100 }); + sender.receive({ packetType: "accept", connectionId: 125, sequence: 90, ack: 0, ackMask: 0, channelId: "control" }); + receiver.receive({ packetType: "accept", connectionId: 125, sequence: 91, ack: 0, ackMask: 0, channelId: "control" }); + + const ack = sender.createAck(); + const request = sender.send("schema", Buffer.from("snapshot"), { reliable: true, ordered: true }); + + assert.equal(ack.sequence, 0); + assert.equal(request.sequence, 1); + assert.deepEqual(receiver.receive(ack).delivered, []); + assert.deepEqual(receiver.receive(request).delivered.map(frame => Buffer.from(frame.payload).toString("utf8")), ["snapshot"]); +}); + +test("CultNet raw replication applies compatible foreign-schema snapshots", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-foreign-schema-raw-")); + + try { + const documentDefinition = defineDocumentType({ + type: "cultnet.note", + schemaId: "cultnet.note.v1", + schemaName: "cultnet.note", + schemaVersion: "cultnet.note.v1", + schema: z.object({ + schema_version: z.string(), + noteId: z.string(), + body: z.string(), + }), + name: "noteId", + }); + const registry = new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: documentDefinition }), + ]); + const originCache = CultCache.builder() + .withDocumentType(documentDefinition) + .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "origin.msgpack"))) + .build(); + const targetCache = CultCache.builder() + .withDocumentType(documentDefinition) + .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "target.msgpack"))) + .build(); + + await originCache.put(documentDefinition, "note:foreign-schema", { + schema_version: "cultnet.note.v1", + noteId: "note:foreign-schema", + body: "accepted by payload shape", + }); + const rawSnapshot = registry.createRawSnapshotResponse(originCache, "foreign-schema-raw"); + rawSnapshot.documents[0]!.schemaId = "runtime.generated.cultnet.note.ui.99"; + + await registry.applyRawSnapshotResponse(targetCache, rawSnapshot); + + assert.equal( + targetCache.getRequired(documentDefinition, "note:foreign-schema").body, + "accepted by payload shape", + ); + assert.equal( + targetCache.getRequiredEnvelope(documentDefinition, "note:foreign-schema").schemaId, + "cultnet.note.v1", + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("CultNet document registry filters snapshots by schema aliases", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-alias-snapshot-")); + + try { + const documentDefinition = defineDocumentType({ + type: "cultnet.alias_note", + schemaId: "sha256:cultnet-alias-note", + schemaName: "cultnet.alias_note", + schemaVersion: "cultnet.alias_note.v1", + schema: z.object({ + schema_version: z.string(), + noteId: z.string(), + body: z.string(), + }), + name: "noteId", + }); + const registry = new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: documentDefinition }), + ]); + const cache = CultCache.builder() + .withDocumentType(documentDefinition) + .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "alias.msgpack"))) + .build(); + + await cache.put(documentDefinition, "note:alias", { + schema_version: "cultnet.alias_note.v1", + noteId: "note:alias", + body: "filtered by alias", + }); + + const typedSnapshot = registry.createSnapshotResponse(cache, "typed-alias", { + schemaVersion: "cultnet.snapshot_request.v0", + messageId: "typed-alias", + schemaIds: ["cultnet.alias_note.v1"], + }); + const rawSnapshot = registry.createRawSnapshotResponse(cache, "raw-alias", { + schemaVersion: "cultnet.snapshot_request.v0", + messageId: "raw-alias", + schemaIds: ["cultnet.alias_note.v1"], + }); + + assert.equal(typedSnapshot.documents[0]?.schemaId, "sha256:cultnet-alias-note"); + assert.equal(typedSnapshot.documents[0]?.recordKey, "note:alias"); + assert.equal(rawSnapshot.documents[0]?.schemaId, "sha256:cultnet-alias-note"); + assert.equal(rawSnapshot.documents[0]?.recordKey, "note:alias"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("CultNet document registry applies typed put and delete messages by schema alias", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-alias-typed-")); + + try { + const documentDefinition = defineDocumentType({ + type: "cultnet.typed_alias_note", + schemaId: "sha256:cultnet-typed-alias-note", + schemaName: "cultnet.typed_alias_note", + schemaVersion: "cultnet.typed_alias_note.v1", + schema: z.object({ + schema_version: z.string(), + noteId: z.string(), + body: z.string(), + }), + name: "noteId", + }); + const registry = new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: documentDefinition }), + ]); + const cache = CultCache.builder() + .withDocumentType(documentDefinition) + .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "typed-alias.msgpack"))) + .build(); + + await registry.applyDocumentPutMessage(cache, { + schemaVersion: "cultnet.document_put.v0", + messageId: "typed-alias-put", + document: { + schemaId: "cultnet.typed_alias_note.v1", + recordKey: "note:typed-alias", + storedAt: "2026-06-27T00:00:00.000Z", + payload: { + schema_version: "cultnet.typed_alias_note.v1", + noteId: "note:typed-alias", + body: "typed alias applied", + }, + }, + }); + + assert.equal( + cache.getRequired(documentDefinition, "note:typed-alias").body, + "typed alias applied", + ); + assert.equal(registry.getBySchemaId("cultnet.typed_alias_note.v1")?.definition.type, documentDefinition.type); + + assert.equal(await registry.applyDocumentDeleteMessage(cache, { + schemaVersion: "cultnet.document_delete.v0", + messageId: "typed-alias-delete", + schemaId: "cultnet.typed_alias_note.v1", + recordKey: "note:typed-alias", + }), true); + assert.equal(cache.get(documentDefinition, "note:typed-alias"), undefined); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + test("CultNet interop slot compatibility defaults missing trailing fields and rejects mismatched slots", () => { const note: InteropNote = { schemaVersion: INTEROP_SCHEMA_VERSION, @@ -1344,3 +1821,49 @@ test("Generated Ghostlight contracts can feed CultCacheTS directly without a Zod rmSync(tempDir, { recursive: true, force: true }); } }); + +test("rudp receiver evicts stranded fragment sets instead of refusing new ones", () => { + // A fragment set is removed only on successful reassembly, so every set that + // loses a fragment is stranded for the life of the session. Ghostlight sends + // Heimdall fragmented envelopes (max_fragment_bytes = 2048), so on the live + // auth path this map grew for every lost fragment and was never swept. + const receiver = new CultNetRudpSession({ connectionId: 9, initialSequence: 1, resendDelayMs: 50 }); + receiver.receive({ packetType: "accept", connectionId: 9, sequence: 1, ack: 0, ackMask: 0, channelId: "control" }); + + // 200 fragment sets that each lose their second fragment. + for (let id = 1; id <= 200; id += 1) { + const stranded = receiver.receive({ + packetType: "data", + connectionId: 9, + sequence: 100 + id, + ack: 0, + ackMask: 0, + channelId: "schema", + fragmentId: id, + fragmentIndex: 0, + fragmentCount: 2, + payload: Buffer.from([id & 0xff]), + }); + assert.equal(stranded.delivered.length, 0, "an incomplete set must not reassemble"); + } + + // Bounded rather than unbounded, and still serving. + assert.ok(receiver.pendingFragmentSetCount <= 64, `pending ${receiver.pendingFragmentSetCount}`); + assert.ok(receiver.fragmentSetsEvicted > 0, "nothing was evicted"); + + // The receiver still completes a fresh fragmented payload, which is what + // erroring at the cap took away: any nonzero loss killed the channel. + const first = receiver.receive({ + packetType: "data", connectionId: 9, sequence: 900, ack: 0, ackMask: 0, + channelId: "schema", fragmentId: 1000, fragmentIndex: 0, fragmentCount: 2, + payload: Buffer.from("he"), + }); + assert.equal(first.delivered.length, 0); + const second = receiver.receive({ + packetType: "data", connectionId: 9, sequence: 901, ack: 0, ackMask: 0, + channelId: "schema", fragmentId: 1000, fragmentIndex: 1, fragmentCount: 2, + payload: Buffer.from("llo"), + }); + assert.equal(second.delivered.length, 1); + assert.equal(Buffer.from(second.delivered[0]!.payload).toString(), "hello"); +}); diff --git a/packages/cultnet-ts/test/fixtures/runtime-presence-vectors.json b/packages/cultnet-ts/test/fixtures/runtime-presence-vectors.json new file mode 100644 index 00000000..043689f4 --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/runtime-presence-vectors.json @@ -0,0 +1,4 @@ +{ + "proof": "dc0018d92367616d6563756c742e72756e74696d655f70726573656e63655f6865616c74682e7632a86865696d64616c6cd9477368613235362d61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161d9477368613235362d62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262b5736572766963652d696e6361726e6174696f6e2d31d9477368613235362d63636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363d9477368613235362d64646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464a27631d9477368613235362d65656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565b26865696d64616c6c2d79676764726173696cd9477368613235362d66666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666b6687474703a2f2f3132372e302e302e313a31343130319294af6865696d64616c6c2e616363657373b26865696d64616c6c2e6163636573732e7631a276310294ac7a6574612e72756e74696d65af7a6574612e72756e74696d652e7631a2763101d9256865696d64616c6c2e63756c746e65742d727564702d70726f76696465722d6865616c7468a77761726d696e67ba4865696d64616c6c2063616e646964617465207761726d696e67c0d9403131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313107cf00000199155c6200a765643235353139c400d94032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232c400", + "signed": "dc0018d92367616d6563756c742e72756e74696d655f70726573656e63655f6865616c74682e7632a86865696d64616c6cd9477368613235362d61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161d9477368613235362d62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262b5736572766963652d696e6361726e6174696f6e2d31d9477368613235362d63636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363d9477368613235362d64646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464a27631d9477368613235362d65656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565b26865696d64616c6c2d79676764726173696cd9477368613235362d66666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666b6687474703a2f2f3132372e302e302e313a31343130319294af6865696d64616c6c2e616363657373b26865696d64616c6c2e6163636573732e7631a276310294ac7a6574612e72756e74696d65af7a6574612e72756e74696d652e7631a2763101d9256865696d64616c6c2e63756c746e65742d727564702d70726f76696465722d6865616c7468a77761726d696e67ba4865696d64616c6c2063616e646964617465207761726d696e67c0d9403131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313107cf00000199155c6200a765643235353139c44011111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111d94032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232c44022222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222" +} diff --git a/packages/cultnet-ts/test/interop/cultnet-interop-peer.ts b/packages/cultnet-ts/test/interop/cultnet-interop-peer.ts index 0af43301..57b7fa37 100644 --- a/packages/cultnet-ts/test/interop/cultnet-interop-peer.ts +++ b/packages/cultnet-ts/test/interop/cultnet-interop-peer.ts @@ -6,7 +6,7 @@ import process from "node:process"; import { setTimeout as delay } from "node:timers/promises"; import { decode, encode } from "@msgpack/msgpack"; -import { CultCache, SingleFileMessagePackBackingStore, defineDocumentType, type AnyCultCacheDocumentDefinition } from "cultcache-ts"; +import { CultCache, SingleFileMessagePackBackingStore, defineDocumentType, type AnyCultCacheDocumentDefinition } from "@gamecult/cultcache-ts"; import { CultNetDocumentRegistry, CultNetPeer, diff --git a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts index bec86942..00fff83d 100644 --- a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts +++ b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts @@ -24,7 +24,9 @@ const pythonCommand = process.env.PYTHON ?? resolvePythonCommand(); const cultNetTsRoot = resolve(__dirname, "../../.."); const cultLibRoot = findAncestor(cultNetTsRoot, "CultLib.sln") ?? resolve(cultNetTsRoot, "..", "CultLib"); const cultcachePyRoot = resolve(cultLibRoot, "packages", "cultcache-py"); -const cultcachePySrc = resolve(cultcachePyRoot, "src"); +const cultcachePySrc = ["cultcache-py", "cultnet-py", "cultmesh-py"] + .map((name) => resolve(cultLibRoot, "packages", name, "src")) + .join(delimiter); const cultmeshKotlinRoot = resolve(cultLibRoot, "packages", "cultmesh-kotlin"); const kotlinHome = process.env.KOTLIN_HOME ?? "C:\\Program Files\\Android\\Android Studio\\plugins\\Kotlin\\kotlinc"; const kotlinJavaHome = process.env.KOTLIN_JAVA_HOME ?? "C:\\Program Files\\Android\\Android Studio\\jbr"; @@ -414,6 +416,12 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state const advertiseHost = findAdvertiseHost(); const servers: RunningServeProcess[] = []; + // Registered before anything is spawned. A peer whose readiness rejects + // would otherwise skip this and strand every peer already running. + t.after(async () => { + await Promise.all(servers.map(stopProcess)); + }); + logInteropPhase("schema-v0", "start rust peer"); servers.push(await spawnServeProcess("rust", { command: rustBinaryPath, @@ -498,10 +506,6 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state })); await servers[servers.length - 1].ready; - t.after(async () => { - await Promise.all(servers.map(stopProcess)); - }); - logInteropPhase("schema-v0", "typescript dials rust"); const tsDial = await runJsonCommand("ts-dial", process.execPath, [ tsPeerScript, @@ -989,6 +993,12 @@ test("CultNet TypeScript and Rust full interop peers exchange schema-v0 over RUD const discoveryPort = await getFreePort(); const advertiseHost = findAdvertiseHost(); const servers: RunningServeProcess[] = []; + // Registered before anything is spawned. A peer whose readiness rejects + // would otherwise skip this and strand every peer already running. + t.after(async () => { + await Promise.all(servers.map(stopProcess)); + }); + servers.push(await spawnServeProcess("rust-rudp", { command: rustBinaryPath, args: [ @@ -1030,10 +1040,6 @@ test("CultNet TypeScript and Rust full interop peers exchange schema-v0 over RUD })); await servers[servers.length - 1].ready; - t.after(async () => { - await Promise.all(servers.map(stopProcess)); - }); - logInteropPhase("ts-rust-full-rudp", "typescript dials rust over rudp"); const tsRudpDial = await runJsonCommand("ts-rust-rudp-dial", process.execPath, [ tsPeerScript, @@ -1139,6 +1145,7 @@ test("CultNet Python and TypeScript exchange schema frames when Python dials a T assert.equal(exitCode, 0, pythonClient.stderr.join("")); } finally { server.close(); + closeUdpSocket(serverSocket); if (pythonClient.child.exitCode === null && !pythonClient.child.killed) { pythonClient.child.kill("SIGTERM"); await once(pythonClient.child, "exit").catch(() => undefined); @@ -1246,6 +1253,7 @@ test("CultNet Python and TypeScript exchange schema-v0 MessagePack messages over assert.equal(exitCode, 0, pythonClient.stderr.join("")); } finally { server.close(); + closeUdpSocket(serverSocket); if (pythonClient.child.exitCode === null && !pythonClient.child.killed) { pythonClient.child.kill("SIGTERM"); await once(pythonClient.child, "exit").catch(() => undefined); @@ -1322,6 +1330,7 @@ test("CultNet Rust and TypeScript exchange schema frames when Rust dials a TypeS assert.equal(exitCode, 0, rustClient.stderr.join("")); } finally { server.close(); + closeUdpSocket(serverSocket); if (rustClient.child.exitCode === null && !rustClient.child.killed) { rustClient.child.kill("SIGTERM"); await once(rustClient.child, "exit").catch(() => undefined); @@ -1431,6 +1440,7 @@ test("CultNet Rust and TypeScript exchange schema-v0 MessagePack messages over R assert.equal(exitCode, 0, rustClient.stderr.join("")); } finally { server.close(); + closeUdpSocket(serverSocket); if (rustClient.child.exitCode === null && !rustClient.child.killed) { rustClient.child.kill("SIGTERM"); await once(rustClient.child, "exit").catch(() => undefined); @@ -1507,6 +1517,7 @@ test("CultNet C# and TypeScript exchange schema frames when C# dials a TypeScrip assert.equal(exitCode, 0, csharpClient.stderr.join("")); } finally { server.close(); + closeUdpSocket(serverSocket); if (csharpClient.child.exitCode === null && !csharpClient.child.killed) { csharpClient.child.kill("SIGTERM"); await once(csharpClient.child, "exit").catch(() => undefined); @@ -1616,6 +1627,7 @@ test("CultNet C# and TypeScript exchange schema-v0 MessagePack messages over RUD assert.equal(exitCode, 0, csharpClient.stderr.join("")); } finally { server.close(); + closeUdpSocket(serverSocket); if (csharpClient.child.exitCode === null && !csharpClient.child.killed) { csharpClient.child.kill("SIGTERM"); await once(csharpClient.child, "exit").catch(() => undefined); @@ -1692,6 +1704,7 @@ test("CultNet Kotlin and TypeScript exchange schema frames when Kotlin dials a T assert.equal(exitCode, 0, kotlinClient.stderr.join("")); } finally { server.close(); + closeUdpSocket(serverSocket); if (kotlinClient.child.exitCode === null && !kotlinClient.child.killed) { kotlinClient.child.kill("SIGTERM"); await once(kotlinClient.child, "exit").catch(() => undefined); @@ -1801,6 +1814,7 @@ test("CultNet Kotlin and TypeScript exchange schema-v0 MessagePack messages over assert.equal(exitCode, 0, kotlinClient.stderr.join("")); } finally { server.close(); + closeUdpSocket(serverSocket); if (kotlinClient.child.exitCode === null && !kotlinClient.child.killed) { kotlinClient.child.kill("SIGTERM"); await once(kotlinClient.child, "exit").catch(() => undefined); @@ -1823,6 +1837,12 @@ test("CultNet TypeScript and C# full interop peers exchange schema-v0 over RUDP" const discoveryPort = await getFreePort(); const advertiseHost = findAdvertiseHost(); const servers: RunningServeProcess[] = []; + // Registered before anything is spawned. A peer whose readiness rejects + // would otherwise skip this and strand every peer already running. + t.after(async () => { + await Promise.all(servers.map(stopProcess)); + }); + servers.push(await spawnServeProcess("csharp-rudp", { command: dotnetCommand, args: [ @@ -1865,10 +1885,6 @@ test("CultNet TypeScript and C# full interop peers exchange schema-v0 over RUDP" })); await servers[servers.length - 1].ready; - t.after(async () => { - await Promise.all(servers.map(stopProcess)); - }); - logInteropPhase("ts-csharp-full-rudp", "typescript dials csharp over rudp"); const tsRudpDial = await runJsonCommand("ts-csharp-rudp-dial", process.execPath, [ tsPeerScript, @@ -1923,6 +1939,12 @@ test("CultNet TypeScript and Kotlin full interop peers exchange schema-v0 over R const discoveryPort = await getFreePort(); const advertiseHost = findAdvertiseHost(); const servers: RunningServeProcess[] = []; + // Registered before anything is spawned. A peer whose readiness rejects + // would otherwise skip this and strand every peer already running. + t.after(async () => { + await Promise.all(servers.map(stopProcess)); + }); + servers.push(await spawnServeProcess("kotlin-rudp", { command: kotlinJavaCommand, args: [ @@ -1966,10 +1988,6 @@ test("CultNet TypeScript and Kotlin full interop peers exchange schema-v0 over R })); await servers[servers.length - 1].ready; - t.after(async () => { - await Promise.all(servers.map(stopProcess)); - }); - logInteropPhase("ts-kotlin-full-rudp", "typescript dials kotlin over rudp"); const tsRudpDial = await runJsonCommand("ts-kotlin-rudp-dial", process.execPath, [ tsPeerScript, @@ -3160,6 +3178,20 @@ function spawnRustRudpMessageClient(remotePort: number): Omit { const socket = dgram.createSocket("udp4"); await new Promise((resolveBind, rejectBind) => { diff --git a/packages/cultnet-ts/test/runtime-presence-health.test.ts b/packages/cultnet-ts/test/runtime-presence-health.test.ts new file mode 100644 index 00000000..fa21aeaa --- /dev/null +++ b/packages/cultnet-ts/test/runtime-presence-health.test.ts @@ -0,0 +1,123 @@ +// Cross-fork conformance for `gamecult.runtime_presence_health.v2`. +// +// The vectors below were produced by `cultnet-rs`, not written by hand: +// +// cargo run --example runtime_presence_vector -p cultnet-rs +// +// That matters. A fixture authored for both sides at once agrees with itself +// and proves nothing; the contract is only real if one fork emits the bytes and +// the other reproduces them. Regenerate these vectors from the Rust example if +// the record ever changes, and never by editing them to match this code. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + RUNTIME_PRESENCE_FIELD_COUNT, + encodeRuntimePresenceHealth, + runtimePresenceProofPayload, + validateRuntimePresence, + type RuntimePresenceHealth, +} from "../src/runtime-presence-health"; + +// Read from the source tree, not a copy beside the compiled test: the vectors +// are generated output and must have exactly one home. +const VECTORS = JSON.parse( + readFileSync( + join(__dirname, "..", "..", "test", "fixtures", "runtime-presence-vectors.json"), + "utf8", + ), +) as { proof: string; signed: string }; + +/** The same statement the Rust example builds. */ +function referencePresence(): RuntimePresenceHealth { + return { + target: "heimdall", + expectedProjectionSha256: `sha256-${"a".repeat(64)}`, + planId: `sha256-${"b".repeat(64)}`, + incarnationId: "service-incarnation-1", + sealedReleaseId: `sha256-${"c".repeat(64)}`, + activationWitnessSha256: `sha256-${"d".repeat(64)}`, + stateSchemaGeneration: "v1", + stateContractSha256: `sha256-${"e".repeat(64)}`, + runtimeId: "heimdall-yggdrasil", + runtimeInstanceId: `sha256-${"f".repeat(64)}`, + boundEndpoint: "http://127.0.0.1:14101", + capabilities: [ + { + capability: "heimdall.access", + schema: "heimdall.access.v1", + compatibility: "v1", + capacity: 2, + }, + { capability: "zeta.runtime", schema: "zeta.runtime.v1", compatibility: "v1", capacity: 1 }, + ], + healthContract: "heimdall.cultnet-rudp-provider-health", + state: "warming", + detail: "Heimdall candidate warming", + writeLeaseSha256: null, + signerIdentityId: "1".repeat(64), + publisherSequence: 7, + observedAtUnixMillis: 1_757_000_000_000, + activationSignerIdentityId: "2".repeat(64), + }; +} + +function hex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("hex"); +} + +test("the proof payload is byte-identical to the one cultnet-rs signs", () => { + assert.equal(hex(runtimePresenceProofPayload(referencePresence())), VECTORS.proof); +}); + +test("the signed record carries both proofs in their own slots", () => { + const signed = encodeRuntimePresenceHealth( + referencePresence(), + new Uint8Array(64).fill(0x11), + new Uint8Array(64).fill(0x22), + ); + // Byte-identical to the fully signed record cultnet-rs emits, so the proofs + // land in their own slots rather than being appended. + assert.equal(hex(signed), VECTORS.signed); +}); + +test("capabilities are ordered canonically regardless of caller order", () => { + const presence = referencePresence(); + const reversed = { ...presence, capabilities: [...presence.capabilities].reverse() }; + assert.equal( + hex(runtimePresenceProofPayload(reversed)), + hex(runtimePresenceProofPayload(presence)), + ); +}); + +test("the tuple is always the full contract width", () => { + assert.equal(RUNTIME_PRESENCE_FIELD_COUNT, 24); +}); + +test("statements the contract forbids are refused before they are signed", () => { + const warmingWithLease = { + ...referencePresence(), + writeLeaseSha256: `sha256-${"0".repeat(64)}`, + }; + assert.throws(() => validateRuntimePresence(warmingWithLease), /cannot claim a process write/); + + const statelessWithLease = { + ...referencePresence(), + state: "active" as const, + stateSchemaGeneration: null, + stateContractSha256: null, + writeLeaseSha256: `sha256-${"0".repeat(64)}`, + }; + assert.throws(() => validateRuntimePresence(statelessWithLease), /stateless runtime presence/); + + assert.throws( + () => validateRuntimePresence({ ...referencePresence(), publisherSequence: 0 }), + /publisher sequence/, + ); + + const partialLineage = { ...referencePresence(), stateContractSha256: null }; + assert.throws(() => validateRuntimePresence(partialLineage), /state lineage is partial/); +}); diff --git a/samples/eve-browser-network/EveBrowserNetworkSample.csproj b/samples/eve-browser-network/EveBrowserNetworkSample.csproj new file mode 100644 index 00000000..50b41781 --- /dev/null +++ b/samples/eve-browser-network/EveBrowserNetworkSample.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + $(MSBuildThisFileDirectory)..\..\..\Eve + false + 1.0.35 + 0.3.3 + $(PackageFeed) + + + + + + + + + + + + diff --git a/samples/eve-browser-network/Program.cs b/samples/eve-browser-network/Program.cs new file mode 100644 index 00000000..c26d654b --- /dev/null +++ b/samples/eve-browser-network/Program.cs @@ -0,0 +1,520 @@ +using System.Net; +using System.Diagnostics; +using System.Text.Json; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using GameCult.Eve.Surface; +using GameCult.Mesh; +using GameCult.Networking; +using GameCult.Networking.WebSockets; +using MessagePack; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; + +const string counterKey = "counter:main"; +const string surfaceKey = "sample.counter"; +const string providerKey = "sample.counter-ui"; +const string commandBoundary = "sample.counter.commands"; +var arguments = Args.Parse(args); +if (arguments.Mode == "provider") +{ + await RunProviderAsync(arguments); + return; +} +if (arguments.Mode == "headless") +{ + await RunHeadlessAsync(arguments); + return; +} +if (arguments.Mode == "odin") +{ + await RunOdinAsync(arguments); + return; +} +throw new InvalidOperationException("Use 'provider', 'headless', or 'odin'."); + +static async Task RunOdinAsync(Args arguments) +{ + if (string.IsNullOrWhiteSpace(arguments.ProviderEndpoint)) + throw new ArgumentException("The Odin fixture requires --provider-endpoint."); + using var catalog = new CultMeshVerseCatalog(); + var routes = new List + { + new( + arguments.AuthorityRuntimeId, + arguments.ProviderEndpoint, + [CultMeshProtocols.Documents.Value], + priority: 10, + generation: arguments.RouteGeneration) + }; + if (!string.IsNullOrWhiteSpace(arguments.DecoyEndpoint)) + { + routes.Add(new CultMeshAuthorityRoute( + arguments.DecoyAuthorityRuntimeId, + arguments.DecoyEndpoint, + [CultMeshProtocols.Documents.Value], + priority: 0, + generation: arguments.DecoyRouteGeneration)); + } + catalog.Upsert(new CultMeshVerseDescriptor( + arguments.VerseId, + arguments.VerseName, + CultMeshVerseAuthorityModel.OperatorCluster, + new CultMeshVerseCompatibility(arguments.TransportVersion, arguments.RulesHash), + authorityRoutes: routes, + description: "Local executable Odin fixture for the browser/network onboarding witness.")); + + await using var schemaServer = new CultNetWebSocketSchemaServer(); + schemaServer.OnCultNet((request, peer) => + { + peer.SendCultNet(CultMeshVerseMessages.CreateCatalogResponse(catalog, request)); + return Task.CompletedTask; + }); + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, arguments.Port)); + var app = builder.Build(); + app.UseWebSockets(); + app.MapCultNetWebSocket("/odin", schemaServer, new CultNetWebSocketEndpointOptions + { + AuthorizeAsync = context => ValueTask.FromResult( + context.Request.Cookies.TryGetValue("cultnet_session", out var token) && + string.Equals(token, arguments.Token, StringComparison.Ordinal)) + }); + await app.StartAsync(); + var address = app.Services.GetRequiredService() + .Features.Get()!.Addresses.Single(); + Console.WriteLine("ODIN_READY " + address.Replace("http://", "ws://", StringComparison.Ordinal) + "/odin"); + await app.WaitForShutdownAsync(); + await app.DisposeAsync(); +} + +static async Task RunProviderAsync(Args arguments) +{ + var documentTypes = new[] { typeof(CounterState), typeof(EveSurfaceDocument), typeof(EveProviderAdvertisementDocument) }; + var registry = CultMesh.CreateCultCacheDocumentRegistry(documentTypes); + var documents = CultMesh.CreateCultNetDocumentRegistry(documentTypes, registry); + using var node = await CultMesh.CreateNodeAsync(arguments.StatePath, new CultMeshNodeOptions + { + StartServer = false, + CacheOptions = new CultCacheOpenOptions + { + Registry = registry, + FlushOnDispose = true, + StoreFlushOnDispose = true + }, + DatabaseOptions = new CultNetDatabaseOptions + { + RuntimeId = arguments.AuthorityRuntimeId, + DocumentRegistry = documents + } + }); + if (node.Cache.Get(new CultRecordKey(counterKey)) == null) + await node.Database.PutAsync(new CultRecordKey(counterKey), CounterState.Initial(counterKey)); + if (node.Cache.Get(new CultRecordKey(surfaceKey)) == null) + await node.Database.PutAsync(new CultRecordKey(surfaceKey), CreateCounterSurface()); + // The advertisement is how a lowering learns where this surface's commands + // go and what comes back. Eve refuses to build a command intent without it, + // and the browser must not have to restate it: the provider is the owner. + await node.Database.PutAsync(new CultRecordKey(providerKey), CreateProviderAdvertisement(arguments)); + await node.FlushAsync(); + + await using var schemaServer = new CultNetWebSocketSchemaServer(); + using var identity = new CultMeshSessionIdentityServer( + schemaServer, + arguments.AuthorityRuntimeId, + [arguments.VerseId], + [CultMeshProtocols.Documents.Value], + [arguments.RouteGeneration]); + using var subscriptions = new CultNetDatabaseSubscriptionServer(schemaServer, node.Database); + using var operationGate = new SemaphoreSlim(1, 1); + using var operations = new CultNetOperationServer(schemaServer, arguments.AuthorityRuntimeId) + .Register( + "sample.counter", + "sample.counter.ping", + "sample.ping_request.v1", + "sample.ping_receipt.v1", + context => Task.FromResult(new PingReceipt { Sequence = context.Value.Sequence })) + .Register( + "sample.counter", + "sample.counter.increment", + "sample.increment.v1", + "gamecult.eve.command_receipt.v1", + async context => + { + await operationGate.WaitAsync(); + try + { + var current = node.Cache.Get(new CultRecordKey(counterKey)) + ?? throw new InvalidOperationException("Counter state is unavailable."); + if (current.Receipts.TryGetValue(context.IdempotencyKey, out var existing)) + return existing; + + var receipt = IncrementReceipt.Accepted( + context.IdempotencyKey, + current.Count + context.Value.Amount); + var receipts = new Dictionary(current.Receipts, StringComparer.Ordinal) + { + [context.IdempotencyKey] = receipt + }; + await node.Database.PutAsync(new CultRecordKey(counterKey), new CounterState + { + CounterId = counterKey, + Count = receipt.Count, + Receipts = receipts + }); + await node.FlushAsync(); + return receipt; + } + finally + { + operationGate.Release(); + } + }); + + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, arguments.Port)); + var app = builder.Build(); + app.UseWebSockets(); + app.MapCultNetWebSocket("/cultmesh", schemaServer, new CultNetWebSocketEndpointOptions + { + AuthorizeAsync = context => ValueTask.FromResult( + context.Request.Cookies.TryGetValue("cultnet_session", out var token) && + string.Equals(token, arguments.Token, StringComparison.Ordinal)) + }); + await app.StartAsync(); + var address = app.Services.GetRequiredService() + .Features.Get()!.Addresses.Single(); + Console.WriteLine("PROVIDER_READY " + address.Replace("http://", "ws://", StringComparison.Ordinal) + "/cultmesh"); + await app.WaitForShutdownAsync(); + await app.DisposeAsync(); +} + +static async Task RunHeadlessAsync(Args arguments) +{ + if (string.IsNullOrWhiteSpace(arguments.OdinEndpoint)) + throw new ArgumentException("The headless client requires --odin."); + CultNetWebSocketSchemaClient CreateClient() => new(options => + options.SetRequestHeader("Cookie", $"cultnet_session={arguments.Token}")); + using var mesh = new CultMeshClient(new CultMeshClientOptions + { + RendezvousEndpoints = [arguments.OdinEndpoint], + Discovery = new CultMeshVerseDiscoveryClientOptions + { + CreateClient = CreateClient, + TransportVersion = arguments.TransportVersion + }, + Sessions = new CultMeshSessionManagerOptions + { + Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment) + }, + Connectors = + [ + new CultMeshUriSchemaTransportConnector( + "cultnet-websocket", + ["ws", "wss"], + _ => CreateClient()) + ] + }); + var target = new CultMeshSessionTarget(arguments.VerseId, arguments.AuthorityRuntimeId); + using var lease = await mesh.LeaseDocumentAsync(target, counterKey); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var lastCount = -1; + using var watch = lease.Handle.Watch(counter => + { + if (counter.Count <= lastCount) return; + lastCount = counter.Count; + if (counter.Count == 0) + Console.WriteLine("HEADLESS_READY " + JsonSerializer.Serialize(new { count = counter.Count })); + else + Console.WriteLine($"HEADLESS_UPDATE_{counter.Count} " + JsonSerializer.Serialize(new + { + count = counter.Count, + receiptIds = counter.Receipts.Values + .Select(receipt => receipt.ReceiptId) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray() + })); + if (counter.Count >= arguments.ExpectedCount) completion.TrySetResult(); + }); + var initial = await lease.Handle.LatestAsync(); + if (lastCount < 0) + { + lastCount = initial.Count; + Console.WriteLine("HEADLESS_READY " + JsonSerializer.Serialize(new { count = initial.Count })); + } + if (initial.Count >= arguments.ExpectedCount) completion.TrySetResult(); + var commandTask = Task.Run(async () => + { + var line = await Console.In.ReadLineAsync(); + if (line == null || !line.StartsWith("INVOKE ", StringComparison.Ordinal)) + throw new InvalidOperationException("The headless sample expected 'INVOKE ' on stdin."); + var commandId = line["INVOKE ".Length..].Trim(); + var result = await mesh.InvokeAsync( + target, + "sample.counter", + "sample.counter.increment", + "sample.increment.v1", + "gamecult.eve.command_receipt.v1", + new IncrementRequest { Amount = 1 }, + sourceRuntimeId: "sample.csharp", + idempotencyKey: commandId); + Console.WriteLine("HEADLESS_RECEIPT " + JsonSerializer.Serialize(new + { + receiptId = result.Value.ReceiptId, + count = result.Value.Count, + status = result.Status + })); + Console.WriteLine("HEADLESS_NETWORK_BENCHMARK " + JsonSerializer.Serialize( + await MeasureOperationSessionAsync(mesh, target, arguments))); + }); + await Task.WhenAll(completion.Task, commandTask).WaitAsync(TimeSpan.FromSeconds(45)); +} + +static async Task MeasureOperationSessionAsync( + CultMeshClient mesh, + CultMeshSessionTarget target, + Args arguments) +{ + const int warmupOperations = 100; + if (arguments.BenchmarkOperations <= 0) + throw new ArgumentOutOfRangeException(nameof(arguments.BenchmarkOperations)); + for (var index = 0; index < warmupOperations; index++) + await PingAsync(mesh, target, "warmup:" + index, index); + + ForceCollection(); + using var process = Process.GetCurrentProcess(); + process.Refresh(); + var managedHeapBefore = GC.GetTotalMemory(forceFullCollection: false); + var privateBytesBefore = process.PrivateMemorySize64; + var latencies = new double[arguments.BenchmarkOperations]; + var clock = Stopwatch.StartNew(); + for (var index = 0; index < arguments.BenchmarkOperations; index++) + { + var started = Stopwatch.GetTimestamp(); + await PingAsync(mesh, target, "benchmark:" + index, index); + latencies[index] = Stopwatch.GetElapsedTime(started).TotalMilliseconds; + } + clock.Stop(); + ForceCollection(); + process.Refresh(); + var managedHeapAfter = GC.GetTotalMemory(forceFullCollection: false); + var privateBytesAfter = process.PrivateMemorySize64; + Array.Sort(latencies); + var p99 = latencies[Math.Clamp((int)Math.Ceiling(latencies.Length * 0.99) - 1, 0, latencies.Length - 1)]; + var managedHeapGrowth = managedHeapAfter - managedHeapBefore; + if (managedHeapGrowth > 8 * 1024 * 1024) + throw new InvalidOperationException($"Retained operation session grew the managed heap by {managedHeapGrowth} bytes."); + if (p99 > 250) + throw new InvalidOperationException($"Retained operation session p99 was {p99:F2} ms."); + return new + { + operations = arguments.BenchmarkOperations, + p99Milliseconds = p99, + operationsPerSecond = arguments.BenchmarkOperations / clock.Elapsed.TotalSeconds, + managedHeapBefore, + managedHeapAfter, + managedHeapGrowth, + privateBytesBefore, + privateBytesAfter, + privateBytesGrowth = privateBytesAfter - privateBytesBefore + }; +} + +static async Task PingAsync( + CultMeshClient mesh, + CultMeshSessionTarget target, + string idempotencyKey, + int sequence) +{ + var result = await mesh.InvokeAsync( + target, + "sample.counter", + "sample.counter.ping", + "sample.ping_request.v1", + "sample.ping_receipt.v1", + new PingRequest { Sequence = sequence }, + sourceRuntimeId: "sample.csharp", + idempotencyKey: idempotencyKey); + if (result.Status != "accepted" || result.Value.Sequence != sequence) + throw new InvalidOperationException("The typed ping receipt did not match its request."); +} + +static void ForceCollection() +{ + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); +} + +static EveProviderAdvertisementDocument CreateProviderAdvertisement(Args arguments) +{ + var worldInteraction = new EveWorldInteractionAdvertisement( + projectionKind: "surface-state", + stateSchemas: ["sample.counter_state.v1"], + commandBoundary: commandBoundary, + commandRecordRef: "", + receiptSchema: EveCommandReceiptDocument.SchemaId, + receiptRecordRef: "", + assetManifestRecordRef: "", + loweringTargets: ["browser"], + ownership: arguments.AuthorityRuntimeId); + return new EveProviderAdvertisementDocument( + providerId: providerKey, + serviceId: arguments.VerseId, + verseId: arguments.VerseId, + title: "CultMesh browser counter", + kind: "sample.daemon", + cultMeshAddress: arguments.AuthorityRuntimeId, + updatedAtUtc: DateTime.UtcNow.ToString("O"), + freshness: new EveProviderFreshness("live", DateTime.UtcNow.ToString("O"), 0), + schemas: ["sample.counter_state.v1", EveSurfaceDocument.SchemaId, EveCommandReceiptDocument.SchemaId], + witnesses: [], + surfaces: + [ + new EveAdvertisedSurface( + surfaceKey, + EveSurfaceDocument.SchemaId, + surfaceKey, + "cultmesh", + "live", + "surface", + worldInteraction) + ], + commands: [new EveAdvertisedCommand("sample.counter.increment", surfaceKey, "cultmesh", "Increment")]); +} + +static EveSurfaceDocument CreateCounterSurface() +{ + var networkRoute = new CultMeshRouteHint(CultMeshLocalityKind.Network, "cultmesh"); + var count = new CultMeshStateBindingDescriptor( + "value", + "sample.counter.count", + "sample.counter_state:counter:main", + "sample.counter_state.v1", + networkRoute); + var increment = CultMesh.OperationBinding( + "sample.counter.increment", + "Increment", + "sample.increment.v1", + networkRoute); + return EveSurface.Create(surfaceKey) + .Provider("sample.counter-ui", "sample.daemon") + .Title("CultMesh browser counter") + .Version(1) + .UpdatedAtUtc("2026-08-17T00:00:00Z") + .RootColumn("counter.root", root => root + .Title("counter.title", "CultMesh browser counter") + .Metric("counter.value", "Canonical count", "0", count) + .Button("counter.increment", "Increment", increment)) + .Build(); +} + +sealed class Args +{ + public string Mode { get; private init; } = ""; + public int Port { get; private init; } + public string StatePath { get; private init; } = "sample-counter.cc"; + public string OdinEndpoint { get; private init; } = ""; + public string ProviderEndpoint { get; private init; } = ""; + public string DecoyEndpoint { get; private init; } = ""; + public string Token { get; private init; } = "sample-session"; + public string VerseId { get; private init; } = "sample.counter"; + public string VerseName { get; private init; } = "CultMesh browser counter"; + public string AuthorityRuntimeId { get; private init; } = "sample.counter-daemon"; + public string DecoyAuthorityRuntimeId { get; private init; } = "sample.decoy-daemon"; + public string TransportVersion { get; private init; } = "cultmesh.v0"; + public string RulesHash { get; private init; } = "sample-counter-v1"; + public string RouteGeneration { get; private init; } = "sample-counter-route-1"; + public string DecoyRouteGeneration { get; private init; } = "sample-decoy-route-1"; + public int ExpectedCount { get; private init; } = 2; + public int BenchmarkOperations { get; private init; } = 10_000; + + public static Args Parse(string[] values) + { + if (values.Length == 0) return new Args(); + var named = new Dictionary(StringComparer.Ordinal); + for (var index = 1; index + 1 < values.Length; index += 2) + named[values[index]] = values[index + 1]; + return new Args + { + Mode = values[0], + Port = named.TryGetValue("--port", out var port) ? int.Parse(port) : 0, + StatePath = named.GetValueOrDefault("--state", "sample-counter.cc"), + OdinEndpoint = named.GetValueOrDefault("--odin", ""), + ProviderEndpoint = named.GetValueOrDefault("--provider-endpoint", ""), + DecoyEndpoint = named.GetValueOrDefault("--decoy-endpoint", ""), + Token = named.GetValueOrDefault("--token", "sample-session"), + VerseId = named.GetValueOrDefault("--verse-id", "sample.counter"), + VerseName = named.GetValueOrDefault("--verse-name", "CultMesh browser counter"), + AuthorityRuntimeId = named.GetValueOrDefault("--authority-runtime-id", "sample.counter-daemon"), + DecoyAuthorityRuntimeId = named.GetValueOrDefault("--decoy-authority-runtime-id", "sample.decoy-daemon"), + TransportVersion = named.GetValueOrDefault("--transport-version", "cultmesh.v0"), + RulesHash = named.GetValueOrDefault("--rules-hash", "sample-counter-v1"), + RouteGeneration = named.GetValueOrDefault("--route-generation", "sample-counter-route-1"), + DecoyRouteGeneration = named.GetValueOrDefault("--decoy-route-generation", "sample-decoy-route-1"), + ExpectedCount = named.TryGetValue("--expected-count", out var expectedCount) + ? int.Parse(expectedCount) + : 2, + BenchmarkOperations = named.TryGetValue("--benchmark-operations", out var benchmarkOperations) + ? int.Parse(benchmarkOperations) + : 10_000 + }; + } +} + +[CultDocument("sample.counter_state", "sample.counter_state.v1")] +[MessagePackObject] +public sealed class CounterState +{ + [Key(0)] public string CounterId { get; set; } = ""; + [Key(1)] public int Count { get; set; } + [Key(2)] public Dictionary Receipts { get; set; } = new(StringComparer.Ordinal); + public static CounterState Initial(string counterId) => new() { CounterId = counterId }; +} + +[MessagePackObject] +public sealed class IncrementRequest +{ + [Key("amount")] public int Amount { get; set; } = 1; +} + +[MessagePackObject] +public sealed class IncrementReceipt +{ + [Key("receiptId")] public string ReceiptId { get; set; } = ""; + [Key("schema")] public string Schema { get; set; } = "gamecult.eve.command_receipt.v1"; + [Key("commandId")] public string CommandId { get; set; } = ""; + [Key("command")] public string Command { get; set; } = "sample.counter.increment"; + [Key("state")] public string State { get; set; } = "accepted"; + [Key("ownerRepo")] public string OwnerRepo { get; set; } = "CultLib"; + [Key("authority")] public string Authority { get; set; } = "provider-daemon"; + [Key("providerId")] public string ProviderId { get; set; } = "sample.counter-ui"; + [Key("surfaceId")] public string SurfaceId { get; set; } = "sample.counter"; + [Key("issuedAtUtc")] public string IssuedAtUtc { get; set; } = ""; + [Key("sourceVersion")] public int SourceVersion { get; set; } + [Key("idempotencyKey")] public string IdempotencyKey { get; set; } = ""; + [Key("count")] public int Count { get; set; } + + public static IncrementReceipt Accepted(string id, int count) => new() + { + ReceiptId = $"receipt:{id}", + CommandId = id, + IssuedAtUtc = DateTimeOffset.UtcNow.ToString("O"), + SourceVersion = count, + IdempotencyKey = id, + Count = count + }; +} + +[MessagePackObject] +public sealed class PingRequest +{ + [Key("sequence")] public int Sequence { get; set; } +} + +[MessagePackObject] +public sealed class PingReceipt +{ + [Key("sequence")] public int Sequence { get; set; } +} diff --git a/samples/eve-browser-network/README.md b/samples/eve-browser-network/README.md new file mode 100644 index 00000000..445bfb9d --- /dev/null +++ b/samples/eve-browser-network/README.md @@ -0,0 +1,58 @@ +# Eve Browser + Headless Counter + +This is the real-process continuation of `samples/eve-two-runtime`: + +- one C# provider owns a durable `.cc` counter, the Eve surface, command + idempotency, and canonical receipts; +- one real Chromium page leases both documents through `cultmesh-browser` and + lowers the provider-owned Eve surface; +- one retained C# headless client discovers the provider through the same Odin + identity, observes the typed counter and canonical receipt ids, survives the + provider route replacement, and invokes through the generic CultMesh client + without application transport or reconnect code; +- one separate local Odin fixture answers the canonical CultMesh Verse catalog + over binary CultNet WebSocket messages; +- one wrong-authority provider is deliberately advertised on a better-priority + route, and both clients must ignore it because the route is bound to another + runtime; +- the browser and C# clients speak the same binary CultNet schema-v0 WebSocket + messages through the authenticated host adapter. + +The provider registers typed commands through `CultNetOperationServer`. +Application code receives `CultNetOperationContext` and returns a +typed receipt; it does not parse base64, switch on wire envelopes, or construct +correlation responses. The counter's durable transaction still owns +idempotency and state mutation. The provider opens one public `CultMeshNode`; +it does not assemble a parallel CultCache/CultNet database host by hand. + +Run the proof from the CultLib repository: + +```powershell +pwsh -File ./scripts/verify-eve-getting-started.ps1 +``` + +The wrapper first proves clean package consumption, then runs this real-process +checkpoint. Run `node scripts/verify-eve-browser-network.mjs --eve-root ../Eve` +directly only to diagnose the network layer. Browser discovery is portable: +`CHROME_PATH` wins when configured, then common Chrome/Chromium/Edge locations +and Playwright's installed Chromium are checked. + +The verifier clicks the Eve button twice with one idempotency key and requires +one state transition. It then restarts the provider on a different physical +port, updates the local Odin fixture, and requires both retained clients to +rediscover and resubscribe. The C# client invokes the second command through +`CultMeshClient.InvokeAsync`; Chromium observes the resulting count and both +clients observe both canonical receipt ids. +The retained C# client then sends 10,000 typed no-op operations over the same +real WebSocket session. The gate records p99 latency, throughput, managed heap, +and private memory, and rejects more than 8 MiB of post-GC managed growth or a +250 ms p99. The no-op provider handler stores no receipt history, so persistence +growth cannot impersonate a client leak. +The fixture speaks the production Verse-catalog contract; a deployment smoke +against the full Odin daemon remains a separate infrastructure gate. + +The `odin` fixture defaults to this sample's Verse, but its `--verse-id`, +`--verse-name`, `--authority-runtime-id`, `--transport-version`, and +`--rules-hash` arguments can advertise another real provider during a product +route-rotation witness. The provider endpoint remains data owned by Odin; the +consumer still connects only by stable Verse and provider identity. diff --git a/samples/eve-browser-network/browser/index.html b/samples/eve-browser-network/browser/index.html new file mode 100644 index 00000000..d23ad8ab --- /dev/null +++ b/samples/eve-browser-network/browser/index.html @@ -0,0 +1,13 @@ + + + + + + CultMesh Eve browser counter + + +
Connecting to the Verse…
+ + + + diff --git a/samples/eve-browser-network/browser/main.ts b/samples/eve-browser-network/browser/main.ts new file mode 100644 index 00000000..fac4cc43 --- /dev/null +++ b/samples/eve-browser-network/browser/main.ts @@ -0,0 +1,110 @@ +import { parseEveCommandReceipt, parseEveProviderAdvertisement, parseEveSurfaceDocument } from "@gamecult/eve-contracts"; +import { renderEveSurface } from "@gamecult/eve-browser-lowering"; +import { + CultMeshBrowserClient, + CultMeshBrowserOdinRendezvous, + decodeCultNetOperationPayload, + decodeCultNetPayload, +} from "cultmesh-browser"; + +// CounterState is an integer-keyed MessagePack object: [counterId, count, receipts]. +const counterCount = (record: Parameters[0]) => + decodeCultNetPayload<[string, number, unknown]>(record)[1]; + +declare global { + interface Window { + __sampleReady?: boolean; + __sampleCount?: number; + __sampleReceipt?: { receiptId: string; count: number }; + __sampleCommandId?: string; + __sampleConnectionStates?: string[]; + __sampleError?: string; + } +} + +const params = new URLSearchParams(location.search); +const odinEndpoint = params.get("odin"); +const token = params.get("token") || "sample-session"; +if (!odinEndpoint) throw new Error("The sample requires an Odin endpoint query parameter."); +document.cookie = `cultnet_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict`; + +const host = document.querySelector("#surface")!; +const receiptOutput = document.querySelector("#receipt")!; + +try { + const mesh = await CultMeshBrowserClient.connect({ + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + runtimeId: "sample.chromium", + trust: { mode: "local-development" }, + rendezvous: new CultMeshBrowserOdinRendezvous({ + endpoints: [odinEndpoint], + runtimeId: "sample.chromium.discovery", + transportVersion: "cultmesh.v0", + }), + }); + window.__sampleConnectionStates = []; + mesh.watchState(state => window.__sampleConnectionStates!.push(state)); + window.__sampleCommandId = "browser-click-1"; + const counter = await mesh.leaseRawDocument({ + schemaId: "sample.counter_state.v1", + recordKey: "counter:main", + subscriptionId: "browser-counter", + }); + const surface = await mesh.leaseRawDocument({ + schemaId: "gamecult.eve.surface.v1", + recordKey: "sample.counter", + subscriptionId: "browser-surface", + }); + // The provider owns the declaration of where this surface's commands go and + // what comes back; the page reads it rather than restating it. + const advertisement = await mesh.leaseRawDocument({ + schemaId: "gamecult.eve.provider_advertisement.v1", + recordKey: "sample.counter-ui", + subscriptionId: "browser-provider", + }); + const provider = parseEveProviderAdvertisement(decodeCultNetPayload(advertisement.current!)); + const currentSurface = parseEveSurfaceDocument(decodeCultNetPayload(surface.current!)); + renderEveSurface(currentSurface, host, { + activeSurfaceId: "sample.counter", + clientId: "sample.chromium", + provider, + commandSink: async intent => { + const response = await mesh.invoke({ + serviceId: "sample.counter", + operation: intent.operation.operationId, + payloadSchema: "sample.increment.v1", + payload: { amount: 1 }, + idempotencyKey: window.__sampleCommandId, + }); + const receipt = parseEveCommandReceipt(decodeCultNetOperationPayload(response)) as { + receiptId: string; + count: number; + }; + window.__sampleReceipt = receipt; + receiptOutput.textContent = receipt.receiptId; + }, + stateBindingResolver: async binding => { + if (binding.pointerId !== "sample.counter.count") return undefined; + return { + latest: async () => counterCount(counter.current!), + watch: callback => counter.watch(record => { + if (!record) return; + const count = counterCount(record); + window.__sampleCount = count; + callback(count); + }), + }; + }, + }); + window.__sampleCount = counterCount(counter.current!); + window.__sampleReady = true; +} catch (error) { + const details = error instanceof AggregateError + ? error.errors.map(inner => inner instanceof Error ? inner.stack || inner.message : String(inner)).join("\n---\n") + : ""; + window.__sampleError = (error instanceof Error ? error.stack || error.message : String(error)) + + (details ? `\n${details}` : ""); + host.textContent = window.__sampleError; + throw error; +} diff --git a/samples/eve-two-runtime/README.md b/samples/eve-two-runtime/README.md new file mode 100644 index 00000000..0981ad1a --- /dev/null +++ b/samples/eve-two-runtime/README.md @@ -0,0 +1,37 @@ +# Eve Two-Consumer Counter + +This is the smallest executable package-consumer checkpoint for CultMesh plus +Eve. It intentionally has one owner for each decision: + +- the provider node owns the counter document and command transaction; +- Eve owns the validated surface and command-receipt contracts; +- CultMesh owns typed observation and the operation boundary; +- the DOM lowerer owns HTML only; +- the headless observer owns no state. + +Run it from the CultLib repository: + +```powershell +pwsh -File ./scripts/verify-eve-getting-started.ps1 +``` + +That command continues into the real Chromium/C# network checkpoint after this +artifact layer passes. Run `verify-eve-two-runtime-sample.ps1` directly only to +diagnose clean package consumption in isolation. + +From clean CultLib and Eve checkouts, the verifier installs missing build +dependencies, builds package artifacts, installs them into a new empty +temporary consumer, and runs `sample.mjs`. Success proves: + +1. no source-tree import is required by the consumer; +2. the provider surface and receipt pass Eve's generated runtime validators; +3. a DOM click executes one idempotent provider operation; +4. the DOM binding and a separate headless observer converge on the same count; +5. the canonical receipt and state survive reopening the `.cc` store. + +This artifact checkpoint is deliberately not the network proof. The DOM lowerer +is hosted by jsdom in Node and both consumers share one local CultMesh node. The +same wrapper continues into a separate real Chromium/C# process chronology with +Odin discovery, route replacement, and persistent provider state. Keeping those +evidence layers separate prevents a fast package test from impersonating the +network. diff --git a/samples/eve-two-runtime/sample.mjs b/samples/eve-two-runtime/sample.mjs new file mode 100644 index 00000000..93ed1dc8 --- /dev/null +++ b/samples/eve-two-runtime/sample.mjs @@ -0,0 +1,247 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { JSDOM } from "jsdom"; +import { z } from "zod"; +import { defineDocumentType } from "@gamecult/cultcache-ts"; +import { CultMesh } from "cultmesh-ts"; +import { + parseEveCommandReceipt, + parseEveSurfaceDocument, +} from "@gamecult/eve-contracts"; +import { renderEveSurface } from "@gamecult/eve-browser-lowering"; + +const providerId = "sample.counter-provider"; +const surfaceId = "sample.counter"; +const stateKey = "counter:main"; + +const counterDocument = defineDocumentType({ + type: "sample.counter_state", + schemaId: "sample.counter_state.v1", + schema: z.object({ counterId: z.string(), count: z.number().int() }), + name: "counterId", +}); + +const surfaceDocument = defineDocumentType({ + type: "gamecult.eve.surface", + schemaId: "gamecult.eve.surface.v1", + schema: z.unknown().transform(value => parseEveSurfaceDocument(value)), +}); + +const receiptDocument = defineDocumentType({ + type: "gamecult.eve.command_receipt", + schemaId: "gamecult.eve.command_receipt.v1", + schema: z.unknown().transform(value => parseEveCommandReceipt(value)), + name: "receiptId", +}); + +function counterSurface() { + return { + type: "surface-state", + schema: "gamecult.eve.surface.v1", + providerId, + providerKind: "sample.daemon", + title: "CultMesh two-runtime counter", + version: 1, + updatedAtUtc: "2026-08-17T00:00:00Z", + surface: { + id: surfaceId, + title: "Counter", + root: { + id: "counter.root", + kind: "column", + props: {}, + children: [ + { + id: "counter.value", + kind: "metric", + props: { label: "Canonical count", value: 0 }, + children: [], + stateBindings: [{ + targetProp: "value", + pointerId: "sample.counter.count", + sourceId: `${counterDocument.type}:${stateKey}`, + schemaId: counterDocument.schemaId, + routeKind: "shared-memory", + }], + }, + { + id: "counter.increment", + kind: "control.button", + props: { label: "Increment", command: "sample.counter.increment" }, + children: [], + }, + ], + }, + styles: {}, + }, + commands: [{ + schema: "gamecult.eve.command.v1", + command: "sample.counter.increment", + label: "Increment", + surfaceId, + transport: "cultmesh", + authority: "provider-daemon", + result: "gamecult.eve.command_receipt.v1", + }], + }; +} + +function installDom() { + const dom = new JSDOM("
", { + url: "https://eve.sample/", + pretendToBeVisual: true, + }); + for (const name of [ + "document", + "window", + "Element", + "HTMLElement", + "HTMLButtonElement", + "HTMLInputElement", + "HTMLTextAreaElement", + "HTMLImageElement", + ]) globalThis[name] = dom.window[name]; + return dom; +} + +async function waitFor(predicate, description) { + const deadline = Date.now() + 2_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}.`); + await new Promise(resolve => setTimeout(resolve, 5)); + } +} + +assert.throws( + () => parseEveSurfaceDocument({ providerId, surface: { id: surfaceId } }), + /Invalid Eve surface/, +); +assert.throws( + () => parseEveCommandReceipt({ receiptId: "unowned" }), + /Invalid Eve commandReceipt/, +); + +const statePath = join(await mkdtemp(join(tmpdir(), "cultmesh-eve-two-runtime-")), "state.cc"); +const node = await CultMesh.startNode(statePath, { + documents: [counterDocument, surfaceDocument, receiptDocument], +}); +await node.put(counterDocument, stateKey, { counterId: stateKey, count: 0 }); +await node.put(surfaceDocument, surfaceId, counterSurface()); +await node.flush(); + +const state = node.document(counterDocument, stateKey, { pollMs: 5 }); +const headless = state.observe({ context: "headless-client" }); +await headless.ready; +const receiptsByIdempotencyKey = new Map(); +const increment = CultMesh.operation("sample.counter.increment", async (_request, context) => { + const idempotencyKey = context.idempotencyKey || "missing"; + const existing = receiptsByIdempotencyKey.get(idempotencyKey) + || node.get(receiptDocument, `receipt:${idempotencyKey}`); + if (existing) return existing; + const next = await state.authoritativeWriter("provider-daemon").update(current => ({ + ...current, + count: current.count + 1, + })); + const receipt = { + receiptId: `receipt:${idempotencyKey}`, + schema: "gamecult.eve.command_receipt.v1", + commandId: idempotencyKey, + command: "sample.counter.increment", + state: "accepted", + ownerRepo: "CultLib", + authority: "provider-daemon", + providerId, + surfaceId, + issuedAtUtc: new Date().toISOString(), + sourceVersion: next.count, + idempotencyKey, + count: next.count, + }; + receiptsByIdempotencyKey.set(idempotencyKey, receipt); + await node.put(receiptDocument, receipt.receiptId, receipt); + await node.flush(); + return receipt; +}); + +const dom = installDom(); +const host = document.querySelector("#surface"); +const browserVerse = CultMesh.verse("local", "browser-client"); +let browserReceipt; +renderEveSurface(await node.document(surfaceDocument, surfaceId).latest(), host, { + activeSurfaceId: surfaceId, + clientId: "browser-client", + // Eve refuses to build a command intent for a surface that has not + // advertised where its commands go and what comes back. The provider + // advertisement is that declaration; a lowering cannot infer it. + provider: { + providerId, + surfaces: [{ + surfaceId, + worldInteraction: { + commandBoundary: "sample.counter.commands", + receiptSchema: "gamecult.eve.command_receipt.v1", + }, + }], + }, + commandSink: async intent => { + browserReceipt = await increment.bind(browserVerse).invoke(intent.payload, { + idempotencyKey: "browser-click-1", + }); + }, + stateBindingResolver: async binding => { + if (binding.pointerId !== "sample.counter.count") return undefined; + return { + latest: async () => (await state.latest("browser-client")).count, + watch: callback => state.watch("browser-client", value => callback(value.count)), + }; + }, +}); + +await waitFor(() => host.querySelector(".metric-value")?.textContent === "0", "initial browser binding"); +host.querySelector("button").click(); +await waitFor(() => browserReceipt?.count === 1, "typed command receipt"); +await waitFor(() => headless.current.count === 1, "headless convergence"); +await waitFor(() => host.querySelector(".metric-value")?.textContent === "1", "browser convergence"); + +const duplicate = await increment.bind(browserVerse).invoke({}, { idempotencyKey: "browser-click-1" }); +assert.equal(duplicate.receiptId, browserReceipt.receiptId); +assert.equal(headless.current.count, 1); + +headless.dispose(); +renderEveSurface(parseEveSurfaceDocument({ + type: "surface-state", + schema: "gamecult.eve.surface.v1", + providerId, + providerKind: "sample.daemon", + title: "Closed counter", + version: 2, + updatedAtUtc: "2026-08-17T00:00:01Z", + surface: { + id: "sample.closed", + root: { id: "closed.root", kind: "column", props: {}, children: [] }, + styles: {}, + }, + commands: [], +}), host); +dom.window.close(); +await node.flush(); +const reopened = await CultMesh.startNode(statePath, { + documents: [counterDocument, surfaceDocument, receiptDocument], +}); +assert.equal(reopened.getRequired(counterDocument, stateKey).count, 1); +assert.equal( + reopened.getRequired(receiptDocument, "receipt:browser-click-1").receiptId, + browserReceipt.receiptId, +); + +console.log(JSON.stringify({ + providerId, + surfaceId, + receiptId: browserReceipt.receiptId, + loweringRuntime: "jsdom", + browserCount: 1, + headlessCount: 1, + restartCount: 1, +})); diff --git a/scripts/build-quic-native.ps1 b/scripts/build-quic-native.ps1 new file mode 100644 index 00000000..68496180 --- /dev/null +++ b/scripts/build-quic-native.ps1 @@ -0,0 +1,61 @@ +param( + [string] $Configuration = "Release", + [string] $Architecture = "x64", + [string] $OutputDirectory = "artifacts\quic-native" +) + +$ErrorActionPreference = "Stop" +if ($Architecture -ne "x64") { + throw "The first CultMesh native QUIC package supports x64 Windows desktop only." +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$version = "2.5.9" +$expectedPackageSha256 = "9877A919E1AA73AA4800F1E8A06B6539021F376228910138EB04540CD956617B" +$cacheRoot = Join-Path $repoRoot "artifacts\dependencies\msquic-schannel-$version" +$packagePath = Join-Path $cacheRoot "Microsoft.Native.Quic.MsQuic.Schannel.$version.zip" +$extractRoot = Join-Path $cacheRoot "package" +$nativeRoot = Join-Path $extractRoot "build\native" +$buildRoot = Join-Path $repoRoot "artifacts\quic-native-build\$Architecture\$Configuration" +$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory +} else { + Join-Path $repoRoot $OutputDirectory +} + +New-Item -ItemType Directory -Force -Path $cacheRoot | Out-Null +if (-not (Test-Path -LiteralPath $packagePath)) { + & curl.exe -L --fail --silent --show-error ` + -o $packagePath ` + "https://www.nuget.org/api/v2/package/Microsoft.Native.Quic.MsQuic.Schannel/$version" + if ($LASTEXITCODE -ne 0) { throw "MsQuic package download failed with exit code $LASTEXITCODE" } +} +$sha256 = [Security.Cryptography.SHA256]::Create() +try { + $packageBytes = [IO.File]::ReadAllBytes($packagePath) + $packageSha256 = [BitConverter]::ToString($sha256.ComputeHash($packageBytes)).Replace("-", "") +} finally { + $sha256.Dispose() +} +if ($packageSha256 -ne $expectedPackageSha256) { + throw "MsQuic package digest does not match the pinned release." +} +if (-not (Test-Path -LiteralPath (Join-Path $nativeRoot "include\msquic.h"))) { + if (Test-Path -LiteralPath $extractRoot) { Remove-Item -LiteralPath $extractRoot -Recurse -Force } + Expand-Archive -LiteralPath $packagePath -DestinationPath $extractRoot +} + +cmake -S (Join-Path $repoRoot "native\GameCult.Mesh.Quic.Native") -B $buildRoot -A x64 ` + "-DMSQUIC_ROOT=$($nativeRoot.Replace('\', '/'))" +if ($LASTEXITCODE -ne 0) { throw "CultMesh native QUIC configure failed with exit code $LASTEXITCODE" } +cmake --build $buildRoot --config $Configuration +if ($LASTEXITCODE -ne 0) { throw "CultMesh native QUIC build failed with exit code $LASTEXITCODE" } + +New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null +Copy-Item -LiteralPath (Join-Path $buildRoot "bin\$Configuration\gamecult_mesh_quic_native.dll") ` + -Destination $outputRoot -Force +Copy-Item -LiteralPath (Join-Path $nativeRoot "bin\x64\msquic.dll") -Destination $outputRoot -Force +Copy-Item -LiteralPath (Join-Path $extractRoot "LICENSE") -Destination (Join-Path $outputRoot "MSQUIC-LICENSE.txt") -Force + +Write-Host "CultMesh native QUIC runtime: $outputRoot" +Get-ChildItem -LiteralPath $outputRoot -File | Select-Object Name,Length diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 new file mode 100644 index 00000000..804f78df --- /dev/null +++ b/scripts/build-unity-package.ps1 @@ -0,0 +1,184 @@ +param( + [string] $Configuration = "Release", + [string] $OutputDirectory = "artifacts\unity\org.gamecult.cultlib", + [string] $NuGetConfig = "", + [switch] $NoRestore, + [switch] $UpdateTemplate +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$projectPath = Join-Path $repoRoot "src\GameCult.Mesh\GameCult.Mesh.csproj" +$webSocketProjectPath = Join-Path $repoRoot "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj" +$quicProjectPath = Join-Path $repoRoot "src\GameCult.Mesh.Quic.Native\GameCult.Mesh.Quic.Native.csproj" +$templateRoot = Join-Path $repoRoot "unity\org.gamecult.cultlib" +$publishRoot = Join-Path $repoRoot "artifacts\unity-publish" +$webSocketPublishRoot = Join-Path $repoRoot "artifacts\unity-websocket-publish" +$quicPublishRoot = Join-Path $repoRoot "artifacts\unity-quic-publish" +$quicNativeRoot = Join-Path $repoRoot "artifacts\unity-quic-native" +$outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory +} else { + Join-Path $repoRoot $OutputDirectory +} +$pluginRoot = Join-Path $outputRoot "Runtime\Plugins" + +if (Test-Path -LiteralPath $publishRoot) { + Remove-Item -LiteralPath $publishRoot -Recurse -Force +} +if (Test-Path -LiteralPath $quicPublishRoot) { + Remove-Item -LiteralPath $quicPublishRoot -Recurse -Force +} +if (Test-Path -LiteralPath $webSocketPublishRoot) { + Remove-Item -LiteralPath $webSocketPublishRoot -Recurse -Force +} +if (Test-Path -LiteralPath $outputRoot) { + Remove-Item -LiteralPath $outputRoot -Recurse -Force +} + +$unityPackageVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version +$publishArguments = @("publish", $projectPath, "-c", $Configuration, "-o", $publishRoot, "-p:CultLibPackageVersion=$unityPackageVersion") +if ($NoRestore) { $publishArguments += "--no-restore" } +if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) { + $publishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))" +} +& dotnet @publishArguments +if ($LASTEXITCODE -ne 0) { + throw "CultLib publish failed with exit code $LASTEXITCODE" +} +$webSocketPublishArguments = @("publish", $webSocketProjectPath, "-c", $Configuration, "-f", "netstandard2.1", "-o", $webSocketPublishRoot, "-p:CultLibPackageVersion=$unityPackageVersion") +if ($NoRestore) { $webSocketPublishArguments += "--no-restore" } +if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) { + $webSocketPublishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))" +} +& dotnet @webSocketPublishArguments +if ($LASTEXITCODE -ne 0) { + throw "CultLib WebSocket publish failed with exit code $LASTEXITCODE" +} +$quicPublishArguments = @("publish", $quicProjectPath, "-c", $Configuration, "-o", $quicPublishRoot, "-p:CultLibPackageVersion=$unityPackageVersion") +if ($NoRestore) { $quicPublishArguments += "--no-restore" } +if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) { + $quicPublishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))" +} +& dotnet @quicPublishArguments +if ($LASTEXITCODE -ne 0) { + throw "CultLib native QUIC managed publish failed with exit code $LASTEXITCODE" +} +& (Join-Path $PSScriptRoot "build-quic-native.ps1") ` + -Configuration $Configuration ` + -OutputDirectory $quicNativeRoot + +New-Item -ItemType Directory -Force -Path $pluginRoot | Out-Null +Copy-Item -LiteralPath (Join-Path $templateRoot "package.json") -Destination $outputRoot +Copy-Item -LiteralPath (Join-Path $templateRoot "README.md") -Destination $outputRoot + +$expectedAssemblies = @( + "ConcurrentCollections.dll", + "GameCult.Caching.dll", + "GameCult.Caching.MessagePack.dll", + "GameCult.Logging.dll", + "GameCult.Mesh.dll", + "GameCult.Mesh.Quic.Native.dll", + "GameCult.Networking.dll", + "GameCult.Networking.WebSockets.dll", + "Isopoh.Cryptography.Argon2.dll", + "Isopoh.Cryptography.Blake2b.dll", + "Isopoh.Cryptography.SecureArray.dll", + "LiteNetLib.dll", + "MessagePack.Annotations.dll", + "MessagePack.dll", + "Microsoft.Bcl.AsyncInterfaces.dll", + "Microsoft.Bcl.TimeProvider.dll", + "Microsoft.NET.StringTools.dll", + "R3.dll", + "System.Collections.Immutable.dll", + "System.ComponentModel.Annotations.dll", + "System.IO.Pipelines.dll", + "System.Runtime.CompilerServices.Unsafe.dll", + "System.Text.Encodings.Web.dll", + "System.Text.Json.dll", + "System.Threading.Channels.dll" +) +$publishedByName = @{} +foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") { + $publishedByName[$assembly.Name] = $assembly +} +foreach ($assembly in Get-ChildItem -LiteralPath $quicPublishRoot -Filter "*.dll") { + $publishedByName[$assembly.Name] = $assembly +} +foreach ($assembly in Get-ChildItem -LiteralPath $webSocketPublishRoot -Filter "*.dll") { + $publishedByName[$assembly.Name] = $assembly +} +foreach ($assemblyName in $expectedAssemblies) { + if (-not $publishedByName.ContainsKey($assemblyName)) { + throw "CultLib Unity package is missing expected assembly: $assemblyName" + } +} + +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef") ` + -Destination (Join-Path $outputRoot "Runtime") +foreach ($assemblyName in $expectedAssemblies) { + $assembly = $publishedByName[$assemblyName] + Copy-Item -LiteralPath $assembly.FullName -Destination $pluginRoot + $pdb = [System.IO.Path]::ChangeExtension($assembly.FullName, ".pdb") + if (Test-Path -LiteralPath $pdb) { + Copy-Item -LiteralPath $pdb -Destination $pluginRoot + } +} +$nativePluginRoot = Join-Path $pluginRoot "x86_64" +New-Item -ItemType Directory -Force -Path $nativePluginRoot | Out-Null +Copy-Item -LiteralPath (Join-Path $quicNativeRoot "gamecult_mesh_quic_native.dll") -Destination $nativePluginRoot +Copy-Item -LiteralPath (Join-Path $quicNativeRoot "msquic.dll") -Destination $nativePluginRoot +New-Item -ItemType Directory -Force -Path (Join-Path $outputRoot "Third Party Notices") | Out-Null +Copy-Item -LiteralPath (Join-Path $quicNativeRoot "MSQUIC-LICENSE.txt") ` + -Destination (Join-Path $outputRoot "Third Party Notices\MSQUIC-LICENSE.txt") + +$meshPackagePath = Join-Path $pluginRoot "GameCult.Mesh.dll" +$meshPackageVersion = [Reflection.AssemblyName]::GetAssemblyName($meshPackagePath).Version.ToString() +if ($meshPackageVersion -ne "$unityPackageVersion.0") { + throw "Packaged GameCult.Mesh.dll version '$meshPackageVersion' does not match package '$unityPackageVersion'." +} +$meshPackageMetadata = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($meshPackagePath)) +foreach ($requiredType in @( + "GameCult.Mesh.CultMeshAuthorityTrustPolicy", + "GameCult.Mesh.CultMeshRouteCertificate", + "GameCult.Mesh.CultMeshHttpsContentTransportConnector")) { + if (-not $meshPackageMetadata.Contains($requiredType.Split('.')[-1])) { + throw "Packaged GameCult.Mesh.dll is missing required API '$requiredType'." + } +} +$webSocketPackageMetadata = [Text.Encoding]::UTF8.GetString( + [IO.File]::ReadAllBytes((Join-Path $pluginRoot "GameCult.Networking.WebSockets.dll"))) +if (-not $webSocketPackageMetadata.Contains("CultNetWebSocketSchemaClient")) { + throw "Packaged WebSocket assembly is missing the Unity-compatible CultNet client." +} + +if ($UpdateTemplate) { + $templatePluginRoot = Join-Path $templateRoot "Runtime\Plugins" + foreach ($assemblyName in $expectedAssemblies) { + Copy-Item -LiteralPath (Join-Path $pluginRoot $assemblyName) -Destination $templatePluginRoot -Force + $pdbName = [System.IO.Path]::ChangeExtension($assemblyName, ".pdb") + $pdb = Join-Path $pluginRoot $pdbName + if (Test-Path -LiteralPath $pdb) { + Copy-Item -LiteralPath $pdb -Destination $templatePluginRoot -Force + } + } + $templateNativePluginRoot = Join-Path $templatePluginRoot "x86_64" + New-Item -ItemType Directory -Force -Path $templateNativePluginRoot | Out-Null + Copy-Item -LiteralPath (Join-Path $nativePluginRoot "gamecult_mesh_quic_native.dll") ` + -Destination $templateNativePluginRoot -Force + Copy-Item -LiteralPath (Join-Path $nativePluginRoot "msquic.dll") ` + -Destination $templateNativePluginRoot -Force + $templateNoticesRoot = Join-Path $templateRoot "Third Party Notices" + New-Item -ItemType Directory -Force -Path $templateNoticesRoot | Out-Null + Copy-Item -LiteralPath (Join-Path $outputRoot "Third Party Notices\MSQUIC-LICENSE.txt") ` + -Destination $templateNoticesRoot -Force + Write-Host "Updated tracked Unity package assemblies: $templatePluginRoot" +} + +$manifest = Get-Content -LiteralPath (Join-Path $outputRoot "package.json") -Raw | ConvertFrom-Json +Write-Host "CultLib Unity package: $outputRoot" +Write-Host "Package: $($manifest.name)@$($manifest.version)" +Write-Host "Managed assemblies: $($expectedAssemblies.Count)" +Write-Host "Native realtime: MsQuic Schannel 2.5.9 (Windows x64)" diff --git a/scripts/measure-reactive-document-performance.mjs b/scripts/measure-reactive-document-performance.mjs new file mode 100644 index 00000000..786cb3ed --- /dev/null +++ b/scripts/measure-reactive-document-performance.mjs @@ -0,0 +1,141 @@ +import { monitorEventLoopDelay, performance } from "node:perf_hooks"; +import process from "node:process"; + +import { CultMesh } from "../packages/cultmesh-ts/dist/index.js"; + +const quick = process.argv.includes("--quick"); +const idleMs = quick ? 250 : 10_000; +const activeMs = quick ? 500 : 10_000; +const results = []; + +for (const documentCount of [1, 100, 1000]) { + results.push(await measure(documentCount)); +} + +console.log(JSON.stringify({ + runtime: "typescript", + workload: { + documentCounts: [1, 100, 1000], + payloadBytes: 16 * 1024, + idleSeconds: idleMs / 1000, + activeSeconds: activeMs / 1000, + updateRateHz: 60, + changedFraction: 0.01, + }, + results, +}, undefined, 2)); + +if (results.some(result => + result.actualPublishes !== result.expectedPublishes || + result.p99PublishLatencyMilliseconds >= 250 || + result.activeHeapPeakGrowthBytes >= 128 * 1024 * 1024)) { + process.exitCode = 1; +} + +async function measure(documentCount) { + const payload = "x".repeat(16 * 1024); + const changedDocumentCount = Math.max(1, Math.floor(documentCount / 100)); + const reactiveDocuments = []; + const publishSignals = new Array(documentCount); + const updateStartedAt = new Float64Array(documentCount); + const latencies = []; + let payloadBytesPublished = 0; + let publishes = 0; + + for (let index = 0; index < documentCount; index++) { + let current = { id: `performance:${index}`, payload, revision: 1 }; + let watcher; + const document = CultMesh.document( + `performance:${index}`, + { schemaId: "gamecult.mesh.performance_probe.v1" }, + async () => current, + { + submitPrediction: async (_context, value) => { + payloadBytesPublished += Buffer.byteLength(JSON.stringify(value)); + latencies.push(performance.now() - updateStartedAt[index]); + publishes++; + current = value; + watcher?.(value); + publishSignals[index]?.(); + }, + watchDocument: (_context, callback) => { + watcher = callback; + return () => { watcher = undefined; }; + }, + }, + ); + const reactive = document.predictionWriter().reactive(); + reactiveDocuments.push(reactive); + } + + await Promise.all(reactiveDocuments.map(document => document.ready)); + collect(); + const idleHeapStart = process.memoryUsage().heapUsed; + const idleCpuStart = process.cpuUsage(); + await delay(idleMs); + collect(); + const idleHeapGrowthBytes = process.memoryUsage().heapUsed - idleHeapStart; + const idleCpu = process.cpuUsage(idleCpuStart); + + collect(); + const activeHeapStart = process.memoryUsage().heapUsed; + let activeHeapPeak = activeHeapStart; + const activeCpuStart = process.cpuUsage(); + const eventLoop = monitorEventLoopDelay({ resolution: 10 }); + eventLoop.enable(); + const activeStartedAt = performance.now(); + let frames = 0; + while (performance.now() - activeStartedAt < activeMs) { + const completions = []; + for (let index = 0; index < changedDocumentCount; index++) { + completions.push(new Promise(resolve => { publishSignals[index] = resolve; })); + updateStartedAt[index] = performance.now(); + reactiveDocuments[index].update(value => { value.revision++; }); + } + await Promise.all(completions); + frames++; + activeHeapPeak = Math.max(activeHeapPeak, process.memoryUsage().heapUsed); + const remaining = frames * (1000 / 60) - (performance.now() - activeStartedAt); + if (remaining > 0) await delay(remaining); + } + eventLoop.disable(); + const activeElapsedSeconds = (performance.now() - activeStartedAt) / 1000; + const activeCpu = process.cpuUsage(activeCpuStart); + const ordered = latencies.toSorted((left, right) => left - right); + + for (const document of reactiveDocuments) document.dispose(); + + return { + documentCount, + changedDocumentCount, + frames, + expectedPublishes: frames * changedDocumentCount, + actualPublishes: publishes, + payloadBytesPublished, + idleHeapGrowthBytes, + idleCpuMilliseconds: (idleCpu.user + idleCpu.system) / 1000, + activeHeapPeakGrowthBytes: activeHeapPeak - activeHeapStart, + activeCpuMilliseconds: (activeCpu.user + activeCpu.system) / 1000, + publishedPayloadBytesPerSecond: payloadBytesPublished / activeElapsedSeconds, + p50PublishLatencyMilliseconds: percentile(ordered, 0.50), + p95PublishLatencyMilliseconds: percentile(ordered, 0.95), + p99PublishLatencyMilliseconds: percentile(ordered, 0.99), + p99EventLoopDelayMilliseconds: eventLoop.percentile(99) / 1_000_000, + }; +} + +function percentile(ordered, ratio) { + if (ordered.length === 0) return 0; + return ordered[Math.max(0, Math.ceil(ordered.length * ratio) - 1)]; +} + +function collect() { + if (typeof globalThis.gc !== "function") { + throw new Error("Run this probe with node --expose-gc."); + } + globalThis.gc(); +} + +function delay(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} diff --git a/scripts/measure-reactive-document-performance.ps1 b/scripts/measure-reactive-document-performance.ps1 new file mode 100644 index 00000000..c0f44eaf --- /dev/null +++ b/scripts/measure-reactive-document-performance.ps1 @@ -0,0 +1,88 @@ +param( + [switch] $Quick, + [switch] $SkipRuntimeBuild, + [string] $PythonPath = "", + [string] $NodePath = "" +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$nodeExecutable = if ([string]::IsNullOrWhiteSpace($NodePath)) { + (Get-Command node -ErrorAction Stop).Source +} +else { + [IO.Path]::GetFullPath($NodePath) +} +$pythonExecutable = if ([string]::IsNullOrWhiteSpace($PythonPath)) { + (Get-Command python -ErrorAction Stop).Source +} +else { + [IO.Path]::GetFullPath($PythonPath) +} +foreach ($executable in @($nodeExecutable, $pythonExecutable)) { + if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { + throw "Performance probe runtime not found: $executable" + } +} + +if (-not $SkipRuntimeBuild) { + $typescript = Join-Path $root "node_modules\typescript\bin\tsc" + foreach ($dependency in @("cultcache-ts", "cultnet-ts", "cultmesh-ts")) { + $dependencyRoot = Join-Path $root "packages\$dependency" + if ($dependency -eq "cultnet-ts") { + & $nodeExecutable (Join-Path $dependencyRoot "tools\generate-swarm-contracts.mjs") + if ($LASTEXITCODE -ne 0) { throw "CultNet TypeScript contract generation failed." } + } + & $nodeExecutable $typescript -p (Join-Path $dependencyRoot "tsconfig.json") --pretty false + if ($LASTEXITCODE -ne 0) { throw "TypeScript performance dependency build failed: $dependency" } + } +} + +$project = Join-Path $root "tools\GameCult.Mesh.PerformanceProbe\GameCult.Mesh.PerformanceProbe.csproj" +$buildArguments = @( + "build", + $project, + "--configuration", "Release", + "--verbosity", "quiet", + "/p:NoWarn=1591%3BCS8632" +) +& dotnet @buildArguments +if ($LASTEXITCODE -ne 0) { + throw "CultMesh reactive document performance probe build failed." +} + +$arguments = @("run", "--project", $project, "--configuration", "Release", "--no-build") +if ($Quick) { + $arguments += @("--", "--quick") +} + +& dotnet @arguments +if ($LASTEXITCODE -ne 0) { + throw "CultMesh C# reactive document performance probe failed." +} + +$runtimeArguments = @() +if ($Quick) { $runtimeArguments += "--quick" } + +& $nodeExecutable --expose-gc (Join-Path $root "scripts\measure-reactive-document-performance.mjs") @runtimeArguments +if ($LASTEXITCODE -ne 0) { + throw "CultMesh TypeScript reactive document performance probe failed." +} + +$pythonSource = (@("cultcache-py", "cultnet-py", "cultmesh-py") | ForEach-Object { Join-Path $root "packages\$_\src" }) -join [IO.Path]::PathSeparator +$previousPythonPath = $env:PYTHONPATH +$env:PYTHONPATH = if ([string]::IsNullOrWhiteSpace($previousPythonPath)) { + $pythonSource +} +else { + $pythonSource + [IO.Path]::PathSeparator + $previousPythonPath +} +try { + & $pythonExecutable (Join-Path $root "packages\cultmesh-py\tools\reactive_performance_probe.py") @runtimeArguments + if ($LASTEXITCODE -ne 0) { + throw "CultMesh Python reactive document performance probe failed." + } +} +finally { + $env:PYTHONPATH = $previousPythonPath +} diff --git a/scripts/pack-nuget.ps1 b/scripts/pack-nuget.ps1 new file mode 100644 index 00000000..95e5cd2f --- /dev/null +++ b/scripts/pack-nuget.ps1 @@ -0,0 +1,105 @@ +param( + [string] $Configuration = "Release", + [string] $OutputDirectory = "artifacts\nuget", + [string] $PackageVersion = "", + [switch] $SkipConsumerSmoke +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory +} else { + Join-Path $repoRoot $OutputDirectory +} + +$projects = @( + "src\GameCult.Logging\GameCult.Logging.csproj", + "src\GameCult.Caching\GameCult.Caching.csproj", + "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", + "src\GameCult.Networking\GameCult.Networking.csproj", + "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj", + "src\GameCult.Mesh\GameCult.Mesh.csproj" +) + +if (Test-Path -LiteralPath $outputRoot) { + Remove-Item -LiteralPath $outputRoot -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null + +foreach ($project in $projects) { + $packArguments = @( + "pack", (Join-Path $repoRoot $project), "-c", $Configuration, "-o", $outputRoot, + "--nologo", "--verbosity", "quiet", "-p:NoWarn=1591%3BCS8632" + ) + if (-not [string]::IsNullOrWhiteSpace($PackageVersion)) { + $packArguments += "-p:CultLibPackageVersion=$PackageVersion" + } + & dotnet @packArguments + if ($LASTEXITCODE -ne 0) { + throw "NuGet pack failed for $project with exit code $LASTEXITCODE" + } +} + +$meshPackage = Get-ChildItem -LiteralPath $outputRoot -Filter "GameCult.Mesh.*.nupkg" | + Where-Object { $_.Name -notlike "*.snupkg" } | + Select-Object -First 1 +if (-not $meshPackage) { + throw "GameCult.Mesh package was not produced." +} + +$version = [regex]::Match($meshPackage.Name, '^GameCult\.Mesh\.(.+)\.nupkg$').Groups[1].Value +$expectedPackages = @( + "GameCult.Logging.$version.nupkg", + "GameCult.Caching.$version.nupkg", + "GameCult.Caching.MessagePack.$version.nupkg", + "GameCult.Networking.$version.nupkg", + "GameCult.Networking.WebSockets.$version.nupkg", + "GameCult.Mesh.$version.nupkg" +) +foreach ($package in $expectedPackages) { + if (-not (Test-Path -LiteralPath (Join-Path $outputRoot $package))) { + throw "NuGet dependency closure is missing $package" + } +} + +if (-not $SkipConsumerSmoke) { + $smokeRoot = Join-Path $repoRoot "artifacts\nuget-consumer-smoke" + if (Test-Path -LiteralPath $smokeRoot) { + Remove-Item -LiteralPath $smokeRoot -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $smokeRoot | Out-Null + $consumerPackages = Join-Path $smokeRoot "packages" + $projectDocument = @" + + + net10.0 + Exe + enable + $outputRoot;https://api.nuget.org/v3/index.json + $consumerPackages + true + + + + + +"@ + $program = @" +using GameCult.Mesh; + +var verse = CultMesh.Verse("nuget-smoke", "cultlib.pack"); +Console.WriteLine(verse.Context.VerseId); +"@ + [IO.File]::WriteAllText((Join-Path $smokeRoot "Consumer.csproj"), $projectDocument, [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $smokeRoot "Program.cs"), $program, [Text.UTF8Encoding]::new($false)) + dotnet run --project (Join-Path $smokeRoot "Consumer.csproj") -c Release ` + --nologo --verbosity quiet + if ($LASTEXITCODE -ne 0) { + throw "GameCult.Mesh NuGet consumer smoke failed with exit code $LASTEXITCODE" + } +} + +Write-Host "CultLib NuGet feed: $outputRoot" +Write-Host "GameCult.Mesh: $version" +Write-Host "Public package closure: $($expectedPackages.Count) packages" diff --git a/scripts/run-typescript-workspaces.mjs b/scripts/run-typescript-workspaces.mjs new file mode 100644 index 00000000..933079da --- /dev/null +++ b/scripts/run-typescript-workspaces.mjs @@ -0,0 +1,30 @@ +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; + +const command = process.argv[2]; +if (!command) throw new Error("Usage: node scripts/run-typescript-workspaces.mjs