Preserve generated equipment rotation

This commit is contained in:
Meta Vens 2026-07-16 15:02:01 +01:00
commit 01f0d69b8d
15 changed files with 78 additions and 20 deletions

View file

@ -1561,7 +1561,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
availableHull.Hardpoints =
[
new AetheriaRuntimeHardpoint("ControlModule", 0, 0, 1, 1, 1, one, "", "None", 0),
new AetheriaRuntimeHardpoint("Weapon", 1, 0, 1, 1, 1, one, "", "None", 0),
new AetheriaRuntimeHardpoint("Weapon", 1, 0, 1, 1, 1, one, "", "Clockwise", 0),
new AetheriaRuntimeHardpoint("Sensors", 2, 0, 1, 1, 1, one, "", "None", 0)
];
var unavailableHull = Item("cheap-foreign-hull", AetheriaRuntimeItemCategories.Hull, "foreign", 1);
@ -1637,6 +1637,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
first.Equipment.Any(value => value.ItemKey == "ship-sensor") &&
first.Equipment.All(value => value.ItemKey != "station-sensor"),
"generated ships must fit hardpoint equipment and reject station-sized sensor gear");
RequireEqual("Clockwise", first.Equipment.Single(value => value.ItemKey == "cannon").Rotation,
"generated equipment must preserve the placement rotation used by hardpoint fitting");
Require(first.HullItemKey == second.HullItemKey &&
first.Equipment.Select(value => value.ItemKey).SequenceEqual(second.Equipment.Select(value => value.ItemKey)),
"same seed, map, faction and catalog must produce the same loadout");

View file

@ -66,8 +66,9 @@ public sealed class AetheriaDaemonLoadoutGenerator
continue;
}
slots.Add(Slot(hardpoint.PositionX, hardpoint.PositionY, item.ItemKey));
Reserve(occupied, item, hardpoint.PositionX, hardpoint.PositionY, ParseRotation(hardpoint.Rotation));
var rotation = ParseRotation(hardpoint.Rotation);
slots.Add(Slot(hardpoint.PositionX, hardpoint.PositionY, item.ItemKey, rotation));
Reserve(occupied, item, hardpoint.PositionX, hardpoint.PositionY, rotation);
previous.Add(item);
}
@ -141,7 +142,7 @@ public sealed class AetheriaDaemonLoadoutGenerator
if (selected.Item == null || !selected.Fit.HasValue)
throw new InvalidOperationException("No available mandatory equipment fits the remaining hull space.");
var fit = selected.Fit.Value;
slots.Add(Slot(fit.X, fit.Y, selected.Item.ItemKey));
slots.Add(Slot(fit.X, fit.Y, selected.Item.ItemKey, fit.Rotation));
Reserve(occupied, selected.Item, fit.X, fit.Y, fit.Rotation);
return selected.Item;
}
@ -163,7 +164,7 @@ public sealed class AetheriaDaemonLoadoutGenerator
if (selected.Item == null || !selected.Fit.HasValue)
return null;
var fit = selected.Fit.Value;
slots.Add(Slot(fit.X, fit.Y, selected.Item.ItemKey));
slots.Add(Slot(fit.X, fit.Y, selected.Item.ItemKey, fit.Rotation));
Reserve(occupied, selected.Item, fit.X, fit.Y, fit.Rotation);
return selected.Item;
}
@ -201,7 +202,7 @@ public sealed class AetheriaDaemonLoadoutGenerator
var fit = FindFit(interiorCells, width, height, item, occupied);
if (!fit.HasValue) continue;
Reserve(occupied, item, fit.Value.X, fit.Value.Y, fit.Value.Rotation);
packed.Add(CargoSlot(fit.Value.X, fit.Value.Y, item.ItemKey));
packed.Add(CargoSlot(fit.Value.X, fit.Value.Y, item.ItemKey, fit.Value.Rotation));
}
return packed.ToArray();
}
@ -362,6 +363,14 @@ public sealed class AetheriaDaemonLoadoutGenerator
_ => 0
};
private static string RotationName(int rotation) => (((rotation % 4) + 4) % 4) switch
{
1 => "Clockwise",
2 => "Half",
3 => "CounterClockwise",
_ => "None"
};
private static bool HasBehavior(AetheriaRuntimeCatalogItem item, string kind) =>
(item.BehaviorKinds ?? Array.Empty<string>()).Contains(kind, StringComparer.Ordinal);
@ -375,19 +384,21 @@ public sealed class AetheriaDaemonLoadoutGenerator
(item.BehaviorKinds ?? Array.Empty<string>()).Any(kind =>
kind.Contains("Weapon", StringComparison.Ordinal)));
private static AetheriaEntityItemSlot Slot(int x, int y, string itemKey) => new()
private static AetheriaEntityItemSlot Slot(int x, int y, string itemKey, int rotation = 0) => new()
{
Position = new AetheriaGridCoord { X = x, Y = y }, ItemKey = itemKey,
Quality = 1, Durability = 1, Quantity = 1, Enabled = true
Quality = 1, Durability = 1, Quantity = 1, Enabled = true,
Rotation = RotationName(rotation)
};
private static AetheriaLoadoutItemSlot CargoSlot(int index, string itemKey) =>
CargoSlot(index % 4, index / 4, itemKey);
private static AetheriaLoadoutItemSlot CargoSlot(int x, int y, string itemKey) => new()
private static AetheriaLoadoutItemSlot CargoSlot(int x, int y, string itemKey, int rotation = 0) => new()
{
Position = new AetheriaGridCoord { X = x, Y = y },
Item = new AetheriaLoadoutItem { ItemKey = itemKey, Quality = 1, Durability = 1, Quantity = 1, Enabled = true }
Item = new AetheriaLoadoutItem { ItemKey = itemKey, Quality = 1, Durability = 1, Quantity = 1, Enabled = true },
Rotation = RotationName(rotation)
};
}

View file

@ -2916,6 +2916,7 @@ static AetheriaRuntimeLoadoutItemSlotCommit[] ToEntitySlotCommits(
{
X = slot.Position?.X ?? 0,
Y = slot.Position?.Y ?? 0,
Rotation = slot.Rotation ?? "None",
Item = new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = slot.ItemKey ?? "",
@ -2996,6 +2997,7 @@ static AetheriaRuntimeLoadoutItemSlotCommit[] ToSlotCommits(
{
X = slot.Position?.X ?? 0,
Y = slot.Position?.Y ?? 0,
Rotation = slot.Rotation ?? "None",
Item = ToLoadoutItemCommit(slot.Item)
})
.ToArray();

View file

@ -248,6 +248,7 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s
new AetheriaLoadoutItemSlot
{
Position = new AetheriaGridCoord { X = 0, Y = 0 },
Rotation = "Clockwise",
Item = new AetheriaLoadoutItem
{
ItemKey = itemKey.ToString(),
@ -275,6 +276,7 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s
new AetheriaEntityItemSlot
{
Position = new AetheriaGridCoord { X = 0, Y = 0 },
Rotation = "CounterClockwise",
ItemKey = itemKey.ToString(),
Quality = 0.9,
Durability = 0.8,
@ -292,6 +294,7 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s
new AetheriaLoadoutItemSlot
{
Position = new AetheriaGridCoord { X = 2, Y = 3 },
Rotation = "Half",
Item = new AetheriaLoadoutItem
{
ItemKey = itemKey.ToString(),
@ -851,6 +854,7 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether
if (loadout?.RootEntity.Hull.ItemKey != AetheriaCatalogKeys.ItemDefinitionFromLegacyId(itemLegacyId).ToString() ||
loadout.RootEntity.Equipment.Length != 1 ||
loadout.RootEntity.Equipment[0].Rotation != "Clockwise" ||
loadout.RootEntity.Equipment[0].Item.Enabled ||
!loadout.RootEntity.Equipment[0].Item.OverrideShutdown ||
loadout.RootEntity.WeaponGroups.Length != 1)
@ -890,10 +894,12 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether
entitySnapshot.Equipment[0].Quality != 0.9 ||
entitySnapshot.Equipment[0].Durability != 0.8 ||
entitySnapshot.Equipment[0].Quantity != 1 ||
entitySnapshot.Equipment[0].Rotation != "CounterClockwise" ||
entitySnapshot.Equipment[0].Enabled ||
!entitySnapshot.Equipment[0].OverrideShutdown ||
entitySnapshot.CargoContents.Length != 1 ||
entitySnapshot.CargoContents[0].Items[0].Item.Quantity != 7 ||
entitySnapshot.CargoContents[0].Items[0].Rotation != "Half" ||
entitySnapshot.DockingBayAssignments.Length != 1 ||
entitySnapshot.DockingBayAssignments[0] != -1 ||
entitySnapshot.Visibility != 12.75 ||

View file

@ -1275,6 +1275,8 @@ static void RequireDaemonPlayableRunGenerationAuthority(string root)
"HasBehavior(candidate, controllerKind)",
"for (var draw = 0; draw < 16 && pool.Count > 0; draw++)",
"FindFit(interiorCells, width, height, item, occupied)",
"slots.Add(Slot(hardpoint.PositionX, hardpoint.PositionY, item.ItemKey, rotation))",
"Rotation = RotationName(rotation)",
"AetheriaLoadoutGenerationReceipt",
"Selections = selectedKeys.Select"
};

View file

@ -120,6 +120,7 @@ public static class AetheriaRuntimeStateMapper
X = slot.X,
Y = slot.Y
},
Rotation = slot.Rotation ?? "None",
Item = ToLoadoutItem(slot.Item)
})
.ToArray();

View file

@ -190,6 +190,9 @@ public sealed class AetheriaLoadoutItemSlot
[Key(1)]
public AetheriaLoadoutItem Item { get; set; } = new();
[Key(2)]
public string Rotation { get; set; } = "None";
}
[MessagePackObject]
@ -681,6 +684,9 @@ public sealed class AetheriaEntityItemSlot
[Key(7)]
public double Temperature { get; set; }
[Key(8)]
public string Rotation { get; set; } = "None";
}
[MessagePackObject]

View file

@ -56,7 +56,7 @@ namespace GameCult.Aetheria.State.Verse
private static AetheriaRuntimeLoadoutItemSlotCommit Clone(AetheriaRuntimeLoadoutItemSlotCommit source, int quantity) => new()
{
X = source.X, Y = source.Y,
X = source.X, Y = source.Y, Rotation = source.Rotation ?? "None",
Item = new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = source.Item.ItemKey, Quality = source.Item.Quality, Durability = source.Item.Durability,

View file

@ -845,16 +845,22 @@ namespace GameCult.Aetheria.State.Verse
[MessagePackObject(true)]
public sealed class AetheriaRuntimeLoadoutItemSlotSnapshot
{
public AetheriaRuntimeLoadoutItemSlotSnapshot(int x, int y, AetheriaRuntimeLoadoutItemSnapshot item)
public AetheriaRuntimeLoadoutItemSlotSnapshot(
int x,
int y,
AetheriaRuntimeLoadoutItemSnapshot item,
string rotation = "None")
{
X = x;
Y = y;
Item = item;
Rotation = rotation ?? "None";
}
public int X { get; set; }
public int Y { get; set; }
public AetheriaRuntimeLoadoutItemSnapshot Item { get; set; }
public string Rotation { get; set; }
}
[MessagePackObject(true)]
@ -1017,7 +1023,8 @@ namespace GameCult.Aetheria.State.Verse
int quantity,
bool enabled,
bool overrideShutdown,
double temperature = 0)
double temperature = 0,
string rotation = "None")
{
X = x;
Y = y;
@ -1028,6 +1035,7 @@ namespace GameCult.Aetheria.State.Verse
Enabled = enabled;
OverrideShutdown = overrideShutdown;
Temperature = temperature;
Rotation = rotation ?? "None";
}
public int X { get; set; }
@ -1039,6 +1047,7 @@ namespace GameCult.Aetheria.State.Verse
public bool Enabled { get; set; }
public bool OverrideShutdown { get; set; }
public double Temperature { get; set; }
public string Rotation { get; set; }
}
[MessagePackObject(true)]

View file

@ -954,7 +954,9 @@ namespace GameCult.Aetheria.State.Verse
var enabled = ReadFieldBool(ref reader, slotFields, 5, true);
var overrideShutdown = ReadFieldBool(ref reader, slotFields, 6);
var temperature = ReadFieldDouble(ref reader, slotFields, 7);
SkipRemaining(ref reader, slotFields, 8);
var rotation = ReadFieldString(ref reader, slotFields, 8);
if (string.IsNullOrWhiteSpace(rotation)) rotation = "None";
SkipRemaining(ref reader, slotFields, 9);
slots[slot] = new AetheriaRuntimeEntityItemSlotSnapshot(
position.X,
position.Y,
@ -964,7 +966,8 @@ namespace GameCult.Aetheria.State.Verse
quantity <= 0 ? 1 : quantity,
enabled,
overrideShutdown,
temperature);
temperature,
rotation);
}
return slots;
@ -1532,8 +1535,10 @@ namespace GameCult.Aetheria.State.Verse
var slotFields = reader.ReadArrayHeader();
var position = ReadFieldGridCoord(ref reader, slotFields, 0);
var item = ReadFieldLoadoutItem(ref reader, slotFields, 1);
SkipRemaining(ref reader, slotFields, 2);
return new AetheriaRuntimeLoadoutItemSlotSnapshot(position.X, position.Y, item);
var rotation = ReadFieldString(ref reader, slotFields, 2);
if (string.IsNullOrWhiteSpace(rotation)) rotation = "None";
SkipRemaining(ref reader, slotFields, 3);
return new AetheriaRuntimeLoadoutItemSlotSnapshot(position.X, position.Y, item, rotation);
}
private static GridCoord ReadFieldGridCoord(ref MessagePackReader reader, int fields, int index)

View file

@ -1983,6 +1983,7 @@ namespace GameCult.Aetheria.State.Verse
{
X = slot?.X ?? 0,
Y = slot?.Y ?? 0,
Rotation = slot?.Rotation ?? "None",
Item = new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = item?.ItemKey ?? "",

View file

@ -1031,7 +1031,8 @@ namespace GameCult.Aetheria.State.Verse
slot.Item.Quantity,
slot.Item.Enabled,
slot.Item.OverrideShutdown,
slot.Item.Temperature))
slot.Item.Temperature,
slot.Rotation ?? "None"))
.ToArray();
}
@ -1059,7 +1060,8 @@ namespace GameCult.Aetheria.State.Verse
slot.Item.Quantity,
slot.Item.Enabled,
slot.Item.OverrideShutdown,
slot.Item.Temperature)))
slot.Item.Temperature),
slot.Rotation ?? "None"))
.ToArray();
}

View file

@ -150,6 +150,9 @@ namespace GameCult.Aetheria.State.Verse
[Key(2)]
public AetheriaRuntimeLoadoutItemCommit Item { get; set; } = new AetheriaRuntimeLoadoutItemCommit();
[Key(3)]
public string Rotation { get; set; } = "None";
}
[MessagePackObject]
@ -420,6 +423,7 @@ namespace GameCult.Aetheria.State.Verse
{
X = slot.X,
Y = slot.Y,
Rotation = slot.Rotation ?? "None",
Item = CloneItem(slot.Item)
};
}

View file

@ -1246,6 +1246,13 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted.
before hydrating row projections. No live caller can enumerate
legacy catalog entries or request catalog-shaped single entries through
`ItemManager`.
Equipment placement rotation is durable semantic state, not an occupancy-loop
temporary. The daemon generator emits the same quarter-turn used to reserve a
hardpoint, free-space fit, or cargo fit; loadout documents, runtime commits,
catalog and viewport snapshots, state mapping, and clone/transfer paths retain
it. This is the required input for daemon-owned thruster direction and torque;
Unity may visualize the resulting actuator state but cannot reconstruct a
missing rotation from prefab transforms.
- Inputs: Unity gameplay code, legacy catalog files, typed state documents, and
CultMesh server state.
- Outputs: `Aetheria.State` emits `.cc` state and CultMesh documents. Runtime

View file

@ -17,7 +17,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity.
|---|---|---|---|---|---|---|
| Run/session state | `ServerShared`, `ActionGameManager` | Aetheria daemon/CultCache | provider/session advertisement | all clients | owner-exists; wired; projected; not proven | restart and multi-client convergence |
| Entity identity/loadout | `ServerShared/Entity*` | daemon | typed entity and inventory surfaces | EveUnity/Electron | owner-exists; wired; projected; not proven | loadout round trip and reconnect |
| Loadout generation | map/faction availability, item cost, hull hardpoints and role constraints | daemon using canonical galaxy, faction knowledge/economy and typed catalog | generated loadout plus provenance/availability facts | Eve inspection and refit surfaces | daemon now uses the fossil candidate filter and seeded weighted selection: manufacturer must be present and allied, ally weight decays by home-zone graph distance, price uses the authored exponent, and hardpoints require matching semantic type, rotated footprint containment, and controller role; generation fills the largest compatible footprint before applying economic weighting, so small gear remains a valid large-mount fallback without displacing available station-scale gear; cargo/capacitor plus station docking equipment must fit remaining hull space; stations draw up to sixteen faction-available equipment rows and pack largest-first into the selected cargo bay's actual interior shape; ordinary ships begin without generated cargo, and the deleted scenario-string path can no longer silently invent non-catalog item identities; station cargo slots and receipts preserve the same canonical keys; zone generation derives one continuing deterministic RNG stream per availability corporation, so cross-faction calls cannot perturb another faction; every generated entity persists an immutable source/selection receipt that reaches Eve as `loadout.item` semantics; Terminus explicitly maps combat-role factions to catalog availability corporations | multi-zone generated-world parity and exact root-to-zone stream sequencing against the fossil |
| Loadout generation | map/faction availability, item cost, hull hardpoints and role constraints | daemon using canonical galaxy, faction knowledge/economy and typed catalog | generated loadout plus provenance/availability facts | Eve inspection and refit surfaces | daemon now uses the fossil candidate filter and seeded weighted selection: manufacturer must be present and allied, ally weight decays by home-zone graph distance, price uses the authored exponent, and hardpoints require matching semantic type, rotated footprint containment, and controller role; the exact placement rotation used by hardpoint/free-space/cargo fitting now survives generated state, durable loadouts, runtime commits, viewport snapshots, cloning, and reconnect instead of disappearing after occupancy reservation; generation fills the largest compatible footprint before applying economic weighting, so small gear remains a valid large-mount fallback without displacing available station-scale gear; cargo/capacitor plus station docking equipment must fit remaining hull space; stations draw up to sixteen faction-available equipment rows and pack largest-first into the selected cargo bay's actual interior shape; ordinary ships begin without generated cargo, and the deleted scenario-string path can no longer silently invent non-catalog item identities; station cargo slots and receipts preserve the same canonical keys; zone generation derives one continuing deterministic RNG stream per availability corporation, so cross-faction calls cannot perturb another faction; every generated entity persists an immutable source/selection receipt that reaches Eve as `loadout.item` semantics; Terminus explicitly maps combat-role factions to catalog availability corporations | multi-zone generated-world parity and exact root-to-zone stream sequencing against the fossil |
| Flight intent | `Ship`, `Thruster`, `AetherDrive`, input paths | daemon intent + Ymir physics | analog movement/look/tractor | EveUnity input driver | movement intents feed Ymir-owned body integration; authored thrust allocation, torque, energy and heat remain simplified | prove original drive/equipment behavior ordering and runtime interpolation |
| Collision | Unity physics and entity handlers | Ymir | collision/result facts | visual interpolation only | Ymir.Core world stepping wired for entity bodies, radial fields and contacts; missing-owner negative proof passes | publish authoritative collision events and prove Unity cannot write transforms back |
| Targeting/contacts | sensors + `ActionGameManager` | daemon | contact/target semantics | target HUD and indicators | simplified owner wired/projected; every authored instant lock behavior now evaluates on every daemon step independently of trigger/burst selection, so an idle weapon cannot retain a stale completed lock; acquisition and decay derive from facing, elapsed time, and observer-local contact confidence instead of granting an immediate perfect lock; target changes and invalid targets clear the daemon state; `weapon.lock.started`, `weapon.lock.acquired`, and `weapon.lock.lost` publish stable one-time chronology with target, weapon identity, reason, and prior/current progress through the generic feedback stream; the provider cockpit and generic combat reticle consume the same continuous selected-target lock without client smoothing or reconstruction | angular loss fixture, visibility/nearest/explicit targeting cases, provider audio/effect roles, and authored reticle parity |