Aetheria/Assets/Scripts/ServerShared/Loadout.cs
Meta Vens 61fc863e6b Stop loadouts from saving or trusting dangling weapon-group indices (Cut 10 fix)
Entity.TryUnequip does not clear WeaponGroups, so Capture saved -1 for a
grouped item that was no longer equipped, and Materialize later threw
IndexOutOfRangeException after every other check passed. Capture now skips
group items that are not equipped. Materialize reports every weapon-group index
outside the loadout's slots as a failure before building anything, so a stale
or hand-edited record builds nothing and charges nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:28:24 +02:00

139 lines
6.4 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
using MessagePack;
using CultMath;
// A ship build as item designs only: the hull's design, and per hull cell a design and its rotation. A design names no
// manufacturer; which product builds a slot is a fact of the galaxy the loadout is materialized in, which keeps a
// loadout portable across galaxies. Player-store state: a loadout outlives runs.
[CultDocument("aetheria.loadout", "1"), MessagePackObject]
public class Loadout
{
[CultName, Key(0)] public string Name;
[Key(1)] public CultRecordRef<HullData> Hull;
[Key(2)] public List<LoadoutSlot> Slots = new List<LoadoutSlot>();
[Key(3)] public int[][] WeaponGroups; // indices into Slots
}
[MessagePackObject]
public class LoadoutSlot
{
[Key(0)] public int2 Position; // hull cell passed to Entity.TryEquip(item, int2)
[Key(1)] public ItemRotation Rotation;
[Key(2)] public CultRecordRef<EquippableItemData> Design;
}
// The owner of loadouts: capture from a live ship, the only writer, and the only builder of a ship from a loadout.
public static class Loadouts
{
// Records the hull design and one slot per equipped item (gear, then cargo bays, then docking bays) with its cell,
// rotation and design. Entity.Equipment also holds the hull's own unit; that is Hull, not a slot. Nothing about the
// units themselves: no product, quality, ingredients, cargo or state.
public static Loadout Capture(ItemManager itemManager, Entity entity, string name)
{
var cache = itemManager.ItemData;
var items = entity.Equipment
.Concat<EquippedItem>(entity.CargoBays)
.Concat(entity.DockingBays)
.Where(item => item.EquippableItem != entity.Hull)
.Distinct()
.ToList();
return new Loadout
{
Name = name,
Hull = cache.RefOf((HullData) itemManager.GetData(entity.Hull)),
Slots = items.Select(item => new LoadoutSlot
{
Position = item.Position,
Rotation = item.EquippableItem.Rotation,
Design = cache.RefOf(itemManager.GetData(item.EquippableItem))
}).ToList(),
WeaponGroups = entity.WeaponGroups
.Select(group => group.items.Select(item => items.IndexOf(item)).Where(index => index >= 0).ToArray()) // skips unequipped items
.ToArray()
};
}
// One Commit to the player store. A loadout with the same name is replaced under its existing key; names are
// unique by construction because GetByName throws on duplicates.
public static void Save(CultCache cache, Loadout loadout)
{
var existing = cache.GetByName<Loadout>(loadout.Name);
var key = existing == null ? (CultRecordKey?) null : cache.TryGetHandle(existing)?.Key;
cache.Commit(batch => batch.Upsert(typeof(Loadout), loadout, key));
}
// All-or-nothing: returns a ship and charges Price only when every design resolved, had an available product and
// fitted. Every failure is listed; on any failure nothing is returned, nothing is charged and nothing outside this
// call has changed. A design is built by its first available product in record-key order. The game passes
// LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer.
public static Ship Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
Predicate<FactionProductData> isAvailable, ref int credits, List<string> failures)
{
var cache = itemManager.ItemData;
var products = cache.GetAll<FactionProductData>().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();
var reported = failures.Count;
FactionProductData Resolve<T>(CultRecordRef<T> design, string where) where T : EquippableItemData
{
var data = cache.Get(design);
if (data == null)
{
failures.Add($"{where}: design {design.Key} is not in the catalog");
return null;
}
var product = products.FirstOrDefault(p => p.Design.Key.Equals(design.Key) && isAvailable(p));
if (product == null) failures.Add($"{where}: no available product of {data.Name}");
return product;
}
var hullProduct = Resolve(loadout.Hull, "hull");
var slotProducts = loadout.Slots.Select(slot => Resolve(slot.Design, Cell(slot))).ToArray();
foreach (var index in (loadout.WeaponGroups ?? new int[0][]).SelectMany(group => group))
if (index < 0 || index >= loadout.Slots.Count) failures.Add($"weapon group: no slot {index}");
if (failures.Count > reported) return null;
var hull = (EquippableItem) itemManager.CreateInstance(hullProduct);
var ship = new Ship(itemManager, liveZone, hull, itemManager.GameplaySettings.DefaultEntitySettings);
var equipped = new EquippedItem[loadout.Slots.Count];
for (var i = 0; i < loadout.Slots.Count; i++)
{
var slot = loadout.Slots[i];
var unit = (EquippableItem) itemManager.CreateInstance(slotProducts[i]);
unit.Rotation = slot.Rotation;
if (!ship.TryEquip(unit, slot.Position))
{
failures.Add($"{Cell(slot)}: {cache.Get(slot.Design).Name} does not fit");
continue;
}
equipped[i] = ship.Equipment
.Concat<EquippedItem>(ship.CargoBays)
.Concat(ship.DockingBays)
.First(item => item.EquippableItem == unit);
}
if (failures.Count > reported) return null;
ship.WeaponGroups = (loadout.WeaponGroups ?? new int[0][])
.Select(indices =>
{
var items = indices.Select(i => equipped[i]).ToList();
return (items.Select(item => item.GetBehavior<Weapon>()).ToList(), items);
})
.ToArray();
credits -= Price(itemManager, loadout);
return ship;
}
// The hull design's price plus each slot design's price.
public static int Price(ItemManager itemManager, Loadout loadout)
{
var cache = itemManager.ItemData;
return (cache.Get(loadout.Hull)?.Price ?? 0) + loadout.Slots.Sum(slot => cache.Get(slot.Design)?.Price ?? 0);
}
private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}";
}