Build the first Godot CultMesh runtime slice
This commit is contained in:
parent
5194579ae0
commit
fd9f46a1f4
19 changed files with 696 additions and 0 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -10,3 +10,11 @@ Thumbs.db
|
|||
.obsidian/workspace.json
|
||||
.obsidian/workspace-mobile.json
|
||||
.trash/
|
||||
|
||||
# C# and Godot runtime state
|
||||
/artifacts/
|
||||
**/bin/
|
||||
**/obj/
|
||||
client/.godot/
|
||||
*.user
|
||||
*.suo
|
||||
|
|
|
|||
9
Delvehold.slnx
Normal file
9
Delvehold.slnx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Delvehold.Protocol/Delvehold.Protocol.csproj" />
|
||||
<Project Path="src/Delvehold.WorldHost/Delvehold.WorldHost.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/client/">
|
||||
<Project Path="client/Delvehold.Client.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
36
Delvehold/Documentation/Implementation Map.md
Normal file
36
Delvehold/Documentation/Implementation Map.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
title: Implementation Map
|
||||
description: Current runtime body, authority boundaries, and focused verification targets.
|
||||
aliases:
|
||||
- /documentation/implementation-map
|
||||
---
|
||||
|
||||
# Implementation Map
|
||||
|
||||
This page describes the body that exists in source. The [[Game Design/index|Game Design Document]] describes the intended game and remains the authority for design.
|
||||
|
||||
## Runtime body
|
||||
|
||||
- `Delvehold.WorldHost` is the sole writer of canonical local world state. It owns the persistent CultCache, the explicit first-world seed, and admission of typed commands.
|
||||
- `Delvehold.Client` is one Godot 4.7.2 C# application. DELVE and HOLD are views inside that application, not separate clients or authorities.
|
||||
- `Delvehold.Protocol` contains the typed CultMesh operation payloads shared by the client and host. It owns no state and performs no simulation.
|
||||
- CultLib revision `334e60f1928b4212a29dd8b0d19b2c099fe6365e` supplies CultCache, CultNet, and CultMesh. Builds reject another revision or a dirty CultLib worktree. The host targets the installed .NET 10 runtime; Godot's C# client targets its supported .NET 8 surface.
|
||||
|
||||
## First live path
|
||||
|
||||
The client discovers the host through its CultMesh rendezvous endpoint and sends `delvehold.world/enter`. The host returns and persists an idempotent typed world-entry command receipt. Entering the world does not advance simulation or grant the client write authority.
|
||||
|
||||
The seed state names one workshop, one commons, one dungeon core, one contract, and one material. These identifiers make the connection observable; they are not a claim that the first playable slice is complete.
|
||||
|
||||
## Forbidden writers
|
||||
|
||||
- The Godot scene tree, UI controls, and client session cannot write canonical world state.
|
||||
- The protocol assembly cannot persist, simulate, or invent defaults on behalf of the host.
|
||||
- Reconnect and replay cannot manufacture a second receipt for the same idempotency key.
|
||||
- JSON is not a load-bearing state format. Canonical state and receipts are typed CultCache documents.
|
||||
|
||||
## Verification layer
|
||||
|
||||
`scripts/verify-runtime.ps1` builds the three projects, starts the host, connects the headless Godot client, rejects an idempotency collision and an invalid projection, restarts the host against the same CultCache, and replays the original enter intent. The smoke compares the complete accepted receipt summary, including its original effective time, across both accepted calls, requires the CultCache state file to remain present, and rejects known canonical-write APIs in client source.
|
||||
|
||||
Dungeon generation, CultMath randomness, CultGeometry isosurface extraction, gameplay mutation, and the Ghostlight boundary remain subsequent cuts.
|
||||
|
|
@ -6,3 +6,4 @@ description: Technical contracts, authority boundaries, and implementation maps.
|
|||
The machinery beneath DELVE/HOLD: typed world state, ownership boundaries, client projections, and the contracts joining them.
|
||||
|
||||
- [[Authority Map|Authority map]]
|
||||
- [[Implementation Map|Implementation map]]
|
||||
|
|
|
|||
13
Directory.Build.props
Normal file
13
Directory.Build.props
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<CultLibRoot Condition="'$(CultLibRoot)' == '' and '$(CULTLIB_ROOT)' != ''">$(CULTLIB_ROOT)</CultLibRoot>
|
||||
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\CultLib</CultLibRoot>
|
||||
<CultLibRevision>334e60f1928b4212a29dd8b0d19b2c099fe6365e</CultLibRevision>
|
||||
<GitExecutable Condition="'$(GitExecutable)' == '' and Exists('C:\Program Files\Git\cmd\git.exe')">C:\Program Files\Git\cmd\git.exe</GitExecutable>
|
||||
<GitExecutable Condition="'$(GitExecutable)' == ''">git</GitExecutable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
22
Directory.Build.targets
Normal file
22
Directory.Build.targets
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<Project>
|
||||
<Target Name="VerifyCultLibRevision"
|
||||
BeforeTargets="ResolveProjectReferences"
|
||||
Condition="'$(SkipCultLibRevisionCheck)' != 'true'">
|
||||
<Error Condition="!Exists('$(CultLibRoot)\src\GameCult.Mesh\GameCult.Mesh.csproj')"
|
||||
Text="CultLibRoot '$(CultLibRoot)' does not contain the required GameCult.Mesh project." />
|
||||
<Exec Command=""$(GitExecutable)" -C "$(CultLibRoot)" rev-parse HEAD"
|
||||
ConsoleToMSBuild="true"
|
||||
StandardOutputImportance="Low">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="ActualCultLibRevision" />
|
||||
</Exec>
|
||||
<Error Condition="'$([System.String]::Copy($(ActualCultLibRevision)).Trim())' != '$(CultLibRevision)'"
|
||||
Text="CultLib must be revision $(CultLibRevision); found $([System.String]::Copy($(ActualCultLibRevision)).Trim()). Set CultLibRoot to the admitted clean worktree." />
|
||||
<Exec Command=""$(GitExecutable)" -C "$(CultLibRoot)" status --porcelain --untracked-files=normal"
|
||||
ConsoleToMSBuild="true"
|
||||
StandardOutputImportance="Low">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="CultLibStatus" />
|
||||
</Exec>
|
||||
<Error Condition="'$([System.String]::Copy($(CultLibStatus)).Trim())' != ''"
|
||||
Text="CultLibRoot must be clean before Delvehold consumes it." />
|
||||
</Target>
|
||||
</Project>
|
||||
17
README.md
17
README.md
|
|
@ -21,3 +21,20 @@ The launcher expects `GameCult-Quartz` beside this repository or at `GAMECULT_QU
|
|||
```
|
||||
|
||||
Generated output is written to `quartz-site/public/`.
|
||||
|
||||
## Local runtime
|
||||
|
||||
The first runtime foundation expects the admitted CultLib worktree at
|
||||
`F:\Projects\CultLib-aetheria-authority` and Godot 4.7.2 Mono at
|
||||
`F:\Applications\Godot_v4.7.2-stable_mono_win64`. Build and exercise the
|
||||
Godot-to-host CultMesh path, including a host restart, with:
|
||||
|
||||
```powershell
|
||||
.\scripts\verify-runtime.ps1
|
||||
```
|
||||
|
||||
Open the project in the Godot editor with:
|
||||
|
||||
```powershell
|
||||
.\scripts\open-godot.ps1
|
||||
```
|
||||
|
|
|
|||
12
client/Delvehold.Client.csproj
Normal file
12
client/Delvehold.Client.csproj
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<Project Sdk="Godot.NET.Sdk/4.7.2">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
<AssemblyName>Delvehold.Client</AssemblyName>
|
||||
<RootNamespace>Delvehold.Client</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\src\Delvehold.Protocol\Delvehold.Protocol.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Mesh\GameCult.Mesh.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
160
client/Main.cs
Normal file
160
client/Main.cs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
using Delvehold.Protocol;
|
||||
using GameCult.Mesh;
|
||||
using Godot;
|
||||
|
||||
namespace Delvehold.Client;
|
||||
|
||||
public partial class Main : Control
|
||||
{
|
||||
private Label _mode = null!;
|
||||
private Label _status = null!;
|
||||
private Button _delve = null!;
|
||||
private Button _hold = null!;
|
||||
private CultMeshClient? _mesh;
|
||||
private string _projection = "hold";
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
BuildInterface();
|
||||
_ = EnterWorldAsync();
|
||||
}
|
||||
|
||||
public override void _ExitTree()
|
||||
{
|
||||
_mesh?.Dispose();
|
||||
}
|
||||
|
||||
private void BuildInterface()
|
||||
{
|
||||
var margin = new MarginContainer();
|
||||
margin.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
margin.AddThemeConstantOverride("margin_left", 48);
|
||||
margin.AddThemeConstantOverride("margin_top", 42);
|
||||
margin.AddThemeConstantOverride("margin_right", 48);
|
||||
margin.AddThemeConstantOverride("margin_bottom", 42);
|
||||
AddChild(margin);
|
||||
|
||||
var column = new VBoxContainer();
|
||||
column.AddThemeConstantOverride("separation", 18);
|
||||
margin.AddChild(column);
|
||||
|
||||
var title = new Label { Text = "DELVE / HOLD" };
|
||||
title.AddThemeFontSizeOverride("font_size", 42);
|
||||
column.AddChild(title);
|
||||
|
||||
_mode = new Label { Text = "HOLD projection" };
|
||||
_mode.AddThemeFontSizeOverride("font_size", 24);
|
||||
column.AddChild(_mode);
|
||||
|
||||
var modes = new HBoxContainer();
|
||||
modes.AddThemeConstantOverride("separation", 12);
|
||||
column.AddChild(modes);
|
||||
_delve = new Button { Text = "DELVE", CustomMinimumSize = new Vector2(160, 48) };
|
||||
_hold = new Button { Text = "HOLD", CustomMinimumSize = new Vector2(160, 48), Disabled = true };
|
||||
_delve.Pressed += () => SelectProjection("delve");
|
||||
_hold.Pressed += () => SelectProjection("hold");
|
||||
modes.AddChild(_delve);
|
||||
modes.AddChild(_hold);
|
||||
|
||||
_status = new Label { Text = "Finding the Greathold world host…", AutowrapMode = TextServer.AutowrapMode.WordSmart };
|
||||
column.AddChild(_status);
|
||||
}
|
||||
|
||||
private void SelectProjection(string projection)
|
||||
{
|
||||
_projection = projection;
|
||||
_mode.Text = projection.ToUpperInvariant() + " projection";
|
||||
_delve.Disabled = projection == "delve";
|
||||
_hold.Disabled = projection == "hold";
|
||||
}
|
||||
|
||||
private async Task EnterWorldAsync()
|
||||
{
|
||||
var options = ClientArguments.Parse(OS.GetCmdlineUserArgs());
|
||||
SelectProjection(options.Projection);
|
||||
try
|
||||
{
|
||||
_mesh = new CultMeshClient(new CultMeshClientOptions
|
||||
{
|
||||
RendezvousEndpoints = [options.Endpoint],
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { TransportVersion = "cultmesh.v0" },
|
||||
Sessions = new CultMeshSessionManagerOptions
|
||||
{
|
||||
Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment)
|
||||
},
|
||||
OperationResponseTimeout = TimeSpan.FromSeconds(8)
|
||||
});
|
||||
var receipt = await _mesh.InvokeAsync<WorldEnterIntent, WorldEnterReceipt>(
|
||||
new CultMeshSessionTarget(
|
||||
DelveholdWorldContract.VerseId,
|
||||
DelveholdWorldContract.AuthorityRuntimeId),
|
||||
DelveholdWorldContract.ServiceId,
|
||||
DelveholdWorldContract.EnterOperation,
|
||||
DelveholdWorldContract.EnterIntentSchema,
|
||||
DelveholdWorldContract.EnterReceiptSchema,
|
||||
new WorldEnterIntent
|
||||
{
|
||||
Projection = _projection
|
||||
},
|
||||
options.ClientRuntimeId,
|
||||
options.SmokeId);
|
||||
|
||||
var line = $"{receipt.Status} {receipt.Value.IdempotencyKey} world={receipt.Value.WorldId} revision={receipt.Value.WorldRevision} effective={receipt.Value.EffectiveAtUtc:O}";
|
||||
var admittedAsExpected = receipt.Status == options.ExpectedStatus;
|
||||
CallDeferred(MethodName.ShowResult, line, admittedAsExpected, options.HeadlessSmoke);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
CallDeferred(MethodName.ShowResult, error.Message, false, options.HeadlessSmoke);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowResult(string message, bool succeeded, bool quit)
|
||||
{
|
||||
_status.Text = succeeded ? message : "Connection failed: " + message;
|
||||
GD.Print($"DELVEHOLD_SMOKE {(succeeded ? "PASS" : "FAIL")} {message}");
|
||||
if (quit) GetTree().Quit(succeeded ? 0 : 1);
|
||||
}
|
||||
|
||||
private sealed record ClientArguments(
|
||||
string Endpoint,
|
||||
string ClientRuntimeId,
|
||||
string SmokeId,
|
||||
string Projection,
|
||||
string ExpectedStatus,
|
||||
bool HeadlessSmoke)
|
||||
{
|
||||
public static ClientArguments Parse(string[] args)
|
||||
{
|
||||
var endpoint = "cultnet+tcp://127.0.0.1:4075";
|
||||
var clientId = "delvehold.godot.local";
|
||||
var smokeId = Guid.NewGuid().ToString("N");
|
||||
var projection = "hold";
|
||||
var expectedStatus = "accepted";
|
||||
var headless = false;
|
||||
for (var index = 0; index < args.Length; index++)
|
||||
{
|
||||
switch (args[index])
|
||||
{
|
||||
case "--endpoint" when index + 1 < args.Length:
|
||||
endpoint = args[++index];
|
||||
break;
|
||||
case "--client-id" when index + 1 < args.Length:
|
||||
clientId = args[++index];
|
||||
break;
|
||||
case "--smoke-id" when index + 1 < args.Length:
|
||||
smokeId = args[++index];
|
||||
headless = true;
|
||||
break;
|
||||
case "--projection" when index + 1 < args.Length:
|
||||
projection = args[++index].ToLowerInvariant();
|
||||
break;
|
||||
case "--expect-status" when index + 1 < args.Length:
|
||||
expectedStatus = args[++index].ToLowerInvariant();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new ClientArguments(endpoint, clientId, smokeId, projection, expectedStatus, headless);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
client/Main.cs.uid
Normal file
1
client/Main.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dgm3vnnuux4iy
|
||||
12
client/Main.tscn
Normal file
12
client/Main.tscn
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource path="res://Main.cs" type="Script" id="1"]
|
||||
|
||||
[node name="Delvehold" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1")
|
||||
33
client/project.godot
Normal file
33
client/project.godot
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
; Engine configuration file.
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; since the parameters that go here are not all obvious.
|
||||
;
|
||||
; Format:
|
||||
; [section] ; section goes between []
|
||||
; param=value ; assign values to parameters
|
||||
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="DELVE/HOLD"
|
||||
run/main_scene="res://Main.tscn"
|
||||
config/features=PackedStringArray("4.7", "C#")
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1280
|
||||
window/size/viewport_height=720
|
||||
window/size/window_width_override=1280
|
||||
window/size/window_height_override=720
|
||||
window/stretch/mode="canvas_items"
|
||||
|
||||
[dotnet]
|
||||
|
||||
project/assembly_name="Delvehold.Client"
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
renderer/rendering_method.mobile="gl_compatibility"
|
||||
environment/defaults/default_clear_color=Color(0.035, 0.027, 0.024, 1)
|
||||
13
scripts/open-godot.ps1
Normal file
13
scripts/open-godot.ps1
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$GodotRoot = 'F:\Applications\Godot_v4.7.2-stable_mono_win64',
|
||||
[string]$CultLibRoot = 'F:\Projects\CultLib-aetheria-authority'
|
||||
)
|
||||
|
||||
$godot = Join-Path $GodotRoot 'Godot_v4.7.2-stable_mono_win64.exe'
|
||||
if (-not (Test-Path -LiteralPath $godot)) {
|
||||
throw "Godot 4.7.2 Mono was not found at '$godot'."
|
||||
}
|
||||
|
||||
$env:CULTLIB_ROOT = $CultLibRoot
|
||||
Start-Process -FilePath $godot -ArgumentList @('--editor', '--path', (Join-Path $PSScriptRoot '..\client'))
|
||||
90
scripts/verify-runtime.ps1
Normal file
90
scripts/verify-runtime.ps1
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$CultLibRoot = 'F:\Projects\CultLib-aetheria-authority',
|
||||
[string]$GodotRoot = 'F:\Applications\Godot_v4.7.2-stable_mono_win64'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = Split-Path $PSScriptRoot -Parent
|
||||
$godot = Join-Path $GodotRoot 'Godot_v4.7.2-stable_mono_win64_console.exe'
|
||||
if (-not (Test-Path -LiteralPath $godot)) {
|
||||
throw "Godot 4.7.2 Mono console was not found at '$godot'."
|
||||
}
|
||||
|
||||
dotnet build (Join-Path $repoRoot 'Delvehold.slnx') -p:CultLibRoot=$CultLibRoot
|
||||
if ($LASTEXITCODE -ne 0) { throw 'The focused .NET/Godot build failed.' }
|
||||
|
||||
$smokeRoot = Join-Path $repoRoot ('artifacts\runtime-smoke\' + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $smokeRoot -Force | Out-Null
|
||||
$statePath = Join-Path $smokeRoot 'world.cc'
|
||||
$port = Get-Random -Minimum 41000 -Maximum 49000
|
||||
$endpoint = "cultnet+tcp://127.0.0.1:$port"
|
||||
$smokeId = 'runtime-smoke-enter-v0'
|
||||
$clientId = 'delvehold.godot.smoke'
|
||||
$hostProject = Join-Path $repoRoot 'src\Delvehold.WorldHost\Delvehold.WorldHost.csproj'
|
||||
$clientProject = Join-Path $repoRoot 'client'
|
||||
|
||||
function Start-WorldHost([string]$suffix) {
|
||||
$stdout = Join-Path $smokeRoot "host-$suffix.out.log"
|
||||
$stderr = Join-Path $smokeRoot "host-$suffix.err.log"
|
||||
$process = Start-Process -FilePath 'dotnet' -PassThru -WindowStyle Hidden `
|
||||
-RedirectStandardOutput $stdout -RedirectStandardError $stderr `
|
||||
-ArgumentList @('run', '--no-build', '--project', $hostProject, '-p:CultLibRoot=' + $CultLibRoot, '--', '--state', $statePath, '--port', $port)
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(20)
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
if ($process.HasExited) {
|
||||
throw "World host exited before readiness. See '$stderr'."
|
||||
}
|
||||
if ((Test-Path -LiteralPath $stdout) -and (Select-String -Path $stdout -Pattern 'DELVEHOLD_HOST_READY' -Quiet)) {
|
||||
return $process
|
||||
}
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||
throw "World host did not become ready. See '$stdout' and '$stderr'."
|
||||
}
|
||||
|
||||
function Invoke-GodotSmoke([string]$suffix, [string]$projection, [string]$expectedStatus, [string]$intentId = $smokeId) {
|
||||
$log = Join-Path $smokeRoot "godot-$suffix.log"
|
||||
& $godot --headless --path $clientProject --editor --quit-after 1 2>&1 | Set-Content -Path $log
|
||||
if ($LASTEXITCODE -ne 0) { throw "Godot import/build failed. See '$log'." }
|
||||
$runtimeOutput = & $godot --headless --path $clientProject -- --endpoint $endpoint --client-id $clientId --smoke-id $intentId --projection $projection --expect-status $expectedStatus 2>&1
|
||||
$runtimeOutput | Tee-Object -FilePath $log -Append | Write-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "Godot runtime smoke failed. See '$log'." }
|
||||
if (-not (Select-String -Path $log -Pattern "DELVEHOLD_SMOKE PASS $expectedStatus $intentId" -Quiet)) {
|
||||
throw "Godot did not report the expected '$expectedStatus' typed receipt. See '$log'."
|
||||
}
|
||||
return (Select-String -Path $log -Pattern "DELVEHOLD_SMOKE PASS $expectedStatus $intentId" | Select-Object -Last 1).Line
|
||||
}
|
||||
|
||||
$forbiddenClientWriters = Select-String -Path (Join-Path $repoRoot 'client\*.cs') `
|
||||
-Pattern 'CultMesh\.CreateNodeAsync|\.Database\.PutAsync|\.Cache\.UpsertAsync'
|
||||
if ($forbiddenClientWriters) {
|
||||
throw 'The Godot client contains a canonical CultCache/CultMesh write path.'
|
||||
}
|
||||
|
||||
$hostProcess = $null
|
||||
try {
|
||||
$hostProcess = Start-WorldHost 'first'
|
||||
$firstReceipt = Invoke-GodotSmoke 'first' 'hold' 'accepted'
|
||||
$null = Invoke-GodotSmoke 'collision' 'delve' 'rejected'
|
||||
$null = Invoke-GodotSmoke 'invalid-projection' 'banana' 'rejected' 'runtime-smoke-invalid-v0'
|
||||
Stop-Process -Id $hostProcess.Id -Force
|
||||
$hostProcess.WaitForExit()
|
||||
$hostProcess = Start-WorldHost 'restart'
|
||||
$restartReceipt = Invoke-GodotSmoke 'restart' 'hold' 'accepted'
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $hostProcess -and -not $hostProcess.HasExited) {
|
||||
Stop-Process -Id $hostProcess.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $statePath)) {
|
||||
throw 'The host did not persist its CultCache state.'
|
||||
}
|
||||
if ($firstReceipt -ne $restartReceipt) {
|
||||
throw "The restarted host did not return the original persisted receipt.`nFirst: $firstReceipt`nRestart: $restartReceipt"
|
||||
}
|
||||
|
||||
Write-Host "DELVEHOLD_RUNTIME_VERIFIED state=$statePath receipt=$smokeId"
|
||||
15
src/Delvehold.Protocol/Delvehold.Protocol.csproj
Normal file
15
src/Delvehold.Protocol/Delvehold.Protocol.csproj
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Delvehold.Protocol</AssemblyName>
|
||||
<RootNamespace>Delvehold.Protocol</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
33
src/Delvehold.Protocol/WorldContracts.cs
Normal file
33
src/Delvehold.Protocol/WorldContracts.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Delvehold.Protocol;
|
||||
|
||||
public static class DelveholdWorldContract
|
||||
{
|
||||
public const string VerseId = "delvehold.shared-world";
|
||||
public const string AuthorityRuntimeId = "delvehold.world-host";
|
||||
public const string ServiceId = "delvehold.world";
|
||||
public const string EnterOperation = "enter";
|
||||
public const string EnterIntentSchema = "delvehold.world_enter_intent.v0";
|
||||
public const string EnterReceiptSchema = "delvehold.world_enter_receipt.v0";
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WorldEnterIntent
|
||||
{
|
||||
[Key(0)] public string Projection { get; set; } = "hold";
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
[CultDocument("delvehold.world_enter_receipt", DelveholdWorldContract.EnterReceiptSchema)]
|
||||
public sealed class WorldEnterReceipt
|
||||
{
|
||||
[Key(0), CultName] public string IdempotencyKey { get; set; } = "";
|
||||
[Key(1)] public string ClientRuntimeId { get; set; } = "";
|
||||
[Key(2)] public string Projection { get; set; } = "hold";
|
||||
[Key(3)] public long WorldRevision { get; set; }
|
||||
[Key(4)] public string WorldId { get; set; } = "";
|
||||
[Key(5)] public string Message { get; set; } = "";
|
||||
[Key(6)] public DateTime EffectiveAtUtc { get; set; }
|
||||
}
|
||||
17
src/Delvehold.WorldHost/Delvehold.WorldHost.csproj
Normal file
17
src/Delvehold.WorldHost/Delvehold.WorldHost.csproj
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>Delvehold.WorldHost</AssemblyName>
|
||||
<RootNamespace>Delvehold.WorldHost</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Delvehold.Protocol\Delvehold.Protocol.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Mesh\GameCult.Mesh.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
170
src/Delvehold.WorldHost/Program.cs
Normal file
170
src/Delvehold.WorldHost/Program.cs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Delvehold.Protocol;
|
||||
using Delvehold.WorldHost;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using GameCult.Mesh;
|
||||
using GameCult.Networking;
|
||||
|
||||
const string routeGeneration = "delvehold-local-v1";
|
||||
var settings = HostSettings.Parse(args);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(settings.StatePath))!);
|
||||
|
||||
var documentTypes = new[] { typeof(DelveholdWorldState), typeof(WorldEnterReceipt) };
|
||||
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(documentTypes);
|
||||
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(documentTypes, cacheRegistry);
|
||||
using var node = await CultMesh.CreateNodeAsync(settings.StatePath, new CultMeshNodeOptions
|
||||
{
|
||||
StartServer = false,
|
||||
CacheOptions = new CultCacheOpenOptions
|
||||
{
|
||||
Registry = cacheRegistry,
|
||||
FlushOnDispose = true,
|
||||
StoreFlushOnDispose = true
|
||||
},
|
||||
DatabaseOptions = new CultNetDatabaseOptions
|
||||
{
|
||||
RuntimeId = DelveholdWorldContract.AuthorityRuntimeId,
|
||||
DocumentRegistry = networkRegistry
|
||||
}
|
||||
});
|
||||
|
||||
var worldKey = new CultRecordKey(WorldStateContract.RecordKey);
|
||||
if (node.Cache.Get<DelveholdWorldState>(worldKey) is null)
|
||||
await node.Database.PutAsync(worldKey, DelveholdWorldState.FirstWorld());
|
||||
await node.FlushAsync();
|
||||
|
||||
using var server = new TcpFramedCultNetSchemaServer(
|
||||
new TcpListener(IPAddress.Loopback, settings.Port));
|
||||
var endpoint = $"cultnet+tcp://127.0.0.1:{server.LocalEndPoint.Port}";
|
||||
using var identity = new CultMeshSessionIdentityServer(
|
||||
server,
|
||||
DelveholdWorldContract.AuthorityRuntimeId,
|
||||
[DelveholdWorldContract.VerseId],
|
||||
[CultMeshProtocols.Documents.Value],
|
||||
[routeGeneration]);
|
||||
|
||||
using var catalog = new CultMeshVerseCatalog();
|
||||
catalog.Upsert(new CultMeshVerseDescriptor(
|
||||
DelveholdWorldContract.VerseId,
|
||||
"DELVE/HOLD shared world",
|
||||
CultMeshVerseAuthorityModel.OperatorCluster,
|
||||
new CultMeshVerseCompatibility("cultmesh.v0", "delvehold-local-rules-v0"),
|
||||
authorityRoutes:
|
||||
[
|
||||
new CultMeshAuthorityRoute(
|
||||
DelveholdWorldContract.AuthorityRuntimeId,
|
||||
endpoint,
|
||||
[CultMeshProtocols.Documents.Value],
|
||||
priority: 10,
|
||||
generation: routeGeneration)
|
||||
],
|
||||
description: "The authoritative DELVE/HOLD world host."));
|
||||
server.OnCultNet<CultMeshVerseCatalogRequestMessage>((request, peer) =>
|
||||
{
|
||||
peer.SendCultNet(CultMeshVerseMessages.CreateCatalogResponse(catalog, request));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
using var operationGate = new SemaphoreSlim(1, 1);
|
||||
using var operations = new CultNetOperationServer(server, DelveholdWorldContract.AuthorityRuntimeId)
|
||||
.Register<WorldEnterIntent, WorldEnterReceipt>(
|
||||
DelveholdWorldContract.ServiceId,
|
||||
DelveholdWorldContract.EnterOperation,
|
||||
DelveholdWorldContract.EnterIntentSchema,
|
||||
DelveholdWorldContract.EnterReceiptSchema,
|
||||
async context =>
|
||||
{
|
||||
await operationGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var sourceRuntimeId = context.SourceRuntimeId?.Trim() ?? "";
|
||||
var projection = context.Value.Projection?.Trim().ToLowerInvariant() ?? "";
|
||||
if (sourceRuntimeId.Length == 0 || (projection != "delve" && projection != "hold"))
|
||||
{
|
||||
var invalidWorld = node.Cache.Get<DelveholdWorldState>(worldKey)
|
||||
?? throw new InvalidOperationException("Canonical world state is unavailable.");
|
||||
return CultNetOperationReply<WorldEnterReceipt>.Rejected(
|
||||
new WorldEnterReceipt
|
||||
{
|
||||
IdempotencyKey = context.IdempotencyKey,
|
||||
ClientRuntimeId = sourceRuntimeId,
|
||||
Projection = projection,
|
||||
WorldRevision = invalidWorld.Revision,
|
||||
WorldId = invalidWorld.WorldId,
|
||||
Message = "World entry requires a caller identity and the DELVE or HOLD projection.",
|
||||
EffectiveAtUtc = DateTime.UtcNow
|
||||
},
|
||||
"invalid-world-entry");
|
||||
}
|
||||
|
||||
var receiptKey = new CultRecordKey("delvehold:receipt:" + context.IdempotencyKey);
|
||||
var existing = node.Cache.Get<WorldEnterReceipt>(receiptKey);
|
||||
if (existing is not null)
|
||||
{
|
||||
var matches = existing.ClientRuntimeId == sourceRuntimeId &&
|
||||
existing.Projection == projection;
|
||||
return matches
|
||||
? CultNetOperationReply<WorldEnterReceipt>.Accepted(existing)
|
||||
: CultNetOperationReply<WorldEnterReceipt>.Rejected(
|
||||
existing,
|
||||
"idempotency-key-collision");
|
||||
}
|
||||
|
||||
var world = node.Cache.Get<DelveholdWorldState>(worldKey)
|
||||
?? throw new InvalidOperationException("Canonical world state is unavailable.");
|
||||
var receipt = new WorldEnterReceipt
|
||||
{
|
||||
IdempotencyKey = context.IdempotencyKey,
|
||||
ClientRuntimeId = sourceRuntimeId,
|
||||
Projection = projection,
|
||||
WorldRevision = world.Revision,
|
||||
WorldId = world.WorldId,
|
||||
Message = $"Entered {world.WorldId} through the {projection.ToUpperInvariant()} projection.",
|
||||
EffectiveAtUtc = DateTime.UtcNow
|
||||
};
|
||||
await node.Database.PutAsync(receiptKey, receipt);
|
||||
await node.FlushAsync();
|
||||
return CultNetOperationReply<WorldEnterReceipt>.Accepted(receipt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
operationGate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
server.PeerFailed += (peer, error) => Console.Error.WriteLine($"DELVEHOLD_PEER_FAILED {peer} {error.Message}");
|
||||
Console.WriteLine($"DELVEHOLD_HOST_READY {endpoint}");
|
||||
|
||||
var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
stopped.TrySetResult();
|
||||
};
|
||||
await Task.WhenAny(stopped.Task, server.BackgroundFailure);
|
||||
if (server.BackgroundFailure.IsCompleted)
|
||||
throw await server.BackgroundFailure;
|
||||
|
||||
internal sealed record HostSettings(string StatePath, int Port)
|
||||
{
|
||||
public static HostSettings Parse(string[] args)
|
||||
{
|
||||
var state = Path.Combine("artifacts", "host", "delvehold.cc");
|
||||
var port = 4075;
|
||||
for (var index = 0; index < args.Length; index++)
|
||||
{
|
||||
switch (args[index])
|
||||
{
|
||||
case "--state" when index + 1 < args.Length:
|
||||
state = args[++index];
|
||||
break;
|
||||
case "--port" when index + 1 < args.Length:
|
||||
port = int.Parse(args[++index]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new HostSettings(state, port);
|
||||
}
|
||||
}
|
||||
34
src/Delvehold.WorldHost/WorldState.cs
Normal file
34
src/Delvehold.WorldHost/WorldState.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Delvehold.WorldHost;
|
||||
|
||||
internal static class WorldStateContract
|
||||
{
|
||||
public const string Schema = "delvehold.world_state.v0";
|
||||
public const string RecordKey = "delvehold:world:canonical";
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
[CultDocument("delvehold.world_state", WorldStateContract.Schema)]
|
||||
public sealed class DelveholdWorldState
|
||||
{
|
||||
[Key(0), CultName] public string WorldId { get; set; } = "";
|
||||
[Key(1)] public long Revision { get; set; }
|
||||
[Key(2)] public string WorkshopId { get; set; } = "";
|
||||
[Key(3)] public string CommonsId { get; set; } = "";
|
||||
[Key(4)] public string DungeonCoreId { get; set; } = "";
|
||||
[Key(5)] public string ContractId { get; set; } = "";
|
||||
[Key(6)] public string MaterialId { get; set; } = "";
|
||||
|
||||
public static DelveholdWorldState FirstWorld() => new()
|
||||
{
|
||||
WorldId = "greathold-first-world",
|
||||
Revision = 1,
|
||||
WorkshopId = "workshop-emberbench",
|
||||
CommonsId = "commons-deepmarket",
|
||||
DungeonCoreId = "core-cinderwake",
|
||||
ContractId = "contract-first-light",
|
||||
MaterialId = "material-wardstone"
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue