Ship S1: one hull record, and a hull names its visual
A mod ship file now holds a HullData at mod-hull:<id> and the ShipAuthoring visual package at mod-ship:<id>. HullData.Visual (key 32) is the typed ref between them; ShipAuthoring.Hull (key 1) is retired. - One joint validator over (hull, visual) is the only semantic check; Load owns the file structure (one hull, one visual, deterministic keys). - Compose copies the mod store's records verbatim, so the derived catalog holds one hull per mod ship. - ship_cc.py edits the hull record's slots directly; the body[HULL_SLOT] indirection is gone. - The package fixture is generated from ShipAuthoringTests.Fixture() (glTF scene included) and exported when AETHERIA_SHIP_FIXTURE_DIR is set. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
de1b5b8fa2
commit
1702e0549f
13 changed files with 622 additions and 319 deletions
|
|
@ -530,6 +530,11 @@ public class HullData : EquippableItemData
|
|||
[Inspectable, JsonProperty("canTow"), Key(29)]
|
||||
public bool CanTow;
|
||||
|
||||
// The hull's visual: a ShipAuthoring record holding its model package, or unset for a Unity-prefab hull. A hull
|
||||
// names exactly one of Prefab and Visual. Keys 30 and 31 are EquippableItemData's, so 32 is the next free key.
|
||||
[JsonProperty("visual"), Key(32)]
|
||||
public CultRecordRef<ShipAuthoring> Visual;
|
||||
|
||||
[IgnoreMember]
|
||||
public Shape InteriorCells
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ using GameCult.Caching;
|
|||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
|
||||
// A mod ship's authoring record. Hull is the existing simulation design, embedded here so a
|
||||
// proof ship has one writable source; the shipped catalog contains no copy of that ship.
|
||||
// A mod ship's visual package: model, anchors, schematic lines. The hull semantics (cells, hardpoints, stats) live in
|
||||
// the ship's own HullData record beside this one, and HullData.Visual is the only binding between the two.
|
||||
[CultDocument("aetheria.ship_authoring", "1"), MessagePackObject]
|
||||
public sealed class ShipAuthoring
|
||||
{
|
||||
[CultName, Key(0)] public string Id;
|
||||
[Key(1)] public HullData Hull;
|
||||
// Key 1 held the embedded HullData until S1; it is retired and never reused.
|
||||
[Key(2)] public string ModelAsset;
|
||||
[Key(3)] public List<ShipAnchor> Anchors = new List<ShipAnchor>();
|
||||
[Key(4)] public List<ShipPolyline> SchematicLines = new List<ShipPolyline>();
|
||||
|
|
@ -54,7 +54,7 @@ public static class ShipAuthoringStore
|
|||
var cache = new CultCache();
|
||||
try
|
||||
{
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, !writable), new[] { typeof(ShipAuthoring) });
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, !writable), new[] { typeof(HullData), typeof(ShipAuthoring) });
|
||||
return cache;
|
||||
}
|
||||
catch
|
||||
|
|
@ -64,29 +64,55 @@ public static class ShipAuthoringStore
|
|||
}
|
||||
}
|
||||
|
||||
public static ShipAuthoring Read(string path)
|
||||
// Writes one ship's two records under their deterministic keys, the shape Load expects.
|
||||
public static void Write(CultCache cache, HullData hull, ShipAuthoring visual)
|
||||
{
|
||||
using var cache = Open(path);
|
||||
var records = cache.GetAll<ShipAuthoring>().ToArray();
|
||||
if (records.Length != 1)
|
||||
throw new InvalidOperationException($"{path} must hold exactly one ship authoring record, found {records.Length}.");
|
||||
Validate(records[0]);
|
||||
return records[0];
|
||||
cache.UpsertAsync(typeof(HullData), hull, ShipModCatalog.HullKey(visual.Id)).GetAwaiter().GetResult();
|
||||
cache.UpsertAsync(typeof(ShipAuthoring), visual, ShipModCatalog.AuthoringKey(visual.Id)).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public static void Validate(ShipAuthoring ship)
|
||||
// The structure of a ship file, without judging its content (drafts load): exactly one hull and one visual, each at
|
||||
// its deterministic key, the hull naming that visual by its typed ref.
|
||||
public static (HullData Hull, ShipAuthoring Visual) Load(string path)
|
||||
{
|
||||
using var cache = Open(path);
|
||||
var hulls = cache.GetAll<HullData>().ToArray();
|
||||
var visuals = cache.GetAll<ShipAuthoring>().ToArray();
|
||||
if (visuals.Length != 1)
|
||||
throw new InvalidOperationException($"{path} must hold exactly one ship authoring record, found {visuals.Length}.");
|
||||
if (hulls.Length != 1)
|
||||
throw new InvalidOperationException($"{path} must hold exactly one hull record, found {hulls.Length}.");
|
||||
var (hull, visual) = (hulls[0], visuals[0]);
|
||||
if (!cache.TryGetHandle(hull).Value.Key.Equals(ShipModCatalog.HullKey(visual.Id)) ||
|
||||
!cache.TryGetHandle(visual).Value.Key.Equals(ShipModCatalog.AuthoringKey(visual.Id)))
|
||||
throw new InvalidOperationException($"{path}: {visual.Id}: records must be stored under {ShipModCatalog.HullKey(visual.Id).Value} and {ShipModCatalog.AuthoringKey(visual.Id).Value}.");
|
||||
return (hull, visual);
|
||||
}
|
||||
|
||||
public static (HullData Hull, ShipAuthoring Visual) Read(string path)
|
||||
{
|
||||
var (hull, visual) = Load(path);
|
||||
Validate(hull, visual);
|
||||
return (hull, visual);
|
||||
}
|
||||
|
||||
// The one semantic check every path shares: the hull and its visual, judged together.
|
||||
public static void Validate(HullData hull, ShipAuthoring ship)
|
||||
{
|
||||
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 (hull == null) 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.Cells.Cast<bool>().Any(occupied => occupied))
|
||||
throw new InvalidOperationException($"{ship.Id}: schematic must contain at least one cell.");
|
||||
// A hull names exactly one visual: a Unity prefab (shipped hulls) or a ShipAuthoring record (mod ships).
|
||||
if (!string.IsNullOrEmpty(hull.Prefab))
|
||||
throw new InvalidOperationException($"{ship.Id}: a mod ship cannot name a Unity prefab.");
|
||||
if (!hull.Visual.Key.Equals(ShipModCatalog.AuthoringKey(ship.Id)))
|
||||
throw new InvalidOperationException($"{ship.Id}: the hull must name its visual record {ShipModCatalog.AuthoringKey(ship.Id).Value}.");
|
||||
if (string.IsNullOrWhiteSpace(ship.ModelAsset) || Path.IsPathRooted(ship.ModelAsset) ||
|
||||
ship.ModelAsset.Replace('\\', '/').Split('/').Any(part => part == ".."))
|
||||
throw new InvalidOperationException($"{ship.Id}: model asset must be a relative package path.");
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ public static class ShipModCatalog
|
|||
{
|
||||
public sealed class Package
|
||||
{
|
||||
public ShipAuthoring Ship;
|
||||
public HullData Hull;
|
||||
public ShipAuthoring Visual;
|
||||
public string ModelPath;
|
||||
public Dictionary<string, uint> NodeIndices;
|
||||
}
|
||||
|
|
@ -32,13 +33,13 @@ public static class ShipModCatalog
|
|||
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 packages = new List<Package>();
|
||||
foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal))
|
||||
{
|
||||
var path = Path.Combine(directory, "ship.cc");
|
||||
if (!File.Exists(path)) continue;
|
||||
// ReadPackage pins each ID to its directory name, so IDs within one mods root are already unique.
|
||||
ships.Add(ReadPackage(path).Ship);
|
||||
packages.Add(ReadPackage(path));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(output));
|
||||
|
|
@ -52,8 +53,9 @@ public static class ShipModCatalog
|
|||
File.Copy(source, temporary);
|
||||
using (var cache = AetheriaStores.Open(temporary, catalogWritable: true))
|
||||
{
|
||||
foreach (var ship in ships)
|
||||
foreach (var package in packages)
|
||||
{
|
||||
var (hull, ship) = (package.Hull, package.Visual);
|
||||
var hullKey = HullKey(ship.Id);
|
||||
var authoringKey = AuthoringKey(ship.Id);
|
||||
if (cache.AllStoredDocuments.Any(record => record.Key.Equals(hullKey) || record.Key.Equals(authoringKey)))
|
||||
|
|
@ -61,18 +63,18 @@ public static class ShipModCatalog
|
|||
// CultCache indexes names per concrete type and lets the last writer win, and GetByName over a base
|
||||
// type throws on a match across subtypes. A mod hull named like any shipped item, or another mod's
|
||||
// hull (upserted earlier in this loop), would make name lookups wrong or throw.
|
||||
if (cache.GetAll<ItemData>().Any(item => string.Equals(item.Name, ship.Hull.Name, StringComparison.Ordinal)))
|
||||
throw new InvalidOperationException($"{ship.Id}: hull name '{ship.Hull.Name}' collides with an existing catalog item.");
|
||||
if (cache.GetAll<ItemData>().Any(item => string.Equals(item.Name, hull.Name, StringComparison.Ordinal)))
|
||||
throw new InvalidOperationException($"{ship.Id}: hull name '{hull.Name}' collides with an existing catalog item.");
|
||||
if (cache.GetAll<ShipAuthoring>().Any(existing => string.Equals(existing.Id, ship.Id, StringComparison.Ordinal)))
|
||||
throw new InvalidOperationException($"{ship.Id}: ship ID collides with an existing catalog ship authoring record.");
|
||||
cache.UpsertAsync(typeof(HullData), ship.Hull, hullKey).GetAwaiter().GetResult();
|
||||
cache.UpsertAsync(typeof(ShipAuthoring), ship, authoringKey).GetAwaiter().GetResult();
|
||||
// Verbatim: the mod store's own two records, under the keys it already holds them at.
|
||||
ShipAuthoringStore.Write(cache, hull, ship);
|
||||
}
|
||||
cache.FlushAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
if (File.Exists(output)) File.Replace(temporary, output, null);
|
||||
else File.Move(temporary, output);
|
||||
return ships.Count;
|
||||
return packages.Count;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -84,7 +86,7 @@ public static class ShipModCatalog
|
|||
{
|
||||
var path = Path.GetFullPath(shipPath);
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
var ship = ShipAuthoringStore.Read(path);
|
||||
var (hull, ship) = ShipAuthoringStore.Read(path);
|
||||
if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"{path}: ship ID must match its package directory name.");
|
||||
var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset));
|
||||
|
|
@ -95,8 +97,8 @@ public static class ShipModCatalog
|
|||
foreach (var anchor in ship.Anchors)
|
||||
if (!modelNodes.ContainsKey(anchor.ModelNodeId))
|
||||
throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}.");
|
||||
CultRecordRefs.Validate(ship.Hull);
|
||||
return new Package { Ship = ship, ModelPath = modelPath, NodeIndices = modelNodes };
|
||||
CultRecordRefs.Validate(hull);
|
||||
return new Package { Hull = hull, Visual = ship, ModelPath = modelPath, NodeIndices = modelNodes };
|
||||
}
|
||||
|
||||
// GLB is the xenos asset boundary. Only its node extras are read here; geometry belongs to the runtime importer.
|
||||
|
|
|
|||
Loading…
Reference in a new issue