Compose validated mod ships into a derived catalog
This commit is contained in:
parent
42eb5214ce
commit
4ba4bbaf2e
9 changed files with 161 additions and 6 deletions
|
|
@ -6,7 +6,7 @@ using GameCult.Caching.MessagePack;
|
|||
// Aetheria's stores: one home store per type, composed here and nowhere else.
|
||||
public static class AetheriaStores
|
||||
{
|
||||
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) };
|
||||
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) };
|
||||
public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) };
|
||||
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ public static class ShipAuthoringStore
|
|||
{
|
||||
if (ship == null) throw new InvalidOperationException("Ship authoring record is null.");
|
||||
if (string.IsNullOrWhiteSpace(ship.Id)) throw new InvalidOperationException("Ship ID is required.");
|
||||
if (!(ship.Id[0] >= 'a' && ship.Id[0] <= 'z' || ship.Id[0] >= '0' && ship.Id[0] <= '9') || ship.Id.Any(c =>
|
||||
!(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-')))
|
||||
throw new InvalidOperationException($"{ship.Id}: ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens.");
|
||||
var hull = ship.Hull ?? throw new InvalidOperationException($"{ship.Id}: hull data is required.");
|
||||
if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required.");
|
||||
if (hull.Shape?.Cells == null || hull.Shape.Width < 1 || hull.Shape.Height < 1 ||
|
||||
|
|
@ -91,12 +94,15 @@ public static class ShipAuthoringStore
|
|||
|
||||
var anchors = ship.Anchors ?? throw new InvalidOperationException($"{ship.Id}: anchors are required.");
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
var nodeIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var anchor in anchors)
|
||||
{
|
||||
if (anchor == null || string.IsNullOrWhiteSpace(anchor.Id) || !ids.Add(anchor.Id))
|
||||
throw new InvalidOperationException($"{ship.Id}: anchor IDs must be present and unique.");
|
||||
if (string.IsNullOrWhiteSpace(anchor.ModelNodeId))
|
||||
throw new InvalidOperationException($"{ship.Id}: anchor {anchor.Id} needs a model node ID.");
|
||||
if (!nodeIds.Add(anchor.ModelNodeId))
|
||||
throw new InvalidOperationException($"{ship.Id}: model node {anchor.ModelNodeId} is claimed by more than one anchor.");
|
||||
}
|
||||
foreach (var role in new[] { "map-icon", "hull-collider", "shield", "tractor" })
|
||||
if (anchors.Count(anchor => anchor.Role == role) != 1)
|
||||
|
|
|
|||
100
Assets/Scripts/ServerShared/ShipModCatalog.cs
Normal file
100
Assets/Scripts/ServerShared/ShipModCatalog.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Text;
|
||||
|
||||
// Builds the one catalog snapshot CultCache needs. Sources remain the shipped catalog and each
|
||||
// mod-owned ship.cc; this output is disposable and never becomes an authoring surface.
|
||||
public static class ShipModCatalog
|
||||
{
|
||||
public static CultRecordKey HullKey(string id) => new CultRecordKey("mod-hull:" + id);
|
||||
public static CultRecordKey AuthoringKey(string id) => new CultRecordKey("mod-ship:" + id);
|
||||
|
||||
public static int Compose(string shippedCatalog, string outputCatalog, string modsRoot)
|
||||
{
|
||||
var source = Path.GetFullPath(shippedCatalog);
|
||||
var output = Path.GetFullPath(outputCatalog);
|
||||
var root = Path.GetFullPath(modsRoot);
|
||||
if (!File.Exists(source)) throw new FileNotFoundException("Shipped catalog is missing", source);
|
||||
if (!Directory.Exists(root)) throw new DirectoryNotFoundException($"Mod root is missing: {root}");
|
||||
if (string.Equals(source, output, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("The derived catalog cannot replace the shipped catalog.");
|
||||
if (output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("The derived catalog cannot replace a mod source or asset.");
|
||||
|
||||
var ships = new List<ShipAuthoring>();
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal))
|
||||
{
|
||||
var path = Path.Combine(directory, "ship.cc");
|
||||
if (!File.Exists(path)) continue;
|
||||
var ship = ShipAuthoringStore.Read(path);
|
||||
if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal) ||
|
||||
!ids.Add(ship.Id))
|
||||
throw new InvalidOperationException($"{path}: ship ID must uniquely match its package directory name.");
|
||||
var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset));
|
||||
if (!modelPath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"{ship.Id}: model asset must name an existing GLB inside its package.");
|
||||
var modelNodes = ReadNodeIds(modelPath);
|
||||
foreach (var anchor in ship.Anchors)
|
||||
if (!modelNodes.Contains(anchor.ModelNodeId))
|
||||
throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}.");
|
||||
CultRecordRefs.Validate(ship.Hull);
|
||||
ships.Add(ship);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(output));
|
||||
var temporary = output + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
File.Copy(source, temporary);
|
||||
using (var cache = AetheriaStores.Open(temporary, catalogWritable: true))
|
||||
{
|
||||
foreach (var ship in ships)
|
||||
{
|
||||
var hullKey = HullKey(ship.Id);
|
||||
var authoringKey = AuthoringKey(ship.Id);
|
||||
if (cache.AllStoredDocuments.Any(record => record.Key.Equals(hullKey) || record.Key.Equals(authoringKey)))
|
||||
throw new InvalidOperationException($"{ship.Id}: a mod key collides with an existing catalog record.");
|
||||
cache.UpsertAsync(typeof(HullData), ship.Hull, hullKey).GetAwaiter().GetResult();
|
||||
cache.UpsertAsync(typeof(ShipAuthoring), ship, authoringKey).GetAwaiter().GetResult();
|
||||
}
|
||||
cache.FlushAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
if (File.Exists(output)) File.Replace(temporary, output, null);
|
||||
else File.Move(temporary, output);
|
||||
return ships.Count;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary)) File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
// GLB is the xenos asset boundary. Only its node extras are read here; geometry belongs to the runtime importer.
|
||||
public static HashSet<string> ReadNodeIds(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var reader = new BinaryReader(stream);
|
||||
if (stream.Length < 20 || reader.ReadUInt32() != 0x46546C67 || reader.ReadUInt32() != 2 ||
|
||||
reader.ReadUInt32() != stream.Length)
|
||||
throw new InvalidOperationException($"{path}: invalid GLB 2 header.");
|
||||
var chunkLength = reader.ReadUInt32();
|
||||
if (reader.ReadUInt32() != 0x4E4F534A || chunkLength > stream.Length - stream.Position)
|
||||
throw new InvalidOperationException($"{path}: missing GLB JSON chunk.");
|
||||
var json = JObject.Parse(Encoding.UTF8.GetString(reader.ReadBytes((int)chunkLength)));
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var node in json["nodes"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>())
|
||||
{
|
||||
var id = (string)node["extras"]?["aetheria.id"];
|
||||
if (string.IsNullOrEmpty(id)) continue;
|
||||
if (!ids.Add(id))
|
||||
throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}.");
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/ServerShared/ShipModCatalog.cs.meta
Normal file
2
Assets/Scripts/ServerShared/ShipModCatalog.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 708d45e8a4144a6e89bcf40e96b0975a
|
||||
|
|
@ -85,6 +85,43 @@ semantic validator. The Blender capture command writes only line data and the
|
|||
still need to be built. The current gameplay catalog and prefab loader do not
|
||||
read these mod records.
|
||||
|
||||
## Runtime catalog seam
|
||||
|
||||
`CultCache` routes `ItemData` to one home backing store, and
|
||||
`ActionGameManager.CultCache` holds that store for the entire play session.
|
||||
Attaching each mod's `.cc` as another item store would violate the cache's
|
||||
one-home invariant. `ship-authoring compose` instead derives one runtime catalog from
|
||||
the shipped catalog and validated mod packages, preserving all shipped record
|
||||
keys. Each mod's stable `ShipAuthoring.Id` yields separate deterministic keys
|
||||
for its copied `HullData` and its runtime authoring record. The generated
|
||||
catalog is disposable; the shipped catalog and each mod's source `.cc` remain
|
||||
the owners. A player session opens one catalog snapshot and cannot hot-swap its
|
||||
designs beneath existing entities.
|
||||
|
||||
Each immediate child of the mods directory has an ID matching its directory
|
||||
name and contains `ship.cc` plus the GLB at the record's relative `ModelAsset`
|
||||
path. Every referenced anchor uses an `aetheria.id` custom property exported
|
||||
into a GLB node's `extras`. Compose checks the asset, unique IDs, and every
|
||||
reference before atomically replacing the derived catalog. For example:
|
||||
|
||||
```text
|
||||
dotnet run --project tools/AetherDb -- ship-authoring compose GameData/Aetheria.cc GameData/Aetheria.modded.cc GameData/Mods
|
||||
```
|
||||
|
||||
This command does not yet make the game open `Aetheria.modded.cc`; the runtime
|
||||
loader and a complete playable ship are still outstanding. In the Quiet probe,
|
||||
206 shipped records became 208 derived records: one `mod-hull:quiet` and one
|
||||
`mod-ship:quiet`. A missing GLB node ID was rejected without changing the
|
||||
previous derived file. The probe's four anchors and zero hardpoints are only
|
||||
a data-path smoke, not a playable Quiet design.
|
||||
|
||||
The construction boundary will resolve a mod hull's authoring record from its
|
||||
derived key, load the package's compiled GLB at runtime, and build the
|
||||
`ShipInstance` references from validated stable node IDs. It must fail before
|
||||
scene construction when an asset or anchor is absent. [Unity glTFast's runtime
|
||||
import](https://github.com/Unity-Technologies/com.unity.cloud.gltfast/blob/main/Packages/com.unity.cloud.gltfast/Documentation~/ImportRuntime.md)
|
||||
is a candidate for the GLB step; it has not been added to the project.
|
||||
|
||||
## Proof gates
|
||||
|
||||
1. A typed `.cc` record round-trips between C# and Blender's Python runtime,
|
||||
|
|
@ -94,7 +131,9 @@ read these mod records.
|
|||
not a second copy of hull semantics.
|
||||
3. A command-line validator rejects malformed cells, overlapping or dangling
|
||||
hardpoints, missing model nodes, and unresolved asset references with
|
||||
precise errors. It changes no live catalog or scene on failure.
|
||||
precise errors. It changes no live catalog or scene on failure. The
|
||||
composer now performs this check for a package before replacing its
|
||||
derived catalog.
|
||||
4. A packaged mod ship loads in a built Unity player without Unity Editor or
|
||||
Blender installed. Runtime assembly creates the same components the
|
||||
current `ShipInstance` expects and uses the existing simulation rules.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Ship collection authoring
|
||||
|
||||
Status: authoring contract for new ships. Longinus and Djinni predate this contract and keep their current prefabs.
|
||||
Status: FBX-to-Unity-prefab contract for ships using the current catalog path. Longinus and Djinni predate this contract and keep their current prefabs. The parallel mod-owned `.cc` path is specified in [moddable-ship-authoring.md](moddable-ship-authoring.md).
|
||||
|
||||
## Objective and ownership
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ public static class Program
|
|||
case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply"));
|
||||
case "ship-authoring": return ShipAuthoringCommands.Run(args.Skip(1).ToArray());
|
||||
default:
|
||||
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate");
|
||||
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,17 @@ public static class ShipAuthoringCommands
|
|||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("ship-authoring create <ship.cc> <stable-id> <name> | inspect <ship.cc> | validate <ship.cc>");
|
||||
Console.Error.WriteLine("ship-authoring create <ship.cc> <stable-id> <name> | inspect <ship.cc> | validate <ship.cc> | compose <shipped.cc> <derived.cc> <mods-dir>");
|
||||
return 1;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (args[0] == "compose" && args.Length == 4)
|
||||
{
|
||||
var count = ShipModCatalog.Compose(args[1], args[2], args[3]);
|
||||
Console.WriteLine($"Composed {count} mod ships into {Path.GetFullPath(args[2])}");
|
||||
return 0;
|
||||
}
|
||||
if (args[0] == "inspect" && args.Length == 2)
|
||||
{
|
||||
using var cache = ShipAuthoringStore.Open(args[1]);
|
||||
|
|
@ -50,7 +56,7 @@ public static class ShipAuthoringCommands
|
|||
Console.WriteLine($"Created draft {path}; fill its model bindings and schematic in Blender.");
|
||||
return 0;
|
||||
}
|
||||
Console.Error.WriteLine("ship-authoring create <ship.cc> <stable-id> <name> | inspect <ship.cc> | validate <ship.cc>");
|
||||
Console.Error.WriteLine("ship-authoring create <ship.cc> <stable-id> <name> | inspect <ship.cc> | validate <ship.cc> | compose <shipped.cc> <derived.cc> <mods-dir>");
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
|||
2
tools/blender/.gitignore
vendored
Normal file
2
tools/blender/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
Loading…
Reference in a new issue