Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature. - Route Loadout to the catalog; the player store no longer holds it. - Materialize takes no credits: affordability, the charge and Loadouts.Price are deleted, since only the player menu priced loadouts. Availability, key-order product choice, all-or-nothing failures and the weapon-group rules stay. - Delete the InventoryPanel Save Loadout and Restore Loadout entries and RestoreLoadout, which also removes its docking-bay overwrite. - The editor opens the catalog writable in the one process cache, and the editor-only console command capturepreset "<name>" [replace] captures the piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name replace unless asked, and commits conditionally on that key. The single-file store then lands only that record onto the file as it is on disk. An unconditional commit would re-serialize every live catalog instance, including any that play has mutated. - Tests: capture -> commit -> fresh reopen round-trip, explicit replace with no duplicate, and a preset commit that does not persist a mutated live Faction. The money-only tests are removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
e7dd62d02a
commit
db83cd290d
6 changed files with 124 additions and 166 deletions
|
|
@ -50,10 +50,12 @@ public class ActionGameManager : MonoBehaviour
|
|||
|
||||
// All three stores attach once and stay attached until the process exits. The run lifecycle is record-level
|
||||
// inside this cache; reopening would replace the catalog instances the galaxy and live entities hold.
|
||||
// The editor opens the catalog writable so the capturepreset console command can author presets in play.
|
||||
_cultCache = AetheriaStores.Open(
|
||||
Path.Combine(GameDataDirectory.FullName, "Aetheria.cc"),
|
||||
runPath: Path.Combine(GameDataDirectory.FullName, "run.cc"),
|
||||
playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc"));
|
||||
playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc"),
|
||||
catalogWritable: Application.isEditor);
|
||||
|
||||
return _cultCache;
|
||||
}
|
||||
|
|
@ -543,6 +545,25 @@ public class ActionGameManager : MonoBehaviour
|
|||
});
|
||||
//Temporary, or not
|
||||
ConsoleController.AddCommand("tow", _ => TowShip());
|
||||
|
||||
// Editor only: capturepreset "<name>" [replace] writes the piloted ship as a catalog preset.
|
||||
if (Application.isEditor)
|
||||
ConsoleController.AddCommand("capturepreset", args =>
|
||||
{
|
||||
var console = ConsoleController.Instance;
|
||||
var name = args.Length > 0 ? args[0] : "";
|
||||
if (string.IsNullOrWhiteSpace(name)) { console.AppendLogLine("usage: capturepreset \"<name>\" [replace]"); return; }
|
||||
if (!(_currentEntity is Ship ship)) { console.AppendLogLine("capturepreset: pilot a ship first"); return; }
|
||||
try
|
||||
{
|
||||
var written = Loadouts.Commit(CultCache, Loadouts.Capture(ItemManager, ship, name), args.Length > 1 && args[1] == "replace");
|
||||
console.AppendLogLine(written ? $"Preset '{name}' written to the catalog" : $"Preset '{name}' changed on disk since load; nothing written");
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
console.AppendLogLine(e.Message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void BeginDrag(DragObject dragObject)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ 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) };
|
||||
public static readonly Type[] CatalogTypes = { typeof(ItemData), 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) };
|
||||
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings), typeof(Loadout) };
|
||||
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };
|
||||
|
||||
// Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given.
|
||||
// A read-only catalog throws when a [CultGlobal] type routed to it has no record. A writable catalog is being
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ 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.
|
||||
// A ship preset (a variant): 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 preset is materialized in, which keeps
|
||||
// a preset portable across galaxies. Authored catalog data: written in CultCache Studio or by the editor capture
|
||||
// command, read at runtime.
|
||||
[CultDocument("aetheria.loadout", "1"), MessagePackObject]
|
||||
public class Loadout
|
||||
{
|
||||
|
|
@ -56,24 +57,34 @@ public static class Loadouts
|
|||
};
|
||||
}
|
||||
|
||||
// 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)
|
||||
// A preset's key derives from its name, so capturing the same name again addresses the same record.
|
||||
public static CultRecordKey KeyOf(string name) => new CultRecordKey("loadout:" + name);
|
||||
|
||||
// Writes the preset to the catalog under KeyOf(Name). An existing preset of that name is replaced only when replace
|
||||
// is set; otherwise this throws and writes nothing. The commit is conditional on the record at that key being what
|
||||
// this cache holds, so it lands only this record onto the catalog file as it is on disk: no other in-memory catalog
|
||||
// state is written, and a preset changed on disk since load (Studio) is not clobbered. Returns false on that conflict.
|
||||
public static bool Commit(CultCache cache, Loadout loadout, bool replace)
|
||||
{
|
||||
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));
|
||||
var key = KeyOf(loadout.Name);
|
||||
var existing = cache.Get<Loadout>(key);
|
||||
if (existing != null && !replace)
|
||||
throw new InvalidOperationException($"Preset '{loadout.Name}' already exists; replace it explicitly.");
|
||||
return cache.Commit(batch =>
|
||||
{
|
||||
batch.Expect(key, existing);
|
||||
batch.Upsert(typeof(Loadout), loadout, key);
|
||||
});
|
||||
}
|
||||
|
||||
// All-or-nothing: returns a ship and charges Price only when credits cover Price, every design resolved, had an
|
||||
// available product and fitted, the loadout has at most WeaponGroupCount weapon groups, and every group index names
|
||||
// a weapon slot. Affordability is decided here and nowhere else. Every failure is listed; on any failure nothing is
|
||||
// returned and nothing is charged. The ship, credits, zone and cache are unchanged, but a failed fit has already
|
||||
// drawn from itemManager.Random. 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. The ship always gets
|
||||
// exactly WeaponGroupCount groups; a loadout with fewer is padded with empty groups.
|
||||
// All-or-nothing: returns a ship only when every design resolved, had an available product and fitted, the loadout
|
||||
// has at most WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on
|
||||
// any failure nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from
|
||||
// itemManager.Random. 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. The ship always gets exactly
|
||||
// WeaponGroupCount groups; a loadout with fewer is padded with empty groups.
|
||||
public static Ship Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
|
||||
Predicate<FactionProductData> isAvailable, ref int credits, List<string> failures)
|
||||
Predicate<FactionProductData> isAvailable, List<string> failures)
|
||||
{
|
||||
var cache = itemManager.ItemData;
|
||||
var products = cache.GetAll<FactionProductData>().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();
|
||||
|
|
@ -104,8 +115,6 @@ public static class Loadouts
|
|||
else if (cache.Get(loadout.Slots[index].Design) is { } design && !design.Behaviors.Any(behavior => behavior is WeaponData))
|
||||
failures.Add($"weapon group: {Cell(loadout.Slots[index])}: {design.Name} is not a weapon");
|
||||
}
|
||||
var price = Price(itemManager, loadout);
|
||||
if (credits < price) failures.Add($"cannot afford {price:n0} credits");
|
||||
if (failures.Count > reported) return null;
|
||||
|
||||
var hull = (EquippableItem) itemManager.CreateInstance(hullProduct);
|
||||
|
|
@ -136,17 +145,8 @@ public static class Loadouts
|
|||
built[g] = (items.Select(item => item.GetBehavior<Weapon>()).ToList(), items);
|
||||
}
|
||||
ship.WeaponGroups = built;
|
||||
credits -= (int) price;
|
||||
return ship;
|
||||
}
|
||||
|
||||
// The hull design's price plus each slot design's price. Summed as long so no loadout wraps to a negative price;
|
||||
// a price beyond any int credit balance is simply unaffordable.
|
||||
public static long Price(ItemManager itemManager, Loadout loadout)
|
||||
{
|
||||
var cache = itemManager.ItemData;
|
||||
return (long) (cache.Get(loadout.Hull)?.Price ?? 0) + loadout.Slots.Sum(slot => (long) (cache.Get(slot.Design)?.Price ?? 0));
|
||||
}
|
||||
|
||||
private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ public class LoadoutGenerator
|
|||
|
||||
// No galaxy means no availability to filter by: every product is on offer. A fixture generates loadouts that
|
||||
// way, so a test can exercise placement and products without standing up a whole galaxy; no game path does.
|
||||
// Restoring a loadout passes this to Loadouts.Materialize, so a loadout and generation share one availability rule.
|
||||
// Materializing a preset passes this to Loadouts.Materialize, so presets and generation share one availability rule.
|
||||
public bool IsAvailable(FactionProductData product) =>
|
||||
Galaxy == null || Galaxy.IsPrelude ||
|
||||
Galaxy.ContainsFaction(product.Manufacturer) && (Faction == null || Faction.Allegiance.ContainsKey(product.Manufacturer));
|
||||
|
|
|
|||
|
|
@ -167,64 +167,11 @@ public class InventoryPanel : MonoBehaviour, IPointerClickHandler
|
|||
if(GameManager.DockingBay!=null && _displayedCargo!=GameManager.DockingBay)
|
||||
ContextMenu.AddOption(GameManager.DockingBay.Name, () => Display(GameManager.DockingBay));
|
||||
|
||||
if (_displayedEntity is Ship displayedShip)
|
||||
{
|
||||
ContextMenu.AddOption("Save Loadout",
|
||||
() =>
|
||||
{
|
||||
var loadoutName = displayedShip.Name;
|
||||
Dialog.Clear();
|
||||
Dialog.Title.text = "Save Loadout";
|
||||
Dialog.AddField("Name", () => loadoutName, s => loadoutName = s);
|
||||
Dialog.Show(() => Loadouts.Save(ActionGameManager.CultCache,
|
||||
Loadouts.Capture(GameManager.ItemManager, displayedShip, loadoutName)));
|
||||
Dialog.MoveToCursor();
|
||||
});
|
||||
}
|
||||
|
||||
var loadouts = ActionGameManager.CultCache.GetAll<Loadout>().ToArray();
|
||||
if (GameManager.DockedEntity != null && loadouts.Any())
|
||||
{
|
||||
ContextMenu.AddDropdown("Restore Loadout",
|
||||
loadouts.Select<Loadout, (string text, Action action, bool enabled)>(loadout =>
|
||||
{
|
||||
var price = Loadouts.Price(GameManager.ItemManager, loadout);
|
||||
return ($"{loadout.Name} - {price:n0}", () => RestoreLoadout(loadout), true);
|
||||
}));
|
||||
}
|
||||
|
||||
ContextMenu.Show();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the loadout against the current galaxy at the docked station. On failure nothing changes: no ship, no
|
||||
// charge, and a dialog lists every slot that could not be built here.
|
||||
private void RestoreLoadout(Loadout loadout)
|
||||
{
|
||||
var station = GameManager.DockedEntity;
|
||||
var failures = new List<string>();
|
||||
var availability = new LoadoutGenerator(ref GameManager.ItemManager.Random, GameManager.ItemManager,
|
||||
ActionGameManager.CurrentGalaxy, GameManager.Zone.GalaxyZone, station.Faction, 0);
|
||||
var ship = Loadouts.Materialize(GameManager.ItemManager, GameManager.Zone, loadout, availability.IsAvailable,
|
||||
ref GameManager.Credits, failures);
|
||||
if (ship == null)
|
||||
{
|
||||
Dialog.Clear();
|
||||
Dialog.Title.text = "Loadout cannot be built here";
|
||||
foreach (var failure in failures) Dialog.AddProperty(failure);
|
||||
Dialog.Show();
|
||||
Dialog.MoveToCursor();
|
||||
return;
|
||||
}
|
||||
|
||||
ship.SetParent(station);
|
||||
ship.IsPlayerShip = true;
|
||||
GameManager.DockingBay.DockedShip = ship;
|
||||
GameManager.CurrentEntity = ship;
|
||||
Display(ship);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_displayedEntity != null && TemperatureDisplay)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ using GameCult.Caching;
|
|||
using GameCult.Caching.MessagePack;
|
||||
using Xunit;
|
||||
|
||||
// Loadouts hold only item designs, live in the player store, and materialize all-or-nothing against the catalog.
|
||||
// Loadouts are ship presets: item designs only, authored into the catalog, materialized all-or-nothing against it.
|
||||
// The fixture hull is 3x3: InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay could use.
|
||||
public sealed class LoadoutTests : IDisposable
|
||||
{
|
||||
|
|
@ -46,8 +46,12 @@ public sealed class LoadoutTests : IDisposable
|
|||
|
||||
public void Dispose() => Directory.Delete(_root, true);
|
||||
|
||||
// The game's shape: a read-only catalog with the run and player stores.
|
||||
private CultCache Open() => AetheriaStores.Open(Catalog, Run, Player);
|
||||
|
||||
// The editor's shape: the catalog writable, as the capture command sees it.
|
||||
private CultCache OpenAuthoring() => AetheriaStores.Open(Catalog, Run, Player, catalogWritable: true);
|
||||
|
||||
// Gear on the hull's hardpoint cell, cargo on an interior cell, and two empty weapon groups (fewer than the game's six).
|
||||
private static Loadout HandBuilt(CultCache cache, string name = "Skiff build") => new Loadout
|
||||
{
|
||||
|
|
@ -62,9 +66,9 @@ public sealed class LoadoutTests : IDisposable
|
|||
};
|
||||
|
||||
// HandBuilt with the Gun on the hardpoint, alone in the first of the game's six weapon groups.
|
||||
private static Loadout Armed(CultCache cache)
|
||||
private static Loadout Armed(CultCache cache, string name = "Armed build")
|
||||
{
|
||||
var armed = HandBuilt(cache, "Armed build");
|
||||
var armed = HandBuilt(cache, name);
|
||||
armed.Slots[0].Design = cache.RefOf<EquippableItemData>(cache.GetByName<GearData>("Gun"));
|
||||
armed.WeaponGroups = Enumerable.Range(0, 6).Select(g => g == 0 ? new[] { 0 } : new int[0]).ToArray();
|
||||
return armed;
|
||||
|
|
@ -76,33 +80,34 @@ public sealed class LoadoutTests : IDisposable
|
|||
return loadout;
|
||||
}
|
||||
|
||||
// Capture from a built ship, commit to the catalog, reopen fresh read-only: the preset and the ship it builds match.
|
||||
[Fact]
|
||||
public void LoadoutRoundTripsThroughAFreshCache()
|
||||
public void CapturedPresetRoundTripsThroughTheCatalog()
|
||||
{
|
||||
Loadout hand;
|
||||
using (var cache = Open())
|
||||
using (var cache = OpenAuthoring())
|
||||
{
|
||||
var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { });
|
||||
hand = Armed(cache);
|
||||
var failures = new List<string>();
|
||||
var ship = Build(items,hand, failures);
|
||||
var ship = Build(items, hand, failures);
|
||||
Assert.Empty(failures);
|
||||
Assert.NotNull(ship);
|
||||
Loadouts.Save(cache, Loadouts.Capture(items, ship, hand.Name));
|
||||
Loadouts.Save(cache, Loadouts.Capture(items, ship, hand.Name));
|
||||
Assert.True(Loadouts.Commit(cache, Loadouts.Capture(items, ship, hand.Name), replace: false));
|
||||
}
|
||||
|
||||
using (var cache = Open())
|
||||
{
|
||||
Assert.Single(cache.GetAll<Loadout>());
|
||||
var loaded = cache.GetByName<Loadout>(hand.Name);
|
||||
var loaded = Assert.Single(cache.GetAll<Loadout>());
|
||||
Assert.Equal(Loadouts.KeyOf(hand.Name), cache.RefOf(loaded).Key);
|
||||
Assert.Equal(hand.Name, loaded.Name);
|
||||
Assert.Equal(hand.Hull.Key, loaded.Hull.Key);
|
||||
Assert.Equal(hand.Slots.Select(Describe), loaded.Slots.Select(Describe));
|
||||
Assert.Equal(hand.WeaponGroups, loaded.WeaponGroups);
|
||||
|
||||
var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { });
|
||||
var failures = new List<string>();
|
||||
var ship = Build(items,loaded, failures);
|
||||
var ship = Build(items, loaded, failures);
|
||||
Assert.Empty(failures);
|
||||
Assert.Equal(hand.Slots.Select(slot => (slot.Position, slot.Design.Key)),
|
||||
ship.Equipment.Concat<EquippedItem>(ship.CargoBays)
|
||||
|
|
@ -110,7 +115,46 @@ public sealed class LoadoutTests : IDisposable
|
|||
.Select(item => (item.Position, cache.RefOf(items.GetData(item.EquippableItem)).Key)));
|
||||
}
|
||||
|
||||
Assert.Single(SchemaNames(Player), name => name == "aetheria.loadout");
|
||||
Assert.Single(SchemaNames(Catalog), name => name == "aetheria.loadout");
|
||||
Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout"));
|
||||
}
|
||||
|
||||
// Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates.
|
||||
[Fact]
|
||||
public void SameNameCaptureReplacesOnlyWhenAsked()
|
||||
{
|
||||
using (var cache = OpenAuthoring())
|
||||
{
|
||||
Assert.True(Loadouts.Commit(cache, HandBuilt(cache), replace: false));
|
||||
var before = Hash(Catalog);
|
||||
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(cache, Armed(cache, "Skiff build"), replace: false));
|
||||
Assert.Contains("Skiff build", error.Message);
|
||||
Assert.Equal(before, Hash(Catalog));
|
||||
Assert.Equal(2, Assert.Single(cache.GetAll<Loadout>()).WeaponGroups.Length);
|
||||
|
||||
Assert.True(Loadouts.Commit(cache, Armed(cache, "Skiff build"), replace: true));
|
||||
Assert.Equal(6, Assert.Single(cache.GetAll<Loadout>()).WeaponGroups.Length);
|
||||
}
|
||||
|
||||
using (var cache = Open())
|
||||
Assert.Equal(6, Assert.Single(cache.GetAll<Loadout>()).WeaponGroups.Length);
|
||||
}
|
||||
|
||||
// The editor's catalog is writable while play mutates live catalog instances; a preset commit must land only itself.
|
||||
[Fact]
|
||||
public void PresetCommitWritesNoOtherInMemoryCatalogState()
|
||||
{
|
||||
using (var cache = OpenAuthoring())
|
||||
{
|
||||
cache.GetByName<Faction>("Maker").ShortName = "XXX";
|
||||
Assert.True(Loadouts.Commit(cache, HandBuilt(cache), replace: false));
|
||||
}
|
||||
|
||||
using (var cache = Open())
|
||||
{
|
||||
Assert.Equal("MKR", cache.GetByName<Faction>("Maker").ShortName);
|
||||
Assert.Single(cache.GetAll<Loadout>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -138,10 +182,11 @@ public sealed class LoadoutTests : IDisposable
|
|||
pending.Enqueue(field.FieldType);
|
||||
}
|
||||
|
||||
using (var cache = Open())
|
||||
Loadouts.Save(cache, HandBuilt(cache));
|
||||
Assert.Contains("aetheria.loadout", SchemaNames(Player));
|
||||
using (var cache = OpenAuthoring())
|
||||
Loadouts.Commit(cache, HandBuilt(cache), replace: false);
|
||||
Assert.Contains("aetheria.loadout", SchemaNames(Catalog));
|
||||
Assert.True(!File.Exists(Run) || SchemaNames(Run).Length == 0);
|
||||
Assert.True(!File.Exists(Player) || SchemaNames(Player).Length == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -157,14 +202,14 @@ public sealed class LoadoutTests : IDisposable
|
|||
var missing = HandBuilt(cache);
|
||||
missing.Slots[1].Design = new CultRecordRef<EquippableItemData>(new CultRecordKey("absent-design"));
|
||||
var failures = new List<string>();
|
||||
Assert.Null(Build(items,missing, failures));
|
||||
Assert.Null(Build(items, missing, failures));
|
||||
var failure = Assert.Single(failures);
|
||||
Assert.Contains("1,1", failure);
|
||||
Assert.Contains("absent-design", failure);
|
||||
|
||||
missing.Slots.Add(new LoadoutSlot { Position = new int2(2, 2), Design = cache.RefOf<EquippableItemData>(cache.GetByName<GearData>("Orphan")) });
|
||||
failures.Clear();
|
||||
Assert.Null(Build(items,missing, failures));
|
||||
Assert.Null(Build(items, missing, failures));
|
||||
Assert.Equal(2, failures.Count);
|
||||
Assert.Contains("slot 1,1", failures[0]);
|
||||
Assert.Contains("slot 2,2", failures[1]);
|
||||
|
|
@ -175,32 +220,18 @@ public sealed class LoadoutTests : IDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedFitBuildsNothingAndChargesNothing()
|
||||
public void FailedFitBuildsNothing()
|
||||
{
|
||||
using var cache = Open();
|
||||
var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { });
|
||||
var clash = HandBuilt(cache);
|
||||
clash.Slots[1].Position = HardpointCell;
|
||||
var failures = new List<string>();
|
||||
var credits = 1000;
|
||||
Assert.Null(Loadouts.Materialize(items, null, clash, _ => true, ref credits, failures));
|
||||
Assert.Equal(1000, credits);
|
||||
Assert.Null(Build(items, clash, failures));
|
||||
var failure = Assert.Single(failures);
|
||||
Assert.Contains("slot 0,0: Crate does not fit", failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriceIsTheSumOfDesignPricesAndIsCharged()
|
||||
{
|
||||
using var cache = Open();
|
||||
var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { });
|
||||
var hand = HandBuilt(cache);
|
||||
Assert.Equal(100 + 10 + 5, Loadouts.Price(items, hand));
|
||||
var credits = 1000;
|
||||
Assert.NotNull(Loadouts.Materialize(items, null, hand, _ => true, ref credits, new List<string>()));
|
||||
Assert.Equal(1000 - 115, credits);
|
||||
}
|
||||
|
||||
// Two more Lamp products, upserted into the live cache so its enumeration order (z before a) disagrees with
|
||||
// record-key order; a reopened snapshot could already hand them back sorted.
|
||||
[Fact]
|
||||
|
|
@ -217,8 +248,7 @@ public sealed class LoadoutTests : IDisposable
|
|||
var hand = HandBuilt(cache);
|
||||
string LampProduct(Predicate<FactionProductData> available, List<string> failures)
|
||||
{
|
||||
var credits = 1000;
|
||||
var ship = Loadouts.Materialize(items, null, hand, available, ref credits, failures);
|
||||
var ship = Loadouts.Materialize(items, null, hand, available, failures);
|
||||
return ship == null ? null : cache.Get(ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem.Product).Name;
|
||||
}
|
||||
|
||||
|
|
@ -238,25 +268,10 @@ public sealed class LoadoutTests : IDisposable
|
|||
var stale = Armed(cache);
|
||||
stale.WeaponGroups = new[] { new[] { 0, 2 }, new[] { -1 } };
|
||||
var failures = new List<string>();
|
||||
var credits = 1000;
|
||||
Assert.Null(Loadouts.Materialize(items, null, stale, _ => true, ref credits, failures));
|
||||
Assert.Equal(1000, credits);
|
||||
Assert.Null(Build(items, stale, failures));
|
||||
Assert.Equal(new[] { "weapon group: no slot 2", "weapon group: no slot -1" }, failures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneCreditShortBuildsNothing()
|
||||
{
|
||||
using var cache = Open();
|
||||
var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { });
|
||||
var hand = HandBuilt(cache);
|
||||
var failures = new List<string>();
|
||||
var credits = (int) Loadouts.Price(items, hand) - 1;
|
||||
Assert.Null(Loadouts.Materialize(items, null, hand, _ => true, ref credits, failures));
|
||||
Assert.Equal(114, credits);
|
||||
Assert.Equal("cannot afford 115 credits", Assert.Single(failures));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnequippingAGroupedWeaponRemovesItFromItsGroups()
|
||||
{
|
||||
|
|
@ -279,9 +294,7 @@ public sealed class LoadoutTests : IDisposable
|
|||
var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { });
|
||||
var lampGrouped = WithDesign(Armed(cache), 0, cache, "Lamp");
|
||||
var failures = new List<string>();
|
||||
var credits = 1000;
|
||||
Assert.Null(Loadouts.Materialize(items, null, lampGrouped, _ => true, ref credits, failures));
|
||||
Assert.Equal(1000, credits);
|
||||
Assert.Null(Build(items, lampGrouped, failures));
|
||||
Assert.Equal("weapon group: slot 0,0: Lamp is not a weapon", Assert.Single(failures));
|
||||
}
|
||||
|
||||
|
|
@ -293,9 +306,7 @@ public sealed class LoadoutTests : IDisposable
|
|||
var wide = Armed(cache);
|
||||
wide.WeaponGroups = wide.WeaponGroups.Append(new[] { 0 }).ToArray();
|
||||
var failures = new List<string>();
|
||||
var credits = 1000;
|
||||
Assert.Null(Loadouts.Materialize(items, null, wide, _ => true, ref credits, failures));
|
||||
Assert.Equal(1000, credits);
|
||||
Assert.Null(Build(items, wide, failures));
|
||||
Assert.Equal("weapon groups: 7 groups, at most 6", Assert.Single(failures));
|
||||
}
|
||||
|
||||
|
|
@ -321,30 +332,9 @@ public sealed class LoadoutTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// The slots alone sum to exactly int.MaxValue; adding the hull must not wrap the total negative and pay the player.
|
||||
[Fact]
|
||||
public void PriceBeyondIntIsUnaffordable()
|
||||
{
|
||||
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
||||
var brick = cache.Upsert(new GearData { Name = "Brick", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = int.MaxValue - 5 });
|
||||
cache.Upsert(new FactionProductData { Name = "Brick by Maker", Design = new CultRecordRef<CraftedItemData>(brick.Key), Manufacturer = cache.RefOf(cache.GetByName<Faction>("Maker")) });
|
||||
|
||||
var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { });
|
||||
var gold = WithDesign(HandBuilt(cache), 0, cache, "Brick");
|
||||
Assert.Equal(100L + int.MaxValue, Loadouts.Price(items, gold));
|
||||
var failures = new List<string>();
|
||||
var credits = int.MaxValue;
|
||||
Assert.Null(Loadouts.Materialize(items, null, gold, _ => true, ref credits, failures));
|
||||
Assert.Equal(int.MaxValue, credits);
|
||||
Assert.StartsWith("cannot afford", Assert.Single(failures));
|
||||
}
|
||||
|
||||
// Everything available and affordable: for tests about placement and failure lists.
|
||||
private static Ship Build(ItemManager items, Loadout loadout, List<string> failures)
|
||||
{
|
||||
var credits = int.MaxValue;
|
||||
return Loadouts.Materialize(items, null, loadout, _ => true, ref credits, failures);
|
||||
}
|
||||
// Everything available: for tests about placement and failure lists.
|
||||
private static Ship Build(ItemManager items, Loadout loadout, List<string> failures) =>
|
||||
Loadouts.Materialize(items, null, loadout, _ => true, failures);
|
||||
|
||||
private static (int2, ItemRotation, CultRecordKey) Describe(LoadoutSlot slot) => (slot.Position, slot.Rotation, slot.Design.Key);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue