Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
3dadfc6349 GameCult.Thing.Contract.Tests: the empty-payload tests use the MessagePack options RecordPayload is not a document
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:56:34 +02:00
Meta Vens
e25311a0c0 GameCult.Thing.Contract: every empty memory is an empty RecordPayload; wire nil is refused on read
Present(ReadOnlyMemory<byte>) is deleted. The constructor copies any view, default
included, as an empty payload; a nil on the wire (which decodes to a default view) is
noted by the constructor and refused in OnAfterDeserialize, so the nil-positions rows hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:53:40 +02:00
Meta Vens
686b32831b GameCult.Thing.Contract: Present refuses the default byte view, not every unbacked empty one
MemoryMarshal.TryGetArray answers true for any empty view, so it cannot tell nil
from empty; equality with default can. The commit before this fails the new payload test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:31:52 +02:00
Meta Vens
9d8f5706ad GameCult.Thing.Contract: a nil payload is still refused after Bytes became ReadOnlyMemory<byte>
The nil-positions vector found the regression (invocation-all-inputs 4.1: the
view decodes nil to default and was accepted though Rust refuses). ThingDomain.Present
for byte views refuses an empty view with no backing store; an empty array stays a
present, empty payload. Test pins both and the empty payload's wire round trip.
The commits before this one fail NilPositionTests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:27:37 +02:00
Meta Vens
f3e3198df9 GameCult.Thing.Contract.Tests: NameSetTests names the MessagePack caching namespace
The reopen test from a9f8b2c did not build without it; this restores the test build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:23:39 +02:00
Meta Vens
19f8d9aeff GameCult.Thing.Contract: Command.cs names System for ReadOnlyMemory<byte>
The previous two commits do not build: Command.cs lacked the using. This one
restores the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:19:32 +02:00
Meta Vens
a9f8b2c242 GameCult.Thing.Contract.Tests: a store with patched type name, id and hash is refused on reopen
Replaces the false comment that reopening never compares type names with the
end-to-end pin: id, content hash and a member type name moved together are
refused; the same patch without the type name opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:17:03 +02:00
Meta Vens
4f87e5c5bf GameCult.Thing.Contract: RecordPayload.Bytes is a copied ReadOnlyMemory<byte>; array arguments and decoded lists pinned
Under thing2:ruling:payload-bytes-read-only the payload holds a copy made at
construction (wire bytes unchanged). New tests: a T[] argument (including
through Builders.Boxed) is copied; no list of any decoded golden is writable;
a caller's buffer written after construction does not change the payload. The
byte[] exemption in the mutable-collection scan is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:17:03 +02:00
Meta Vens
9405c7a595 GameCult.Thing.Contract.Tests: the persisted-catalog entries in NameSetTests move the content hash too
An entry equal to the local one in content hash resolves as Exact without comparing names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:55:40 +02:00
Meta Vens
963900cc0d GameCult.Thing.Contract.Tests: NameSetTests exercises CultCache's schema comparison on the persisted catalog
Reopening a single-file store at CultLib b3bd8dd5 does not compare recorded type names (a store
patched in id and Int64 opens and reads), so the test hands ResolvePersistedSchema a persisted
catalog entry with one member type changed (and one with Assets as a List): both are refused,
and the same entry with a moved id and unchanged names resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:44:48 +02:00
Meta Vens
1495952e53 GameCult.Thing.Contract.Tests: the reopen test moves the schema id with the type name; goldens README keeps its line endings
A store whose id still matches the local registry is not compared, so the patched store changes
the id and Int64 together, as a real shape change does. Presence threshold set to the measured
174 required slots. goldens/README.md is the base file plus the nil-vector section (the earlier
commit rewrote its line endings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:37:45 +02:00
Meta Vens
9586392d14 GameCult.Thing.Contract: the name set (tests/thing-contract-parity) and NameSetTests
catalog-members.tsv (27 rows) and type-names.txt (168 types) are generated from the C# types on
Yggdrasil at CultLib b3bd8dd5 and read by NameSetTests; the package owns the names, every other
runtime is held to them. NameSetTests also patches System.Int64 to System.Int32 in a store C#
wrote and requires reopen to refuse. Presence and list tests no longer count ThingDomainException
as a door.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:27:31 +02:00
Meta Vens
fe376ce9bd GameCult.Thing.Contract.Tests: Builders types its two unit-arm arrays (the test project did not compile without it)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:22:03 +02:00
Meta Vens
b8e8abd981 GameCult.Thing.Contract: ParamDecl.Kind keeps its name (a rename sed hit its assignment)
The two previous GameCult.Thing.Contract commits do not build: ParamDecl's constructor assigned
InputKind. This restores Kind = Present(kind, "Kind").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:21:08 +02:00
Meta Vens
a6a7a97634 GameCult.Thing.Contract: a unit arm is its arm type; the 58 static instances are gone
Deleted the 38 + 17 + 3 internal static Instance fields and public static unit-arm properties.
Unit arms are built with new ImageFitContain() and told by a type test; the arm constructors are
public. Builders rewritten (65 uses); UnitArmTests replaces the SameAs assertions: every decoded
unit arm is its arm type, no union base exposes a static member of its own kind, and
new ImageFitFill() still writes 92 02 90.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:18:51 +02:00
Meta Vens
ee80246cd0 GameCult.Thing.Contract: nil refused in every required slot, lists read-only, slot 2 is InputKind
ThingDomain.Present and Items; every constructor, factory and key builder passes each non-nullable
reference parameter through Present (refusal names the slot, never a value) and each list through
Items (a private read-only copy, nil items refused). Lists are IReadOnlyList on every type.
TextInputNode's slot 2 property and parameter are InputKind, matching Rust input_kind.
Tests: NilPositionTests reads the Rust vector row by row; PresenceTests and ReadOnlyListTests
walk every door by reflection; InputKindNameTests. NameSetTests and the unit-arm rewrite follow.
Unit-arm statics are still present in this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:18:45 +02:00
Meta Vens
85baafcb1d thing-contract: bless goldens/nil-positions.tsv (2,866 rows, Rust decode door)
Blessed with THING_BLESS=1 on Yggdrasil; the sha256 list of every existing golden is identical
before and after (only this file is new), and the compare run passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:18:24 +02:00
Meta Vens
9ed4a72a53 thing-contract: TextInputNode's slot 2 is input_kind in every runtime; the nil vector
Rename kind to input_kind at key 2 (the wire is the slot key; no golden byte moves).
nil_positions.rs walks every array element of every value golden, splices nil over it and
records whether the Rust decode door accepts it, into goldens/nil-positions.tsv. The tsv is
blessed by the next commit; this one builds, and its nil test fails until then.
golden_bytes, blessing and goldens_dir move to tests/common so both test files share one path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:14:35 +02:00
Meta Vens
3e46195b3f GameCult.Thing.Contract: comments no longer spell the words the negative greps hunt
Comment-only; builds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:37:26 +02:00
Meta Vens
280979c86d GameCult.Thing.Contract.Tests: the package held to the Rust goldens byte for byte, and attacked as an outside consumer
Goldens, refusals, store read, keys, domains at every constructor and every door (CultLib
MessagePack, MessagePack Standard, System.Text.Json), factories-only reflection, deepest chain
the 256 bound admits. Goldens are linked from crates/thing-contract/tests/goldens, read only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:37:04 +02:00
Meta Vens
33eed442c7 GameCult.Thing.Contract: the gamecult.thing.* wire types for .NET, every door through a validating constructor
One C# type per Rust type, keys in the crate's slot order. Unions are abstract classes with an
internal constructor and sealed arms; unit arms are shared instances. ThingDomain owns the four
domains and ThingKeys the key grammar. CommandResult is built by four factories only; its
constructor is private. Builds against CultLib b3bd8dd5 through CultLibRoot. Tests follow in the
next commit; this one builds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:37:04 +02:00
34 changed files with 6773 additions and 22 deletions

2
.gitignore vendored
View file

@ -14,6 +14,8 @@ packages/org.gamecult.eve.surface/bin~/
packages/org.gamecult.eve.surface/obj~/
!packages/org.gamecult.eve.unity-uitoolkit/
!packages/org.gamecult.eve.unity-uitoolkit/**
!packages/GameCult.Thing.Contract/
!packages/GameCult.Thing.Contract/**
obj/
artifacts/
tools/deps/

View file

@ -159,8 +159,10 @@ pub struct TextInputNode {
pub common: NodeCommon,
#[cultcache(key = 1)]
pub multiline: bool,
/// Named `input_kind` in every runtime (Rust `input_kind`, C# `InputKind`, TypeScript `inputKind`): a field
/// named `kind` collides with the union tag in TypeScript. The wire is the slot key, 2.
#[cultcache(key = 2)]
pub kind: InputKind,
pub input_kind: InputKind,
#[cultcache(key = 3)]
pub placeholder: TextSource,
#[cultcache(key = 4)]

View file

@ -3,8 +3,29 @@
#![allow(dead_code)]
use std::path::PathBuf;
use thing_contract::*;
pub fn goldens_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens")
}
pub fn blessing() -> bool {
std::env::var("THING_BLESS").as_deref() == Ok("1")
}
/// Bless the file or compare against it. Returns the committed bytes.
pub fn golden_bytes(file: &str, produced: &[u8]) -> Vec<u8> {
let path = goldens_dir().join(file);
if blessing() {
std::fs::write(&path, produced).unwrap_or_else(|error| panic!("{file}: {error}"));
}
let committed = std::fs::read(&path).unwrap_or_else(|error| panic!("{file}: {error}"));
assert_eq!(committed, produced, "{file}: the committed golden differs from what the crate writes");
committed
}
pub fn seq(value: i32) -> Seq {
Seq::new(value).unwrap()
}

View file

@ -16,25 +16,6 @@ const PINNED_CLOCK: &str = "2026-01-01T00:00:00Z";
// A value that must never appear in a refusal.
const CANARY: &str = "CANARY-7f3a91c2";
fn goldens_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens")
}
fn blessing() -> bool {
std::env::var("THING_BLESS").as_deref() == Ok("1")
}
/// Bless the file or compare against it. Returns the committed bytes.
fn golden_bytes(file: &str, produced: &[u8]) -> Vec<u8> {
let path = goldens_dir().join(file);
if blessing() {
std::fs::write(&path, produced).unwrap_or_else(|error| panic!("{file}: {error}"));
}
let committed = std::fs::read(&path).unwrap_or_else(|error| panic!("{file}: {error}"));
assert_eq!(committed, produced, "{file}: the committed golden differs from what the crate writes");
committed
}
fn golden<T: DatabaseEntry + PartialEq + Debug>(name: &str, value: &T) -> String {
let produced = rmp_serde::to_vec(value).unwrap();
let committed = golden_bytes(&format!("{name}.bin"), &produced);
@ -150,7 +131,7 @@ fn full_surface() -> SurfaceDocument {
Node::TextInput(TextInputNode {
common: common("name-input"),
multiline: true,
kind: InputKind::Number,
input_kind: InputKind::Number,
placeholder: lit_text("name"),
initial: TextSource::Param(param("initial")),
submit: command("rename"),
@ -158,7 +139,7 @@ fn full_surface() -> SurfaceDocument {
Node::TextInput(TextInputNode {
common: common("title-input"),
multiline: false,
kind: InputKind::Text,
input_kind: InputKind::Text,
placeholder: lit_text("title"),
initial: TextSource::Param(param("initial")),
submit: command("retitle"),

View file

@ -40,3 +40,9 @@ when absent. Arm keys are fixed for v1.
| `rgba-channel-out-of-range.bin` | the Rgba slot array `[256, 0, 0, 255]` | refused when decoded as an Rgba: a channel is a byte, 0 to 255 |
Domain refusals are the contract's rule, which the TypeScript and C# packages mirror: each is held to refuse these files, without the leaf set a Rust `u8` or a checked newtype has.
## The nil vector
`nil-positions.tsv` is written only by `crates/thing-contract/tests/nil_positions.rs` (`THING_BLESS=1`). For every element inside an array, at every depth, of each value golden above except `surface.cc` (the eleven `.bin` files from `surface-all-arms` to `asset-catalog`), the test replaces that element with nil (`0xC0`) and decodes the whole payload with the Rust decode door, `rmp_serde::from_slice` into the golden's kind. A row is `golden`, `path` (dotted array indices from the root, e.g. `3.0.1`), the element's byte `start` and `end` in the golden, and `ACCEPT` or `REFUSE`. An accepted variant re-encodes to the spliced bytes. Maps are not walked.
A slot is required exactly when its Rust type is not optional, so `REFUSE` rows are the required slots and `ACCEPT` rows the optional ones. The Rust decode door decides every row; the TypeScript and C# packages keep no list of required slots and are held to every row.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,129 @@
//! The nil vector: for every element of every array of every value golden, the golden with that
//! element replaced by nil, and whether the Rust decode door accepts it. A slot is required
//! exactly when its Rust type is not optional, so the rows are the required-slot set; the C# and
//! TypeScript packages hold no list of their own and are held to every row.
//! `THING_BLESS=1` rewrites `goldens/nil-positions.tsv`; any other run compares.
mod common;
use std::fmt::Debug;
use common::*;
use serde::Serialize;
use serde::de::DeserializeOwned;
use thing_contract::*;
/// One MessagePack value's byte span; `kids` is set for arrays only (maps are never walked).
struct Span {
start: usize,
end: usize,
kids: Option<Vec<Span>>,
}
fn be(bytes: &[u8], at: usize, width: usize) -> usize {
bytes[at..at + width].iter().fold(0, |acc, byte| acc << 8 | *byte as usize)
}
fn collection(bytes: &[u8], start: usize, mut at: usize, count: usize, is_array: bool) -> Span {
let mut kids = Vec::new();
for _ in 0..count {
let kid = item(bytes, at);
at = kid.end;
kids.push(kid);
}
Span { start, end: at, kids: is_array.then_some(kids) }
}
fn item(bytes: &[u8], at: usize) -> Span {
let tag = bytes[at];
let flat = |len: usize| Span { start: at, end: at + len, kids: None };
match tag {
0x00..=0x7f | 0xe0..=0xff | 0xc0 | 0xc2 | 0xc3 => flat(1),
0x80..=0x8f => collection(bytes, at, at + 1, (tag & 0x0f) as usize * 2, false),
0x90..=0x9f => collection(bytes, at, at + 1, (tag & 0x0f) as usize, true),
0xa0..=0xbf => flat(1 + (tag & 0x1f) as usize),
0xc4 | 0xd9 => flat(2 + be(bytes, at + 1, 1)),
0xc5 | 0xda => flat(3 + be(bytes, at + 1, 2)),
0xc6 | 0xdb => flat(5 + be(bytes, at + 1, 4)),
0xca => flat(5),
0xcb => flat(9),
0xcc | 0xd0 => flat(2),
0xcd | 0xd1 => flat(3),
0xce | 0xd2 => flat(5),
0xcf | 0xd3 => flat(9),
0xdc => collection(bytes, at, at + 3, be(bytes, at + 1, 2), true),
0xdd => collection(bytes, at, at + 5, be(bytes, at + 1, 4), true),
0xde => collection(bytes, at, at + 3, be(bytes, at + 1, 2) * 2, false),
0xdf => collection(bytes, at, at + 5, be(bytes, at + 1, 4) * 2, false),
0xd4 => flat(3),
0xd5 => flat(4),
0xd6 => flat(6),
0xd7 => flat(10),
0xd8 => flat(18),
0xc7 => flat(3 + be(bytes, at + 1, 1)),
0xc8 => flat(4 + be(bytes, at + 1, 2)),
0xc9 => flat(6 + be(bytes, at + 1, 4)),
_ => panic!("unsupported tag {tag:#x} at {at}"),
}
}
/// Every element inside an array, at every depth, in walk order, with its dotted index path.
fn elements(span: &Span, prefix: &str, out: &mut Vec<(String, usize, usize)>) {
for (index, kid) in span.kids.iter().flatten().enumerate() {
let path = if prefix.is_empty() { index.to_string() } else { format!("{prefix}.{index}") };
out.push((path.clone(), kid.start, kid.end));
elements(kid, &path, out);
}
}
/// The rows of one golden: nil spliced over each element, decoded as `T`. An accepted variant
/// must re-encode to the spliced bytes.
fn rows<T: DeserializeOwned + Serialize + Debug>(file: &str, out: &mut String) -> (usize, usize) {
let bytes = std::fs::read(goldens_dir().join(format!("{file}.bin"))).unwrap();
let mut found = Vec::new();
elements(&item(&bytes, 0), "", &mut found);
let (mut accepted, mut refused) = (0, 0);
for (path, start, end) in found {
let mut spliced = bytes[..start].to_vec();
spliced.push(0xc0);
spliced.extend_from_slice(&bytes[end..]);
let verdict = match rmp_serde::from_slice::<T>(&spliced) {
Ok(value) => {
assert_eq!(rmp_serde::to_vec(&value).unwrap(), spliced, "{file} {path}: an accepted nil must re-encode to the spliced bytes");
accepted += 1;
"ACCEPT"
}
Err(_) => {
refused += 1;
"REFUSE"
}
};
out.push_str(&format!("{file}\t{path}\t{start}\t{end}\t{verdict}\n"));
}
(accepted, refused)
}
#[test]
fn nil_positions_match() {
let mut table = String::new();
let mut totals = Vec::new();
totals.push(("surface-all-arms", rows::<SurfaceDocument>("surface-all-arms", &mut table)));
totals.push(("composition-all-params", rows::<CompositionDocument>("composition-all-params", &mut table)));
totals.push(("invocation-all-inputs", rows::<CommandInvocation>("invocation-all-inputs", &mut table)));
for file in [
"result-pending",
"result-completed",
"result-completed-open-surface",
"result-completed-open-external",
"result-denied",
"result-denied-no-node",
"result-failed",
] {
totals.push((file, rows::<CommandResult>(file, &mut table)));
}
totals.push(("asset-catalog", rows::<AssetCatalog>("asset-catalog", &mut table)));
for (file, (accepted, refused)) in &totals {
println!("NILTOTAL {file} accept={accepted} refuse={refused}");
}
golden_bytes("nil-positions.tsv", table.as_bytes());
}

View file

@ -0,0 +1,44 @@
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
namespace GameCult.Thing.Contract;
[MessagePackObject]
public sealed class AssetEntry
{
[Key(0)] public string Id { get; }
[Key(1)] public string MediaType { get; }
[Key(2)] public string Uri { get; }
[Key(3)] public int Width { get; }
[Key(4)] public int Height { get; }
public AssetEntry(string id, string mediaType, string uri, int width, int height)
{
Id = ThingDomain.Present(id, "Id");
MediaType = ThingDomain.Present(mediaType, "MediaType");
Uri = ThingDomain.Present(uri, "Uri");
Width = ThingDomain.Count(width);
Height = ThingDomain.Count(height);
}
}
[CultDocument("gamecult.thing.asset_catalog", "gamecult.thing.asset_catalog.v1")]
[MessagePackObject]
public sealed class AssetCatalog
{
[Key(0)] public string ProviderId { get; }
[Key(1)] public string CatalogId { get; }
[Key(2)] public long Version { get; }
[Key(3)] public IReadOnlyList<AssetEntry> Assets { get; }
public AssetCatalog(string providerId, string catalogId, long version, IReadOnlyList<AssetEntry> assets)
{
ProviderId = ThingDomain.Present(providerId, "ProviderId");
CatalogId = ThingDomain.Segment(catalogId);
Version = ThingDomain.Version(version);
Assets = ThingDomain.Items(assets, "Assets");
}
[IgnoreMember] public string RecordKey => ThingKeys.AssetCatalogKey(ProviderId, CatalogId);
}

View file

@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
namespace GameCult.Thing.Contract;
[Union(0, typeof(CommandIdRef))]
[Union(1, typeof(CommandRefParam))]
public abstract class CommandRef
{
internal CommandRef() { }
}
[MessagePackObject]
public sealed class CommandIdRef : CommandRef
{
[Key(0)] public string Id { get; }
public CommandIdRef(string id)
{
Id = ThingDomain.Present(id, "Id");
}
}
[MessagePackObject]
public sealed class CommandRefParam : CommandRef
{
[Key(0)] public string Name { get; }
public CommandRefParam(string name)
{
Name = ThingDomain.Present(name, "Name");
}
}
[Union(0, typeof(TextValue))]
[Union(1, typeof(NumberValue))]
[Union(2, typeof(BoolValue))]
[Union(3, typeof(ChoiceValue))]
public abstract class InputValue
{
internal InputValue() { }
}
[MessagePackObject]
public sealed class TextValue : InputValue
{
[Key(0)] public string Value { get; }
public TextValue(string value)
{
Value = ThingDomain.Present(value, "Value");
}
}
[MessagePackObject]
public sealed class NumberValue : InputValue
{
[Key(0)] public double Value { get; }
public NumberValue(double value)
{
Value = value;
}
}
[MessagePackObject]
public sealed class BoolValue : InputValue
{
[Key(0)] public bool Value { get; }
public BoolValue(bool value)
{
Value = value;
}
}
[MessagePackObject]
public sealed class ChoiceValue : InputValue
{
[Key(0)] public string Value { get; }
public ChoiceValue(string value)
{
Value = ThingDomain.Present(value, "Value");
}
}
[MessagePackObject]
public sealed class CommandDecl
{
[Key(0)] public string Id { get; }
[Key(1)] public string PayloadSchema { get; }
[Key(2)] public IReadOnlyList<string> Captures { get; }
[Key(3)] public string? Confirm { get; }
public CommandDecl(string id, string payloadSchema, IReadOnlyList<string> captures, string? confirm)
{
Id = ThingDomain.Present(id, "Id");
PayloadSchema = ThingDomain.Present(payloadSchema, "PayloadSchema");
Captures = ThingDomain.Items(captures, "Captures");
Confirm = confirm;
}
}
/// <summary>
/// A typed record as its MessagePack bytes. Every empty memory (<c>ReadOnlyMemory&lt;byte&gt;.Empty</c>, a default view,
/// an empty array) is an empty payload, as Rust's empty <c>Vec&lt;u8&gt;</c> is. A nil on the wire is not an empty
/// payload: it decodes to a default view the constructor cannot tell from an empty one, so the constructor notes it and
/// the decode door refuses it once the value is read.
/// </summary>
[MessagePackObject]
public sealed class RecordPayload : IMessagePackSerializationCallbackReceiver
{
private readonly bool _viewWasDefault;
[Key(0)] public string Schema { get; }
[Key(1)] public ReadOnlyMemory<byte> Bytes { get; }
public RecordPayload(string schema, ReadOnlyMemory<byte> bytes)
{
Schema = ThingDomain.Present(schema, "Schema");
_viewWasDefault = bytes.Equals(default(ReadOnlyMemory<byte>));
Bytes = bytes.ToArray();
}
void IMessagePackSerializationCallbackReceiver.OnBeforeSerialize() { }
void IMessagePackSerializationCallbackReceiver.OnAfterDeserialize()
{
if (_viewWasDefault) throw new ThingDomainException("Bytes", "present");
}
}
[MessagePackObject]
public sealed class CapturedInput
{
[Key(0)] public string Node { get; }
[Key(1)] public InputValue Value { get; }
public CapturedInput(string node, InputValue value)
{
Node = ThingDomain.Present(node, "Node");
Value = ThingDomain.Present(value, "Value");
}
}
[CultDocument("gamecult.thing.command_invocation", "gamecult.thing.command_invocation.v1")]
[MessagePackObject]
public sealed class CommandInvocation
{
[Key(0)] public string InvocationId { get; }
[Key(1)] public string SurfaceKey { get; }
[Key(2)] public long SurfaceVersion { get; }
[Key(3)] public string Command { get; }
[Key(4)] public RecordPayload Payload { get; }
[Key(5)] public IReadOnlyList<CapturedInput> Captured { get; }
[Key(6)] public string Client { get; }
[Key(7)] public string IssuedAt { get; }
[Key(8)] public string? Delegation { get; }
public CommandInvocation(string invocationId, string surfaceKey, long surfaceVersion, string command, RecordPayload payload, IReadOnlyList<CapturedInput> captured, string client, string issuedAt, string? delegation)
{
InvocationId = ThingDomain.Present(invocationId, "InvocationId");
SurfaceKey = ThingDomain.Present(surfaceKey, "SurfaceKey");
SurfaceVersion = ThingDomain.Version(surfaceVersion);
Command = ThingDomain.Present(command, "Command");
Payload = ThingDomain.Present(payload, "Payload");
Captured = ThingDomain.Items(captured, "Captured");
Client = ThingDomain.Present(client, "Client");
IssuedAt = ThingDomain.Present(issuedAt, "IssuedAt");
Delegation = delegation;
}
[IgnoreMember] public string RecordKey => ThingKeys.InvocationKey(InvocationId);
}

View file

@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
namespace GameCult.Thing.Contract;
/// <summary>A value was outside its domain. Names the domain and its bound, never the value.</summary>
public sealed class ThingDomainException : Exception
{
public ThingDomainException(string domain, string bound) : base($"{domain} must be {bound}")
{
Domain = domain;
Bound = bound;
}
/// <summary>The domain that refused.</summary>
public string Domain { get; }
/// <summary>The bound that was not met.</summary>
public string Bound { get; }
}
/// <summary>
/// The value domains of the contract's numbers, local names and slots. A required slot is never null, and a list is a
/// private copy that holds no nil item. Every constructor whose field carries a domain calls the
/// matching check, and every deserializer reaches a value through that constructor, so no door skips a check.
/// </summary>
public static class ThingDomain
{
/// <summary>A result's position in its invocation's answers, counting from 1.</summary>
public static int Seq(int value) => value >= 1 ? value : throw new ThingDomainException("Seq", "at least 1");
/// <summary>A document or source version, never negative.</summary>
public static long Version(long value) => value >= 0 ? value : throw new ThingDomainException("Version", "at least 0");
public static long? Version(long? value) => value.HasValue ? Version(value.Value) : null;
/// <summary>A count, an extent or an index, never negative.</summary>
public static int Count(int value) => value >= 0 ? value : throw new ThingDomainException("Count", "at least 0");
public static int? Count(int? value) => value.HasValue ? Count(value.Value) : null;
/// <summary>A name the contract joins into a key: non-empty, no '.'.</summary>
public static string Segment(string value) =>
string.IsNullOrEmpty(value) ? throw new ThingDomainException("Segment", "non-empty")
: value.IndexOf('.') >= 0 ? throw new ThingDomainException("Segment", "free of '.'")
: value;
/// <summary>A required slot: the value, or a refusal naming the slot when it is null.</summary>
public static T Present<T>(T? value, string slot) where T : class =>
value ?? throw new ThingDomainException(slot, "present");
/// <summary>
/// A required list slot: a read-only copy, so the caller's list cannot change the value afterwards. A null list or
/// a null item is refused naming the slot.
/// </summary>
public static IReadOnlyList<T> Items<T>(IReadOnlyList<T>? items, string slot) where T : class
{
if (items is null) throw new ThingDomainException(slot, "present");
var copy = new T[items.Count];
for (var i = 0; i < copy.Length; i++)
copy[i] = items[i] ?? throw new ThingDomainException(slot, "free of nil items");
return Array.AsReadOnly(copy);
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<Description>The gamecult.thing.* wire types, held to the Rust crate's goldens.</Description>
<PackageId>GameCult.Thing.Contract</PackageId>
<Version>0.1.0</Version>
<Authors>GameCult</Authors>
<PackageLicenseExpression>MPL-2.0</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/GameCult/Eve</PackageProjectUrl>
<RepositoryUrl>https://github.com/GameCult/Eve</RepositoryUrl>
<BaseOutputPath>obj\bin\</BaseOutputPath>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,25 @@
using System.Globalization;
namespace GameCult.Thing.Contract;
/// <summary>
/// The key of each kind. Each key joins free-string provider or publisher ids (dotted, as the Verse names them)
/// with typed trailing parts: a segment holds no dot and a seq or version is digits, so a key splits at its last dot
/// (surface, asset catalog, result) or last two (composition), and two different field sets never build one key.
/// </summary>
public static class ThingKeys
{
public static string SurfaceKey(string providerId, string label) =>
$"thing:surface:{ThingDomain.Present(providerId, "ProviderId")}.{ThingDomain.Segment(label)}";
public static string CompositionKey(string publisher, string name, long version) =>
$"thing:composition:{ThingDomain.Present(publisher, "Publisher")}.{ThingDomain.Segment(name)}.v{ThingDomain.Version(version).ToString(CultureInfo.InvariantCulture)}";
public static string InvocationKey(string invocationId) => $"thing:invocation:{ThingDomain.Present(invocationId, "InvocationId")}";
public static string ResultKey(string invocationId, int seq) =>
$"thing:result:{ThingDomain.Present(invocationId, "InvocationId")}.{ThingDomain.Seq(seq).ToString(CultureInfo.InvariantCulture)}";
public static string AssetCatalogKey(string providerId, string catalogId) =>
$"thing:asset_catalog:{ThingDomain.Present(providerId, "ProviderId")}.{ThingDomain.Segment(catalogId)}";
}

View file

@ -0,0 +1,663 @@
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
namespace GameCult.Thing.Contract;
[Union(0, typeof(BoxNode))]
[Union(1, typeof(TextNode))]
[Union(2, typeof(ImageNode))]
[Union(3, typeof(ButtonNode))]
[Union(4, typeof(ToggleNode))]
[Union(5, typeof(SliderNode))]
[Union(6, typeof(TextInputNode))]
[Union(7, typeof(SelectNode))]
[Union(8, typeof(RepeatNode))]
[Union(9, typeof(OverlayNode))]
[Union(10, typeof(PlotNode))]
[Union(11, typeof(EmbedNode))]
[Union(12, typeof(UseNode))]
public abstract class Node
{
internal Node() { }
}
[MessagePackObject]
public sealed class BoxNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public BoxStyle Style { get; }
[Key(2)] public Paint Paint { get; }
[Key(3)] public Role Role { get; }
[Key(4)] public IReadOnlyList<Node> Children { get; }
public BoxNode(NodeCommon common, BoxStyle style, Paint paint, Role role, IReadOnlyList<Node> children)
{
Common = ThingDomain.Present(common, "Common");
Style = ThingDomain.Present(style, "Style");
Paint = ThingDomain.Present(paint, "Paint");
Role = ThingDomain.Present(role, "Role");
Children = ThingDomain.Items(children, "Children");
}
}
[MessagePackObject]
public sealed class TextNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public TextSource Content { get; }
[Key(2)] public string StyleToken { get; }
[Key(3)] public bool Wrap { get; }
[Key(4)] public int? MaxLines { get; }
[Key(5)] public bool Selectable { get; }
public TextNode(NodeCommon common, TextSource content, string styleToken, bool wrap, int? maxLines, bool selectable)
{
Common = ThingDomain.Present(common, "Common");
Content = ThingDomain.Present(content, "Content");
StyleToken = ThingDomain.Present(styleToken, "StyleToken");
Wrap = wrap;
MaxLines = ThingDomain.Count(maxLines);
Selectable = selectable;
}
}
[MessagePackObject]
public sealed class ImageNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public string Catalog { get; }
[Key(2)] public string Asset { get; }
[Key(3)] public ImageFit Fit { get; }
[Key(4)] public TextSource Alt { get; }
public ImageNode(NodeCommon common, string catalog, string asset, ImageFit fit, TextSource alt)
{
Common = ThingDomain.Present(common, "Common");
Catalog = ThingDomain.Present(catalog, "Catalog");
Asset = ThingDomain.Present(asset, "Asset");
Fit = ThingDomain.Present(fit, "Fit");
Alt = ThingDomain.Present(alt, "Alt");
}
}
[MessagePackObject]
public sealed class ButtonNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public IReadOnlyList<Node> Content { get; }
[Key(2)] public CommandRef OnActivate { get; }
[Key(3)] public BoolSource Enabled { get; }
public ButtonNode(NodeCommon common, IReadOnlyList<Node> content, CommandRef onActivate, BoolSource enabled)
{
Common = ThingDomain.Present(common, "Common");
Content = ThingDomain.Items(content, "Content");
OnActivate = ThingDomain.Present(onActivate, "OnActivate");
Enabled = ThingDomain.Present(enabled, "Enabled");
}
}
[MessagePackObject]
public sealed class ToggleNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public BoolSource Checked { get; }
[Key(2)] public CommandRef OnChange { get; }
public ToggleNode(NodeCommon common, BoolSource @checked, CommandRef onChange)
{
Common = ThingDomain.Present(common, "Common");
Checked = ThingDomain.Present(@checked, "Checked");
OnChange = ThingDomain.Present(onChange, "OnChange");
}
}
[MessagePackObject]
public sealed class SliderNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public double Min { get; }
[Key(2)] public double Max { get; }
[Key(3)] public double Step { get; }
[Key(4)] public NumberSource Value { get; }
[Key(5)] public CommandRef Commit { get; }
public SliderNode(NodeCommon common, double min, double max, double step, NumberSource value, CommandRef commit)
{
Common = ThingDomain.Present(common, "Common");
Min = min;
Max = max;
Step = step;
Value = ThingDomain.Present(value, "Value");
Commit = ThingDomain.Present(commit, "Commit");
}
}
[MessagePackObject]
public sealed class TextInputNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public bool Multiline { get; }
[Key(2)] public InputKind InputKind { get; }
[Key(3)] public TextSource Placeholder { get; }
[Key(4)] public TextSource Initial { get; }
[Key(5)] public CommandRef Submit { get; }
public TextInputNode(NodeCommon common, bool multiline, InputKind inputKind, TextSource placeholder, TextSource initial, CommandRef submit)
{
Common = ThingDomain.Present(common, "Common");
Multiline = multiline;
InputKind = ThingDomain.Present(inputKind, "InputKind");
Placeholder = ThingDomain.Present(placeholder, "Placeholder");
Initial = ThingDomain.Present(initial, "Initial");
Submit = ThingDomain.Present(submit, "Submit");
}
}
[MessagePackObject]
public sealed class SelectNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public OptionList Options { get; }
[Key(2)] public Bind Value { get; }
[Key(3)] public CommandRef OnChange { get; }
public SelectNode(NodeCommon common, OptionList options, Bind value, CommandRef onChange)
{
Common = ThingDomain.Present(common, "Common");
Options = ThingDomain.Present(options, "Options");
Value = ThingDomain.Present(value, "Value");
OnChange = ThingDomain.Present(onChange, "OnChange");
}
}
[MessagePackObject]
public sealed class RepeatNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public CollectionSource Collection { get; }
[Key(2)] public MemberPath KeyMember { get; }
[Key(3)] public Node Template { get; }
public RepeatNode(NodeCommon common, CollectionSource collection, MemberPath keyMember, Node template)
{
Common = ThingDomain.Present(common, "Common");
Collection = ThingDomain.Present(collection, "Collection");
KeyMember = ThingDomain.Present(keyMember, "KeyMember");
Template = ThingDomain.Present(template, "Template");
}
}
[MessagePackObject]
public sealed class OverlayNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public Anchor Anchor { get; }
[Key(2)] public bool Modal { get; }
[Key(3)] public CommandRef Dismiss { get; }
[Key(4)] public IReadOnlyList<Node> Children { get; }
public OverlayNode(NodeCommon common, Anchor anchor, bool modal, CommandRef dismiss, IReadOnlyList<Node> children)
{
Common = ThingDomain.Present(common, "Common");
Anchor = ThingDomain.Present(anchor, "Anchor");
Modal = modal;
Dismiss = ThingDomain.Present(dismiss, "Dismiss");
Children = ThingDomain.Items(children, "Children");
}
}
[MessagePackObject]
public sealed class PlotNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public Bind Series { get; }
[Key(2)] public PlotMark Mark { get; }
[Key(3)] public double? XMin { get; }
[Key(4)] public double? XMax { get; }
[Key(5)] public double? YMin { get; }
[Key(6)] public double? YMax { get; }
public PlotNode(NodeCommon common, Bind series, PlotMark mark, double? xMin, double? xMax, double? yMin, double? yMax)
{
Common = ThingDomain.Present(common, "Common");
Series = ThingDomain.Present(series, "Series");
Mark = ThingDomain.Present(mark, "Mark");
XMin = xMin;
XMax = xMax;
YMin = yMin;
YMax = yMax;
}
}
[MessagePackObject]
public sealed class EmbedNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public string SlotKind { get; }
[Key(2)] public string SlotId { get; }
[Key(3)] public double Width { get; }
[Key(4)] public double Height { get; }
public EmbedNode(NodeCommon common, string slotKind, string slotId, double width, double height)
{
Common = ThingDomain.Present(common, "Common");
SlotKind = ThingDomain.Present(slotKind, "SlotKind");
SlotId = ThingDomain.Present(slotId, "SlotId");
Width = width;
Height = height;
}
}
[MessagePackObject]
public sealed class UseNode : Node
{
[Key(0)] public NodeCommon Common { get; }
[Key(1)] public CompositionRef Composition { get; }
[Key(2)] public IReadOnlyList<Arg> Args { get; }
public UseNode(NodeCommon common, CompositionRef composition, IReadOnlyList<Arg> args)
{
Common = ThingDomain.Present(common, "Common");
Composition = ThingDomain.Present(composition, "Composition");
Args = ThingDomain.Items(args, "Args");
}
}
[Union(0, typeof(ImageFitContain))]
[Union(1, typeof(ImageFitCover))]
[Union(2, typeof(ImageFitFill))]
[Union(3, typeof(ImageFitUnscaled))]
public abstract class ImageFit
{
internal ImageFit() { }
}
[MessagePackObject]
public sealed class ImageFitContain : ImageFit
{
public ImageFitContain() { }
}
[MessagePackObject]
public sealed class ImageFitCover : ImageFit
{
public ImageFitCover() { }
}
[MessagePackObject]
public sealed class ImageFitFill : ImageFit
{
public ImageFitFill() { }
}
[MessagePackObject]
public sealed class ImageFitUnscaled : ImageFit
{
public ImageFitUnscaled() { }
}
[Union(0, typeof(InputKindText))]
[Union(1, typeof(InputKindNumber))]
public abstract class InputKind
{
internal InputKind() { }
}
[MessagePackObject]
public sealed class InputKindText : InputKind
{
public InputKindText() { }
}
[MessagePackObject]
public sealed class InputKindNumber : InputKind
{
public InputKindNumber() { }
}
[Union(0, typeof(LiteralOptions))]
[Union(1, typeof(BoundOptions))]
public abstract class OptionList
{
internal OptionList() { }
}
[MessagePackObject]
public sealed class LiteralOptions : OptionList
{
[Key(0)] public IReadOnlyList<SelectOption> Options { get; }
public LiteralOptions(IReadOnlyList<SelectOption> options)
{
Options = ThingDomain.Items(options, "Options");
}
}
[MessagePackObject]
public sealed class BoundOptions : OptionList
{
[Key(0)] public CollectionSource Collection { get; }
[Key(1)] public MemberPath ValueMember { get; }
[Key(2)] public MemberPath LabelMember { get; }
public BoundOptions(CollectionSource collection, MemberPath valueMember, MemberPath labelMember)
{
Collection = ThingDomain.Present(collection, "Collection");
ValueMember = ThingDomain.Present(valueMember, "ValueMember");
LabelMember = ThingDomain.Present(labelMember, "LabelMember");
}
}
[Union(0, typeof(AnchorViewport))]
[Union(1, typeof(AnchorNode))]
public abstract class Anchor
{
internal Anchor() { }
}
[MessagePackObject]
public sealed class AnchorViewport : Anchor
{
public AnchorViewport() { }
}
[MessagePackObject]
public sealed class AnchorNode : Anchor
{
[Key(0)] public string Node { get; }
public AnchorNode(string node)
{
Node = ThingDomain.Present(node, "Node");
}
}
[Union(0, typeof(PlotMarkLine))]
[Union(1, typeof(PlotMarkBar))]
[Union(2, typeof(PlotMarkArea))]
public abstract class PlotMark
{
internal PlotMark() { }
}
[MessagePackObject]
public sealed class PlotMarkLine : PlotMark
{
public PlotMarkLine() { }
}
[MessagePackObject]
public sealed class PlotMarkBar : PlotMark
{
public PlotMarkBar() { }
}
[MessagePackObject]
public sealed class PlotMarkArea : PlotMark
{
public PlotMarkArea() { }
}
[Union(0, typeof(TextArg))]
[Union(1, typeof(NumberArg))]
[Union(2, typeof(BoolArg))]
[Union(3, typeof(ArgValueBind))]
[Union(4, typeof(CollectionArg))]
[Union(5, typeof(CommandArg))]
[Union(6, typeof(SlotArg))]
public abstract class ArgValue
{
internal ArgValue() { }
}
[MessagePackObject]
public sealed class TextArg : ArgValue
{
[Key(0)] public string Value { get; }
public TextArg(string value)
{
Value = ThingDomain.Present(value, "Value");
}
}
[MessagePackObject]
public sealed class NumberArg : ArgValue
{
[Key(0)] public double Value { get; }
public NumberArg(double value)
{
Value = value;
}
}
[MessagePackObject]
public sealed class BoolArg : ArgValue
{
[Key(0)] public bool Value { get; }
public BoolArg(bool value)
{
Value = value;
}
}
[MessagePackObject]
public sealed class ArgValueBind : ArgValue
{
[Key(0)] public StateSource Source { get; }
[Key(1)] public MemberPath Member { get; }
[Key(2)] public Format? Format { get; }
public ArgValueBind(StateSource source, MemberPath member, Format? format)
{
Source = ThingDomain.Present(source, "Source");
Member = ThingDomain.Present(member, "Member");
Format = format;
}
}
[MessagePackObject]
public sealed class CollectionArg : ArgValue
{
[Key(0)] public CollectionSource Source { get; }
public CollectionArg(CollectionSource source)
{
Source = ThingDomain.Present(source, "Source");
}
}
[MessagePackObject]
public sealed class CommandArg : ArgValue
{
[Key(0)] public CommandRef Command { get; }
public CommandArg(CommandRef command)
{
Command = ThingDomain.Present(command, "Command");
}
}
[MessagePackObject]
public sealed class SlotArg : ArgValue
{
[Key(0)] public IReadOnlyList<Node> Nodes { get; }
public SlotArg(IReadOnlyList<Node> nodes)
{
Nodes = ThingDomain.Items(nodes, "Nodes");
}
}
[Union(0, typeof(ParamKindText))]
[Union(1, typeof(ParamKindNumber))]
[Union(2, typeof(ParamKindBool))]
[Union(3, typeof(ParamKindBind))]
[Union(4, typeof(ParamKindCollection))]
[Union(5, typeof(ParamKindCommand))]
[Union(6, typeof(ParamKindSlot))]
public abstract class ParamKind
{
internal ParamKind() { }
}
[MessagePackObject]
public sealed class ParamKindText : ParamKind
{
public ParamKindText() { }
}
[MessagePackObject]
public sealed class ParamKindNumber : ParamKind
{
public ParamKindNumber() { }
}
[MessagePackObject]
public sealed class ParamKindBool : ParamKind
{
public ParamKindBool() { }
}
[MessagePackObject]
public sealed class ParamKindBind : ParamKind
{
public ParamKindBind() { }
}
[MessagePackObject]
public sealed class ParamKindCollection : ParamKind
{
public ParamKindCollection() { }
}
[MessagePackObject]
public sealed class ParamKindCommand : ParamKind
{
public ParamKindCommand() { }
}
[MessagePackObject]
public sealed class ParamKindSlot : ParamKind
{
public ParamKindSlot() { }
}
[MessagePackObject]
public sealed class NodeCommon
{
[Key(0)] public string Id { get; }
[Key(1)] public BoolSource Visible { get; }
[Key(2)] public TextSource? A11yLabel { get; }
public NodeCommon(string id, BoolSource visible, TextSource? a11yLabel)
{
Id = ThingDomain.Present(id, "Id");
Visible = ThingDomain.Present(visible, "Visible");
A11yLabel = a11yLabel;
}
}
[MessagePackObject]
public sealed class SelectOption
{
[Key(0)] public string Value { get; }
[Key(1)] public string Label { get; }
public SelectOption(string value, string label)
{
Value = ThingDomain.Present(value, "Value");
Label = ThingDomain.Present(label, "Label");
}
}
[MessagePackObject]
public sealed class CompositionRef
{
[Key(0)] public string Publisher { get; }
[Key(1)] public string Name { get; }
[Key(2)] public long Version { get; }
public CompositionRef(string publisher, string name, long version)
{
Publisher = ThingDomain.Present(publisher, "Publisher");
Name = ThingDomain.Segment(name);
Version = ThingDomain.Version(version);
}
}
[MessagePackObject]
public sealed class Arg
{
[Key(0)] public string Name { get; }
[Key(1)] public ArgValue Value { get; }
public Arg(string name, ArgValue value)
{
Name = ThingDomain.Present(name, "Name");
Value = ThingDomain.Present(value, "Value");
}
}
[MessagePackObject]
public sealed class ParamDecl
{
[Key(0)] public string Name { get; }
[Key(1)] public ParamKind Kind { get; }
public ParamDecl(string name, ParamKind kind)
{
Name = ThingDomain.Present(name, "Name");
Kind = ThingDomain.Present(kind, "Kind");
}
}
[CultDocument("gamecult.thing.surface", "gamecult.thing.surface.v1")]
[MessagePackObject]
public sealed class SurfaceDocument
{
[Key(0)] public string ProviderId { get; }
[Key(1)] public string Label { get; }
[Key(2)] public long Version { get; }
[Key(3)] public string Title { get; }
[Key(4)] public Node Root { get; }
[Key(5)] public IReadOnlyList<CommandDecl> Commands { get; }
public SurfaceDocument(string providerId, string label, long version, string title, Node root, IReadOnlyList<CommandDecl> commands)
{
ProviderId = ThingDomain.Present(providerId, "ProviderId");
Label = ThingDomain.Segment(label);
Version = ThingDomain.Version(version);
Title = ThingDomain.Present(title, "Title");
Root = ThingDomain.Present(root, "Root");
Commands = ThingDomain.Items(commands, "Commands");
}
[IgnoreMember] public string RecordKey => ThingKeys.SurfaceKey(ProviderId, Label);
}
[CultDocument("gamecult.thing.composition", "gamecult.thing.composition.v1")]
[MessagePackObject]
public sealed class CompositionDocument
{
[Key(0)] public string Publisher { get; }
[Key(1)] public string Name { get; }
[Key(2)] public long Version { get; }
[Key(3)] public IReadOnlyList<ParamDecl> Params { get; }
[Key(4)] public Node Body { get; }
public CompositionDocument(string publisher, string name, long version, IReadOnlyList<ParamDecl> @params, Node body)
{
Publisher = ThingDomain.Present(publisher, "Publisher");
Name = ThingDomain.Segment(name);
Version = ThingDomain.Version(version);
Params = ThingDomain.Items(@params, "Params");
Body = ThingDomain.Present(body, "Body");
}
[IgnoreMember] public string RecordKey => ThingKeys.CompositionKey(Publisher, Name, Version);
}

View file

@ -0,0 +1,132 @@
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
namespace GameCult.Thing.Contract;
[Union(0, typeof(PendingOutcome))]
[Union(1, typeof(CompletedOutcome))]
[Union(2, typeof(DeniedOutcome))]
[Union(3, typeof(FailedOutcome))]
public abstract class Outcome
{
internal Outcome() { }
}
[MessagePackObject]
public sealed class PendingOutcome : Outcome
{
[Key(0)] public string Message { get; }
public PendingOutcome(string message)
{
Message = ThingDomain.Present(message, "Message");
}
}
[MessagePackObject]
public sealed class CompletedOutcome : Outcome
{
[Key(0)] public long? SourceVersion { get; }
[Key(1)] public bool ClearDrafts { get; }
[Key(2)] public Effect? Effect { get; }
public CompletedOutcome(long? sourceVersion, bool clearDrafts, Effect? effect)
{
SourceVersion = ThingDomain.Version(sourceVersion);
ClearDrafts = clearDrafts;
Effect = effect;
}
}
[MessagePackObject]
public sealed class DeniedOutcome : Outcome
{
[Key(0)] public string Reason { get; }
[Key(1)] public string? Node { get; }
public DeniedOutcome(string reason, string? node)
{
Reason = ThingDomain.Present(reason, "Reason");
Node = node;
}
}
[MessagePackObject]
public sealed class FailedOutcome : Outcome
{
[Key(0)] public string Reason { get; }
public FailedOutcome(string reason)
{
Reason = ThingDomain.Present(reason, "Reason");
}
}
[Union(0, typeof(OpenSurfaceEffect))]
[Union(1, typeof(OpenExternalEffect))]
public abstract class Effect
{
internal Effect() { }
}
[MessagePackObject]
public sealed class OpenSurfaceEffect : Effect
{
[Key(0)] public string Key { get; }
public OpenSurfaceEffect(string key)
{
Key = ThingDomain.Present(key, "Key");
}
}
[MessagePackObject]
public sealed class OpenExternalEffect : Effect
{
[Key(0)] public string Url { get; }
[Key(1)] public CommandRef Resume { get; }
public OpenExternalEffect(string url, CommandRef resume)
{
Url = ThingDomain.Present(url, "Url");
Resume = ThingDomain.Present(resume, "Resume");
}
}
/// <summary>
/// A provider's answer to one invocation. Results are built by the four factories and nothing else: the constructor is
/// private (MessagePack calls it on decode, and it holds the same <see cref="ThingDomain.Seq"/> bound), and no API takes a
/// state as free text or as an untyped value. <c>Seq</c> counts from 1 per invocation. The copied invocation id is a
/// convenience: core binds a result to an invocation it issued, so the copy is not provenance.
/// </summary>
[CultDocument("gamecult.thing.command_result", "gamecult.thing.command_result.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class CommandResult
{
[Key(0)] public string InvocationId { get; }
[Key(1)] public int Seq { get; }
[Key(2)] public Outcome Outcome { get; }
[SerializationConstructor]
private CommandResult(string invocationId, int seq, Outcome outcome)
{
InvocationId = ThingDomain.Present(invocationId, "InvocationId");
Seq = ThingDomain.Seq(seq);
Outcome = ThingDomain.Present(outcome, "Outcome");
}
[IgnoreMember] public string RecordKey => ThingKeys.ResultKey(InvocationId, Seq);
public static CommandResult Pending(CommandInvocation invocation, int seq, string message) =>
new(ThingDomain.Present(invocation, "Invocation").InvocationId, seq, new PendingOutcome(message));
public static CommandResult Completed(CommandInvocation invocation, int seq, long? sourceVersion, bool clearDrafts, Effect? effect) =>
new(ThingDomain.Present(invocation, "Invocation").InvocationId, seq, new CompletedOutcome(sourceVersion, clearDrafts, effect));
public static CommandResult Denied(CommandInvocation invocation, int seq, string reason, string? node) =>
new(ThingDomain.Present(invocation, "Invocation").InvocationId, seq, new DeniedOutcome(reason, node));
public static CommandResult Failed(CommandInvocation invocation, int seq, string reason) =>
new(ThingDomain.Present(invocation, "Invocation").InvocationId, seq, new FailedOutcome(reason));
}

View file

@ -0,0 +1,321 @@
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
namespace GameCult.Thing.Contract;
[Union(0, typeof(TextLiteral))]
[Union(1, typeof(TextSourceBound))]
[Union(2, typeof(TextSourceParam))]
public abstract class TextSource
{
internal TextSource() { }
}
[MessagePackObject]
public sealed class TextLiteral : TextSource
{
[Key(0)] public string Value { get; }
public TextLiteral(string value)
{
Value = ThingDomain.Present(value, "Value");
}
}
[MessagePackObject]
public sealed class TextSourceBound : TextSource
{
[Key(0)] public StateSource Source { get; }
[Key(1)] public MemberPath Member { get; }
[Key(2)] public Format? Format { get; }
public TextSourceBound(StateSource source, MemberPath member, Format? format)
{
Source = ThingDomain.Present(source, "Source");
Member = ThingDomain.Present(member, "Member");
Format = format;
}
}
[MessagePackObject]
public sealed class TextSourceParam : TextSource
{
[Key(0)] public string Name { get; }
public TextSourceParam(string name)
{
Name = ThingDomain.Present(name, "Name");
}
}
[Union(0, typeof(NumberLiteral))]
[Union(1, typeof(NumberSourceBound))]
[Union(2, typeof(NumberSourceParam))]
public abstract class NumberSource
{
internal NumberSource() { }
}
[MessagePackObject]
public sealed class NumberLiteral : NumberSource
{
[Key(0)] public double Value { get; }
public NumberLiteral(double value)
{
Value = value;
}
}
[MessagePackObject]
public sealed class NumberSourceBound : NumberSource
{
[Key(0)] public StateSource Source { get; }
[Key(1)] public MemberPath Member { get; }
[Key(2)] public Format? Format { get; }
public NumberSourceBound(StateSource source, MemberPath member, Format? format)
{
Source = ThingDomain.Present(source, "Source");
Member = ThingDomain.Present(member, "Member");
Format = format;
}
}
[MessagePackObject]
public sealed class NumberSourceParam : NumberSource
{
[Key(0)] public string Name { get; }
public NumberSourceParam(string name)
{
Name = ThingDomain.Present(name, "Name");
}
}
[Union(0, typeof(BoolLiteral))]
[Union(1, typeof(BoolSourceBound))]
[Union(2, typeof(BoolSourceParam))]
public abstract class BoolSource
{
internal BoolSource() { }
}
[MessagePackObject]
public sealed class BoolLiteral : BoolSource
{
[Key(0)] public bool Value { get; }
public BoolLiteral(bool value)
{
Value = value;
}
}
[MessagePackObject]
public sealed class BoolSourceBound : BoolSource
{
[Key(0)] public StateSource Source { get; }
[Key(1)] public MemberPath Member { get; }
[Key(2)] public Format? Format { get; }
public BoolSourceBound(StateSource source, MemberPath member, Format? format)
{
Source = ThingDomain.Present(source, "Source");
Member = ThingDomain.Present(member, "Member");
Format = format;
}
}
[MessagePackObject]
public sealed class BoolSourceParam : BoolSource
{
[Key(0)] public string Name { get; }
public BoolSourceParam(string name)
{
Name = ThingDomain.Present(name, "Name");
}
}
[Union(0, typeof(StateRef))]
[Union(1, typeof(StateSourceItem))]
public abstract class StateSource
{
internal StateSource() { }
}
[MessagePackObject]
public sealed class StateRef : StateSource
{
[Key(0)] public string? Provider { get; }
[Key(1)] public string Schema { get; }
[Key(2)] public string Key { get; }
public StateRef(string? provider, string schema, string key)
{
Provider = provider;
Schema = ThingDomain.Present(schema, "Schema");
Key = ThingDomain.Present(key, "Key");
}
}
[MessagePackObject]
public sealed class StateSourceItem : StateSource
{
public StateSourceItem() { }
}
[Union(0, typeof(MemberName))]
[Union(1, typeof(MemberIndex))]
public abstract class MemberStep
{
internal MemberStep() { }
}
[MessagePackObject]
public sealed class MemberName : MemberStep
{
[Key(0)] public string Name { get; }
public MemberName(string name)
{
Name = ThingDomain.Present(name, "Name");
}
}
[MessagePackObject]
public sealed class MemberIndex : MemberStep
{
[Key(0)] public int Index { get; }
public MemberIndex(int index)
{
Index = ThingDomain.Count(index);
}
}
[Union(0, typeof(NumberFormat))]
[Union(1, typeof(FormatDuration))]
[Union(2, typeof(FormatTimestamp))]
[Union(3, typeof(AffixFormat))]
public abstract class Format
{
internal Format() { }
}
[MessagePackObject]
public sealed class NumberFormat : Format
{
[Key(0)] public int Precision { get; }
[Key(1)] public bool Percent { get; }
[Key(2)] public string? Unit { get; }
public NumberFormat(int precision, bool percent, string? unit)
{
Precision = ThingDomain.Count(precision);
Percent = percent;
Unit = unit;
}
}
[MessagePackObject]
public sealed class FormatDuration : Format
{
public FormatDuration() { }
}
[MessagePackObject]
public sealed class FormatTimestamp : Format
{
public FormatTimestamp() { }
}
[MessagePackObject]
public sealed class AffixFormat : Format
{
[Key(0)] public string Prefix { get; }
[Key(1)] public string Suffix { get; }
public AffixFormat(string prefix, string suffix)
{
Prefix = ThingDomain.Present(prefix, "Prefix");
Suffix = ThingDomain.Present(suffix, "Suffix");
}
}
[Union(0, typeof(RecordsSource))]
[Union(1, typeof(CollectionSourceMember))]
[Union(2, typeof(CollectionSourceParam))]
public abstract class CollectionSource
{
internal CollectionSource() { }
}
[MessagePackObject]
public sealed class RecordsSource : CollectionSource
{
[Key(0)] public string? Provider { get; }
[Key(1)] public string Schema { get; }
[Key(2)] public string KeyPrefix { get; }
public RecordsSource(string? provider, string schema, string keyPrefix)
{
Provider = provider;
Schema = ThingDomain.Present(schema, "Schema");
KeyPrefix = ThingDomain.Present(keyPrefix, "KeyPrefix");
}
}
[MessagePackObject]
public sealed class CollectionSourceMember : CollectionSource
{
[Key(0)] public StateSource Source { get; }
[Key(1)] public MemberPath Member { get; }
[Key(2)] public Format? Format { get; }
public CollectionSourceMember(StateSource source, MemberPath member, Format? format)
{
Source = ThingDomain.Present(source, "Source");
Member = ThingDomain.Present(member, "Member");
Format = format;
}
}
[MessagePackObject]
public sealed class CollectionSourceParam : CollectionSource
{
[Key(0)] public string Name { get; }
public CollectionSourceParam(string name)
{
Name = ThingDomain.Present(name, "Name");
}
}
[MessagePackObject]
public sealed class Bind
{
[Key(0)] public StateSource Source { get; }
[Key(1)] public MemberPath Member { get; }
[Key(2)] public Format? Format { get; }
public Bind(StateSource source, MemberPath member, Format? format)
{
Source = ThingDomain.Present(source, "Source");
Member = ThingDomain.Present(member, "Member");
Format = format;
}
}
[MessagePackObject]
public sealed class MemberPath
{
[Key(0)] public IReadOnlyList<MemberStep> Steps { get; }
public MemberPath(IReadOnlyList<MemberStep> steps)
{
Steps = ThingDomain.Items(steps, "Steps");
}
}

View file

@ -0,0 +1,496 @@
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
namespace GameCult.Thing.Contract;
[Union(0, typeof(DimensionPx))]
[Union(1, typeof(DimensionPercent))]
[Union(2, typeof(DimensionFr))]
[Union(3, typeof(DimensionAuto))]
[Union(4, typeof(DimensionContent))]
public abstract class Dimension
{
internal Dimension() { }
}
[MessagePackObject]
public sealed class DimensionPx : Dimension
{
[Key(0)] public double Value { get; }
public DimensionPx(double value)
{
Value = value;
}
}
[MessagePackObject]
public sealed class DimensionPercent : Dimension
{
[Key(0)] public double Value { get; }
public DimensionPercent(double value)
{
Value = value;
}
}
[MessagePackObject]
public sealed class DimensionFr : Dimension
{
[Key(0)] public double Value { get; }
public DimensionFr(double value)
{
Value = value;
}
}
[MessagePackObject]
public sealed class DimensionAuto : Dimension
{
public DimensionAuto() { }
}
[MessagePackObject]
public sealed class DimensionContent : Dimension
{
public DimensionContent() { }
}
[Union(0, typeof(DisplayFlex))]
[Union(1, typeof(DisplayGrid))]
[Union(2, typeof(DisplayBlock))]
public abstract class Display
{
internal Display() { }
}
[MessagePackObject]
public sealed class DisplayFlex : Display
{
public DisplayFlex() { }
}
[MessagePackObject]
public sealed class DisplayGrid : Display
{
public DisplayGrid() { }
}
[MessagePackObject]
public sealed class DisplayBlock : Display
{
public DisplayBlock() { }
}
[Union(0, typeof(DirectionRow))]
[Union(1, typeof(DirectionColumn))]
[Union(2, typeof(DirectionRowReverse))]
[Union(3, typeof(DirectionColumnReverse))]
public abstract class Direction
{
internal Direction() { }
}
[MessagePackObject]
public sealed class DirectionRow : Direction
{
public DirectionRow() { }
}
[MessagePackObject]
public sealed class DirectionColumn : Direction
{
public DirectionColumn() { }
}
[MessagePackObject]
public sealed class DirectionRowReverse : Direction
{
public DirectionRowReverse() { }
}
[MessagePackObject]
public sealed class DirectionColumnReverse : Direction
{
public DirectionColumnReverse() { }
}
[Union(0, typeof(WrapNoWrap))]
[Union(1, typeof(WrapWrap))]
[Union(2, typeof(WrapWrapReverse))]
public abstract class Wrap
{
internal Wrap() { }
}
[MessagePackObject]
public sealed class WrapNoWrap : Wrap
{
public WrapNoWrap() { }
}
[MessagePackObject]
public sealed class WrapWrap : Wrap
{
public WrapWrap() { }
}
[MessagePackObject]
public sealed class WrapWrapReverse : Wrap
{
public WrapWrapReverse() { }
}
[Union(0, typeof(PositionRelative))]
[Union(1, typeof(PositionAbsolute))]
public abstract class Position
{
internal Position() { }
}
[MessagePackObject]
public sealed class PositionRelative : Position
{
public PositionRelative() { }
}
[MessagePackObject]
public sealed class PositionAbsolute : Position
{
public PositionAbsolute() { }
}
[Union(0, typeof(OverflowVisible))]
[Union(1, typeof(OverflowClip))]
[Union(2, typeof(OverflowScroll))]
public abstract class Overflow
{
internal Overflow() { }
}
[MessagePackObject]
public sealed class OverflowVisible : Overflow
{
public OverflowVisible() { }
}
[MessagePackObject]
public sealed class OverflowClip : Overflow
{
public OverflowClip() { }
}
[MessagePackObject]
public sealed class OverflowScroll : Overflow
{
public OverflowScroll() { }
}
[Union(0, typeof(ItemAlignAuto))]
[Union(1, typeof(ItemAlignStart))]
[Union(2, typeof(ItemAlignEnd))]
[Union(3, typeof(ItemAlignCenter))]
[Union(4, typeof(ItemAlignStretch))]
[Union(5, typeof(ItemAlignBaseline))]
public abstract class ItemAlign
{
internal ItemAlign() { }
}
[MessagePackObject]
public sealed class ItemAlignAuto : ItemAlign
{
public ItemAlignAuto() { }
}
[MessagePackObject]
public sealed class ItemAlignStart : ItemAlign
{
public ItemAlignStart() { }
}
[MessagePackObject]
public sealed class ItemAlignEnd : ItemAlign
{
public ItemAlignEnd() { }
}
[MessagePackObject]
public sealed class ItemAlignCenter : ItemAlign
{
public ItemAlignCenter() { }
}
[MessagePackObject]
public sealed class ItemAlignStretch : ItemAlign
{
public ItemAlignStretch() { }
}
[MessagePackObject]
public sealed class ItemAlignBaseline : ItemAlign
{
public ItemAlignBaseline() { }
}
[Union(0, typeof(ContentAlignAuto))]
[Union(1, typeof(ContentAlignStart))]
[Union(2, typeof(ContentAlignEnd))]
[Union(3, typeof(ContentAlignCenter))]
[Union(4, typeof(ContentAlignStretch))]
[Union(5, typeof(ContentAlignSpaceBetween))]
[Union(6, typeof(ContentAlignSpaceAround))]
[Union(7, typeof(ContentAlignSpaceEvenly))]
public abstract class ContentAlign
{
internal ContentAlign() { }
}
[MessagePackObject]
public sealed class ContentAlignAuto : ContentAlign
{
public ContentAlignAuto() { }
}
[MessagePackObject]
public sealed class ContentAlignStart : ContentAlign
{
public ContentAlignStart() { }
}
[MessagePackObject]
public sealed class ContentAlignEnd : ContentAlign
{
public ContentAlignEnd() { }
}
[MessagePackObject]
public sealed class ContentAlignCenter : ContentAlign
{
public ContentAlignCenter() { }
}
[MessagePackObject]
public sealed class ContentAlignStretch : ContentAlign
{
public ContentAlignStretch() { }
}
[MessagePackObject]
public sealed class ContentAlignSpaceBetween : ContentAlign
{
public ContentAlignSpaceBetween() { }
}
[MessagePackObject]
public sealed class ContentAlignSpaceAround : ContentAlign
{
public ContentAlignSpaceAround() { }
}
[MessagePackObject]
public sealed class ContentAlignSpaceEvenly : ContentAlign
{
public ContentAlignSpaceEvenly() { }
}
[Union(0, typeof(ThemeToken))]
[Union(1, typeof(Rgba))]
public abstract class ColorRef
{
internal ColorRef() { }
}
[MessagePackObject]
public sealed class ThemeToken : ColorRef
{
[Key(0)] public string Name { get; }
public ThemeToken(string name)
{
Name = ThingDomain.Present(name, "Name");
}
}
[MessagePackObject]
public sealed class Rgba : ColorRef
{
[Key(0)] public byte R { get; }
[Key(1)] public byte G { get; }
[Key(2)] public byte B { get; }
[Key(3)] public byte A { get; }
public Rgba(byte r, byte g, byte b, byte a)
{
R = r;
G = g;
B = b;
A = a;
}
}
[Union(0, typeof(RoleGroup))]
[Union(1, typeof(RoleRegion))]
[Union(2, typeof(RoleList))]
[Union(3, typeof(RoleListItem))]
[Union(4, typeof(RoleHeading))]
[Union(5, typeof(RoleDialog))]
[Union(6, typeof(RoleToolbar))]
public abstract class Role
{
internal Role() { }
}
[MessagePackObject]
public sealed class RoleGroup : Role
{
public RoleGroup() { }
}
[MessagePackObject]
public sealed class RoleRegion : Role
{
public RoleRegion() { }
}
[MessagePackObject]
public sealed class RoleList : Role
{
public RoleList() { }
}
[MessagePackObject]
public sealed class RoleListItem : Role
{
public RoleListItem() { }
}
[MessagePackObject]
public sealed class RoleHeading : Role
{
public RoleHeading() { }
}
[MessagePackObject]
public sealed class RoleDialog : Role
{
public RoleDialog() { }
}
[MessagePackObject]
public sealed class RoleToolbar : Role
{
public RoleToolbar() { }
}
[MessagePackObject]
public sealed class Size2
{
[Key(0)] public Dimension Width { get; }
[Key(1)] public Dimension Height { get; }
public Size2(Dimension width, Dimension height)
{
Width = ThingDomain.Present(width, "Width");
Height = ThingDomain.Present(height, "Height");
}
}
[MessagePackObject]
public sealed class Edges
{
[Key(0)] public Dimension Top { get; }
[Key(1)] public Dimension Right { get; }
[Key(2)] public Dimension Bottom { get; }
[Key(3)] public Dimension Left { get; }
public Edges(Dimension top, Dimension right, Dimension bottom, Dimension left)
{
Top = ThingDomain.Present(top, "Top");
Right = ThingDomain.Present(right, "Right");
Bottom = ThingDomain.Present(bottom, "Bottom");
Left = ThingDomain.Present(left, "Left");
}
}
[MessagePackObject]
public sealed class BoxStyle
{
[Key(0)] public Display Display { get; }
[Key(1)] public Direction Direction { get; }
[Key(2)] public Wrap Wrap { get; }
[Key(3)] public Size2 Gap { get; }
[Key(4)] public Edges Padding { get; }
[Key(5)] public Edges Margin { get; }
[Key(6)] public Size2 Size { get; }
[Key(7)] public Size2 MinSize { get; }
[Key(8)] public Size2 MaxSize { get; }
[Key(9)] public Position Position { get; }
[Key(10)] public Edges Inset { get; }
[Key(11)] public Overflow Overflow { get; }
[Key(12)] public ItemAlign AlignItems { get; }
[Key(13)] public ItemAlign AlignSelf { get; }
[Key(14)] public ContentAlign AlignContent { get; }
[Key(15)] public ContentAlign JustifyContent { get; }
[Key(16)] public double Grow { get; }
[Key(17)] public double Shrink { get; }
[Key(18)] public Dimension Basis { get; }
[Key(19)] public IReadOnlyList<Dimension> GridColumns { get; }
[Key(20)] public IReadOnlyList<Dimension> GridRows { get; }
public BoxStyle(Display display, Direction direction, Wrap wrap, Size2 gap, Edges padding, Edges margin, Size2 size, Size2 minSize, Size2 maxSize, Position position, Edges inset, Overflow overflow, ItemAlign alignItems, ItemAlign alignSelf, ContentAlign alignContent, ContentAlign justifyContent, double grow, double shrink, Dimension basis, IReadOnlyList<Dimension> gridColumns, IReadOnlyList<Dimension> gridRows)
{
Display = ThingDomain.Present(display, "Display");
Direction = ThingDomain.Present(direction, "Direction");
Wrap = ThingDomain.Present(wrap, "Wrap");
Gap = ThingDomain.Present(gap, "Gap");
Padding = ThingDomain.Present(padding, "Padding");
Margin = ThingDomain.Present(margin, "Margin");
Size = ThingDomain.Present(size, "Size");
MinSize = ThingDomain.Present(minSize, "MinSize");
MaxSize = ThingDomain.Present(maxSize, "MaxSize");
Position = ThingDomain.Present(position, "Position");
Inset = ThingDomain.Present(inset, "Inset");
Overflow = ThingDomain.Present(overflow, "Overflow");
AlignItems = ThingDomain.Present(alignItems, "AlignItems");
AlignSelf = ThingDomain.Present(alignSelf, "AlignSelf");
AlignContent = ThingDomain.Present(alignContent, "AlignContent");
JustifyContent = ThingDomain.Present(justifyContent, "JustifyContent");
Grow = grow;
Shrink = shrink;
Basis = ThingDomain.Present(basis, "Basis");
GridColumns = ThingDomain.Items(gridColumns, "GridColumns");
GridRows = ThingDomain.Items(gridRows, "GridRows");
}
}
[MessagePackObject]
public sealed class Border
{
[Key(0)] public ColorRef Color { get; }
[Key(1)] public double Width { get; }
public Border(ColorRef color, double width)
{
Color = ThingDomain.Present(color, "Color");
Width = width;
}
}
[MessagePackObject]
public sealed class Paint
{
[Key(0)] public ColorRef? Background { get; }
[Key(1)] public Border? Border { get; }
[Key(2)] public double Radius { get; }
public Paint(ColorRef? background, Border? border, double radius)
{
Background = background;
Border = border;
Radius = radius;
}
}

View file

@ -0,0 +1,297 @@
using GameCult.Thing.Contract;
namespace GameCult.Thing.Contract.Tests;
/// <summary>
/// The values the goldens and the other tests share, ported from crates/thing-contract/tests/common/mod.rs and
/// tests/goldens.rs. Every value is built through the package's public constructors; no bytes are spelled here.
/// </summary>
internal static class Builders
{
// A Bind's slots, held until a union arm takes them: the arms carry the slots flat, so there is no shared Bind arm.
internal sealed record BindParts(StateSource Source, MemberPath Member, Format? Format)
{
public Bind ToBind() => new(Source, Member, Format);
public TextSource Text() => new TextSourceBound(Source, Member, Format);
public NumberSource Number() => new NumberSourceBound(Source, Member, Format);
public BoolSource Bool() => new BoolSourceBound(Source, Member, Format);
public CollectionSource Collection() => new CollectionSourceMember(Source, Member, Format);
public ArgValue Arg() => new ArgValueBind(Source, Member, Format);
}
public static TextSource LitText(string value) => new TextLiteral(value);
public static BoolSource LitBool(bool value) => new BoolLiteral(value);
public static NumberSource LitNumber(double value) => new NumberLiteral(value);
public static MemberPath Names(params string[] names) =>
new(names.Select(name => (MemberStep)new MemberName(name)).ToList());
public static BindParts RecordBind(MemberPath member, Format? format) =>
new(new StateRef("provider.a", "gamecult.thing.test.v1", "row-1"), member, format);
public static BindParts ItemBind(MemberPath member) => new(new StateSourceItem(), member, null);
public static CommandRef Command(string id) => new CommandIdRef(id);
public static NodeCommon Common(string id) => new(id, LitBool(true), null);
public static Dimension Px(double value) => new DimensionPx(value);
public static Size2 Size(Dimension width, Dimension height) => new(width, height);
public static Edges AllEdges(Dimension all) => new(all, all, all, all);
public static BoxStyle Style(
Display? display = null,
Direction? direction = null,
Wrap? wrap = null,
Position? position = null,
Overflow? overflow = null,
ItemAlign? alignItems = null,
ItemAlign? alignSelf = null,
ContentAlign? alignContent = null,
ContentAlign? justifyContent = null,
Dimension? basis = null,
List<Dimension>? gridColumns = null,
List<Dimension>? gridRows = null) =>
new(
display ?? new DisplayFlex(),
direction ?? new DirectionColumn(),
wrap ?? new WrapNoWrap(),
Size(Px(4), Px(8)),
AllEdges(Px(2)),
AllEdges(new DimensionAuto()),
Size(new DimensionPercent(100), new DimensionContent()),
Size(Px(0), Px(0)),
Size(new DimensionAuto(), new DimensionAuto()),
position ?? new PositionRelative(),
AllEdges(new DimensionAuto()),
overflow ?? new OverflowVisible(),
alignItems ?? new ItemAlignStretch(),
alignSelf ?? new ItemAlignAuto(),
alignContent ?? new ContentAlignStart(),
justifyContent ?? new ContentAlignSpaceBetween(),
1.0,
0.0,
basis ?? new DimensionAuto(),
gridColumns ?? new List<Dimension>(),
gridRows ?? new List<Dimension>());
public static Paint PlainPaint() => new(null, null, 0.0);
public static Node Boxed(string id, params Node[] children) =>
new BoxNode(Common(id), Style(), PlainPaint(), new RoleGroup(), children.ToList());
public static Node Text(string id, string content) =>
new TextNode(Common(id), LitText(content), "body", true, null, false);
public static CommandInvocation Invocation() => new(
"3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b",
"thing:surface:provider.a.main",
7,
"go",
new RecordPayload("gamecult.thing.test.go.v1", new byte[] { 0x91, 0x01, 0xff, 0x00 }),
new List<CapturedInput>
{
new("name-input", new TextValue("Ada")),
new("level", new NumberValue(0.75)),
new("flag", new BoolValue(true)),
new("pick", new ChoiceValue("a")),
},
"renderer.web",
"2026-01-01T00:00:00Z",
"delegate-1");
public static CommandInvocation InvocationNamed(string id) => new(
id, "thing:surface:provider.a.main", 7, "go",
new RecordPayload("gamecult.thing.test.go.v1", new byte[] { 0x91, 0x01, 0xff, 0x00 }),
new List<CapturedInput>(), "renderer.web", "2026-01-01T00:00:00Z", null);
public static List<BindParts> BindAllFormats()
{
var number = new NumberFormat(2, true, "ms");
var affix = new AffixFormat("$", "/s");
var indexed = new MemberPath(new List<MemberStep> { new MemberName("items"), new MemberIndex(3) });
return new List<BindParts>
{
RecordBind(indexed, number),
RecordBind(Names("elapsed"), new FormatDuration()),
RecordBind(Names("at"), new FormatTimestamp()),
RecordBind(Names("price"), affix),
RecordBind(Names("plain"), null),
ItemBind(Names("name")),
};
}
public static SurfaceDocument FullSurface()
{
var binds = BindAllFormats();
var boundText = binds[3].Text();
var boundBool = binds[4].Bool();
var boundNumber = binds[0].Number();
var painted = new BoxNode(
new NodeCommon("painted", new BoolSourceParam("show"), new TextSourceParam("label")),
Style(
display: new DisplayGrid(),
direction: new DirectionRowReverse(),
wrap: new WrapWrapReverse(),
position: new PositionAbsolute(),
overflow: new OverflowScroll(),
alignItems: new ItemAlignBaseline(),
alignSelf: new ItemAlignCenter(),
alignContent: new ContentAlignSpaceAround(),
justifyContent: new ContentAlignSpaceEvenly(),
gridColumns: new List<Dimension> { new DimensionFr(1), Px(120) },
gridRows: new List<Dimension> { new DimensionContent() }),
new Paint(new ThemeToken("surface.raised"), new Border(new Rgba(1, 2, 3, 255), 1.5), 4.0),
new RoleRegion(),
new List<Node> { Text("deep-text", "deep") });
var repeat = new RepeatNode(
Common("rows"),
new RecordsSource(null, "gamecult.thing.test.v1", "row-"),
Names("id"),
new TextNode(Common("row-name"), ItemBind(Names("name")).Text(), "body", false, 2, true));
var leaves = new List<Node>
{
new TextNode(Common("t-bound"), boundText, "label", true, null, false),
new ImageNode(Common("img"), "icons", "gear", new ImageFitCover(), LitText("a gear")),
new ButtonNode(Common("go"), new List<Node> { Text("go-label", "Go") }, Command("go"), boundBool),
new ToggleNode(Common("flag"), boundBool, new CommandRefParam("toggle")),
new SliderNode(Common("level"), 0.0, 1.0, 0.25, boundNumber, Command("set-level")),
new TextInputNode(Common("name-input"), true, new InputKindNumber(), LitText("name"), new TextSourceParam("initial"), Command("rename")),
new TextInputNode(Common("title-input"), false, new InputKindText(), LitText("title"), new TextSourceParam("initial"), Command("retitle")),
new SelectNode(
Common("pick"),
new LiteralOptions(new List<SelectOption> { new("a", "Alpha") }),
binds[4].ToBind(),
Command("pick")),
new SelectNode(
Common("pick-bound"),
new BoundOptions(binds[0].Collection(), Names("id"), Names("name")),
binds[5].ToBind(),
Command("pick")),
repeat,
new OverlayNode(Common("menu"), new AnchorNode("go"), false, Command("close"), new List<Node> { Text("menu-item", "one") }),
new OverlayNode(Common("dialog"), new AnchorViewport(), true, Command("close"), new List<Node>()),
new PlotNode(Common("trend"), binds[1].ToBind(), new PlotMarkArea(), 0.0, null, null, 100.0),
new EmbedNode(Common("viewport"), "world.scene", "main", 640.0, 480.0),
new UseNode(
Common("kv"),
new CompositionRef("thing.std", "key-value", 1),
new List<Arg> { new("key", new TextArg("k")) }),
};
var deep = Boxed("d4", painted);
foreach (var id in new[] { "d3", "d2", "d1" }) deep = Boxed(id, deep);
var children = new List<Node> { deep };
children.AddRange(leaves);
children.Add(new PlotNode(Common("trend-line"), binds[2].ToBind(), new PlotMarkLine(), null, null, null, null));
children.Add(new PlotNode(Common("trend-bar"), binds[2].ToBind(), new PlotMarkBar(), null, null, null, null));
var fits = new ImageFit[] { new ImageFitContain(), new ImageFitFill(), new ImageFitUnscaled() };
for (var index = 0; index < fits.Length; index++)
children.Add(new ImageNode(Common($"img-fit-{index}"), "icons", "gear", fits[index], LitText("")));
var roles = new Role[] { new RoleList(), new RoleListItem(), new RoleHeading(), new RoleDialog(), new RoleToolbar() };
for (var index = 0; index < roles.Length; index++)
{
var roleStyle = index == 0
? Style(direction: new DirectionRow(), alignContent: new ContentAlignAuto(), justifyContent: new ContentAlignStretch())
: Style();
children.Add(new BoxNode(Common($"role-{index}"), roleStyle, PlainPaint(), roles[index], new List<Node>()));
}
children.Add(new BoxNode(
Common("block"),
Style(
display: new DisplayBlock(),
direction: new DirectionColumnReverse(),
wrap: new WrapWrap(),
overflow: new OverflowClip(),
alignContent: new ContentAlignCenter(),
justifyContent: new ContentAlignEnd(),
alignItems: new ItemAlignEnd(),
alignSelf: new ItemAlignStart(),
basis: new DimensionContent()),
PlainPaint(),
new RoleGroup(),
new List<Node>()));
children.Add(new TextNode(Common("t-param"), new TextSourceParam("title"), "title", true, null, false));
children.Add(new ToggleNode(new NodeCommon("literal-number", new BoolSourceParam("v"), null), LitBool(false), Command("toggle")));
children.Add(new SliderNode(Common("slider-literal"), -1.0, 1.0, 0.5, LitNumber(0.5), Command("set-level")));
children.Add(new SliderNode(Common("slider-param"), 0.0, 10.0, 1.0, new NumberSourceParam("n"), Command("set-level")));
children.Add(new RepeatNode(Common("rows-param"), new CollectionSourceParam("rows"), Names("id"), Text("row-param", "r")));
return new SurfaceDocument(
"provider.a",
"main",
7,
"All arms",
Boxed("root", children.ToArray()),
new List<CommandDecl>
{
new("go", "gamecult.thing.test.go.v1", new List<string> { "name-input", "level" }, "Go now?"),
new("close", "gamecult.thing.test.close.v1", new List<string>(), null),
});
}
public static CompositionDocument FullComposition()
{
var args = new List<Arg>
{
new("text", new TextArg("t")),
new("number", new NumberArg(2.5)),
new("bool", new BoolArg(true)),
new("bind", RecordBind(Names("x"), null).Arg()),
new("collection", new CollectionArg(new CollectionSourceParam("rows"))),
new("command", new CommandArg(Command("go"))),
new("slot", new SlotArg(new List<Node> { Text("slot-text", "inside"), Boxed("slot-box") })),
};
var innerUse = new UseNode(Common("inner"), new CompositionRef("thing.std", "card", 2), args);
return new CompositionDocument(
"provider.a",
"panel",
3,
new List<ParamDecl>
{
new("title", new ParamKindText()),
new("count", new ParamKindNumber()),
new("on", new ParamKindBool()),
new("source", new ParamKindBind()),
new("rows", new ParamKindCollection()),
new("act", new ParamKindCommand()),
new("body", new ParamKindSlot()),
},
Boxed("panel-root", Text("panel-title", "t"), innerUse));
}
public static List<(string Name, CommandResult Result)> Results(CommandInvocation invocation) => new()
{
("result-pending", CommandResult.Pending(invocation, 1, "working")),
("result-completed", CommandResult.Completed(invocation, 2, 8, true, null)),
("result-completed-open-surface", CommandResult.Completed(invocation, 3, null, false, new OpenSurfaceEffect("thing:surface:provider.a.next"))),
("result-completed-open-external", CommandResult.Completed(invocation, 4, 9, true, new OpenExternalEffect("https://example.test/auth", Command("resume")))),
("result-denied", CommandResult.Denied(invocation, 5, "not yours", "go")),
("result-denied-no-node", CommandResult.Denied(invocation, 6, "not allowed", null)),
("result-failed", CommandResult.Failed(invocation, 7, "broke")),
};
public static AssetCatalog FullAssetCatalog() => new(
"provider.a",
"icons",
2,
new List<AssetEntry>
{
new("gear", "image/png", "assets/gear.png", 32, 32),
new("bolt", "image/svg+xml", "assets/bolt.svg", 0, 0),
});
public static SurfaceDocument SurfaceOf(string provider, string label) =>
new(provider, label, 1, "", Boxed("r"), new List<CommandDecl>());
public static CompositionDocument CompositionOf(string publisher, string name, long version) =>
new(publisher, name, version, new List<ParamDecl>(), Boxed("r"));
public static AssetCatalog CatalogOf(string provider, string id) => new(provider, id, 1, new List<AssetEntry>());
}

View file

@ -0,0 +1,61 @@
using MessagePack;
using static GameCult.Thing.Contract.Tests.Builders;
namespace GameCult.Thing.Contract.Tests;
/// <summary>
/// CultLib bounds payload nesting at 256 MessagePack containers (the document array is 1). A Box chain costs 3 containers per
/// level (union array, slots array, children list), and the deepest leaf's style adds 4 beyond its slots, so a chain of
/// 84 boxes nests 3 * 84 + 4 = 256 deep and 85 nests 259. Decoding recurses per level, so the default test thread carries
/// the deepest chain the bound admits: this package sets no thread size.
/// </summary>
public sealed class DeepTreeTests
{
private const int DeepestAdmitted = 84;
private static SurfaceDocument Chain(int levels)
{
var node = Boxed("leaf");
for (var level = levels - 1; level >= 1; level--) node = Boxed($"deep-{level}", node);
return new SurfaceDocument("provider.a", "deep", 1, "deep", node, new List<CommandDecl>());
}
[Test]
public void TheDeepestChainTheBoundAdmitsRoundTripsOnTheTestThread()
{
var chain = Chain(DeepestAdmitted);
var bytes = Wire.Write(chain);
var decoded = (SurfaceDocument)Wire.Read(typeof(SurfaceDocument), bytes);
Assert.That(Convert.ToHexString(Wire.Write(decoded)), Is.EqualTo(Convert.ToHexString(bytes)));
var depth = 0;
for (var node = decoded.Root; node is BoxNode box; node = box.Children.SingleOrDefault()!) depth++;
Assert.That(depth, Is.EqualTo(DeepestAdmitted));
}
[Test]
public void OneLevelDeeperIsRefusedByTheBoundOnReadAndOnWrite()
{
var chain = Chain(DeepestAdmitted + 1);
var write = Assert.Throws<MessagePackSerializationException>(() => Wire.Write(chain))!;
Assert.That(write.Message, Does.Contain("payload nests 257").And.Contain("the limit is 256"));
// The bytes a hostile sender could produce, past the writer's own check.
var bytes = MessagePackSerializer.Serialize(typeof(SurfaceDocument), chain, Wire.Options);
var read = Assert.Throws<MessagePackSerializationException>(() => Wire.Read(typeof(SurfaceDocument), bytes))!;
Assert.That(read.Message, Does.Contain("payload nests 257").And.Contain("the limit is 256"));
}
[Test]
public void AThousandNodesWideRoundTrips()
{
var children = Enumerable.Range(0, 998).Select(index => Text($"t{index}", "x")).ToList();
children.Add(Boxed("tail"));
var wide = new SurfaceDocument("provider.a", "wide", 1, "wide", Boxed("root", children.ToArray()), new List<CommandDecl>());
var bytes = Wire.Write(wide);
var decoded = (SurfaceDocument)Wire.Read(typeof(SurfaceDocument), bytes);
Assert.That(((BoxNode)decoded.Root).Children, Has.Count.EqualTo(999));
Assert.That(Convert.ToHexString(Wire.Write(decoded)), Is.EqualTo(Convert.ToHexString(bytes)));
}
}

View file

@ -0,0 +1,153 @@
using System.Text.Json;
using MessagePack;
using static GameCult.Thing.Contract.Tests.Builders;
namespace GameCult.Thing.Contract.Tests;
/// <summary>The four domains, at every constructor that carries one and at every door that reaches a constructor.</summary>
public sealed class DomainTests
{
private static CommandInvocation Inv() => Invocation();
// Each domain-carrying field of contract-rs r2, with one out-of-domain value. The expected text is Rust's DomainError Display.
private static IEnumerable<TestCaseData> OutOfDomain()
{
TestCaseData Case(string field, string message, Action build) => new TestCaseData(message, build).SetName($"OutOfDomain({field})");
yield return Case("CommandResult.Seq", "Seq must be at least 1", () => CommandResult.Pending(Inv(), 0, "m"));
yield return Case("CommandResult.Seq min", "Seq must be at least 1", () => CommandResult.Failed(Inv(), int.MinValue, "r"));
yield return Case("CompletedOutcome.SourceVersion", "Version must be at least 0", () => new CompletedOutcome(-1, false, null));
yield return Case("CommandResult.Completed source version", "Version must be at least 0", () => CommandResult.Completed(Inv(), 1, -1, false, null));
yield return Case("CommandInvocation.SurfaceVersion", "Version must be at least 0",
() => new CommandInvocation("i", "k", -1, "go", new RecordPayload("s", Array.Empty<byte>()), new List<CapturedInput>(), "c", "t", null));
yield return Case("SurfaceDocument.Label empty", "Segment must be non-empty", () => SurfaceOf("p", ""));
yield return Case("SurfaceDocument.Label dotted", "Segment must be free of '.'", () => SurfaceOf("p", "a.b"));
yield return Case("SurfaceDocument.Version", "Version must be at least 0", () => new SurfaceDocument("p", "l", -1, "t", Boxed("r"), new List<CommandDecl>()));
yield return Case("CompositionDocument.Name empty", "Segment must be non-empty", () => CompositionOf("p", "", 1));
yield return Case("CompositionDocument.Name dotted", "Segment must be free of '.'", () => CompositionOf("p", "a.b", 1));
yield return Case("CompositionDocument.Version", "Version must be at least 0", () => CompositionOf("p", "n", -1));
yield return Case("CompositionRef.Name empty", "Segment must be non-empty", () => new CompositionRef("p", "", 1));
yield return Case("CompositionRef.Name dotted", "Segment must be free of '.'", () => new CompositionRef("p", "a.b", 1));
yield return Case("CompositionRef.Version", "Version must be at least 0", () => new CompositionRef("p", "n", -1));
yield return Case("AssetCatalog.CatalogId empty", "Segment must be non-empty", () => CatalogOf("p", ""));
yield return Case("AssetCatalog.CatalogId dotted", "Segment must be free of '.'", () => CatalogOf("p", "a.b"));
yield return Case("AssetCatalog.Version", "Version must be at least 0", () => new AssetCatalog("p", "c", -1, new List<AssetEntry>()));
yield return Case("AssetEntry.Width", "Count must be at least 0", () => new AssetEntry("i", "m", "u", -1, 0));
yield return Case("AssetEntry.Height", "Count must be at least 0", () => new AssetEntry("i", "m", "u", 0, int.MinValue));
yield return Case("TextNode.MaxLines", "Count must be at least 0",
() => new TextNode(Common("t"), LitText("x"), "body", true, -1, false));
yield return Case("MemberIndex.Index", "Count must be at least 0", () => new MemberIndex(-1));
yield return Case("NumberFormat.Precision", "Count must be at least 0", () => new NumberFormat(-1, false, null));
}
[TestCaseSource(nameof(OutOfDomain))]
public void OutOfDomainConstructionIsRefusedNamingTheDomainAndBound(string message, Action build)
{
var error = Assert.Throws<ThingDomainException>(() => build())!;
Assert.That(error.Message, Is.EqualTo(message));
}
[Test]
public void BoundaryValuesAreInDomain()
{
Assert.DoesNotThrow(() => CommandResult.Pending(Inv(), 1, "m"));
Assert.DoesNotThrow(() => CommandResult.Pending(Inv(), int.MaxValue, "m"));
Assert.DoesNotThrow(() => new CompletedOutcome(0, false, null));
Assert.DoesNotThrow(() => new CompletedOutcome(null, false, null));
Assert.DoesNotThrow(() => new AssetEntry("i", "m", "u", 0, 0));
Assert.DoesNotThrow(() => new TextNode(Common("t"), LitText("x"), "body", true, 0, false));
Assert.DoesNotThrow(() => new TextNode(Common("t"), LitText("x"), "body", true, null, false));
Assert.DoesNotThrow(() => new MemberIndex(0));
Assert.DoesNotThrow(() => new CompositionRef("p", "n", 0));
Assert.DoesNotThrow(() => SurfaceOf("p", "a-b_c"));
}
[Test]
public void TheMessageNeverEchoesTheValue()
{
var dotted = Assert.Throws<ThingDomainException>(() => SurfaceOf("p", $"{Wire.Canary}.x"))!;
Assert.That(dotted.Message, Is.EqualTo("Segment must be free of '.'"));
Assert.That(dotted.ToString(), Does.Not.Contain(Wire.Canary));
Assert.That((dotted.Domain, dotted.Bound), Is.EqualTo(("Segment", "free of '.'")));
var seq = Assert.Throws<ThingDomainException>(() => CommandResult.Failed(Inv(), -77, "r"))!;
Assert.That(seq.Message, Does.Not.Contain("77"));
var version = Assert.Throws<ThingDomainException>(() => CompositionOf("p", "n", -77))!;
Assert.That(version.Message, Does.Not.Contain("77"));
var count = Assert.Throws<ThingDomainException>(() => new MemberIndex(-77))!;
Assert.That(count.Message, Does.Not.Contain("77"));
}
// ---- the doors: every deserializer reaches the one constructor ----
private static byte[] AssetEntryBytes(int width)
{
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
writer.WriteArrayHeader(5);
writer.Write("gear");
writer.Write("image/png");
writer.Write(Wire.Canary);
writer.Write(width);
writer.Write(32);
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
[Test]
public void CultLibMessagePackRefusesThroughTheConstructor()
{
var error = Assert.Throws<MessagePackSerializationException>(
() => MessagePackSerializer.Deserialize<AssetEntry>(AssetEntryBytes(-5), Wire.Options))!;
Assert.That(Wire.Flatten(error), Does.Contain("Count must be at least 0"));
Assert.That(Wire.Flatten(error), Does.Not.Contain(Wire.Canary));
Assert.That(MessagePackSerializer.Deserialize<AssetEntry>(AssetEntryBytes(5), Wire.Options).Width, Is.EqualTo(5));
}
[Test]
public void MessagePackStandardRefusesThroughTheConstructor()
{
var error = Assert.Throws<MessagePackSerializationException>(
() => MessagePackSerializer.Deserialize<AssetEntry>(AssetEntryBytes(-5), MessagePackSerializerOptions.Standard))!;
Assert.That(Wire.Flatten(error), Does.Contain("Count must be at least 0"));
Assert.That(Wire.Flatten(error), Does.Not.Contain(Wire.Canary));
Assert.That(MessagePackSerializer.Deserialize<AssetEntry>(AssetEntryBytes(5), MessagePackSerializerOptions.Standard).Width, Is.EqualTo(5));
}
[Test]
public void SystemTextJsonRefusesThroughTheConstructor()
{
string Json(int width) => $"{{\"Id\":\"gear\",\"MediaType\":\"image/png\",\"Uri\":\"{Wire.Canary}\",\"Width\":{width},\"Height\":32}}";
var error = Assert.Catch(() => JsonSerializer.Deserialize<AssetEntry>(Json(-5)))!;
Assert.That(Wire.Flatten(error), Does.Contain("Count must be at least 0"));
Assert.That(Wire.Flatten(error), Does.Not.Contain(Wire.Canary));
Assert.That(JsonSerializer.Deserialize<AssetEntry>(Json(5))!.Width, Is.EqualTo(5));
}
[Test]
public void ResultSeqZeroIsRefusedByEveryDoor()
{
var bytes = Wire.Golden("result-seq-zero.bin");
var cult = Assert.Throws<MessagePackSerializationException>(() => Wire.Read(typeof(CommandResult), bytes))!;
Assert.That(Wire.Flatten(cult), Does.Contain("Seq must be at least 1"));
var standard = Assert.Throws<MessagePackSerializationException>(
() => MessagePackSerializer.Deserialize<CommandResult>(bytes, MessagePackSerializerOptions.Standard))!;
Assert.That(Wire.Flatten(standard), Does.Contain("Seq must be at least 1"));
Assert.That(Wire.Flatten(standard), Does.Not.Contain(Wire.Canary));
// System.Text.Json has no constructor it may call on a result: the type is refused outright.
Assert.Throws<NotSupportedException>(
() => JsonSerializer.Deserialize<CommandResult>("{\"InvocationId\":\"inv-1\",\"Seq\":1,\"Outcome\":null}"));
}
[Test]
public void ResultsAtSeqOneDecodeThroughTheStandardDoorToo()
{
var bytes = Wire.Golden("result-pending.bin");
var standard = MessagePackSerializer.Deserialize<CommandResult>(bytes, MessagePackSerializerOptions.Standard);
Assert.That(standard.Seq, Is.EqualTo(1));
Assert.That(standard.Outcome, Is.TypeOf<PendingOutcome>());
}
}

View file

@ -0,0 +1,81 @@
using System.Reflection;
using MessagePack;
namespace GameCult.Thing.Contract.Tests;
/// <summary>
/// Every way a caller can build a contract value without decoding: each public constructor, each public static factory
/// returning its own type, and the key builders. Arguments are built through the same public constructors a consumer
/// would use, so a test's inputs never come from a private door.
/// </summary>
internal static class Doors
{
private static readonly Assembly Package = typeof(CommandResult).Assembly;
public sealed record Door(string Name, MethodBase Method)
{
public ParameterInfo[] Parameters => Method.GetParameters();
public object? Call(object?[] args)
{
try
{
return Method is ConstructorInfo ctor ? ctor.Invoke(args) : Method.Invoke(null, args);
}
catch (TargetInvocationException error)
{
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(error.InnerException!).Throw();
throw;
}
}
public object?[] ValidArgs() => Parameters.Select(p => Make(p.ParameterType)).ToArray();
}
public static IReadOnlyList<Door> All()
{
var doors = new List<Door>();
foreach (var type in Package.GetExportedTypes().Where(t => (!t.IsAbstract || t.IsSealed) && t != typeof(ThingDomainException)))
{
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.Instance))
doors.Add(new Door($"new {type.Name}", ctor));
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly))
{
if (type.Name == nameof(ThingDomain)) continue;
if (method.ReturnType == type || type.Name == nameof(ThingKeys)) doors.Add(new Door($"{type.Name}.{method.Name}", method));
}
}
return doors;
}
/// <summary>A valid value of <paramref name="type"/>: the first union arm of a union, the first public constructor otherwise.</summary>
public static object Make(Type type, int depth = 0)
{
if (depth > 40) throw new InvalidOperationException($"no finite valid value of {type.Name}");
var underlying = Nullable.GetUnderlyingType(type);
if (underlying != null) return Make(underlying, depth);
if (type == typeof(string)) return "x";
if (type == typeof(int)) return 1;
if (type == typeof(long)) return 1L;
if (type == typeof(double)) return 1.0;
if (type == typeof(bool)) return true;
if (type == typeof(byte)) return (byte)1;
if (type == typeof(byte[])) return new byte[] { 1 };
if (type == typeof(ReadOnlyMemory<byte>)) return new ReadOnlyMemory<byte>(new byte[] { 1 });
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IReadOnlyList<>))
return MakeList(type.GetGenericArguments()[0], 0, depth);
var arms = type.GetCustomAttributes<UnionAttribute>(false).ToList();
if (type.IsAbstract && arms.Count > 0) return Make(arms[0].SubType, depth + 1);
var ctor = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance).First();
return ctor.Invoke(ctor.GetParameters().Select(p => Make(p.ParameterType, depth + 1)).ToArray());
}
/// <summary>A mutable list of <paramref name="count"/> valid elements, as a consumer holds one before constructing.</summary>
public static System.Collections.IList MakeList(Type element, int count, int depth = 0)
{
var list = (System.Collections.IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(element))!;
for (var i = 0; i < count; i++) list.Add(Make(element, depth + 1));
return list;
}
}

View file

@ -0,0 +1,142 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using MessagePack;
using static GameCult.Thing.Contract.Tests.Builders;
namespace GameCult.Thing.Contract.Tests;
/// <summary>
/// The package is attacked as an outside consumer would: through its public surface only. A public or init setter would
/// admit an object initializer or a with-expression; a record would admit both; a public union constructor would admit a
/// subtype nobody declared; a public CommandResult constructor would admit a result no factory built.
/// </summary>
public sealed class FactoriesOnlyTests
{
private static readonly Assembly Package = typeof(CommandResult).Assembly;
private static IEnumerable<Type> PublicTypes() => Package.GetExportedTypes();
[Test]
public void TheCommandResultHasNoPublicConstructor()
{
Assert.That(typeof(CommandResult).GetConstructors(BindingFlags.Public | BindingFlags.Instance), Is.Empty);
Assert.That(typeof(CommandResult).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance), Has.Length.EqualTo(1));
}
[Test]
public void NoContractTypeHasAPublicOrInitSetterAField_OrIsARecord()
{
var offenders = new List<string>();
foreach (var type in PublicTypes())
{
foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
var setter = property.SetMethod;
if (setter == null) continue;
var init = setter.ReturnParameter.GetRequiredCustomModifiers().Contains(typeof(IsExternalInit));
if (setter.IsPublic || init || setter.IsAssembly || setter.IsFamily) offenders.Add($"{type.Name}.{property.Name} setter{(init ? " (init)" : "")}");
}
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
offenders.Add($"{type.Name}.{field.Name} public field");
if (type.GetMethod("<Clone>$", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) != null)
offenders.Add($"{type.Name} is a record (<Clone>$)");
}
Assert.That(offenders, Is.Empty);
Assert.That(PublicTypes().Count(), Is.GreaterThan(100), "the scan reached the package's types");
}
[Test]
public void EveryUnionBaseConstructorIsNonPublic()
{
var unions = PublicTypes().Where(t => t.IsAbstract && t.GetCustomAttributes<UnionAttribute>(false).Any()).ToList();
Assert.That(unions, Has.Count.GreaterThan(25));
foreach (var union in unions)
{
Assert.That(union.GetConstructors(BindingFlags.Public | BindingFlags.Instance), Is.Empty, union.Name);
Assert.That(union.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance), Is.Not.Empty, union.Name);
}
}
[Test]
public void EveryUnionArmIsSealedAndDeclared()
{
// The arms are the union attributes' types, each sealed and deriving the union directly: no outside subtype exists.
var unions = PublicTypes().Where(t => t.IsAbstract && t.GetCustomAttributes<UnionAttribute>(false).Any());
foreach (var union in unions)
{
var arms = union.GetCustomAttributes<UnionAttribute>(false).ToList();
Assert.That(arms.Select(a => a.Key), Is.Unique, union.Name);
foreach (var arm in arms)
{
Assert.That(arm.SubType.IsSealed, Is.True, $"{union.Name}.{arm.SubType.Name}");
Assert.That(arm.SubType.BaseType, Is.EqualTo(union), $"{union.Name}.{arm.SubType.Name}");
}
var derived = Package.GetTypes().Where(t => t.BaseType == union).ToList();
Assert.That(derived, Is.EquivalentTo(arms.Select(a => a.SubType)), $"{union.Name}: every subtype is a declared arm");
}
}
[Test]
public void EveryDocumentCarriesTheCratesSchemaNameAndVersion()
{
var expected = new Dictionary<Type, (string, string)>
{
[typeof(SurfaceDocument)] = ("gamecult.thing.surface", "gamecult.thing.surface.v1"),
[typeof(CompositionDocument)] = ("gamecult.thing.composition", "gamecult.thing.composition.v1"),
[typeof(CommandInvocation)] = ("gamecult.thing.command_invocation", "gamecult.thing.command_invocation.v1"),
[typeof(CommandResult)] = ("gamecult.thing.command_result", "gamecult.thing.command_result.v1"),
[typeof(AssetCatalog)] = ("gamecult.thing.asset_catalog", "gamecult.thing.asset_catalog.v1"),
};
foreach (var (type, (name, version)) in expected)
{
var attribute = type.GetCustomAttribute<GameCult.Caching.CultDocumentAttribute>()!;
Assert.That((attribute.SchemaName, attribute.SchemaVersion), Is.EqualTo((name, version)), type.Name);
}
var documents = PublicTypes().Where(t => t.GetCustomAttribute<GameCult.Caching.CultDocumentAttribute>() != null).ToList();
Assert.That(documents, Is.EquivalentTo(expected.Keys));
}
[Test]
public void TheFactoriesCopyTheInvocationIdAndHoldSeq()
{
var invocation = InvocationNamed("an-invocation-no-constructor-can-guess");
var built = new[]
{
CommandResult.Pending(invocation, 1, "m"),
CommandResult.Completed(invocation, 2, null, false, null),
CommandResult.Denied(invocation, 3, "r", null),
CommandResult.Failed(invocation, 4, "r"),
};
for (var index = 0; index < built.Length; index++)
{
Assert.That(built[index].InvocationId, Is.EqualTo("an-invocation-no-constructor-can-guess"));
Assert.That(built[index].Seq, Is.EqualTo(index + 1));
}
Assert.That(built[0].Outcome, Is.TypeOf<PendingOutcome>());
Assert.That(((PendingOutcome)built[0].Outcome).Message, Is.EqualTo("m"));
Assert.That(built[1].Outcome, Is.TypeOf<CompletedOutcome>());
Assert.That(built[2].Outcome, Is.TypeOf<DeniedOutcome>());
Assert.That(built[3].Outcome, Is.TypeOf<FailedOutcome>());
}
[Test]
public void TheFactoriesFillEveryOutcomeSlot()
{
var invocation = InvocationNamed("i");
var effect = new OpenSurfaceEffect("k");
var completed = (CompletedOutcome)CommandResult.Completed(invocation, 1, 5, true, effect).Outcome;
Assert.That((completed.SourceVersion, completed.ClearDrafts, completed.Effect), Is.EqualTo(((long?)5, true, (Effect?)effect)));
var bare = (CompletedOutcome)CommandResult.Completed(invocation, 1, null, false, null).Outcome;
Assert.That((bare.SourceVersion, bare.ClearDrafts, bare.Effect), Is.EqualTo(((long?)null, false, (Effect?)null)));
var denied = (DeniedOutcome)CommandResult.Denied(invocation, 1, "no", "go").Outcome;
Assert.That((denied.Reason, denied.Node), Is.EqualTo(("no", (string?)"go")));
var failed = (FailedOutcome)CommandResult.Failed(invocation, 1, "broke").Outcome;
Assert.That(failed.Reason, Is.EqualTo("broke"));
}
}

View file

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<BaseOutputPath>obj\bin\</BaseOutputPath>
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
</PropertyGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\packages\GameCult.Thing.Contract\GameCult.Thing.Contract.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\crates\thing-contract\tests\goldens\*"
Link="goldens\%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
<None Include="..\..\tests\thing-contract-parity\*"
Link="parity\%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,140 @@
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using static GameCult.Thing.Contract.Tests.Builders;
namespace GameCult.Thing.Contract.Tests;
public sealed class GoldenTests
{
private static readonly string[] RefusalGoldens =
{
"result-thing1-flat", "node-unknown-arm", "outcome-unknown-arm", "result-extra-slot", "result-seq-zero",
"surface-version-negative", "surface-label-dotted", "asset-extent-negative", "rgba-channel-out-of-range",
};
private static IEnumerable<TestCaseData> ValueGoldens()
{
yield return new TestCaseData("surface-all-arms", (Func<object>)FullSurface);
yield return new TestCaseData("composition-all-params", (Func<object>)FullComposition);
yield return new TestCaseData("invocation-all-inputs", (Func<object>)Invocation);
foreach (var (name, _) in Results(Invocation()))
{
var captured = name;
yield return new TestCaseData(captured, (Func<object>)(() => Results(Invocation()).Single(r => r.Name == captured).Result));
}
yield return new TestCaseData("asset-catalog", (Func<object>)FullAssetCatalog);
}
[TestCaseSource(nameof(ValueGoldens))]
public void Goldens(string name, Func<object> build)
{
var value = build();
var golden = Wire.Golden($"{name}.bin");
Assert.That(Convert.ToHexString(Wire.Write(value)), Is.EqualTo(Convert.ToHexString(golden)), $"{name}: the package writes the committed bytes");
var decoded = Wire.Read(value.GetType(), golden);
Assert.That(Convert.ToHexString(Wire.Write(decoded)), Is.EqualTo(Convert.ToHexString(golden)), $"{name}: decoding and re-encoding keeps the bytes");
}
[Test]
public void EveryBinGoldenIsNamedByACase()
{
var named = ValueGoldens().Select(c => (string)c.Arguments[0]!).Concat(RefusalGoldens)
.OrderBy(n => n, StringComparer.Ordinal).ToArray();
var onDisk = Directory.GetFiles(Path.GetDirectoryName(Wire.GoldenPath("x"))!, "*.bin")
.Select(f => Path.GetFileNameWithoutExtension(f)).OrderBy(n => n, StringComparer.Ordinal).ToArray();
Assert.That(onDisk, Is.EqualTo(named));
}
// ---- refusals ----
private static string Refused(Type type, string golden)
{
var bytes = Wire.Golden($"{golden}.bin");
var error = Assert.Throws<MessagePackSerializationException>(() => Wire.Read(type, bytes))!;
var text = Wire.Flatten(error);
Assert.That(text, Does.Not.Contain(Wire.Canary), $"{golden}: the refusal echoes no value");
return text;
}
[Test]
public void Thing1FlatMapIsRefused() => Refused(typeof(CommandResult), "result-thing1-flat");
[TestCase("node-unknown-arm", typeof(SurfaceDocument), "Node", "13")]
[TestCase("outcome-unknown-arm", typeof(CommandResult), "Outcome", "4")]
public void UnknownArmsAreRefusedNamingUnionAndKey(string golden, Type type, string union, string key)
{
var text = Refused(type, golden);
Assert.That(text, Does.Contain($"{typeof(CommandResult).Namespace}.{union} has no arm for key {key}"));
}
[TestCase("result-seq-zero", typeof(CommandResult), "Seq")]
[TestCase("surface-version-negative", typeof(SurfaceDocument), "Version")]
[TestCase("surface-label-dotted", typeof(SurfaceDocument), "Segment")]
[TestCase("asset-extent-negative", typeof(AssetCatalog), "Count")]
public void DomainGoldensAreRefusedNamingTheDomain(string golden, Type type, string domain)
{
Assert.That(Refused(type, golden), Does.Contain($"{domain} must be"));
}
[Test]
public void RgbaChannelAbove255IsRefused()
{
var bytes = Wire.Golden("rgba-channel-out-of-range.bin");
var error = Assert.Throws<MessagePackSerializationException>(
() => MessagePackSerializer.Deserialize<Rgba>(bytes, Wire.Options))!;
var text = Wire.Flatten(error);
Assert.That(text, Does.Not.Contain(Wire.Canary));
Assert.That(text, Does.Contain("Rgba"));
}
[Test]
public void ExtraTrailingSlotIsReadAsPendingSeqOne()
{
var golden = Wire.Golden("result-extra-slot.bin");
var result = (CommandResult)Wire.Read(typeof(CommandResult), golden);
Assert.That(result.InvocationId, Is.EqualTo("inv-1"));
Assert.That(result.Seq, Is.EqualTo(1));
Assert.That(result.Outcome, Is.TypeOf<PendingOutcome>());
Assert.That(((PendingOutcome)result.Outcome).Message, Is.EqualTo("working"));
// Read, never written back: re-encoding drops the extra slot.
Assert.That(Wire.Write(result).Length, Is.LessThan(golden.Length));
}
// ---- the store ----
// surface.cc's catalog is written by cultcache-rs with empty members, so this proves bytes and keys only; the type
// names are NameSetTests.
[Test]
public void StoreRead()
{
var scratch = Path.Combine(Path.GetTempPath(), $"thing-contract-store-{Guid.NewGuid():N}.cc");
File.Copy(Wire.GoldenPath("surface.cc"), scratch);
try
{
using var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Wire.Registry, ReadOnly = true });
var cases = new (object Value, string Golden, string Key)[]
{
(cache.GetAll<SurfaceDocument>().Single(), "surface-all-arms", "thing:surface:provider.a.main"),
(cache.GetAll<CompositionDocument>().Single(), "composition-all-params", "thing:composition:provider.a.panel.v3"),
(cache.GetAll<CommandInvocation>().Single(), "invocation-all-inputs", "thing:invocation:3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b"),
(cache.GetAll<CommandResult>().Single(), "result-pending", "thing:result:3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b.1"),
(cache.GetAll<AssetCatalog>().Single(), "asset-catalog", "thing:asset_catalog:provider.a.icons"),
};
foreach (var (value, golden, key) in cases)
{
Assert.That(Convert.ToHexString(Wire.Write(value)), Is.EqualTo(Convert.ToHexString(Wire.Golden($"{golden}.bin"))), golden);
var recordKey = (string)value.GetType().GetProperty("RecordKey")!.GetValue(value)!;
Assert.That(recordKey, Is.EqualTo(key), golden);
Assert.That(cache.Get(new CultRecordKey(recordKey)), Is.SameAs(value), $"{golden}: stored under the package's own key");
}
}
finally
{
File.Delete(scratch);
}
}
}

View file

@ -0,0 +1,18 @@
using System.Reflection;
using MessagePack;
namespace GameCult.Thing.Contract.Tests;
/// <summary>One name in every runtime: Rust input_kind, C# InputKind, TypeScript inputKind. The wire is the slot key, 2.</summary>
public sealed class InputKindNameTests
{
[Test]
public void TextInputNodeSlotTwoIsInputKindAndThereIsNoKind()
{
var properties = typeof(TextInputNode).GetProperties(BindingFlags.Public | BindingFlags.Instance);
var slotTwo = properties.Single(p => p.GetCustomAttribute<KeyAttribute>()?.IntKey == 2);
Assert.That((slotTwo.Name, slotTwo.PropertyType), Is.EqualTo(("InputKind", typeof(InputKind))));
Assert.That(properties.Select(p => p.Name), Does.Not.Contain("Kind"));
Assert.That(typeof(TextInputNode).GetConstructors().Single().GetParameters()[2].Name, Is.EqualTo("inputKind"));
}
}

View file

@ -0,0 +1,66 @@
using static GameCult.Thing.Contract.Tests.Builders;
namespace GameCult.Thing.Contract.Tests;
public sealed class KeyTests
{
[Test]
public void KeysAreBuiltFromTheFields()
{
var invocation = InvocationNamed("an-invocation-no-constructor-can-guess");
Assert.That(invocation.RecordKey, Is.EqualTo("thing:invocation:an-invocation-no-constructor-can-guess"));
Assert.That(CommandResult.Failed(invocation, 3, "r").RecordKey, Is.EqualTo("thing:result:an-invocation-no-constructor-can-guess.3"));
Assert.That(SurfaceOf("p", "main").RecordKey, Is.EqualTo("thing:surface:p.main"));
Assert.That(CompositionOf("pub", "card", 2).RecordKey, Is.EqualTo("thing:composition:pub.card.v2"));
Assert.That(CatalogOf("p", "icons").RecordKey, Is.EqualTo("thing:asset_catalog:p.icons"));
}
[Test]
public void KeysAreInjective()
{
Assert.That(SurfaceOf("a.b", "c").RecordKey, Is.EqualTo("thing:surface:a.b.c"));
Assert.Throws<ThingDomainException>(() => SurfaceOf("a", "b.c"));
Assert.That(SurfaceOf("a", "c").RecordKey, Is.Not.EqualTo(SurfaceOf("a.b", "c").RecordKey));
Assert.That(SurfaceOf("a.b", "c").RecordKey, Is.Not.EqualTo(SurfaceOf("a", "bc").RecordKey));
Assert.That(CompositionOf("a.b", "c", 1).RecordKey, Is.EqualTo("thing:composition:a.b.c.v1"));
Assert.That(CompositionOf("a.b", "c", 1).RecordKey, Is.Not.EqualTo(CompositionOf("a", "bc", 1).RecordKey));
Assert.That(CompositionOf("a", "c", 1).RecordKey, Is.Not.EqualTo(CompositionOf("a", "c", 11).RecordKey));
Assert.That(CompositionOf("a", "c", 1).RecordKey, Is.Not.EqualTo(CompositionOf("a", "c", 0).RecordKey));
Assert.That(CatalogOf("a.b", "c").RecordKey, Is.EqualTo("thing:asset_catalog:a.b.c"));
Assert.That(CatalogOf("a.b", "c").RecordKey, Is.Not.EqualTo(CatalogOf("a", "bc").RecordKey));
var first = CommandResult.Failed(InvocationNamed("x.1"), 2, "r").RecordKey;
var second = CommandResult.Failed(InvocationNamed("x"), 12, "r").RecordKey;
Assert.That(first, Is.EqualTo("thing:result:x.1.2"));
Assert.That(second, Is.EqualTo("thing:result:x.12"));
Assert.That(first, Is.Not.EqualTo(second));
}
[TestCase("")]
[TestCase(".")]
[TestCase("a.b")]
public void EveryKeyBuilderRefusesABadSegment(string segment)
{
void Names(TestDelegate act)
{
var error = Assert.Throws<ThingDomainException>(act)!;
Assert.That(error.Message, Does.StartWith("Segment must be"));
}
Names(() => ThingKeys.SurfaceKey("p", segment));
Names(() => ThingKeys.CompositionKey("p", segment, 1));
Names(() => ThingKeys.AssetCatalogKey("p", segment));
Names(() => SurfaceOf("p", segment));
Names(() => CompositionOf("p", segment, 1));
Names(() => CatalogOf("p", segment));
}
[Test]
public void KeyBuildersRefuseOutOfDomainNumbers()
{
Assert.Throws<ThingDomainException>(() => ThingKeys.CompositionKey("p", "n", -1));
Assert.Throws<ThingDomainException>(() => ThingKeys.ResultKey("i", 0));
}
}

View file

@ -0,0 +1,142 @@
using GameCult.Caching;
using GameCult.Caching.MessagePack;
namespace GameCult.Thing.Contract.Tests;
/// <summary>
/// The names a CultCache store records for the package's documents. CultLib's C# reference compares catalog type names
/// when a store reopens, so these names are the C# package's to own: tests/thing-contract-parity holds them for every
/// runtime package, and they change only in the commit that changes the C# types.
/// </summary>
public sealed class NameSetTests
{
private static string ParityPath(string file) => Path.Combine(AppContext.BaseDirectory, "parity", file);
private static string[] Lines(string file) => File.ReadAllLines(ParityPath(file)).Where(l => l.Length > 0).ToArray();
[Test]
public void TheCatalogEntriesAreTheMembersFile()
{
var actual = Wire.DocumentTypes.OrderBy(t => Wire.Registry.GetRequired(t).ToCatalogEntry().SchemaId, StringComparer.Ordinal)
.SelectMany(t =>
{
var entry = Wire.Registry.GetRequired(t).ToCatalogEntry();
return entry.Members.Select(m => $"{entry.SchemaId}\t{m.Slot}\t{m.MemberName}\t{m.TypeName}");
}).ToArray();
Assert.That(actual, Is.EqualTo(Lines("catalog-members.tsv")));
Assert.That(actual, Has.Length.GreaterThan(20));
}
[Test]
public void ThePublicTypesAreTheTypeNamesFile()
{
var actual = typeof(CommandResult).Assembly.GetExportedTypes().Select(t => t.FullName!).OrderBy(n => n, StringComparer.Ordinal).ToArray();
Assert.That(actual, Is.EqualTo(Lines("type-names.txt")));
Assert.That(actual, Has.Length.GreaterThan(150));
}
private static CultSchemaCatalogEntry Persisted(Type document, string memberTypeFrom, string memberTypeTo)
{
var local = Wire.Registry.GetRequired(document).ToCatalogEntry();
return new CultSchemaCatalogEntry
{
SchemaId = local.SchemaId[..^1] + (local.SchemaId[^1] == '0' ? '1' : '0'),
SchemaName = local.SchemaName,
SchemaVersion = local.SchemaVersion,
ContentHash = local.ContentHash[..^1] + (local.ContentHash[^1] == '0' ? '1' : '0'),
CanonicalSchemaJson = local.CanonicalSchemaJson,
Members = local.Members.Select(m => new CultSchemaMemberCatalogEntry
{
Slot = m.Slot,
MemberName = m.MemberName,
TypeName = m.TypeName == memberTypeFrom ? memberTypeTo : m.TypeName,
IsReference = m.IsReference,
IsMany = m.IsMany,
TargetSchemaName = m.TargetSchemaName,
IsName = m.IsName,
IndexAlias = m.IndexAlias,
}).ToArray(),
};
}
// A persisted entry equal in id and content hash resolves without comparison, so the entries below move both.
[Test]
public void APersistedCatalogWhoseTypeNameDiffersIsRefusedByTheComparison()
{
var unchanged = Persisted(typeof(AssetCatalog), "-", "-");
Assert.That(Wire.Registry.ResolvePersistedSchemaReport(unchanged.SchemaId, new[] { unchanged }).Kind,
Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift), "a moved id with the same names resolves");
var renamed = Persisted(typeof(AssetCatalog), "System.Int64", "System.Int32");
var error = Assert.Throws<InvalidOperationException>(() => Wire.Registry.ResolvePersistedSchema(renamed.SchemaId, new[] { renamed }))!;
Assert.That(error.Message, Does.Contain("changed type").And.Contain("System.Int32").And.Contain("System.Int64"));
var listed = Persisted(typeof(AssetCatalog), "System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.AssetEntry>",
"System.Collections.Generic.List<GameCult.Thing.Contract.AssetEntry>");
Assert.Throws<InvalidOperationException>(() => Wire.Registry.ResolvePersistedSchema(listed.SchemaId, new[] { listed }),
"a store written when Assets was a List is refused");
}
private static int Patch(byte[] bytes, string from, string to)
{
var f = System.Text.Encoding.UTF8.GetBytes(from);
var t = System.Text.Encoding.UTF8.GetBytes(to);
Assert.That(t, Has.Length.EqualTo(f.Length));
var count = 0;
for (var i = 0; i + f.Length <= bytes.Length; i++)
{
if (!bytes.AsSpan(i, f.Length).SequenceEqual(f)) continue;
t.CopyTo(bytes, i);
count++;
}
return count;
}
private static string Moved(string hex) => hex[..^1] + (hex[^1] == '0' ? '1' : '0');
// CultCache reopens a store by schema id; an id it does not own is resolved by name and compared member by member.
// A store whose recorded id and content hash name another schema and whose type name was changed is refused, while
// the same patch without the type name opens: the refusal is the type-name comparison, end to end.
[Test]
public void AStoreWhoseRecordedTypeNameDiffersIsRefusedOnReopen()
{
var path = Path.Combine(Path.GetTempPath(), $"thing-names-{Guid.NewGuid():N}.cc");
try
{
using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Wire.Registry }))
{
cache.Commit(b => b.Upsert(Builders.FullAssetCatalog()));
cache.FlushAsync().GetAwaiter().GetResult();
}
var entry = Wire.Registry.GetRequired(typeof(AssetCatalog)).ToCatalogEntry();
var written = File.ReadAllBytes(path);
byte[] Patched(bool typeName)
{
var bytes = (byte[])written.Clone();
Assert.That(Patch(bytes, entry.SchemaId, Moved(entry.SchemaId)), Is.GreaterThan(0), "the schema id was recorded");
Assert.That(Patch(bytes, entry.ContentHash, Moved(entry.ContentHash)), Is.GreaterThan(0), "the content hash was recorded");
if (typeName) Assert.That(Patch(bytes, "System.Int64", "System.Int32"), Is.GreaterThan(0), "a member type name was recorded");
return bytes;
}
int Reopen(byte[] bytes)
{
File.WriteAllBytes(path, bytes);
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Wire.Registry, ReadOnly = true });
return cache.GetAll<AssetCatalog>().Count();
}
Assert.That(Reopen(written), Is.EqualTo(1), "the unpatched store reopens");
Assert.That(Reopen(Patched(typeName: false)), Is.EqualTo(1), "a moved id and hash with the same names reopens");
var error = Assert.Catch(() => Reopen(Patched(typeName: true)))!;
Assert.That(Wire.Flatten(error), Does.Contain("changed type").And.Contain("System.Int32").And.Contain("System.Int64"));
}
finally
{
File.Delete(path);
}
}
}

View file

@ -0,0 +1,75 @@
using MessagePack;
namespace GameCult.Thing.Contract.Tests;
/// <summary>
/// The nil vector, written by the Rust crate and read here (goldens/nil-positions.tsv): for every array element of every
/// value golden, the golden with that element replaced by nil, and whether Rust's decode door accepted it. A slot is
/// required exactly when its Rust type is not optional, so this package keeps no list of required slots: it is held to
/// every row.
/// </summary>
public sealed class NilPositionTests
{
private sealed record Row(string Golden, string Path, int Start, int End, bool Accept);
private static readonly Dictionary<string, Type> Kinds = new()
{
["surface-all-arms"] = typeof(SurfaceDocument),
["composition-all-params"] = typeof(CompositionDocument),
["invocation-all-inputs"] = typeof(CommandInvocation),
["result-pending"] = typeof(CommandResult),
["result-completed"] = typeof(CommandResult),
["result-completed-open-surface"] = typeof(CommandResult),
["result-completed-open-external"] = typeof(CommandResult),
["result-denied"] = typeof(CommandResult),
["result-denied-no-node"] = typeof(CommandResult),
["result-failed"] = typeof(CommandResult),
["asset-catalog"] = typeof(AssetCatalog),
};
private static List<Row> Rows() =>
File.ReadAllLines(Wire.GoldenPath("nil-positions.tsv")).Select(line =>
{
var cells = line.Split('\t');
Assert.That(cells, Has.Length.EqualTo(5), line);
Assert.That(cells[4], Is.AnyOf("ACCEPT", "REFUSE"), line);
return new Row(cells[0], cells[1], int.Parse(cells[2]), int.Parse(cells[3]), cells[4] == "ACCEPT");
}).ToList();
private static IEnumerable<string> Goldens() => Kinds.Keys;
[Test]
public void TheVectorNamesEveryValueGoldenAndNothingElse()
{
var rows = Rows();
Assert.That(rows.Select(r => r.Golden).Distinct().OrderBy(n => n, StringComparer.Ordinal), Is.EqualTo(Kinds.Keys.OrderBy(n => n, StringComparer.Ordinal)));
Assert.That(rows, Has.Count.GreaterThan(1500));
Assert.That(rows.Count(r => r.Accept), Is.GreaterThan(0));
}
[TestCaseSource(nameof(Goldens))]
public void EveryNilPositionIsDecidedAsRustDecidedIt(string golden)
{
var rows = Rows().Where(r => r.Golden == golden).ToList();
Assert.That(rows, Is.Not.Empty);
var bytes = Wire.Golden($"{golden}.bin");
var wrong = new List<string>();
foreach (var row in rows)
{
var spliced = bytes[..row.Start].Concat(new byte[] { 0xC0 }).Concat(bytes[row.End..]).ToArray();
try
{
var decoded = Wire.Read(Kinds[golden], spliced);
if (!row.Accept) wrong.Add($"{row.Path}: accepted though Rust refused");
else if (!Wire.Write(decoded).SequenceEqual(spliced)) wrong.Add($"{row.Path}: accepted but re-encodes to other bytes");
}
catch (MessagePackSerializationException)
{
if (row.Accept) wrong.Add($"{row.Path}: refused though Rust accepted");
}
}
TestContext.Progress.WriteLine($"NILROWS {golden} rows={rows.Count} accept={rows.Count(r => r.Accept)} refuse={rows.Count(r => !r.Accept)}");
Assert.That(wrong.Take(20), Is.Empty, $"{golden}: {wrong.Count} rows differ from Rust");
}
}

View file

@ -0,0 +1,81 @@
using System.Reflection;
namespace GameCult.Thing.Contract.Tests;
/// <summary>
/// The constructor door: a slot the annotations make non-null refuses null, naming the slot; a slot they make nullable
/// accepts it. The annotations mirror the Rust contract's Option types (the nil vector proves that against Rust), so no
/// list of required slots lives here.
/// </summary>
public sealed class PresenceTests
{
private static string Slot(string parameter) => parameter.TrimStart('@');
[Test]
public void EveryConstructorAndFactoryHoldsPresenceAtEveryReferenceParameter()
{
var failures = new List<string>();
var required = 0;
var optional = 0;
var context = new NullabilityInfoContext();
foreach (var door in Doors.All())
{
var parameters = door.Parameters;
for (var index = 0; index < parameters.Length; index++)
{
var parameter = parameters[index];
var isValue = parameter.ParameterType.IsValueType && Nullable.GetUnderlyingType(parameter.ParameterType) == null;
if (isValue) continue;
var state = context.Create(parameter).WriteState;
var args = door.ValidArgs();
args[index] = null;
try
{
door.Call(args);
if (state == NullabilityState.NotNull) failures.Add($"{door.Name}({parameter.Name}) accepted null");
else optional++;
}
catch (ThingDomainException error)
{
if (state != NullabilityState.NotNull) failures.Add($"{door.Name}({parameter.Name}) refused null though nullable: {error.Message}");
else if (!string.Equals(error.Domain, Slot(parameter.Name!), StringComparison.OrdinalIgnoreCase) && error.Domain != "Segment")
failures.Add($"{door.Name}({parameter.Name}) named {error.Domain}");
else required++;
}
catch (Exception error)
{
failures.Add($"{door.Name}({parameter.Name}) threw {error.GetType().Name}");
}
}
}
Assert.That(failures, Is.Empty);
TestContext.Progress.WriteLine($"PRESENCE required={required} optional={optional}");
Assert.That(required, Is.GreaterThan(150), "the scan reached the required slots");
Assert.That(optional, Is.GreaterThan(15), "the scan reached the optional slots");
}
[Test]
public void ARefusalNamesTheSlotAndNeverAValue()
{
var invocation = Builders.InvocationNamed("i");
var pending = Assert.Throws<ThingDomainException>(() => CommandResult.Pending(invocation, 1, null!))!;
Assert.That(pending.Domain, Is.EqualTo("Message"));
Assert.That(pending.Message, Is.EqualTo("Message must be present"));
var outcome = Assert.Throws<ThingDomainException>(() => CommandResult.Pending(null!, 1, Wire.Canary))!;
Assert.That(outcome.Domain, Is.EqualTo("Invocation"));
Assert.That(outcome.Message, Does.Not.Contain(Wire.Canary));
var uri = Assert.Throws<ThingDomainException>(() => new AssetEntry("id", "image/png", null!, 1, 1))!;
Assert.That(uri.Domain, Is.EqualTo("Uri"));
}
[Test]
public void ARefusedListNamesTheSlot()
{
Assert.That(Assert.Throws<ThingDomainException>(() => ThingDomain.Items<string>(null, "Captures"))!.Domain, Is.EqualTo("Captures"));
var nilItem = Assert.Throws<ThingDomainException>(() => ThingDomain.Items<string>(new List<string> { "a", null! }, "Captures"))!;
Assert.That((nilItem.Domain, nilItem.Bound), Is.EqualTo(("Captures", "free of nil items")));
var withCanary = Assert.Throws<ThingDomainException>(() => ThingDomain.Items<string>(new List<string> { Wire.Canary, null! }, "Captures"))!;
Assert.That(withCanary.Message, Does.Not.Contain(Wire.Canary));
}
}

View file

@ -0,0 +1,200 @@
using MessagePack;
using System.Collections;
using System.Reflection;
namespace GameCult.Thing.Contract.Tests;
/// <summary>
/// A list a consumer can mutate after construction escapes every constructor check, and is how a cycle reaches the
/// writer (cultlib-gaps:follow_up:cs-write-depth-before-serialize owns the writer's side). The package holds each list
/// as a read-only copy of what was passed.
/// </summary>
public sealed class ReadOnlyListTests
{
private static bool IsMutableCollection(Type type) =>
(typeof(IList).IsAssignableFrom(type) || type.GetInterfaces().Concat(new[] { type }).Any(i =>
i.IsGenericType && (i.GetGenericTypeDefinition() == typeof(ICollection<>) || i.GetGenericTypeDefinition() == typeof(IDictionary<,>))));
private static IEnumerable<(Doors.Door Door, int Index, Type Element)> ListParameters() =>
from door in Doors.All()
from parameter in door.Parameters.Select((p, i) => (p, i))
where parameter.p.ParameterType.IsGenericType && parameter.p.ParameterType.GetGenericTypeDefinition() == typeof(IReadOnlyList<>)
select (door, parameter.i, parameter.p.ParameterType.GetGenericArguments()[0]);
[Test]
public void NoPublicPropertyOfAnyContractTypeIsAMutableCollection()
{
var offenders = new List<string>();
var checkedProperties = 0;
foreach (var type in typeof(CommandResult).Assembly.GetExportedTypes())
foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
{
checkedProperties++;
if (IsMutableCollection(property.PropertyType)) offenders.Add($"{type.Name}.{property.Name}: {property.PropertyType.Name}");
}
Assert.That(offenders, Is.Empty);
Assert.That(checkedProperties, Is.GreaterThan(200));
}
[Test]
public void EveryListParameterIsCopiedAndTheCopyCannotGrow()
{
var lists = ListParameters().ToList();
Assert.That(lists, Has.Count.GreaterThanOrEqualTo(14), "the scan reached every list slot");
foreach (var (door, index, element) in lists)
{
var args = door.ValidArgs();
var passed = Doors.MakeList(element, 1);
args[index] = passed;
var value = door.Call(args)!;
var name = door.Parameters[index].Name!.TrimStart('@');
var property = value.GetType().GetProperty(char.ToUpperInvariant(name[0]) + name[1..])!;
var held = (IList)property.GetValue(value)!;
Assert.That(held, Has.Count.EqualTo(1), door.Name);
passed.Add(Doors.Make(element));
Assert.That(held, Has.Count.EqualTo(1), $"{door.Name}: adding to the passed list changed the value");
Assert.That(held, Is.Not.SameAs(passed), door.Name);
Assert.Throws<NotSupportedException>(() => held.Add(Doors.Make(element)), $"{door.Name}: the held list grew");
Assert.Throws<NotSupportedException>(() => held[0] = Doors.Make(element), $"{door.Name}: the held list was written");
}
}
[Test]
public void EveryListParameterRefusesANilItemAndANilList()
{
foreach (var (door, index, element) in ListParameters())
{
var args = door.ValidArgs();
var withNil = Doors.MakeList(element, 1);
withNil.Add(null);
args[index] = withNil;
var item = Assert.Throws<ThingDomainException>(() => door.Call(args), $"{door.Name}: nil item")!;
Assert.That(item.Bound, Is.EqualTo("free of nil items"), door.Name);
args[index] = null;
var list = Assert.Throws<ThingDomainException>(() => door.Call(args), $"{door.Name}: nil list")!;
Assert.That(list.Bound, Is.EqualTo("present"), door.Name);
}
}
[Test]
public void AnArrayPassedToAConstructorIsCopiedNotKept()
{
var child = Builders.Text("t", "x");
var other = Builders.Text("u", "y");
var array = new[] { child };
var box = (BoxNode)Builders.Boxed("b", array);
array[0] = other;
Assert.That(box.Children[0], Is.SameAs(child), "writing the caller's array changed the value");
Assert.That(box.Children, Is.Not.SameAs(array));
Assert.That(box.Children is Node[], Is.False, "the held list is the caller's array type");
var boxed = (BoxNode)Builders.Boxed("c", child, other);
Assert.That(boxed.Children, Has.Count.EqualTo(2));
foreach (var (door, index, element) in ListParameters())
{
var args = door.ValidArgs();
var passed = Array.CreateInstance(element, 1);
passed.SetValue(Doors.Make(element), 0);
args[index] = passed;
var value = door.Call(args)!;
var name = door.Parameters[index].Name!.TrimStart('@');
var held = (IList)value.GetType().GetProperty(char.ToUpperInvariant(name[0]) + name[1..])!.GetValue(value)!;
var first = held[0];
passed.SetValue(Doors.Make(element), 0);
Assert.That(held[0], Is.SameAs(first), $"{door.Name}: writing the caller's array changed the value");
Assert.That(held, Is.Not.SameAs(passed), door.Name);
}
}
private static void WalkLists(object? value, int depth, Action<object> onList)
{
if (value is null || depth > 200 || value is string || value.GetType().IsPrimitive || value is ReadOnlyMemory<byte>) return;
if (value is IList list)
{
onList(list);
foreach (var item in list) WalkLists(item, depth + 1, onList);
return;
}
if (value.GetType().Namespace != "GameCult.Thing.Contract") return;
foreach (var property in value.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
WalkLists(property.GetValue(value), depth + 1, onList);
}
private static readonly (string Golden, Type Type)[] DecodedGoldens =
{
("surface-all-arms", typeof(SurfaceDocument)), ("composition-all-params", typeof(CompositionDocument)),
("invocation-all-inputs", typeof(CommandInvocation)), ("asset-catalog", typeof(AssetCatalog)),
("result-pending", typeof(CommandResult)), ("result-completed", typeof(CommandResult)),
("result-completed-open-surface", typeof(CommandResult)), ("result-completed-open-external", typeof(CommandResult)),
("result-denied", typeof(CommandResult)), ("result-denied-no-node", typeof(CommandResult)), ("result-failed", typeof(CommandResult)),
};
[Test]
public void NoListOfADecodedGoldenIsWritable()
{
var lists = 0;
foreach (var (golden, type) in DecodedGoldens)
WalkLists(Wire.Read(type, Wire.Golden($"{golden}.bin")), 0, list =>
{
lists++;
var l = (IList)list;
Assert.That(l.IsReadOnly && l.IsFixedSize, Is.True, $"{golden}: {l.GetType().Name} is writable");
Assert.That(l is Array, Is.False, $"{golden}: {l.GetType().Name} is an array");
Assert.Throws<NotSupportedException>(() => l.Add(null), golden);
if (l.Count > 0) Assert.Throws<NotSupportedException>(() => l[0] = l[0], golden);
});
Assert.That(lists, Is.GreaterThan(20));
}
[Test]
public void APayloadHoldsACopyOfTheBytesItWasBuiltFrom()
{
var buffer = new byte[] { 1, 2, 3 };
var payload = new RecordPayload("s", buffer);
buffer[0] = 9;
Assert.That(payload.Bytes.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 }), "writing the caller's buffer changed the payload");
var shared = new byte[] { 4, 5, 6, 7 };
var slice = new RecordPayload("s", new ReadOnlyMemory<byte>(shared, 1, 2));
shared[1] = 0;
Assert.That(slice.Bytes.ToArray(), Is.EqualTo(new byte[] { 5, 6 }));
Assert.That(System.Runtime.InteropServices.MemoryMarshal.TryGetArray(payload.Bytes, out var segment) && ReferenceEquals(segment.Array, buffer), Is.False);
}
[Test]
public void EveryEmptyMemoryIsAnEmptyPayloadThatWritesAsRustsEmptyVec()
{
// Rust's empty Vec<u8> is bin8 of length 0 (c4 00); a RecordPayload is [schema, bytes].
var rust = new byte[] { 0x92, 0xA1, (byte)'s', 0xC4, 0x00 };
var empties = new Dictionary<string, ReadOnlyMemory<byte>>
{
["ReadOnlyMemory.Empty"] = ReadOnlyMemory<byte>.Empty,
["Memory.Empty"] = Memory<byte>.Empty,
["default"] = default(ReadOnlyMemory<byte>),
["Array.Empty"] = Array.Empty<byte>(),
["new byte[0]"] = new byte[0],
};
foreach (var (name, memory) in empties)
{
var payload = new RecordPayload("s", memory);
Assert.That(payload.Bytes.Length, Is.EqualTo(0), name);
Assert.That(MessagePackSerializer.Serialize(payload, Wire.Options), Is.EqualTo(rust), name);
Assert.That(MessagePackSerializer.Serialize(MessagePackSerializer.Deserialize<RecordPayload>(rust, Wire.Options), Wire.Options), Is.EqualTo(rust), name);
}
}
[Test]
public void ANilPayloadOnTheWireIsRefusedAndAnEmptyOneIsKeptThroughTheWire()
{
Assert.Throws<MessagePackSerializationException>(() => MessagePackSerializer.Deserialize<RecordPayload>(new byte[] { 0x92, 0xA1, (byte)'s', 0xC0 }, Wire.Options));
var empty = new CommandInvocation("i", "k", 1, "go", new RecordPayload("s", Array.Empty<byte>()), new List<CapturedInput>(), "c", "t", null);
var decoded = (CommandInvocation)Wire.Read(typeof(CommandInvocation), Wire.Write(empty));
Assert.That(decoded.Payload.Bytes.IsEmpty, Is.True);
Assert.That(Wire.Write(decoded), Is.EqualTo(Wire.Write(empty)));
}
}

View file

@ -0,0 +1,64 @@
using System.Reflection;
using MessagePack;
namespace GameCult.Thing.Contract.Tests;
/// <summary>
/// A unit arm is its arm type: there is one way to build it (<c>new ImageFitFill()</c>) and one way to tell it (a type
/// test). No union base exposes a static instance, so no code can compare by reference and see a decoded arm differ.
/// </summary>
public sealed class UnitArmTests
{
private static IEnumerable<Type> Unions() =>
typeof(CommandResult).Assembly.GetExportedTypes().Where(t => t.IsAbstract && t.GetCustomAttributes<UnionAttribute>(false).Any());
private static IEnumerable<(Type Union, UnionAttribute Arm)> UnitArms() =>
Unions().SelectMany(u => u.GetCustomAttributes<UnionAttribute>(false).Select(a => (u, a)))
.Where(x => x.a.SubType.GetConstructor(Type.EmptyTypes) != null);
[Test]
public void EveryDecodedUnitArmIsItsArmType()
{
var arms = UnitArms().ToList();
Assert.That(arms, Has.Count.EqualTo(58));
foreach (var (union, arm) in arms)
{
var bytes = new byte[] { 0x92, (byte)arm.Key, 0x90 };
var read = MessagePackSerializer.Deserialize(union, bytes, Wire.Options);
Assert.That(read, Is.TypeOf(arm.SubType), $"{union.Name} key {arm.Key}");
Assert.That(MessagePackSerializer.Serialize(union, read, Wire.Options), Is.EqualTo(bytes), $"{union.Name} key {arm.Key}");
}
}
[Test]
public void NoUnionBaseExposesAStaticMemberOfItsOwnKind()
{
var offenders = new List<string>();
var unions = Unions().ToList();
Assert.That(unions, Has.Count.GreaterThan(25));
foreach (var union in unions)
{
foreach (var member in union.GetMembers(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly))
{
var type = member switch
{
PropertyInfo p => p.PropertyType,
FieldInfo f => f.FieldType,
MethodInfo m => m.ReturnType,
_ => null,
};
if (type != null && type.IsAssignableTo(union)) offenders.Add($"{union.Name}.{member.Name}");
}
}
Assert.That(offenders, Is.Empty);
}
[Test]
public void ConstructingAUnitArmWritesAnEmptySlotArray()
{
// [key, []]: array(2), the key, array(0).
Assert.That(MessagePackSerializer.Serialize<ImageFit>(new ImageFitFill(), Wire.Options), Is.EqualTo(new byte[] { 0x92, 0x02, 0x90 }));
Assert.That(MessagePackSerializer.Serialize<InputKind>(new InputKindNumber(), Wire.Options), Is.EqualTo(new byte[] { 0x92, 0x01, 0x90 }));
}
}

View file

@ -0,0 +1,40 @@
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
namespace GameCult.Thing.Contract.Tests;
/// <summary>The one way the tests reach the wire: CultLib's document serialization and the committed Rust goldens.</summary>
internal static class Wire
{
public const string Canary = "CANARY-7f3a91c2";
public static readonly Type[] DocumentTypes =
{
typeof(SurfaceDocument), typeof(CompositionDocument), typeof(CommandInvocation), typeof(CommandResult), typeof(AssetCatalog),
};
public static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(DocumentTypes);
public static MessagePackSerializerOptions Options => CultDocumentMessagePackSerialization.OptionsFor(typeof(CommandResult).Assembly);
public static byte[] Write(object value) => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType(), Registry);
public static object Read(Type type, byte[] bytes) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, Registry);
public static string GoldenPath(string file) => Path.Combine(AppContext.BaseDirectory, "goldens", file);
public static byte[] Golden(string file)
{
var bytes = File.ReadAllBytes(GoldenPath(file));
Assert.That(bytes, Is.Not.Empty, file);
return bytes;
}
public static string Flatten(Exception error)
{
var parts = new List<string>();
for (Exception? e = error; e != null; e = e.InnerException) parts.Add($"{e.GetType().Name}: {e.Message}");
return string.Join(" | ", parts);
}
}

View file

@ -0,0 +1,10 @@
# thing-contract-parity
The names the C# package `GameCult.Thing.Contract` gives its types. The C# package owns them because CultLib's C# reference compares catalog type names when a store reopens, and every runtime package is held to the C# names.
| File | Rows |
|---|---|
| `catalog-members.tsv` | One row per document slot, `schemaId`, `slot`, `memberName`, `typeName`, as `Wire.Registry`'s catalog entries print them. A list slot reads `System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.X>`. |
| `type-names.txt` | The full name of every public type in `GameCult.Thing.Contract`, sorted ordinally. |
`tests/GameCult.Thing.Contract.Tests/NameSetTests.cs` fails when either file disagrees with the C# types. These files change only in the commit that changes the C# types, and the other runtime packages read them without writing them.

View file

@ -0,0 +1,27 @@
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 0 InvocationId System.String
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 1 SurfaceKey System.String
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 2 SurfaceVersion System.Int64
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 3 Command System.String
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 4 Payload GameCult.Thing.Contract.RecordPayload
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 5 Captured System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.CapturedInput>
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 6 Client System.String
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 7 IssuedAt System.String
sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 8 Delegation System.String
sha256:3d751a6fb07487b0c84cf0ae1c4fe55e5ef0c1656eab41ca3236846a638bdd3f 0 ProviderId System.String
sha256:3d751a6fb07487b0c84cf0ae1c4fe55e5ef0c1656eab41ca3236846a638bdd3f 1 CatalogId System.String
sha256:3d751a6fb07487b0c84cf0ae1c4fe55e5ef0c1656eab41ca3236846a638bdd3f 2 Version System.Int64
sha256:3d751a6fb07487b0c84cf0ae1c4fe55e5ef0c1656eab41ca3236846a638bdd3f 3 Assets System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.AssetEntry>
sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 0 Publisher System.String
sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 1 Name System.String
sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 2 Version System.Int64
sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 3 Params System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.ParamDecl>
sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 4 Body GameCult.Thing.Contract.Node
sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 0 ProviderId System.String
sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 1 Label System.String
sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 2 Version System.Int64
sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 3 Title System.String
sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 4 Root GameCult.Thing.Contract.Node
sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 5 Commands System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.CommandDecl>
sha256:e0273199abadb60da0bd9a7bcb863e131ef08bb2284f7eb729f76159caa369fd 0 InvocationId System.String
sha256:e0273199abadb60da0bd9a7bcb863e131ef08bb2284f7eb729f76159caa369fd 1 Seq System.Int32
sha256:e0273199abadb60da0bd9a7bcb863e131ef08bb2284f7eb729f76159caa369fd 2 Outcome GameCult.Thing.Contract.Outcome
1 sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 0 InvocationId System.String
2 sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 1 SurfaceKey System.String
3 sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 2 SurfaceVersion System.Int64
4 sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 3 Command System.String
5 sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 4 Payload GameCult.Thing.Contract.RecordPayload
6 sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 5 Captured System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.CapturedInput>
7 sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 6 Client System.String
8 sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 7 IssuedAt System.String
9 sha256:186674dff7658d23646b25b28f54d27b3f05cee34b51a631580be65002c41aa8 8 Delegation System.String
10 sha256:3d751a6fb07487b0c84cf0ae1c4fe55e5ef0c1656eab41ca3236846a638bdd3f 0 ProviderId System.String
11 sha256:3d751a6fb07487b0c84cf0ae1c4fe55e5ef0c1656eab41ca3236846a638bdd3f 1 CatalogId System.String
12 sha256:3d751a6fb07487b0c84cf0ae1c4fe55e5ef0c1656eab41ca3236846a638bdd3f 2 Version System.Int64
13 sha256:3d751a6fb07487b0c84cf0ae1c4fe55e5ef0c1656eab41ca3236846a638bdd3f 3 Assets System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.AssetEntry>
14 sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 0 Publisher System.String
15 sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 1 Name System.String
16 sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 2 Version System.Int64
17 sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 3 Params System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.ParamDecl>
18 sha256:531f96346fbff5160daedc82ada2ad5cb3d0477631d2b580ccd1a2834f041b32 4 Body GameCult.Thing.Contract.Node
19 sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 0 ProviderId System.String
20 sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 1 Label System.String
21 sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 2 Version System.Int64
22 sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 3 Title System.String
23 sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 4 Root GameCult.Thing.Contract.Node
24 sha256:da2ca90e225d803191fd9a31ad1894e7dfab186f82e892717be8130d2e7ebf33 5 Commands System.Collections.Generic.IReadOnlyList<GameCult.Thing.Contract.CommandDecl>
25 sha256:e0273199abadb60da0bd9a7bcb863e131ef08bb2284f7eb729f76159caa369fd 0 InvocationId System.String
26 sha256:e0273199abadb60da0bd9a7bcb863e131ef08bb2284f7eb729f76159caa369fd 1 Seq System.Int32
27 sha256:e0273199abadb60da0bd9a7bcb863e131ef08bb2284f7eb729f76159caa369fd 2 Outcome GameCult.Thing.Contract.Outcome

View file

@ -0,0 +1,168 @@
GameCult.Thing.Contract.AffixFormat
GameCult.Thing.Contract.Anchor
GameCult.Thing.Contract.AnchorNode
GameCult.Thing.Contract.AnchorViewport
GameCult.Thing.Contract.Arg
GameCult.Thing.Contract.ArgValue
GameCult.Thing.Contract.ArgValueBind
GameCult.Thing.Contract.AssetCatalog
GameCult.Thing.Contract.AssetEntry
GameCult.Thing.Contract.Bind
GameCult.Thing.Contract.BoolArg
GameCult.Thing.Contract.BoolLiteral
GameCult.Thing.Contract.BoolSource
GameCult.Thing.Contract.BoolSourceBound
GameCult.Thing.Contract.BoolSourceParam
GameCult.Thing.Contract.BoolValue
GameCult.Thing.Contract.Border
GameCult.Thing.Contract.BoundOptions
GameCult.Thing.Contract.BoxNode
GameCult.Thing.Contract.BoxStyle
GameCult.Thing.Contract.ButtonNode
GameCult.Thing.Contract.CapturedInput
GameCult.Thing.Contract.ChoiceValue
GameCult.Thing.Contract.CollectionArg
GameCult.Thing.Contract.CollectionSource
GameCult.Thing.Contract.CollectionSourceMember
GameCult.Thing.Contract.CollectionSourceParam
GameCult.Thing.Contract.ColorRef
GameCult.Thing.Contract.CommandArg
GameCult.Thing.Contract.CommandDecl
GameCult.Thing.Contract.CommandIdRef
GameCult.Thing.Contract.CommandInvocation
GameCult.Thing.Contract.CommandRef
GameCult.Thing.Contract.CommandRefParam
GameCult.Thing.Contract.CommandResult
GameCult.Thing.Contract.CompletedOutcome
GameCult.Thing.Contract.CompositionDocument
GameCult.Thing.Contract.CompositionRef
GameCult.Thing.Contract.ContentAlign
GameCult.Thing.Contract.ContentAlignAuto
GameCult.Thing.Contract.ContentAlignCenter
GameCult.Thing.Contract.ContentAlignEnd
GameCult.Thing.Contract.ContentAlignSpaceAround
GameCult.Thing.Contract.ContentAlignSpaceBetween
GameCult.Thing.Contract.ContentAlignSpaceEvenly
GameCult.Thing.Contract.ContentAlignStart
GameCult.Thing.Contract.ContentAlignStretch
GameCult.Thing.Contract.DeniedOutcome
GameCult.Thing.Contract.Dimension
GameCult.Thing.Contract.DimensionAuto
GameCult.Thing.Contract.DimensionContent
GameCult.Thing.Contract.DimensionFr
GameCult.Thing.Contract.DimensionPercent
GameCult.Thing.Contract.DimensionPx
GameCult.Thing.Contract.Direction
GameCult.Thing.Contract.DirectionColumn
GameCult.Thing.Contract.DirectionColumnReverse
GameCult.Thing.Contract.DirectionRow
GameCult.Thing.Contract.DirectionRowReverse
GameCult.Thing.Contract.Display
GameCult.Thing.Contract.DisplayBlock
GameCult.Thing.Contract.DisplayFlex
GameCult.Thing.Contract.DisplayGrid
GameCult.Thing.Contract.Edges
GameCult.Thing.Contract.Effect
GameCult.Thing.Contract.EmbedNode
GameCult.Thing.Contract.FailedOutcome
GameCult.Thing.Contract.Format
GameCult.Thing.Contract.FormatDuration
GameCult.Thing.Contract.FormatTimestamp
GameCult.Thing.Contract.ImageFit
GameCult.Thing.Contract.ImageFitContain
GameCult.Thing.Contract.ImageFitCover
GameCult.Thing.Contract.ImageFitFill
GameCult.Thing.Contract.ImageFitUnscaled
GameCult.Thing.Contract.ImageNode
GameCult.Thing.Contract.InputKind
GameCult.Thing.Contract.InputKindNumber
GameCult.Thing.Contract.InputKindText
GameCult.Thing.Contract.InputValue
GameCult.Thing.Contract.ItemAlign
GameCult.Thing.Contract.ItemAlignAuto
GameCult.Thing.Contract.ItemAlignBaseline
GameCult.Thing.Contract.ItemAlignCenter
GameCult.Thing.Contract.ItemAlignEnd
GameCult.Thing.Contract.ItemAlignStart
GameCult.Thing.Contract.ItemAlignStretch
GameCult.Thing.Contract.LiteralOptions
GameCult.Thing.Contract.MemberIndex
GameCult.Thing.Contract.MemberName
GameCult.Thing.Contract.MemberPath
GameCult.Thing.Contract.MemberStep
GameCult.Thing.Contract.Node
GameCult.Thing.Contract.NodeCommon
GameCult.Thing.Contract.NumberArg
GameCult.Thing.Contract.NumberFormat
GameCult.Thing.Contract.NumberLiteral
GameCult.Thing.Contract.NumberSource
GameCult.Thing.Contract.NumberSourceBound
GameCult.Thing.Contract.NumberSourceParam
GameCult.Thing.Contract.NumberValue
GameCult.Thing.Contract.OpenExternalEffect
GameCult.Thing.Contract.OpenSurfaceEffect
GameCult.Thing.Contract.OptionList
GameCult.Thing.Contract.Outcome
GameCult.Thing.Contract.Overflow
GameCult.Thing.Contract.OverflowClip
GameCult.Thing.Contract.OverflowScroll
GameCult.Thing.Contract.OverflowVisible
GameCult.Thing.Contract.OverlayNode
GameCult.Thing.Contract.Paint
GameCult.Thing.Contract.ParamDecl
GameCult.Thing.Contract.ParamKind
GameCult.Thing.Contract.ParamKindBind
GameCult.Thing.Contract.ParamKindBool
GameCult.Thing.Contract.ParamKindCollection
GameCult.Thing.Contract.ParamKindCommand
GameCult.Thing.Contract.ParamKindNumber
GameCult.Thing.Contract.ParamKindSlot
GameCult.Thing.Contract.ParamKindText
GameCult.Thing.Contract.PendingOutcome
GameCult.Thing.Contract.PlotMark
GameCult.Thing.Contract.PlotMarkArea
GameCult.Thing.Contract.PlotMarkBar
GameCult.Thing.Contract.PlotMarkLine
GameCult.Thing.Contract.PlotNode
GameCult.Thing.Contract.Position
GameCult.Thing.Contract.PositionAbsolute
GameCult.Thing.Contract.PositionRelative
GameCult.Thing.Contract.RecordPayload
GameCult.Thing.Contract.RecordsSource
GameCult.Thing.Contract.RepeatNode
GameCult.Thing.Contract.Rgba
GameCult.Thing.Contract.Role
GameCult.Thing.Contract.RoleDialog
GameCult.Thing.Contract.RoleGroup
GameCult.Thing.Contract.RoleHeading
GameCult.Thing.Contract.RoleList
GameCult.Thing.Contract.RoleListItem
GameCult.Thing.Contract.RoleRegion
GameCult.Thing.Contract.RoleToolbar
GameCult.Thing.Contract.SelectNode
GameCult.Thing.Contract.SelectOption
GameCult.Thing.Contract.Size2
GameCult.Thing.Contract.SliderNode
GameCult.Thing.Contract.SlotArg
GameCult.Thing.Contract.StateRef
GameCult.Thing.Contract.StateSource
GameCult.Thing.Contract.StateSourceItem
GameCult.Thing.Contract.SurfaceDocument
GameCult.Thing.Contract.TextArg
GameCult.Thing.Contract.TextInputNode
GameCult.Thing.Contract.TextLiteral
GameCult.Thing.Contract.TextNode
GameCult.Thing.Contract.TextSource
GameCult.Thing.Contract.TextSourceBound
GameCult.Thing.Contract.TextSourceParam
GameCult.Thing.Contract.TextValue
GameCult.Thing.Contract.ThemeToken
GameCult.Thing.Contract.ThingDomain
GameCult.Thing.Contract.ThingDomainException
GameCult.Thing.Contract.ThingKeys
GameCult.Thing.Contract.ToggleNode
GameCult.Thing.Contract.UseNode
GameCult.Thing.Contract.Wrap
GameCult.Thing.Contract.WrapNoWrap
GameCult.Thing.Contract.WrapWrap
GameCult.Thing.Contract.WrapWrapReverse