Merge origin/main (CultLib 2.0.1) into the C2a landing branch
CHANGELOG: C2a's Unreleased above main's [2.0.1], verbatim. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
5baa02fa54
20 changed files with 231 additions and 6 deletions
27
README.md
27
README.md
|
|
@ -30,6 +30,30 @@ bespoke JSON bridge at every boundary. A frontend is just another CultMesh
|
|||
client: the daemon publishes a typed Eve/CultUI surface, and any compatible
|
||||
runtime can lower that surface where it needs to live.
|
||||
|
||||
## Entry Points
|
||||
|
||||
- C# reference runtime: `src/` (`GameCult.Caching`, `GameCult.Networking`,
|
||||
`GameCult.Mesh`, `GameCult.Geometry`, `GameCult.Unity`). Build and test with
|
||||
`dotnet build CultLib.sln` and `dotnet test CultLib.sln`.
|
||||
- Other runtimes, wire-compatible with the C# CultCache: `packages/cultcache-ts`,
|
||||
`cultcache-py`, `cultcache-rs`, and the CultNet and CultMesh packages beside
|
||||
them under `packages/`; portable math in `packages/cultmath`.
|
||||
- Smallest working path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`.
|
||||
Application path: `src/GameCult.Mesh/docs/getting-started/README.md` and
|
||||
`samples/eve-two-runtime`.
|
||||
|
||||
## Live And Planned
|
||||
|
||||
Live: typed CultCache persistence, CultNet schema-v0 transports and native RUDP,
|
||||
CultMesh nodes with Verse discovery, shard replication and authority leases,
|
||||
the geometry documents, and CultUI for Unity. The browser client boundary
|
||||
(`packages/cultmesh-browser`, `GameCult.Networking.WebSockets`) is proven on
|
||||
loopback under an explicit `local-development` trust policy by
|
||||
`samples/eve-browser-network`.
|
||||
|
||||
Planned: a complete public Verse proof over deployed infrastructure; the
|
||||
remaining gates are listed in `src/GameCult.Mesh/docs/browser-verse-transport.md`.
|
||||
|
||||
## Which Package Do I Want?
|
||||
|
||||
If the job is shared game state, start here before inventing a fourth drawer
|
||||
|
|
@ -97,7 +121,6 @@ The solution includes:
|
|||
- `GameCult.Logging`: common logging abstraction plus console and file implementations
|
||||
- `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.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
|
||||
|
|
@ -125,7 +148,6 @@ src/
|
|||
GameCult.Logging/
|
||||
GameCult.Caching/
|
||||
GameCult.Caching.MessagePack/
|
||||
GameCult.Caching.NewtonsoftJson/
|
||||
GameCult.Geometry/
|
||||
GameCult.Networking/
|
||||
GameCult.Networking.WebSockets/
|
||||
|
|
@ -344,7 +366,6 @@ Each subproject has a local README with package-specific detail:
|
|||
- [GameCult.Logging](src/GameCult.Logging/README.md)
|
||||
- [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.Geometry](src/GameCult.Geometry/README.md)
|
||||
- [GameCult.Networking](src/GameCult.Networking/README.md)
|
||||
- [GameCult.Mesh](src/GameCult.Mesh/README.md)
|
||||
|
|
|
|||
|
|
@ -1087,7 +1087,22 @@ namespace GameCult.Caching
|
|||
errors.Add($"slot {localMember.Slot} changed type '{persistedMember.TypeName}' -> '{localMember.TypeName}'");
|
||||
}
|
||||
|
||||
if (localMember.IsReference != persistedMember.IsReference)
|
||||
// 1.0.x persisted a CultRecordRef<T> collection as a plain value slot (not a reference, no target); the
|
||||
// payload is the same array of keys. Gaining the reference classification on an otherwise identical slot
|
||||
// is compatible drift. Losing it, or any other change of reference or target, is not.
|
||||
var gainedReference = !persistedMember.IsReference
|
||||
&& persistedMember.TargetSchemaName == null
|
||||
&& localMember.IsReference
|
||||
&& localMember.TargetSchemaName != null;
|
||||
if (gainedReference)
|
||||
{
|
||||
warnings.Add(new CultSchemaMigrationWarning
|
||||
{
|
||||
Code = "gained_reference_semantics",
|
||||
Message = $"Local slot {localMember.Slot} ('{localMember.Name}') is a reference to '{localMember.TargetSchemaName}'; the persisted schema stored it as a plain value with the same payload."
|
||||
});
|
||||
}
|
||||
else if (localMember.IsReference != persistedMember.IsReference)
|
||||
{
|
||||
errors.Add($"slot {localMember.Slot} changed reference semantics");
|
||||
}
|
||||
|
|
@ -1097,7 +1112,7 @@ namespace GameCult.Caching
|
|||
errors.Add($"slot {localMember.Slot} changed cardinality");
|
||||
}
|
||||
|
||||
if (!string.Equals(localMember.TargetSchemaName, persistedMember.TargetSchemaName, StringComparison.Ordinal))
|
||||
if (!gainedReference && !string.Equals(localMember.TargetSchemaName, persistedMember.TargetSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add($"slot {localMember.Slot} changed target schema '{persistedMember.TargetSchemaName ?? "<none>"}' -> '{localMember.TargetSchemaName ?? "<none>"}'");
|
||||
}
|
||||
|
|
|
|||
169
tests/GameCult.Caching.Tests/RefListDriftTests.cs
Normal file
169
tests/GameCult.Caching.Tests/RefListDriftTests.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// 1.0.x persisted a list of record references (CultRecordRef<T>[]) as a plain value slot: isReference false, no target.
|
||||
// 2.0 classifies the same slot as a reference to T. The payload is the same array of key strings, so a store written by
|
||||
// 1.0.x opens: gaining reference classification on an otherwise identical slot is compatible drift. A reference that is
|
||||
// lost, retargeted, or that arrives with a different type or cardinality stays a refusal.
|
||||
public class RefListDriftTests
|
||||
{
|
||||
[CultDocument("tests.reflist_target", "tests.reflist_target.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class RefListTarget
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name = string.Empty;
|
||||
}
|
||||
|
||||
[CultDocument("tests.reflist_holder", "tests.reflist_holder.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class RefListHolder
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
public CultRecordRef<RefListTarget>[] Items = Array.Empty<CultRecordRef<RefListTarget>>();
|
||||
}
|
||||
|
||||
// The reverse of the 1.0.x drift: a slot persisted as a reference list, declared by a later version as a plain value.
|
||||
[CultDocument("tests.reflist_loss", "tests.reflist_loss.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class RefListLossBefore
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
public CultRecordRef<RefListTarget>[] Items = Array.Empty<CultRecordRef<RefListTarget>>();
|
||||
}
|
||||
|
||||
[CultDocument("tests.reflist_loss", "tests.reflist_loss.v2")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class RefListLossAfter
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
public string[] Items = Array.Empty<string>();
|
||||
}
|
||||
|
||||
private static string VectorPath(string name)
|
||||
{
|
||||
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
|
||||
{
|
||||
var vectors = Path.Combine(dir.FullName, "tests", "vectors", "ref-list-drift");
|
||||
if (Directory.Exists(vectors)) return Path.Combine(vectors, name);
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException($"tests/vectors/ref-list-drift not found above {TestContext.CurrentContext.TestDirectory}.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_1_0_Store_With_A_Ref_List_Slot_Opens_And_Rewrites_Under_The_Reference_Schema()
|
||||
{
|
||||
var copy = Path.Combine(Path.GetTempPath(), $"reflist-drift-{Guid.NewGuid():N}.cc");
|
||||
File.Copy(VectorPath("old-store.cc"), copy);
|
||||
try
|
||||
{
|
||||
using var cache = new CultCache();
|
||||
var store = new SingleFileMessagePackBackingStore(copy);
|
||||
cache.AddBackingStore(store);
|
||||
await cache.PullAllBackingStoresAsync();
|
||||
Assert.That(
|
||||
store.LastSchemaMigrationReports.Single(report => report.PersistedSchemaName == "tests.reflist_holder").Kind,
|
||||
Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift));
|
||||
|
||||
var holder = cache.GetAll<RefListHolder>().Single();
|
||||
Assert.That(holder.Items.Select(item => item.Key.Value), Is.EqualTo(new[] { "alpha", "beta" }));
|
||||
Assert.That(cache.Get<RefListTarget>(holder.Items[0].Key)!.Name, Is.EqualTo("alpha"));
|
||||
|
||||
// A write flushes the store under the 2.0 schema; the list reads back unchanged.
|
||||
await cache.AddAsync(new RefListTarget { Name = "gamma" });
|
||||
store.PushAll();
|
||||
using var reopened = new CultCache();
|
||||
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy));
|
||||
await reopened.PullAllBackingStoresAsync();
|
||||
Assert.That(string.Join(",", reopened.GetAll<RefListTarget>().Select(target => target.Name).OrderBy(name => name)), Is.EqualTo("alpha,beta,gamma"));
|
||||
Assert.That(reopened.GetAll<RefListHolder>().Single().Items.Select(item => item.Key.Value).ToArray(), Is.EqualTo(new[] { "alpha", "beta" }));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(copy);
|
||||
}
|
||||
}
|
||||
|
||||
private static CultSchemaMigrationReport Resolve(Action<CultSchemaMemberCatalogEntry> persistedItems)
|
||||
{
|
||||
var catalog = CultDocumentRegistry.Shared.GetRequired<RefListHolder>().ToCatalogEntry();
|
||||
catalog.SchemaId = "persisted.tests.reflist_holder.v1";
|
||||
catalog.ContentHash = "sha256:persisted-by-1.0.x";
|
||||
catalog.CompatibleSchemaIds = new[] { CultDocumentRegistry.Shared.GetRequired<RefListHolder>().SchemaId };
|
||||
persistedItems(catalog.Members.Single(member => member.Slot == 1));
|
||||
return CultDocumentRegistry.Shared.ResolvePersistedSchemaReport(catalog.SchemaId, new[] { catalog });
|
||||
}
|
||||
|
||||
private static void AsWrittenBy10(CultSchemaMemberCatalogEntry member)
|
||||
{
|
||||
member.IsReference = false;
|
||||
member.TargetSchemaName = null;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_Reference_Gained_On_An_Otherwise_Identical_Slot_Is_Compatible_Drift()
|
||||
{
|
||||
var report = Resolve(AsWrittenBy10);
|
||||
Assert.That(report.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift));
|
||||
Assert.That(report.Warnings.Select(warning => warning.Code), Does.Contain("gained_reference_semantics"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_Persisted_Reference_Retargeted_Or_Gaining_Over_A_Foreign_Target_Still_Refuses()
|
||||
{
|
||||
Assert.That(
|
||||
() => Resolve(member => member.TargetSchemaName = "tests.other_target"),
|
||||
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed target schema"));
|
||||
Assert.That(
|
||||
() => Resolve(member => { member.IsReference = false; member.TargetSchemaName = "tests.other_target"; }),
|
||||
Throws.TypeOf<InvalidOperationException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_Lost_Reference_Still_Refuses()
|
||||
{
|
||||
var registry = CultDocumentRegistry.Shared;
|
||||
var catalog = registry.GetRequired<RefListLossBefore>().ToCatalogEntry();
|
||||
catalog.SchemaId = "persisted.tests.reflist_loss.v1";
|
||||
catalog.ContentHash = "sha256:persisted-by-2.0";
|
||||
catalog.CompatibleSchemaIds = new[] { registry.GetRequired<RefListLossAfter>().SchemaId };
|
||||
catalog.Members.Single(member => member.Slot == 1).TypeName = "System.String[]";
|
||||
Assert.That(
|
||||
() => registry.ResolvePersistedSchemaReport(catalog.SchemaId, new[] { catalog }),
|
||||
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed reference semantics"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_Gained_Reference_Does_Not_Excuse_A_Changed_Type_Or_Cardinality()
|
||||
{
|
||||
Assert.That(
|
||||
() => Resolve(member => { AsWrittenBy10(member); member.TypeName = "System.String[]"; }),
|
||||
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed type"));
|
||||
Assert.That(
|
||||
() => Resolve(member => { AsWrittenBy10(member); member.IsMany = !member.IsMany; }),
|
||||
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed cardinality"));
|
||||
}
|
||||
}
|
||||
}
|
||||
10
tests/vectors/ref-list-drift/README.md
Normal file
10
tests/vectors/ref-list-drift/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# ref-list-drift vector
|
||||
|
||||
`old-store.cc` is a `cultcache.store.v1` file in the shape CultLib 1.0.x wrote for a list of record references: the
|
||||
`Items` slot (`CultRecordRef<RefListTarget>[]`) is persisted with `isReference:false` and no target schema, and its
|
||||
payload is an array of key strings. Its layout is copied from a real 1.0.x-written store (Aetheria `run.cc`, schema
|
||||
`aetheria.savedgame`, slots `Zones` and `Factions`), pruned to two schemas and three records: `alpha` and `beta`
|
||||
(`tests.reflist_target`) and `list` (`tests.reflist_holder`, `Items = [alpha, beta]`).
|
||||
|
||||
Hand-written by a script outside the runtime under test; never regenerated by CultCache. A change to this file is a
|
||||
reviewed commit.
|
||||
BIN
tests/vectors/ref-list-drift/old-store.cc
Normal file
BIN
tests/vectors/ref-list-drift/old-store.cc
Normal file
Binary file not shown.
|
|
@ -9,6 +9,16 @@ All notable changes to this package are documented in this file.
|
|||
- A single-file store flush refuses a file it cannot read (empty, truncated, an unreadable stamp or body) with `CultStoreUnreadableException` and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again.
|
||||
- A single-file store reads v1, v2 and v3 stamps and refuses v4 and later by name; a directory store reads v4 and v5 and refuses v6 and later by name. Each judges the stamp before the body, so an unreadable body never makes a newer store look like damage.
|
||||
|
||||
## [2.0.1] - 2026-10-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- A store written by 1.0.x with a list of record references (`CultRecordRef<T>[]` or any `IEnumerable<CultRecordRef<T>>` slot)
|
||||
opens again. 2.0.0 classified such a slot as a reference to `T`, 1.0.x had persisted it as a plain value, and the schema
|
||||
comparison refused the store with "changed reference semantics" and "changed target schema". The payload is the same array
|
||||
of keys, so gaining the reference classification on an otherwise identical slot is now compatible drift (migration warning
|
||||
`gained_reference_semantics`). A reference that is lost or retargeted, and any change of type or cardinality, still refuses.
|
||||
|
||||
## [2.0.0] - 2026-10-22
|
||||
|
||||
### Breaking
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "org.gamecult.cultlib",
|
||||
"displayName": "CultLib",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"unity": "2021.3",
|
||||
"dependencies": {
|
||||
"org.gamecult.cultmath": "0.5.0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue