Merge eureka/aetheria-release-product-lines-weapons (cut-product-lines-weapons r5, h4) into master
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6179846249
28 changed files with 1032 additions and 216 deletions
|
|
@ -6,9 +6,10 @@ using GameCult.Caching;
|
|||
using MessagePack;
|
||||
using CultMath;
|
||||
|
||||
// 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
|
||||
// A ship preset (a variant): the hull's design, and per hull cell a design and its rotation. A slot names a design and
|
||||
// may name a product of it; a design-only slot is open on purpose, and whoever materializes the preset chooses its
|
||||
// product from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which keeps a preset
|
||||
// portable across galaxies. A preset that needs a brand names it. Authored catalog data: written in CultCache Studio or by the editor capture
|
||||
// command, read at runtime.
|
||||
[CultDocument("aetheria.loadout", "1"), MessagePackObject]
|
||||
public class Loadout
|
||||
|
|
@ -17,6 +18,7 @@ public class Loadout
|
|||
[Key(1)] public CultRecordRef<HullData> Hull;
|
||||
[Key(2)] public List<LoadoutSlot> Slots = new List<LoadoutSlot>();
|
||||
[Key(3)] public int[][] WeaponGroups; // indices into Slots
|
||||
[Key(4)] public CultRecordRef<FactionProductData> HullProduct; // unset: the hull's design alone, chosen at materialization
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
|
|
@ -25,14 +27,15 @@ 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;
|
||||
[Key(3)] public CultRecordRef<FactionProductData> Product; // unset: design-only, chosen at materialization
|
||||
}
|
||||
|
||||
// The owner of loadouts: capture from a live ship, the only writer, and the only builder of an entity 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.
|
||||
// rotation, design and product (unset for an unbranded unit). Entity.Equipment also holds the hull's own unit; that is
|
||||
// Hull, not a slot. Nothing else about the units: no quality, ingredients, cargo or state.
|
||||
public static Loadout Capture(ItemManager itemManager, Entity entity, string name)
|
||||
{
|
||||
var cache = itemManager.ItemData;
|
||||
|
|
@ -46,11 +49,13 @@ public static class Loadouts
|
|||
{
|
||||
Name = name,
|
||||
Hull = cache.RefOf((HullData) itemManager.GetData(entity.Hull)),
|
||||
HullProduct = ProductRef(itemManager, entity.Hull),
|
||||
Slots = items.Select(item => new LoadoutSlot
|
||||
{
|
||||
Position = item.Position,
|
||||
Rotation = item.EquippableItem.Rotation,
|
||||
Design = cache.RefOf(itemManager.GetData(item.EquippableItem))
|
||||
Design = cache.RefOf(itemManager.GetData(item.EquippableItem)),
|
||||
Product = ProductRef(itemManager, item.EquippableItem)
|
||||
}).ToList(),
|
||||
WeaponGroups = entity.WeaponGroups
|
||||
.Select(group => group.items.Select(item => items.IndexOf(item)).ToArray())
|
||||
|
|
@ -58,6 +63,9 @@ public static class Loadouts
|
|||
};
|
||||
}
|
||||
|
||||
private static CultRecordRef<FactionProductData> ProductRef(ItemManager itemManager, CraftedItemInstance unit) =>
|
||||
itemManager.Brand(unit).Product is { } product ? itemManager.ItemData.RefOf(product) : default;
|
||||
|
||||
// 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);
|
||||
|
||||
|
|
@ -95,24 +103,23 @@ public static class Loadouts
|
|||
// All-or-nothing: returns an entity only when every design resolved 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. How each design is built is Resolve's rule. A game spawner is to pass
|
||||
// LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer; RunStart does. The hull's type
|
||||
// itemManager.Random. How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game
|
||||
// spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does. The hull's type
|
||||
// picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is
|
||||
// put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is
|
||||
// padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots,
|
||||
// so the next commit drops them.
|
||||
public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
|
||||
Predicate<FactionProductData> isAvailable, List<string> failures)
|
||||
Func<CraftedItemData, FactionProductData> choose, List<string> failures)
|
||||
{
|
||||
var cache = itemManager.ItemData;
|
||||
var products = ProductsInKeyOrder(cache);
|
||||
var reported = failures.Count;
|
||||
|
||||
var hullBuild = Resolve(itemManager, products, loadout.Hull, isAvailable, "hull", failures);
|
||||
var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures);
|
||||
var hullData = cache.Get(loadout.Hull);
|
||||
if (hullData is { HullType: HullType.Station })
|
||||
failures.Add($"hull: {hullData.Name} is a station hull; a preset builds a ship or a turret");
|
||||
var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, products, slot.Design, isAvailable, Cell(slot), failures)).ToArray();
|
||||
var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures)).ToArray();
|
||||
var groups = loadout.WeaponGroups ?? new int[0][];
|
||||
var groupCount = itemManager.GameplaySettings.WeaponGroupCount;
|
||||
if (groups.Length > groupCount) failures.Add($"weapon groups: {groups.Length} groups, at most {groupCount}");
|
||||
|
|
@ -124,7 +131,7 @@ public static class Loadouts
|
|||
}
|
||||
if (failures.Count > reported) return null;
|
||||
|
||||
var hull = hullBuild();
|
||||
var hull = (EquippableItem) hullBuild();
|
||||
var settings = itemManager.GameplaySettings.DefaultEntitySettings;
|
||||
Entity entity = hullData.HullType == HullType.Turret
|
||||
? new OrbitalEntity(itemManager, liveZone, hull, default, settings)
|
||||
|
|
@ -133,7 +140,7 @@ public static class Loadouts
|
|||
for (var i = 0; i < loadout.Slots.Count; i++)
|
||||
{
|
||||
var slot = loadout.Slots[i];
|
||||
var unit = slotBuilds[i]();
|
||||
var unit = (EquippableItem) slotBuilds[i]();
|
||||
unit.Rotation = slot.Rotation;
|
||||
if (!entity.TryEquip(unit, slot.Position))
|
||||
{
|
||||
|
|
@ -159,39 +166,51 @@ public static class Loadouts
|
|||
}
|
||||
|
||||
// How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A
|
||||
// design is built by its first available product in record-key order. A design with no product at all is outside
|
||||
// the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through products) and is built
|
||||
// unbranded, with no maker, at UnbrandedQuality. A design whose products are all unavailable is not built.
|
||||
public static Func<EquippableItem> Resolve<T>(ItemManager itemManager, CultRecordRef<T> design,
|
||||
Predicate<FactionProductData> isAvailable, string where, List<string> failures) where T : EquippableItemData =>
|
||||
Resolve(itemManager, ProductsInKeyOrder(itemManager.ItemData), design, isAvailable, where, failures);
|
||||
|
||||
private static Func<EquippableItem> Resolve<T>(ItemManager itemManager, FactionProductData[] products,
|
||||
CultRecordRef<T> design, Predicate<FactionProductData> isAvailable, string where, List<string> failures)
|
||||
where T : EquippableItemData
|
||||
// pinned product is built as named, whatever choose says; it must be a product of the design. A design with no
|
||||
// product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through
|
||||
// products) and is built unbranded, with no maker, at UnbrandedQuality. Otherwise choose names the product, and
|
||||
// null (nothing available) or a product of another design is a failure.
|
||||
public static Func<CraftedItemInstance> Resolve<T>(ItemManager itemManager, CultRecordRef<T> design,
|
||||
CultRecordRef<FactionProductData> product, Func<CraftedItemData, FactionProductData> choose, string where,
|
||||
List<string> failures) where T : CraftedItemData
|
||||
{
|
||||
var data = itemManager.ItemData.Get(design);
|
||||
var cache = itemManager.ItemData;
|
||||
var data = cache.Get(design);
|
||||
if (data == null)
|
||||
{
|
||||
failures.Add($"{where}: design {design.Key} is not in the catalog");
|
||||
return null;
|
||||
}
|
||||
|
||||
var made = products.Where(p => p.Design.Key.Equals(design.Key)).ToArray();
|
||||
if (made.Length == 0)
|
||||
return () => (EquippableItem) itemManager.CreateInstance(itemManager.CreateLot(data, default, UnbrandedQuality));
|
||||
|
||||
var product = made.FirstOrDefault(p => isAvailable(p));
|
||||
if (product == null)
|
||||
FactionProductData made;
|
||||
if (product.IsSet())
|
||||
{
|
||||
failures.Add($"{where}: no available product of {data.Name}");
|
||||
made = cache.Get(product);
|
||||
if (made == null)
|
||||
{
|
||||
failures.Add($"{where}: product {product.Key} is not in the catalog");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(design.Key)))
|
||||
return () => itemManager.CreateInstance(itemManager.CreateLot(data, default, UnbrandedQuality));
|
||||
made = choose(data);
|
||||
if (made == null)
|
||||
{
|
||||
failures.Add($"{where}: no available product of {data.Name}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!made.Design.Key.Equals(design.Key))
|
||||
{
|
||||
failures.Add($"{where}: product {made.Name} is not a product of {data.Name}");
|
||||
return null;
|
||||
}
|
||||
return () => (EquippableItem) itemManager.CreateInstance(product);
|
||||
return () => itemManager.CreateInstance(made);
|
||||
}
|
||||
|
||||
internal static FactionProductData[] ProductsInKeyOrder(CultCache cache) =>
|
||||
cache.GetAll<FactionProductData>().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();
|
||||
|
||||
private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ public class LoadoutGenerator
|
|||
{
|
||||
var (available, preferManufacturers) = AvailableProducts(filter, required);
|
||||
return available.WeightedRandomElements(ref Random, entry =>
|
||||
(preferManufacturers ? ManufacturerPreference(entry.product.Manufacturer) : 1) *
|
||||
(preferManufacturers ? ProductWeight(entry.product) : 1) *
|
||||
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
|
||||
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
|
||||
count);
|
||||
|
|
@ -243,8 +243,8 @@ public class LoadoutGenerator
|
|||
|
||||
// No galaxy, or no faction, means no allegiance 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; a scenario
|
||||
// stage has no faction and reaches every maker. Loadouts.Materialize takes availability as a predicate; RunStart,
|
||||
// the game's one preset spawner, passes this, so presets and generation share one availability rule. A faction
|
||||
// stage has no faction and reaches every maker. Loadouts.Materialize takes a chooser and ChooseProduct is the game's,
|
||||
// so presets and generation share this one availability rule. A faction
|
||||
// always reaches its own manufacturer's gear, and otherwise gear made by a manufacturer its allegiance names
|
||||
// (operator, 2026-09-30: allegiance lists only other factions). Whether that manufacturer is present in the galaxy
|
||||
// does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available).
|
||||
|
|
@ -252,6 +252,25 @@ public class LoadoutGenerator
|
|||
Galaxy == null || Faction == null ||
|
||||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer);
|
||||
|
||||
// How much this outfitter favors a product: its maker's preference. The one weight of generation and of ChooseProduct.
|
||||
public float ProductWeight(FactionProductData product) => ManufacturerPreference(product.Manufacturer);
|
||||
|
||||
// The product a design named without one is built as (ruling loadout-product-optional-context-chooses): among the
|
||||
// products of the design this outfitter can get, one draw from the run's seeded Random weighted by ProductWeight;
|
||||
// null when none, and uniform over them when every weight is 0. Candidates are ordered by name so the draw's
|
||||
// enumeration, and so its result, never depends on record keys. All of the policy is this body.
|
||||
public FactionProductData ChooseProduct(CraftedItemData design)
|
||||
{
|
||||
var key = ItemManager.ItemData.RefOf(design).Key;
|
||||
var candidates = ItemManager.ItemData.GetAll<FactionProductData>()
|
||||
.Where(product => product.Design.Key.Equals(key) && IsAvailable(product))
|
||||
.OrderBy(product => product.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (candidates.Length == 0) return null;
|
||||
var uniform = candidates.All(product => ProductWeight(product) <= 0);
|
||||
return candidates.WeightedRandomElements(ref Random, product => uniform ? 1 : ProductWeight(product), 1)[0];
|
||||
}
|
||||
|
||||
// Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters.
|
||||
// A manufacturer with no home zone in this galaxy weighs as if its headquarters lay one jump beyond the farthest
|
||||
// zone this zone reaches: reachable through allegiance, least preferred.
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ using float3 = CultMath.float3;
|
|||
// The run half of a scenario's vocabulary (docs/scenarios-cut.md, R.4). A scenario's Stage builds its fits and places
|
||||
// them here; nothing joins the arena until Stage returns with no failure, and then RunStart admits it all at once.
|
||||
// Every verb names what it could not do instead of throwing, so one bad name in a script lists every bad name.
|
||||
// Designs and hulls are named as the catalog names them. Only RunStart builds a stage.
|
||||
// Designs, hulls and products are named as the catalog names them. Only RunStart builds a stage.
|
||||
public sealed class ScenarioStage
|
||||
{
|
||||
private readonly ItemManager _items;
|
||||
private readonly Predicate<FactionProductData> _available;
|
||||
private readonly Func<CraftedItemData, FactionProductData> _choose;
|
||||
private readonly List<string> _failures = new List<string>();
|
||||
private readonly List<Placement> _placed = new List<Placement>();
|
||||
private int _placements;
|
||||
|
|
@ -25,8 +25,9 @@ public sealed class ScenarioStage
|
|||
StartingHull = startingHull;
|
||||
TutorialGenerationSettings = tutorialGenerationSettings;
|
||||
Credits = items.GameplaySettings.StartingCredits;
|
||||
// A fit names designs, not manufacturers, so what the galaxy offers decides which product builds each one.
|
||||
_available = new LoadoutGenerator(ref items.Random, items, arena.Galaxy, arena.GalaxyZone, null, 2).IsAvailable;
|
||||
// A design named without a product is chosen by the generator's ChooseProduct. The stage outfits with no faction,
|
||||
// so every maker is reachable and the run's seed draws.
|
||||
_choose = new LoadoutGenerator(ref items.Random, items, arena.Galaxy, arena.GalaxyZone, null, 2).ChooseProduct;
|
||||
}
|
||||
|
||||
public Galaxy Galaxy => Arena.Galaxy;
|
||||
|
|
@ -49,18 +50,24 @@ public sealed class ScenarioStage
|
|||
public bool Piloted;
|
||||
}
|
||||
|
||||
// A hull fitted slot by slot: each slot a design, the hull cell it goes in, and its rotation.
|
||||
// A hull fitted slot by slot: each slot a design or a product, the hull cell it goes in, and its rotation. A name is
|
||||
// a design (open: the stage chooses its product) or a product (pinned: that brand).
|
||||
public ScenarioFit Fit(string hull, params (string design, int2 cell, ItemRotation rotation)[] slots)
|
||||
{
|
||||
var where = $"fit {hull}";
|
||||
var hullData = Named<HullData>(hull, where);
|
||||
var (hullData, hullProduct) = NamedOrProduct<HullData>(hull, where);
|
||||
var loadout = new Loadout { Name = hull, WeaponGroups = new int[0][] };
|
||||
if (hullData != null) loadout.Hull = _items.ItemData.RefOf(hullData);
|
||||
if (hullProduct != null) loadout.HullProduct = _items.ItemData.RefOf(hullProduct);
|
||||
foreach (var (design, cell, rotation) in slots)
|
||||
{
|
||||
var data = Named<EquippableItemData>(design, where);
|
||||
var (data, product) = NamedOrProduct<EquippableItemData>(design, where);
|
||||
if (data == null) continue;
|
||||
loadout.Slots.Add(new LoadoutSlot { Design = _items.ItemData.RefOf(data), Position = cell, Rotation = rotation });
|
||||
loadout.Slots.Add(new LoadoutSlot
|
||||
{
|
||||
Design = _items.ItemData.RefOf(data), Position = cell, Rotation = rotation,
|
||||
Product = product == null ? default : _items.ItemData.RefOf(product)
|
||||
});
|
||||
}
|
||||
return hullData != null && loadout.Slots.Count == slots.Length ? new ScenarioFit(loadout) : null;
|
||||
}
|
||||
|
|
@ -119,38 +126,22 @@ public sealed class ScenarioStage
|
|||
return entity;
|
||||
}
|
||||
|
||||
// Items in the entity's first cargo bay, one per design named. A name is an equippable design (built as a fit
|
||||
// builds it) or a consumable design (built from its first available product in key order, as a generated hold is
|
||||
// stocked); a name that is neither is a failure.
|
||||
public void Cargo(Entity entity, params string[] designs)
|
||||
// Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage
|
||||
// chooses its product) or a product of one (pinned); a name that is neither is a failure.
|
||||
public void Cargo(Entity entity, params string[] names)
|
||||
{
|
||||
if (entity == null) return;
|
||||
var where = $"cargo of {entity.Name}";
|
||||
var bay = entity.CargoBays.FirstOrDefault();
|
||||
foreach (var design in designs)
|
||||
foreach (var name in names)
|
||||
{
|
||||
var data = Named<CraftedItemData>(design, where, found => found is EquippableItemData || found is ConsumableItemData);
|
||||
var (data, product) = NamedOrProduct<CraftedItemData>(name, where, found => found is EquippableItemData || found is ConsumableItemData);
|
||||
if (data == null) continue;
|
||||
Func<CraftedItemInstance> build;
|
||||
if (data is ConsumableItemData)
|
||||
{
|
||||
var key = _items.ItemData.RefOf(data).Key;
|
||||
var product = Loadouts.ProductsInKeyOrder(_items.ItemData).Where(p => p.Design.Key.Equals(key)).FirstOrDefault(p => _available(p));
|
||||
if (product == null)
|
||||
{
|
||||
_failures.Add($"{where}: no product offers {design}");
|
||||
continue;
|
||||
}
|
||||
build = () => _items.CreateInstance(product);
|
||||
}
|
||||
else
|
||||
{
|
||||
var equippable = Loadouts.Resolve(_items, _items.ItemData.RefOf((EquippableItemData) data), _available, where, _failures);
|
||||
if (equippable == null) continue;
|
||||
build = equippable;
|
||||
}
|
||||
var build = Loadouts.Resolve(_items, _items.ItemData.RefOf(data),
|
||||
product == null ? default : _items.ItemData.RefOf(product), _choose, where, _failures);
|
||||
if (build == null) continue;
|
||||
if (bay == null || !bay.TryStore(build()))
|
||||
_failures.Add($"{where}: no room for {design}");
|
||||
_failures.Add($"{where}: no room for {name}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,7 +154,7 @@ public sealed class ScenarioStage
|
|||
else
|
||||
{
|
||||
var failures = new List<string>();
|
||||
entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _available, failures);
|
||||
entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _choose, failures);
|
||||
_failures.AddRange(failures.Select(failure => $"{where} ({fit.Loadout.Name}): {failure}"));
|
||||
if (entity == null) return null;
|
||||
}
|
||||
|
|
@ -173,6 +164,17 @@ public sealed class ScenarioStage
|
|||
return entity;
|
||||
}
|
||||
|
||||
// A name is a design, or else exactly one product (names are unique) whose design is a T passing among: the design
|
||||
// and that product. Neither is Named's failure.
|
||||
private (T design, FactionProductData product) NamedOrProduct<T>(string name, string where, Func<T, bool> among = null) where T : CraftedItemData
|
||||
{
|
||||
var cache = _items.ItemData;
|
||||
var products = cache.GetAll<FactionProductData>().Where(p => p.Name == name && cache.Get(p.Design) is T design && (among == null || among(design))).Take(2).ToArray();
|
||||
if (products.Length == 1 && !cache.GetAll<T>().Any(data => data.Name == name && (among == null || among(data))))
|
||||
return ((T) cache.Get(products[0].Design), products[0]);
|
||||
return (Named(name, where, among), null);
|
||||
}
|
||||
|
||||
private T Named<T>(string name, string where, Func<T, bool> among = null) where T : CraftedItemData
|
||||
{
|
||||
var named = _items.ItemData.GetAll<T>().Where(data => data.Name == name && (among == null || among(data))).Take(2).ToArray();
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ public sealed class AiFusedDiscipline : Scenario
|
|||
(weapon, int2(1, 8), None),
|
||||
("Radiator", int2(2, 2), CounterClockwise),
|
||||
("Radiator", int2(3, 2), Clockwise),
|
||||
("Compact Sensor", int2(3, 10), None),
|
||||
("Fire Control Array", int2(1, 6), Clockwise));
|
||||
("not if i see you first", int2(3, 10), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(1, 6), Clockwise)); // Fire Control Array
|
||||
|
||||
stage.Player(stage.Generated("Djinni"), float2(0, 0));
|
||||
stage.Place(Armed("Smoke Refused"), Hostile, facing: float2(0, -1), stance: ScenarioStance.Hostile, piloted: true);
|
||||
|
|
|
|||
|
|
@ -30,25 +30,25 @@ public sealed class Arcs : Scenario
|
|||
("Rugged Drive", int2(4, 14), CounterClockwise),
|
||||
("Rugged Drive", int2(5, 14), Clockwise),
|
||||
("Compact Reactor", int2(4, 4), None),
|
||||
("Guided Missile Launcher", int2(2, 5), None),
|
||||
("Guided Missile Launcher", int2(7, 5), None),
|
||||
("Cell Blaster", int2(3, 8), None),
|
||||
("Cell Blaster", int2(6, 8), None),
|
||||
("GT 3K", int2(2, 5), None), // Guided Missile Launcher
|
||||
("GT 3K", int2(7, 5), None), // Guided Missile Launcher
|
||||
("FastBlast+-", int2(3, 8), None), // Cell Blaster
|
||||
("FastBlast+-", int2(6, 8), None), // Cell Blaster
|
||||
("Radiator", int2(4, 2), CounterClockwise),
|
||||
("Radiator", int2(5, 2), Clockwise),
|
||||
("Compact Sensor", int2(5, 10), None),
|
||||
("Fire Control Array", int2(3, 6), Clockwise),
|
||||
("Autocannon", int2(8, 4), Clockwise),
|
||||
("Charged Scatter Blaster", int2(0, 4), CounterClockwise));
|
||||
("not if i see you first", int2(5, 10), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(3, 6), Clockwise), // Fire Control Array
|
||||
("🫳🔫", int2(8, 4), Clockwise), // Autocannon
|
||||
("ChargeBlast SG", int2(0, 4), CounterClockwise)); // Charged Scatter Blaster
|
||||
var turret = stage.Fit("Turret",
|
||||
("Light Machine Gun", int2(1, 2), None),
|
||||
("Light Machine Gun", int2(5, 2), None),
|
||||
("ClearPath", int2(1, 2), None), // Light Machine Gun
|
||||
("ClearPath", int2(5, 2), None), // Light Machine Gun
|
||||
("Long Radiator", int2(0, 2), CounterClockwise),
|
||||
("Long Radiator", int2(7, 2), Clockwise),
|
||||
("Compact Reactor", int2(3, 3), None),
|
||||
("Turret Control Module", int2(3, 5), None),
|
||||
("Compact Sensor", int2(4, 5), None),
|
||||
("Fire Control Array", int2(3, 6), None));
|
||||
("not if i see you first", int2(4, 5), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(3, 6), None)); // Fire Control Array
|
||||
|
||||
stage.Player(fighter, float2(0, 0), facing: float2(0, 1));
|
||||
stage.Place(stage.Bare("Longinus"), Bow);
|
||||
|
|
|
|||
|
|
@ -26,14 +26,14 @@ public sealed class Belt : Scenario
|
|||
("Rugged Drive", int2(2, 14), CounterClockwise),
|
||||
("Rugged Drive", int2(3, 14), Clockwise),
|
||||
("Compact Reactor", int2(2, 4), None),
|
||||
("Guided Missile Launcher", int2(0, 5), None),
|
||||
("Guided Missile Launcher", int2(5, 5), None),
|
||||
("Lightning Projector", int2(1, 8), None),
|
||||
("Cell Blaster", int2(4, 8), None),
|
||||
("GT 3K", int2(0, 5), None), // Guided Missile Launcher
|
||||
("GT 3K", int2(5, 5), None), // Guided Missile Launcher
|
||||
("DragOnBreath", int2(1, 8), None), // Lightning Projector
|
||||
("FastBlast+-", int2(4, 8), None), // Cell Blaster
|
||||
("Radiator", int2(2, 2), CounterClockwise),
|
||||
("Radiator", int2(3, 2), Clockwise),
|
||||
("Compact Sensor", int2(3, 10), None),
|
||||
("Fire Control Array", int2(1, 6), Clockwise));
|
||||
("not if i see you first", int2(3, 10), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(1, 6), Clockwise)); // Fire Control Array
|
||||
|
||||
// The belt is a ring about its parent's position at the belt orbit's distance. The player stands 100 m outside
|
||||
// it, at the point nearest the origin, facing along the ring.
|
||||
|
|
|
|||
|
|
@ -36,14 +36,14 @@ public sealed class Crossing : Scenario
|
|||
("Rugged Drive", int2(2, 14), CounterClockwise),
|
||||
("Rugged Drive", int2(3, 14), Clockwise),
|
||||
("Compact Reactor", int2(2, 4), None),
|
||||
("Guided Missile Launcher", int2(0, 5), None),
|
||||
("Guided Missile Launcher", int2(5, 5), None),
|
||||
("Lightning Projector", int2(1, 8), None),
|
||||
("Cell Blaster", int2(4, 8), None),
|
||||
("GT 3K", int2(0, 5), None), // Guided Missile Launcher
|
||||
("GT 3K", int2(5, 5), None), // Guided Missile Launcher
|
||||
("DragOnBreath", int2(1, 8), None), // Lightning Projector
|
||||
("FastBlast+-", int2(4, 8), None), // Cell Blaster
|
||||
("Radiator", int2(2, 2), CounterClockwise),
|
||||
("Radiator", int2(3, 2), Clockwise),
|
||||
("Compact Sensor", int2(3, 10), None),
|
||||
("Fire Control Array", int2(1, 6), Clockwise));
|
||||
("not if i see you first", int2(3, 10), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(1, 6), Clockwise)); // Fire Control Array
|
||||
|
||||
stage.Player(fighter, Origin, facing: float2(0, 1));
|
||||
stage.Place(stage.Generated("Djinni"), CrosserAt, facing: CrosserFacing, stance: ScenarioStance.Hostile, velocity: CrosserVelocity);
|
||||
|
|
|
|||
|
|
@ -20,19 +20,19 @@ public sealed class Duel : Scenario
|
|||
("Rugged Drive", int2(2, 14), CounterClockwise),
|
||||
("Rugged Drive", int2(3, 14), Clockwise),
|
||||
("Compact Reactor", int2(2, 4), None),
|
||||
("Guided Missile Launcher", int2(0, 5), None),
|
||||
("Guided Missile Launcher", int2(5, 5), None),
|
||||
("Cell Blaster", int2(1, 8), None),
|
||||
("Cell Blaster", int2(4, 8), None),
|
||||
("GT 3K", int2(0, 5), None), // Guided Missile Launcher
|
||||
("GT 3K", int2(5, 5), None), // Guided Missile Launcher
|
||||
("FastBlast+-", int2(1, 8), None), // Cell Blaster
|
||||
("FastBlast+-", int2(4, 8), None), // Cell Blaster
|
||||
("Radiator", int2(2, 2), CounterClockwise),
|
||||
("Radiator", int2(3, 2), Clockwise),
|
||||
("Compact Sensor", int2(3, 10), None),
|
||||
("not if i see you first", int2(3, 10), None), // Compact Sensor
|
||||
("Capacitor", int2(1, 5), None),
|
||||
("Fire Control Array", int2(1, 6), Clockwise),
|
||||
("Cargo Bay", int2(2, 8), None));
|
||||
("Panopticon Prime", int2(1, 6), Clockwise), // Fire Control Array
|
||||
("Store-All Plus", int2(2, 8), None)); // Cargo Bay
|
||||
|
||||
stage.Player(fighter, float2(0, 0));
|
||||
var rival = stage.Place(fighter, float2(0, 900), facing: float2(0, -1), stance: ScenarioStance.Hostile, piloted: true);
|
||||
stage.Cargo(rival, "Light Laser");
|
||||
stage.Cargo(rival, "Spectra");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,11 +35,11 @@ public sealed class FusedRounds : Scenario
|
|||
("Compact Reactor", int2(4, 4), None),
|
||||
("Smoke Proximity", int2(3, 8), None),
|
||||
("Smoke Delayed", int2(6, 8), None),
|
||||
("Cluster Cannon", int2(5, 17), None),
|
||||
("DeathCluster", int2(5, 17), None), // Cluster Cannon
|
||||
("Radiator", int2(4, 2), CounterClockwise),
|
||||
("Radiator", int2(5, 2), Clockwise),
|
||||
("Compact Sensor", int2(5, 10), None),
|
||||
("Fire Control Array", int2(3, 6), Clockwise));
|
||||
("not if i see you first", int2(5, 10), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(3, 6), Clockwise)); // Fire Control Array
|
||||
|
||||
stage.Player(shooter, float2(0, 0), facing: float2(0, 1));
|
||||
stage.Place(stage.Bare("Longinus"), Mid, stance: ScenarioStance.Hostile);
|
||||
|
|
|
|||
|
|
@ -20,14 +20,14 @@ public sealed class LauncherAngles : Scenario
|
|||
("Rugged Drive", int2(2, 14), CounterClockwise),
|
||||
("Rugged Drive", int2(3, 14), Clockwise),
|
||||
("Compact Reactor", int2(2, 4), None),
|
||||
("Guided Missile Launcher", int2(0, 5), None),
|
||||
("Swarm Rocket Pod", int2(5, 5), None),
|
||||
("Cell Blaster", int2(1, 8), None),
|
||||
("Cell Blaster", int2(4, 8), None),
|
||||
("GT 3K", int2(0, 5), None), // Guided Missile Launcher
|
||||
("Leonid", int2(5, 5), None), // Swarm Rocket Pod
|
||||
("FastBlast+-", int2(1, 8), None), // Cell Blaster
|
||||
("FastBlast+-", int2(4, 8), None), // Cell Blaster
|
||||
("Radiator", int2(2, 2), CounterClockwise),
|
||||
("Radiator", int2(3, 2), Clockwise),
|
||||
("Compact Sensor", int2(3, 10), None),
|
||||
("Fire Control Array", int2(1, 6), Clockwise));
|
||||
("not if i see you first", int2(3, 10), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(1, 6), Clockwise)); // Fire Control Array
|
||||
|
||||
stage.Player(launcher, float2(0, 0));
|
||||
stage.Place(stage.Bare("Longinus"), float2(0, 900), facing: float2(1, 0), stance: ScenarioStance.Hostile);
|
||||
|
|
|
|||
|
|
@ -29,16 +29,16 @@ public sealed class LockOn : Scenario
|
|||
("Rugged Drive", int2(2, 14), CounterClockwise),
|
||||
("Rugged Drive", int2(3, 14), Clockwise),
|
||||
("Compact Reactor", int2(2, 4), None),
|
||||
("Guided Missile Launcher", int2(0, 5), None),
|
||||
("Guided Missile Launcher", int2(5, 5), None),
|
||||
("Cell Blaster", int2(1, 8), None),
|
||||
("Cell Blaster", int2(4, 8), None),
|
||||
("GT 3K", int2(0, 5), None), // Guided Missile Launcher
|
||||
("GT 3K", int2(5, 5), None), // Guided Missile Launcher
|
||||
("FastBlast+-", int2(1, 8), None), // Cell Blaster
|
||||
("FastBlast+-", int2(4, 8), None), // Cell Blaster
|
||||
("Radiator", int2(2, 2), CounterClockwise),
|
||||
("Radiator", int2(3, 2), Clockwise),
|
||||
("Compact Sensor", int2(3, 10), None),
|
||||
("not if i see you first", int2(3, 10), None), // Compact Sensor
|
||||
("Capacitor", int2(1, 5), None),
|
||||
("Fire Control Array", int2(1, 6), Clockwise),
|
||||
("Cargo Bay", int2(2, 8), None));
|
||||
("Panopticon Prime", int2(1, 6), Clockwise), // Fire Control Array
|
||||
("Store-All Plus", int2(2, 8), None)); // Cargo Bay
|
||||
|
||||
stage.Player(fighter, float2(0, 0), facing: float2(0, 1));
|
||||
stage.Place(fighter, HostileAt, facing: normalize(-HostileAt), stance: ScenarioStance.Hostile, piloted: true);
|
||||
|
|
|
|||
|
|
@ -26,15 +26,15 @@ public sealed class Pursuit : Scenario
|
|||
("Rugged Drive", int2(2, 14), CounterClockwise),
|
||||
("Rugged Drive", int2(3, 14), Clockwise),
|
||||
("Compact Reactor", int2(2, 4), None),
|
||||
("Mine Launcher", int2(0, 5), None),
|
||||
("Swarm Rocket Pod", int2(5, 5), None),
|
||||
("Cell Blaster", int2(1, 8), None),
|
||||
("Cell Blaster", int2(4, 8), None),
|
||||
("parting gift", int2(0, 5), None), // Mine Launcher
|
||||
("Leonid", int2(5, 5), None), // Swarm Rocket Pod
|
||||
("FastBlast+-", int2(1, 8), None), // Cell Blaster
|
||||
("FastBlast+-", int2(4, 8), None), // Cell Blaster
|
||||
("Radiator", int2(2, 2), CounterClockwise),
|
||||
("Radiator", int2(3, 2), Clockwise),
|
||||
("Compact Sensor", int2(3, 10), None),
|
||||
("Fire Control Array", int2(1, 6), Clockwise),
|
||||
("Cargo Bay", int2(2, 8), None));
|
||||
("not if i see you first", int2(3, 10), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(1, 6), Clockwise), // Fire Control Array
|
||||
("Store-All Plus", int2(2, 8), None)); // Cargo Bay
|
||||
var pursuer = stage.Fit("Longinus",
|
||||
("Cockpit 2x2", int2(2, 6), None),
|
||||
("Large Drive", int2(1, 0), Reversed),
|
||||
|
|
@ -42,19 +42,19 @@ public sealed class Pursuit : Scenario
|
|||
("Rugged Drive", int2(2, 14), CounterClockwise),
|
||||
("Rugged Drive", int2(3, 14), Clockwise),
|
||||
("Compact Reactor", int2(2, 4), None),
|
||||
("Guided Missile Launcher", int2(0, 5), None),
|
||||
("Guided Missile Launcher", int2(5, 5), None),
|
||||
("Cell Blaster", int2(1, 8), None),
|
||||
("Cell Blaster", int2(4, 8), None),
|
||||
("GT 3K", int2(0, 5), None), // Guided Missile Launcher
|
||||
("GT 3K", int2(5, 5), None), // Guided Missile Launcher
|
||||
("FastBlast+-", int2(1, 8), None), // Cell Blaster
|
||||
("FastBlast+-", int2(4, 8), None), // Cell Blaster
|
||||
("Radiator", int2(2, 2), CounterClockwise),
|
||||
("Radiator", int2(3, 2), Clockwise),
|
||||
("Compact Sensor", int2(3, 10), None),
|
||||
("not if i see you first", int2(3, 10), None), // Compact Sensor
|
||||
("Capacitor", int2(1, 5), None),
|
||||
("Fire Control Array", int2(1, 6), Clockwise),
|
||||
("Cargo Bay", int2(2, 8), None));
|
||||
("Panopticon Prime", int2(1, 6), Clockwise), // Fire Control Array
|
||||
("Store-All Plus", int2(2, 8), None)); // Cargo Bay
|
||||
|
||||
var player = stage.Player(layer, float2(0, 0), facing: float2(0, 1));
|
||||
stage.Cargo(player, "Coolant Vent", "Coolant Vent", "Coolant Vent", "Thruster Overdrive", "Thruster Overdrive");
|
||||
stage.Cargo(player, "Morning Fog", "Morning Fog", "Morning Fog", "send it", "send it");
|
||||
stage.Place(pursuer, float2(0, -Astern), facing: float2(0, 1), stance: ScenarioStance.Hostile, piloted: true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ public sealed class RefusedRounds : Scenario
|
|||
("Smoke Refused", int2(3, 8), None),
|
||||
("Smoke Refused Beam", int2(6, 8), None),
|
||||
("Smoke Refused Charge", int2(3, 17), None),
|
||||
("Autocannon", int2(5, 17), None),
|
||||
("🫳🔫", int2(5, 17), None), // Autocannon
|
||||
("Radiator", int2(4, 2), CounterClockwise),
|
||||
("Radiator", int2(5, 2), Clockwise),
|
||||
("Compact Sensor", int2(5, 10), None),
|
||||
("Fire Control Array", int2(3, 6), Clockwise));
|
||||
("not if i see you first", int2(5, 10), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(3, 6), Clockwise)); // Fire Control Array
|
||||
|
||||
stage.Player(shooter, float2(0, 0), facing: float2(0, 1));
|
||||
stage.Place(stage.Bare("Longinus"), Target);
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ public sealed class StarvedReactor : Scenario
|
|||
("Rugged Drive", int2(2, 14), CounterClockwise),
|
||||
("Rugged Drive", int2(3, 14), Clockwise),
|
||||
("Muon Fusion Reactor", int2(2, 4), None),
|
||||
("Light Laser", int2(1, 8), None),
|
||||
("Light Laser", int2(4, 8), None),
|
||||
("Spectra", int2(1, 8), None), // Light Laser
|
||||
("Spectra", int2(4, 8), None), // Light Laser
|
||||
("Radiator", int2(2, 2), CounterClockwise),
|
||||
("Radiator", int2(3, 2), Clockwise),
|
||||
("Compact Sensor", int2(3, 10), None),
|
||||
("Fire Control Array", int2(1, 6), Clockwise));
|
||||
("not if i see you first", int2(3, 10), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(1, 6), Clockwise)); // Fire Control Array
|
||||
stage.Player(starved, float2(0, 0));
|
||||
stage.Place(stage.Bare("Longinus"), float2(0, 400));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ public sealed class WeaponFeel : Scenario
|
|||
("Rugged Drive", int2(4, 14), CounterClockwise),
|
||||
("Rugged Drive", int2(5, 14), Clockwise),
|
||||
("Compact Reactor", int2(4, 4), None),
|
||||
("Charged Scatter Blaster", int2(3, 17), None),
|
||||
("Autocannon", int2(5, 17), None),
|
||||
("Cell Blaster", int2(3, 8), None),
|
||||
("ChargeBlast SG", int2(3, 17), None), // Charged Scatter Blaster
|
||||
("🫳🔫", int2(5, 17), None), // Autocannon
|
||||
("FastBlast+-", int2(3, 8), None), // Cell Blaster
|
||||
("Radiator", int2(4, 2), CounterClockwise),
|
||||
("Radiator", int2(5, 2), Clockwise),
|
||||
("Compact Sensor", int2(5, 10), None),
|
||||
("Fire Control Array", int2(3, 6), Clockwise));
|
||||
("not if i see you first", int2(5, 10), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(3, 6), Clockwise)); // Fire Control Array
|
||||
|
||||
stage.Player(shooter, float2(0, 0), facing: float2(0, 1));
|
||||
stage.Place(stage.Bare("Longinus"), Target);
|
||||
|
|
|
|||
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
Binary file not shown.
|
|
@ -172,7 +172,8 @@ public sealed class CatalogRoleTests
|
|||
Assert.Equal(generic, design.Name);
|
||||
Assert.Equal(Program.Renames.Single(r => r.Generic == generic).Functional, design.Description);
|
||||
var products = cache.GetAll<FactionProductData>().Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray();
|
||||
Assert.Equal(sold.Select(p => p.Key).OrderBy(k => k, StringComparer.Ordinal), products.Select(p => cache.RefOf(p).Key.Value).OrderBy(k => k, StringComparer.Ordinal));
|
||||
// Later product lines add sellers to these designs; the renamed designs keep the products they were renamed with.
|
||||
Assert.All(sold, product => Assert.Contains(products, p => cache.RefOf(p).Key.Value == product.Key));
|
||||
foreach (var (key, name, maker) in sold)
|
||||
{
|
||||
var product = products.Single(p => cache.RefOf(p).Key.Value == key);
|
||||
|
|
@ -190,7 +191,7 @@ public sealed class CatalogRoleTests
|
|||
var mine = Assert.Single(cache.GetAll<EquippableItemData>(), d => cache.RefOf(d).Key.Value == "1b4a2bb358eb432eb1cfc0ccf3317303");
|
||||
Assert.Equal("Mine Launcher", mine.Name);
|
||||
Assert.Equal("Lays a drifting proximity mine that arms after two seconds.", mine.Description);
|
||||
var brand = Assert.Single(cache.GetAll<FactionProductData>(), p => p.Design.Key.Equals(cache.RefOf(mine).Key));
|
||||
var brand = Assert.Single(cache.GetAll<FactionProductData>(), p => p.Design.Key.Equals(cache.RefOf(mine).Key) && p.Name == "parting gift");
|
||||
Assert.Equal("parting gift", brand.Name);
|
||||
Assert.Equal("DME", cache.Get(brand.Manufacturer).ShortName);
|
||||
}
|
||||
|
|
@ -260,10 +261,7 @@ public sealed class CatalogRoleTests
|
|||
AssertNamed(Design("Muon Fusion Station Reactor"), "Modifier", "core");
|
||||
|
||||
// Each seller's means come from its maker's profile; the Pirates are middling and inconsistent.
|
||||
var profiles = new Dictionary<string, (float Mean, float Dev)>
|
||||
{
|
||||
["Zhestokost"] = (.50f, .08f), ["R&D"] = (.60f, .15f), ["DME"] = (.55f, .20f), ["Lightsail"] = (.55f, .10f), ["Pirates"] = (.45f, .22f),
|
||||
};
|
||||
var profiles = new Dictionary<string, (float Mean, float Dev)>(Program.MakerProfiles) { ["Pirates"] = (.45f, .22f) };
|
||||
foreach (var design in new[] { autocannon, lrm, srm, mine, drive })
|
||||
{
|
||||
var sellers = cache.GetAll<FactionProductData>().Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray();
|
||||
|
|
|
|||
|
|
@ -67,11 +67,6 @@ public sealed class LaunchSuppressorCatalogTests
|
|||
}
|
||||
|
||||
// Exactly these six products and these makers, each authoring both roles at its maker's own mean and deviation.
|
||||
var profiles = new Dictionary<string, (float Mean, float Dev)>
|
||||
{
|
||||
["NiteLife"] = (.55f, .12f), ["Finch"] = (.65f, .20f), ["DME"] = (.55f, .20f), ["R&D"] = (.60f, .15f),
|
||||
["Lightsail"] = (.55f, .10f), ["Alakrita"] = (.60f, .14f),
|
||||
};
|
||||
foreach (var (name, makers) in new[]
|
||||
{
|
||||
(StrongName, new[] { "DME", "Finch", "NiteLife", "R&D" }),
|
||||
|
|
@ -84,7 +79,7 @@ public sealed class LaunchSuppressorCatalogTests
|
|||
Assert.Equal(products.Length, products.Select(p => p.Name).Distinct().Count());
|
||||
Assert.All(products, product =>
|
||||
{
|
||||
var (mean, dev) = profiles[cache.Get(product.Manufacturer).ShortName];
|
||||
var (mean, dev) = Program.MakerProfiles[cache.Get(product.Manufacturer).ShortName];
|
||||
Assert.Equal(new[] { "baffles", "heat sink" }, product.Roles.Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray());
|
||||
Assert.All(product.Roles, r =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -252,7 +252,8 @@ public sealed class LoadoutTests : IDisposable
|
|||
Assert.DoesNotContain(forbidden, bad => bad.IsAssignableFrom(type));
|
||||
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>))
|
||||
{
|
||||
Assert.True(typeof(EquippableItemData).IsAssignableFrom(type.GetGenericArguments()[0]), $"{type} names a non-design record");
|
||||
var named = type.GetGenericArguments()[0];
|
||||
Assert.True(typeof(EquippableItemData).IsAssignableFrom(named) || named == typeof(FactionProductData), $"{type} names a record that is neither a design nor a product");
|
||||
continue;
|
||||
}
|
||||
if (type.IsArray) pending.Enqueue(type.GetElementType());
|
||||
|
|
@ -312,38 +313,6 @@ public sealed class LoadoutTests : IDisposable
|
|||
Assert.Contains("slot 0,0: Crate does not fit", failure);
|
||||
}
|
||||
|
||||
// Two new makers, each with one Lamp product, 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. Each gets its own
|
||||
// maker rather than sharing "Maker".
|
||||
[Fact]
|
||||
public void FirstAvailableProductInKeyOrderBuildsTheSlot()
|
||||
{
|
||||
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
||||
var lamp = new CultRecordRef<CraftedItemData>(cache.RefOf(cache.GetByName<GearData>("Lamp")).Key);
|
||||
var zMaker = cache.Upsert(new Faction { Name = "Zeta", ShortName = "ZET" });
|
||||
var aMaker = cache.Upsert(new Faction { Name = "Alpha", ShortName = "ALP" });
|
||||
cache.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData { Name = "lamp-z", Design = lamp, Manufacturer = zMaker }, new CultRecordKey("lamp-z")));
|
||||
cache.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData { Name = "lamp-a", Design = lamp, Manufacturer = aMaker }, new CultRecordKey("lamp-a")));
|
||||
Assert.Equal(new[] { "lamp-z", "lamp-a" }, cache.GetAll<FactionProductData>().Select(p => p.Name).Where(name => name.StartsWith("lamp-")));
|
||||
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
||||
var hand = HandBuilt(cache);
|
||||
string LampProduct(Predicate<FactionProductData> available, List<string> failures)
|
||||
{
|
||||
var ship = Loadouts.Materialize(items, null, hand, available, failures);
|
||||
if (ship == null) return null;
|
||||
var unit = ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem;
|
||||
return items.Brand(unit).Product.Name;
|
||||
}
|
||||
|
||||
var failures = new List<string>();
|
||||
Assert.Equal("lamp-a", LampProduct(p => p.Name != "Lamp by Maker", failures));
|
||||
Assert.Equal("lamp-z", LampProduct(p => p.Name != "Lamp by Maker" && p.Name != "lamp-a", failures));
|
||||
Assert.Empty(failures);
|
||||
Assert.Null(LampProduct(p => !p.Name.StartsWith("Lamp") && !p.Name.StartsWith("lamp"), failures));
|
||||
Assert.Contains("slot 0,0: no available product of Lamp", Assert.Single(failures));
|
||||
}
|
||||
|
||||
// Scenarios Q3 (docs/scenarios-cut.md): a design no product makes is outside the economy, and a preset builds it
|
||||
// unbranded -- no maker -- at the one named quality. "Orphan" has no product in this catalog.
|
||||
[Fact]
|
||||
|
|
@ -368,7 +337,8 @@ public sealed class LoadoutTests : IDisposable
|
|||
using var cache = Open();
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
||||
var failures = new List<string>();
|
||||
Assert.Null(Loadouts.Materialize(items, null, HandBuilt(cache), p => p.Name != "Lamp by Maker", failures));
|
||||
var soleProduct = SoleProduct(cache);
|
||||
Assert.Null(Loadouts.Materialize(items, null, HandBuilt(cache), design => design.Name == "Lamp" ? null : soleProduct(design), failures));
|
||||
Assert.Equal("slot 0,0: no available product of Lamp", Assert.Single(failures));
|
||||
}
|
||||
|
||||
|
|
@ -918,9 +888,17 @@ public sealed class LoadoutTests : IDisposable
|
|||
Assert.Equal(originalThreshold, template.ShutdownPerformance);
|
||||
}
|
||||
|
||||
// Everything available: for tests about placement and failure lists.
|
||||
// The deterministic chooser for tests about placement and failure lists: the design's only product, and nothing (so a
|
||||
// failure) when it has several.
|
||||
internal static Func<CraftedItemData, FactionProductData> SoleProduct(CultCache cache) => design =>
|
||||
{
|
||||
var key = cache.RefOf(design).Key;
|
||||
var made = cache.GetAll<FactionProductData>().Where(p => p.Design.Key.Equals(key)).Take(2).ToArray();
|
||||
return made.Length == 1 ? made[0] : null;
|
||||
};
|
||||
|
||||
private static Entity Build(ItemManager items, Loadout loadout, List<string> failures) =>
|
||||
Loadouts.Materialize(items, null, loadout, _ => true, failures);
|
||||
Loadouts.Materialize(items, null, loadout, SoleProduct(items.ItemData), failures);
|
||||
|
||||
private static (int2, ItemRotation, CultRecordKey) Describe(LoadoutSlot slot) => (slot.Position, slot.Rotation, slot.Design.Key);
|
||||
|
||||
|
|
|
|||
|
|
@ -53,18 +53,6 @@ public sealed partial class RunStartTests
|
|||
}
|
||||
}
|
||||
|
||||
// Brand finds a lot's product by (maker, design), so a maker may sell one product per design.
|
||||
[Fact]
|
||||
public void NoMakerSellsTwoProductsOfOneDesign()
|
||||
{
|
||||
var twice = _cache.GetAll<FactionProductData>()
|
||||
.GroupBy(product => (Maker: product.Manufacturer.Key.Value, Design: product.Design.Key.Value))
|
||||
.Where(group => group.Count() > 1)
|
||||
.Select(group => string.Join(", ", group.Select(product => product.Name)))
|
||||
.ToArray();
|
||||
Assert.Empty(twice);
|
||||
}
|
||||
|
||||
// A unit minted from a Pirates rebrand titles and brands as that rebrand: the lot records its product, so the
|
||||
// emoji name shows although the same design is also sold by its original maker.
|
||||
[Fact]
|
||||
|
|
|
|||
278
tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
Normal file
278
tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using Xunit;
|
||||
using Random = CultMath.Random;
|
||||
|
||||
// Ruling loadout-product-optional-context-chooses: a slot or fit may name a product; a design-only slot is chosen by
|
||||
// the outfitter (LoadoutGenerator.ChooseProduct), never by record-key order.
|
||||
public sealed class ProductChoiceTests : IDisposable
|
||||
{
|
||||
private static readonly int2 HardpointCell = new int2(0, 0);
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-productchoice-" + Guid.NewGuid().ToString("N"));
|
||||
private readonly List<CultCache> _caches = new List<CultCache>();
|
||||
private int _catalogs;
|
||||
|
||||
public ProductChoiceTests() => Directory.CreateDirectory(_root);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var cache in _caches) cache.Dispose();
|
||||
Directory.Delete(_root, true);
|
||||
}
|
||||
|
||||
// One Skiff (made by Alpha only) and one Lamp with a product from each of Alpha and Zeta. Two outfitting factions:
|
||||
// OutA's allegiance names only Alpha, OutZ's only Zeta. lampKeys are the Lamp products' record keys in insertion
|
||||
// order [lamp-z, lamp-a], so a permutation shuffles both keys and enumeration.
|
||||
private sealed class Rig
|
||||
{
|
||||
public CultCache Cache;
|
||||
public ItemManager Items;
|
||||
public Galaxy Galaxy;
|
||||
public Faction OutA, OutZ, OutBoth, OutEven, OutNone;
|
||||
|
||||
// zone is the index of the zone the outfitter stands in; -1 stands in none, so distance does not weigh.
|
||||
public LoadoutGenerator Outfitter(Faction faction, uint seed, int zone = -1)
|
||||
{
|
||||
var random = new Random(seed);
|
||||
return new LoadoutGenerator(ref random, Items, faction == null ? null : Galaxy, faction == null || zone < 0 ? null : Galaxy.Zones[zone], faction, .5f);
|
||||
}
|
||||
|
||||
public FactionProductData Product(string name) => Cache.GetAll<FactionProductData>().Single(p => p.Name == name);
|
||||
public CultRecordRef<FactionProductData> ProductRef(string name) => Cache.RefOf(Product(name));
|
||||
|
||||
public Loadout Fit(CultRecordRef<FactionProductData> lamp = default) => new Loadout
|
||||
{
|
||||
Name = "Lamp skiff",
|
||||
Hull = Cache.RefOf(Cache.GetByName<HullData>("Skiff")),
|
||||
HullProduct = ProductRef("Skiff by Alpha"),
|
||||
Slots = { new LoadoutSlot { Position = HardpointCell, Design = Cache.RefOf<EquippableItemData>(Cache.GetByName<GearData>("Lamp")), Product = lamp } },
|
||||
WeaponGroups = new int[0][]
|
||||
};
|
||||
|
||||
public string LampBrand(Entity ship)
|
||||
{
|
||||
var unit = ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem;
|
||||
return Items.Brand(unit).Product.Name;
|
||||
}
|
||||
}
|
||||
|
||||
private Rig Build(params string[] lampOrder)
|
||||
{
|
||||
var root = Path.Combine(_root, "rig" + _catalogs++);
|
||||
Directory.CreateDirectory(root);
|
||||
var catalog = Path.Combine(root, "Aetheria.cc");
|
||||
using (var seed = AetheriaStores.Open(catalog, catalogWritable: true))
|
||||
{
|
||||
seed.Upsert(new VerseGrammar { Revision = 1 });
|
||||
var alpha = seed.Upsert(new Faction { Name = "Alpha", ShortName = "ALP" });
|
||||
var zeta = seed.Upsert(new Faction { Name = "Zeta", ShortName = "ZET" });
|
||||
var mu = seed.Upsert(new Faction { Name = "Mu", ShortName = "MU" });
|
||||
seed.Upsert(new Faction { Name = "OutA", ShortName = "OTA", Allegiance = { { alpha, 1f } } });
|
||||
seed.Upsert(new Faction { Name = "OutZ", ShortName = "OTZ", Allegiance = { { zeta, 1f } } });
|
||||
seed.Upsert(new Faction { Name = "OutBoth", ShortName = "OTB", Allegiance = { { alpha, 1f }, { zeta, .1f } } });
|
||||
seed.Upsert(new Faction { Name = "OutEven", ShortName = "OTE", Allegiance = { { alpha, 1f }, { zeta, 1f } } });
|
||||
seed.Upsert(new Faction { Name = "OutLoose", ShortName = "OTL", Allegiance = { { alpha, 1f }, { zeta, .25f } } });
|
||||
seed.Upsert(new Faction { Name = "OutZero", ShortName = "OTO", Allegiance = { { alpha, 0f }, { zeta, 0f } } });
|
||||
seed.Upsert(new Faction { Name = "OutNone", ShortName = "OTN" });
|
||||
var shape = new Shape(3, 3);
|
||||
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
||||
var skiff = seed.Upsert(new HullData
|
||||
{
|
||||
Name = "Skiff", HullType = HullType.Ship, Shape = shape, Price = 100,
|
||||
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } }
|
||||
});
|
||||
var lamp = seed.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10 });
|
||||
void Sell(string name, CultRecordKey design, CultRecordRef<Faction> maker, string key) =>
|
||||
seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData
|
||||
{ Name = name, Design = new CultRecordRef<CraftedItemData>(design), Manufacturer = maker }, new CultRecordKey(key)));
|
||||
Sell("Skiff by Alpha", skiff.Key, alpha, "skiff-a");
|
||||
var makers = new Dictionary<string, CultRecordRef<Faction>> { ["lamp-a"] = alpha, ["lamp-z"] = zeta, ["lamp-m"] = mu };
|
||||
// lampOrder holds product names; the record key of the i-th inserted is "k" + i, so keys and insertion order
|
||||
// both follow the permutation.
|
||||
for (var i = 0; i < lampOrder.Length; i++) Sell(lampOrder[i], lamp.Key, makers[lampOrder[i]], "k" + i);
|
||||
seed.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
var cache = AetheriaStores.Open(catalog, Path.Combine(root, "run.cc"), Path.Combine(root, "player.cc"));
|
||||
_caches.Add(cache);
|
||||
var outA = cache.GetByName<Faction>("OutA");
|
||||
var outZ = cache.GetByName<Faction>("OutZ");
|
||||
var game = new SavedGame
|
||||
{
|
||||
// Alpha's headquarters are in zone 0 and Zeta's in zone 1.
|
||||
Factions = new[] { cache.RefOf(outA), cache.RefOf(outZ), cache.RefOf(cache.GetByName<Faction>("Alpha")), cache.RefOf(cache.GetByName<Faction>("Zeta")) },
|
||||
Relationships = new[] { FactionRelationship.Neutral, FactionRelationship.Neutral, FactionRelationship.Neutral, FactionRelationship.Neutral },
|
||||
HomeZones = new Dictionary<int, int> { { 0, 0 }, { 1, 0 }, { 2, 0 }, { 3, 1 } },
|
||||
BossZones = new Dictionary<int, int>(),
|
||||
DiscoveredZones = new[] { 0 },
|
||||
ActionBarBindings = new SavedActionBarBinding[0],
|
||||
Exit = -1
|
||||
};
|
||||
var zones = new[]
|
||||
{
|
||||
new SavedZone { Name = "Zone 0", Position = new float2(0, 0), AdjacentZones = new[] { 1 }, Factions = new[] { 0, 1 }, Owner = 0, Contents = null },
|
||||
new SavedZone { Name = "Zone 1", Position = new float2(1, 0), AdjacentZones = new[] { 0 }, Factions = new[] { 0, 1 }, Owner = 0, Contents = null }
|
||||
};
|
||||
RunSave.Commit(cache, game, zones, new ProvenanceLedger());
|
||||
return new Rig
|
||||
{
|
||||
Cache = cache, OutA = outA, OutZ = outZ, OutBoth = cache.GetByName<Faction>("OutBoth"), OutEven = cache.GetByName<Faction>("OutEven"), OutNone = cache.GetByName<Faction>("OutNone"),
|
||||
Items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }),
|
||||
Galaxy = new Galaxy(cache, cache.GetGlobal<SavedGame>(), _ => { })
|
||||
};
|
||||
}
|
||||
|
||||
// Small seeds give correlated first draws, so the seeds a distribution test runs over are spread across the range.
|
||||
private static uint Seed(int i) => unchecked((uint) (i * 2654435761L));
|
||||
|
||||
private static Entity Materialize(Rig rig, Loadout loadout, Func<CraftedItemData, FactionProductData> choose, List<string> failures) =>
|
||||
Loadouts.Materialize(rig.Items, null, loadout, choose, failures);
|
||||
|
||||
[Fact]
|
||||
public void ADifferentOutfitterChoosesADifferentProduct()
|
||||
{
|
||||
var rig = Build("lamp-z", "lamp-a");
|
||||
var failures = new List<string>();
|
||||
Assert.Equal("lamp-a", rig.LampBrand(Materialize(rig, rig.Fit(), rig.Outfitter(rig.OutA, 1).ChooseProduct, failures)));
|
||||
Assert.Equal("lamp-z", rig.LampBrand(Materialize(rig, rig.Fit(), rig.Outfitter(rig.OutZ, 1).ChooseProduct, failures)));
|
||||
Assert.Empty(failures);
|
||||
}
|
||||
|
||||
// An outfitter that reaches both makers favors the one its allegiance weighs ten times heavier: the draw follows
|
||||
// ManufacturerPreference rather than splitting evenly.
|
||||
[Fact]
|
||||
public void TheDrawFollowsTheOutfittersPreference()
|
||||
{
|
||||
var rig = Build("lamp-z", "lamp-a");
|
||||
var lamp = rig.Cache.GetByName<GearData>("Lamp");
|
||||
var drawn = Enumerable.Range(1, 200).Select(i => rig.Outfitter(rig.OutBoth, Seed(i)).ChooseProduct(lamp).Name).ToArray();
|
||||
var alpha = drawn.Count(name => name == "lamp-a");
|
||||
Assert.InRange(alpha, 160, 199);
|
||||
Assert.Contains("lamp-z", drawn);
|
||||
}
|
||||
|
||||
// An outfitter that reaches no maker of the design gets nothing, however the weights fall: the slot fails.
|
||||
[Fact]
|
||||
public void AnOutfitterThatReachesNoMakerGetsNoProduct()
|
||||
{
|
||||
var rig = Build("lamp-z", "lamp-a");
|
||||
var generator = rig.Outfitter(rig.OutNone, 1);
|
||||
Assert.Null(generator.ChooseProduct(rig.Cache.GetByName<GearData>("Lamp")));
|
||||
var failures = new List<string>();
|
||||
Assert.Null(Materialize(rig, rig.Fit(), generator.ChooseProduct, failures));
|
||||
Assert.Equal("slot 0,0: no available product of Lamp", Assert.Single(failures));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANullFactionDrawIsSeededAndReachesEveryMaker()
|
||||
{
|
||||
var rig = Build("lamp-z", "lamp-a");
|
||||
var lamp = rig.Cache.GetByName<GearData>("Lamp");
|
||||
Assert.Equal(rig.Outfitter(null, 7).ChooseProduct(lamp).Name, rig.Outfitter(null, 7).ChooseProduct(lamp).Name);
|
||||
var seen = Enumerable.Range(1, 64).Select(i => rig.Outfitter(null, Seed(i)).ChooseProduct(lamp).Name).Distinct().OrderBy(name => name).ToArray();
|
||||
Assert.Equal(new[] { "lamp-a", "lamp-z" }, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APinnedProductIsBuiltInEveryContext()
|
||||
{
|
||||
var rig = Build("lamp-z", "lamp-a");
|
||||
var failures = new List<string>();
|
||||
var pinned = rig.Fit(rig.ProductRef("lamp-z"));
|
||||
Assert.Equal("lamp-z", rig.LampBrand(Materialize(rig, pinned, rig.Outfitter(rig.OutA, 1).ChooseProduct, failures)));
|
||||
Assert.Equal("lamp-z", rig.LampBrand(Materialize(rig, pinned, _ => null, failures)));
|
||||
Assert.Empty(failures);
|
||||
|
||||
var foreign = rig.Fit(rig.ProductRef("Skiff by Alpha"));
|
||||
Assert.Null(Materialize(rig, foreign, rig.Outfitter(null, 1).ChooseProduct, failures));
|
||||
Assert.Equal("slot 0,0: product Skiff by Alpha is not a product of Lamp", Assert.Single(failures));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShuffledKeysDoNotChangeTheChoice()
|
||||
{
|
||||
var orders = new[] { new[] { "lamp-a", "lamp-m", "lamp-z" }, new[] { "lamp-z", "lamp-a", "lamp-m" }, new[] { "lamp-m", "lamp-z", "lamp-a" } };
|
||||
var rigs = orders.Select(order => Build(order)).ToArray();
|
||||
foreach (var faction in new Func<Rig, Faction>[] { _ => null, rig => rig.OutA })
|
||||
for (uint seed = 1; seed <= 16; seed++)
|
||||
{
|
||||
var chosen = rigs.Select(rig => rig.Outfitter(faction(rig), seed).ChooseProduct(rig.Cache.GetByName<GearData>("Lamp"))?.Name).Distinct().ToArray();
|
||||
Assert.Single(chosen);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaptureKeepsTheLotsProduct()
|
||||
{
|
||||
var rig = Build("lamp-z", "lamp-a");
|
||||
var failures = new List<string>();
|
||||
var ship = Materialize(rig, rig.Fit(rig.ProductRef("lamp-z")), rig.Outfitter(rig.OutA, 1).ChooseProduct, failures);
|
||||
var captured = Loadouts.Capture(rig.Items, ship, "captured");
|
||||
Assert.Equal(rig.ProductRef("lamp-z").Key, captured.Slots.Single().Product.Key);
|
||||
Assert.Equal(rig.ProductRef("Skiff by Alpha").Key, captured.HullProduct.Key);
|
||||
|
||||
var rebuilt = Materialize(rig, captured, _ => null, failures);
|
||||
Assert.Empty(failures);
|
||||
Assert.Equal("lamp-z", rig.LampBrand(rebuilt));
|
||||
Assert.Equal("Skiff by Alpha", rig.Items.Brand(rebuilt.Hull).Product.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AChooserOfAnotherDesignIsRefused()
|
||||
{
|
||||
var rig = Build("lamp-z", "lamp-a");
|
||||
var failures = new List<string>();
|
||||
Assert.Null(Materialize(rig, rig.Fit(), _ => rig.Product("Skiff by Alpha"), failures));
|
||||
Assert.Equal("slot 0,0: product Skiff by Alpha is not a product of Lamp", Assert.Single(failures));
|
||||
}
|
||||
|
||||
private static int MakerDraws(Rig rig, Faction faction, int zone, string maker, int n)
|
||||
{
|
||||
var lamp = rig.Cache.GetByName<GearData>("Lamp");
|
||||
return Enumerable.Range(1, n).Count(i => rig.Outfitter(faction, Seed(i), zone).ChooseProduct(lamp).Name == maker);
|
||||
}
|
||||
|
||||
// Two makers an even allegiance weighs the same differ only by distance: standing in a zone, the maker whose
|
||||
// headquarters is nearer comes up about twice as often (1 against 1/2).
|
||||
[Fact]
|
||||
public void ANearerMakerIsFavored()
|
||||
{
|
||||
var rig = Build("lamp-z", "lamp-a");
|
||||
Assert.InRange(MakerDraws(rig, rig.OutEven, 0, "lamp-a", 300), 180, 220);
|
||||
Assert.InRange(MakerDraws(rig, rig.OutEven, 1, "lamp-a", 300), 80, 120);
|
||||
}
|
||||
|
||||
// A weight that read the allegiance alone would give 1 : .25 here, an 80% draw; the zone and allegiance together
|
||||
// give 1 : .125 for alpha at its own zone, 89%.
|
||||
[Fact]
|
||||
public void AnAlliedMakerWeighsByItsAllegiance()
|
||||
{
|
||||
var rig = Build("lamp-z", "lamp-a");
|
||||
Assert.InRange(MakerDraws(rig, rig.Cache.GetByName<Faction>("OutLoose"), 0, "lamp-a", 300), 245, 290);
|
||||
}
|
||||
|
||||
// When every reachable maker weighs 0 the draw is uniform over them, not a pick of the first or last.
|
||||
[Fact]
|
||||
public void WhenEveryWeightIsZeroTheDrawIsUniform()
|
||||
{
|
||||
var rig = Build("lamp-z", "lamp-a");
|
||||
var zero = rig.Cache.GetByName<Faction>("OutZero");
|
||||
Assert.InRange(MakerDraws(rig, zero, 0, "lamp-a", 300), 100, 200);
|
||||
Assert.InRange(MakerDraws(rig, zero, 0, "lamp-z", 300), 100, 200);
|
||||
}
|
||||
|
||||
// The wire positions of the new fields: a preset written by one build is read by the next.
|
||||
[Fact]
|
||||
public void TheProductRefsSitAtTheirWirePositions()
|
||||
{
|
||||
Assert.Equal(4, typeof(Loadout).GetField(nameof(Loadout.HullProduct)).GetCustomAttribute<KeyAttribute>().IntKey);
|
||||
Assert.Equal(3, typeof(LoadoutSlot).GetField(nameof(LoadoutSlot.Product)).GetCustomAttribute<KeyAttribute>().IntKey);
|
||||
}
|
||||
}
|
||||
72
tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs
Normal file
72
tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
|
||||
// The weapon product lines the shipped catalog holds (campaign aetheria-release, cut product-lines-weapons; rulings
|
||||
// catalog-grows-generic-designs-branded-products, maker-design-tiers-allowed): a sold weapon design has several brands, a
|
||||
// brand name is one product's, and every row of the command's table is in the catalog with the quality its maker's profile gives.
|
||||
// The rows come from the command's own table and the profiles from its own maker-profile table, so a row edited in one place
|
||||
// and not shipped fails here.
|
||||
public sealed class ProductLinesCatalogTests
|
||||
{
|
||||
// A design with any product is sold; the Smoke test designs have none and stay exempt.
|
||||
[Fact]
|
||||
public void EveryWeaponDesignWithAProductHasTwoMakers()
|
||||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var products = cache.GetAll<FactionProductData>().ToArray();
|
||||
var unsold = new List<string>();
|
||||
foreach (var design in cache.GetAll<WeaponItemData>())
|
||||
{
|
||||
var key = cache.RefOf(design).Key;
|
||||
var makers = products.Where(p => p.Design.Key.Equals(key)).Select(p => p.Manufacturer.Key).Distinct().Count();
|
||||
if (makers == 0)
|
||||
{
|
||||
unsold.Add(design.Name);
|
||||
continue;
|
||||
}
|
||||
Assert.True(makers >= 2, $"the weapon design \"{design.Name}\" is sold by {makers} maker");
|
||||
}
|
||||
// The designs no product sells are the Smoke test designs and no others: a design that lost all its products is a failure.
|
||||
Assert.All(unsold, name => Assert.StartsWith("Smoke ", name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoTwoProductsShareAName()
|
||||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var shared = cache.GetAll<FactionProductData>()
|
||||
.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(group => group.Count() > 1)
|
||||
.Select(group => group.Key)
|
||||
.ToArray();
|
||||
Assert.Empty(shared);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeaponLineRowsAreShipped()
|
||||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var table = ProductLinesCommand.Table("weapons");
|
||||
Assert.Equal(20, table.Count);
|
||||
foreach (var row in table)
|
||||
{
|
||||
var product = Assert.Single(cache.GetAll<FactionProductData>(), p => p.Name == row.Name);
|
||||
var design = cache.Get(product.Design);
|
||||
Assert.Equal(row.Design, design.Name);
|
||||
Assert.Equal(row.Maker, cache.Get(product.Manufacturer).ShortName);
|
||||
Assert.Equal(row.Description, product.Description);
|
||||
var (mean, dev) = Program.MakerProfiles[row.Maker];
|
||||
Assert.Equal(
|
||||
design.Roles.Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal),
|
||||
product.Roles.Select(r => r.Role).OrderBy(n => n, StringComparer.Ordinal));
|
||||
foreach (var role in product.Roles)
|
||||
{
|
||||
Assert.Equal(mean, role.Mean, 4);
|
||||
Assert.Equal(dev, role.StandardDeviation, 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
262
tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs
Normal file
262
tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using Xunit;
|
||||
|
||||
// AetherDb's product-lines command (cut product-lines-weapons), run on a scratch copy of the shipped catalog with the weapon
|
||||
// line's twenty products removed from it. Apply must write what the shipped catalog holds, only its own records, finish a
|
||||
// catalog that holds part of the line, refuse a name that is another product's, and accept a maker that already sells the design.
|
||||
public sealed class ProductLinesCommandTests : IDisposable
|
||||
{
|
||||
private static readonly string[] Names = ProductLinesCommand.Table("weapons").Select(r => r.Name).ToArray();
|
||||
private static readonly string[] Line = { "weapons" };
|
||||
private static readonly string[] LineApply = { "weapons", "apply" };
|
||||
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "product-lines-command-" + Guid.NewGuid().ToString("N"));
|
||||
private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc");
|
||||
|
||||
// The rows as the shipped catalog holds them, taken before they are removed from the scratch copy.
|
||||
private readonly string[] _shipped;
|
||||
private readonly string[] _shippedKeys;
|
||||
|
||||
private static string Describe(CultCache cache, FactionProductData product) =>
|
||||
$"{cache.Get(product.Design).Name} | {product.Name} | {cache.Get(product.Manufacturer).ShortName} | {product.Description} | " +
|
||||
string.Join(",", product.Roles.OrderBy(r => r.Role, StringComparer.Ordinal).Select(r => $"{r.Role}:{r.Mean}:{r.StandardDeviation}"));
|
||||
|
||||
public ProductLinesCommandTests()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_root, "GameData"));
|
||||
Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources"));
|
||||
var shipped = AetherDb.FindRoot();
|
||||
File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath);
|
||||
File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset"));
|
||||
using var cache = Open();
|
||||
var products = cache.GetAll<FactionProductData>().Where(p => Names.Contains(p.Name)).ToArray();
|
||||
Assert.Equal(Names.Length, products.Length);
|
||||
_shipped = products.Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
_shippedKeys = products.Select(p => cache.RefOf(p).Key.Value).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
foreach (var product in products) Assert.True(cache.Remove(cache.RefOf(product).Key));
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
public void Dispose() => Directory.Delete(_root, true);
|
||||
|
||||
private CultCache Open()
|
||||
{
|
||||
var cache = new CultCache(TestCatalog.Registry());
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
|
||||
return cache;
|
||||
}
|
||||
|
||||
private string[] Line20()
|
||||
{
|
||||
using var cache = Open();
|
||||
return cache.GetAll<FactionProductData>().Where(p => Names.Contains(p.Name)).Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private string[] Line20Keys()
|
||||
{
|
||||
using var cache = Open();
|
||||
return cache.GetAll<FactionProductData>().Where(p => Names.Contains(p.Name)).Select(p => cache.RefOf(p).Key.Value).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_dry_run_writes_nothing_and_apply_lands_the_shipped_rows_once()
|
||||
{
|
||||
var before = File.ReadAllBytes(CatalogPath);
|
||||
Assert.Empty(Line20());
|
||||
|
||||
Assert.Equal(0, ProductLinesCommand.Run(Line, _root));
|
||||
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
|
||||
Assert.Empty(Line20());
|
||||
|
||||
Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root));
|
||||
var landed = File.ReadAllBytes(CatalogPath);
|
||||
Assert.NotEqual(before, landed);
|
||||
// The command derives each record's key from the product's name, so applying the table again lands the keys the
|
||||
// shipped catalog holds.
|
||||
Assert.Equal(_shipped, Line20());
|
||||
Assert.Equal(_shippedKeys, Line20Keys());
|
||||
|
||||
// A second apply finds everything authored and writes nothing; so does a dry run.
|
||||
Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root));
|
||||
Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
|
||||
Assert.Equal(0, ProductLinesCommand.Run(Line, _root));
|
||||
Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
|
||||
}
|
||||
|
||||
// Re-spell every record's payload in a longer, equally valid MessagePack form (an array16 header for the fixarray one). A
|
||||
// write that re-encodes records it did not author shows as changed bytes; one that lands onto the file as it is leaves them.
|
||||
private int WidenEveryPayload()
|
||||
{
|
||||
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath));
|
||||
var widened = 0;
|
||||
foreach (var record in snapshot.Records)
|
||||
{
|
||||
var payload = record.Payload;
|
||||
if (payload.Length == 0 || (payload[0] & 0xF0) != 0x90) continue;
|
||||
record.Payload = new byte[] { 0xDC, 0x00, (byte)(payload[0] & 0x0F) }.Concat(payload.Skip(1)).ToArray();
|
||||
widened++;
|
||||
}
|
||||
File.WriteAllBytes(CatalogPath, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
||||
return widened;
|
||||
}
|
||||
|
||||
private Dictionary<string, CultPersistedRecord> Records() =>
|
||||
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)).Records.ToDictionary(r => r.Key, StringComparer.Ordinal);
|
||||
|
||||
[Fact]
|
||||
public void Apply_writes_only_the_records_it_authors()
|
||||
{
|
||||
Assert.True(WidenEveryPayload() > 100, "the scratch catalog should hold many records in the widened spelling");
|
||||
var before = Records();
|
||||
|
||||
Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root));
|
||||
|
||||
var after = Records();
|
||||
Assert.Equal(before.Count + Names.Length, after.Count);
|
||||
foreach (var (key, record) in before)
|
||||
{
|
||||
Assert.True(after.TryGetValue(key, out var kept), "a record the command did not author is gone");
|
||||
Assert.Equal(record.SchemaId, kept.SchemaId);
|
||||
Assert.Equal(record.StoredAt, kept.StoredAt);
|
||||
Assert.True(record.Payload.AsSpan().SequenceEqual(kept.Payload), "a record the command did not author has changed bytes");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_finishes_a_catalog_that_holds_part_of_the_line()
|
||||
{
|
||||
Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root));
|
||||
using (var cache = Open())
|
||||
{
|
||||
foreach (var product in cache.GetAll<FactionProductData>().Where(p => p.Name == "Merlin" || p.Name == "Tripwire").ToArray())
|
||||
Assert.True(cache.Remove(cache.RefOf(product).Key));
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
Assert.Equal(Names.Length - 2, Line20().Length);
|
||||
|
||||
Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root));
|
||||
Assert.Equal(_shipped, Line20());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_refuses_a_row_name_that_is_another_products_and_writes_nothing()
|
||||
{
|
||||
using (var cache = Open())
|
||||
{
|
||||
var other = cache.GetAll<WeaponItemData>().First(w => w.Name == "Light Machine Gun");
|
||||
var maker = cache.GetAll<Faction>().First(f => f.ShortName == "Zhestokost");
|
||||
cache.Upsert(new FactionProductData { Name = "Sparrow", Design = new CultRecordRef<CraftedItemData>(cache.RefOf(other).Key), Manufacturer = cache.RefOf(maker) });
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
var conflicted = File.ReadAllBytes(CatalogPath);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => ProductLinesCommand.Run(LineApply, _root));
|
||||
Assert.Equal(conflicted, File.ReadAllBytes(CatalogPath));
|
||||
}
|
||||
|
||||
// Ruling maker-design-tiers-allowed: a maker that already sells a design may sell another product of it.
|
||||
[Fact]
|
||||
public void Apply_accepts_a_maker_that_already_sells_the_design()
|
||||
{
|
||||
using (var cache = Open())
|
||||
{
|
||||
var laser = cache.GetAll<WeaponItemData>().First(w => w.Name == "Light Laser");
|
||||
var lucent = cache.GetAll<Faction>().First(f => f.ShortName == "Lucent");
|
||||
cache.Upsert(new FactionProductData { Name = "Scratch tier", Design = new CultRecordRef<CraftedItemData>(cache.RefOf(laser).Key), Manufacturer = cache.RefOf(lucent) });
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root));
|
||||
Assert.Equal(_shipped, Line20());
|
||||
using var after = Open();
|
||||
Assert.Contains(after.GetAll<FactionProductData>(), p => p.Name == "Scratch tier");
|
||||
}
|
||||
|
||||
// Master's committed catalog holds records written on a schema older than today's (HullData lost a member after the last
|
||||
// catalog write). The scratch copy gets that shape: the hull records point at an earlier schema id the loader resolves to
|
||||
// the current type.
|
||||
private string MakeHullsStale()
|
||||
{
|
||||
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath));
|
||||
var current = snapshot.SchemaCatalog.Single(e => e.SchemaName == "aetheria.hulldata");
|
||||
var stale = new CultSchemaCatalogEntry
|
||||
{
|
||||
SchemaId = "sha256:earlier-hulldata", SchemaName = current.SchemaName, SchemaVersion = current.SchemaVersion,
|
||||
ContentHash = "sha256:earlier-hulldata", CanonicalSchemaJson = current.CanonicalSchemaJson, Members = current.Members,
|
||||
};
|
||||
snapshot.SchemaCatalog = snapshot.SchemaCatalog.Append(stale).ToArray();
|
||||
var hulls = snapshot.Records.Where(r => r.SchemaId == current.SchemaId).ToArray();
|
||||
Assert.NotEmpty(hulls);
|
||||
foreach (var hull in hulls) hull.SchemaId = stale.SchemaId;
|
||||
File.WriteAllBytes(CatalogPath, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
||||
return stale.SchemaId;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_lands_on_a_catalog_that_holds_records_of_an_older_schema_and_replays_to_the_same_bytes()
|
||||
{
|
||||
var stale = MakeHullsStale();
|
||||
var before = Records();
|
||||
|
||||
Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root));
|
||||
var landed = File.ReadAllBytes(CatalogPath);
|
||||
Assert.Equal(_shipped, Line20());
|
||||
var after = Records();
|
||||
foreach (var (key, record) in before)
|
||||
Assert.True(record.Payload.AsSpan().SequenceEqual(after[key].Payload) && record.SchemaId == after[key].SchemaId, "a record the command did not author changed");
|
||||
Assert.Contains(after.Values, r => r.SchemaId == stale);
|
||||
|
||||
Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root));
|
||||
Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
|
||||
}
|
||||
|
||||
private void AddProduct(string name, string design, string maker)
|
||||
{
|
||||
using var cache = Open();
|
||||
var designRecord = cache.GetAll<WeaponItemData>().First(w => w.Name == design);
|
||||
var makerRecord = cache.GetAll<Faction>().First(f => f.ShortName == maker);
|
||||
cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef<CraftedItemData>(cache.RefOf(designRecord).Key), Manufacturer = cache.RefOf(makerRecord) });
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
// Sparrow is the Light Laser by Finch. A product of that name is the row only when design and maker both match.
|
||||
[Theory]
|
||||
[InlineData("Sparrow", "Light Machine Gun", "Finch")]
|
||||
[InlineData("Sparrow", "Light Laser", "Zhestokost")]
|
||||
[InlineData("sparrow", "Light Laser", "Finch")]
|
||||
public void Apply_refuses_a_name_unless_design_and_maker_both_match_it_ignoring_case(string name, string design, string maker)
|
||||
{
|
||||
AddProduct(name, design, maker);
|
||||
var conflicted = File.ReadAllBytes(CatalogPath);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => ProductLinesCommand.Run(LineApply, _root));
|
||||
Assert.Equal(conflicted, File.ReadAllBytes(CatalogPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_refuses_a_row_name_that_is_a_designs_and_writes_nothing()
|
||||
{
|
||||
using (var cache = Open())
|
||||
{
|
||||
cache.Upsert(new WeaponItemData { Name = "Tripwire" });
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
var conflicted = File.ReadAllBytes(CatalogPath);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => ProductLinesCommand.Run(LineApply, _root));
|
||||
Assert.Equal(conflicted, File.ReadAllBytes(CatalogPath));
|
||||
}
|
||||
|
||||
// The command is reached as AetherDb's product-lines word, with the line after it; the shipped catalog holds the line.
|
||||
[Fact]
|
||||
public void The_product_lines_word_runs_the_named_line_and_refuses_an_unknown_one()
|
||||
{
|
||||
Assert.Equal(0, Program.Main(new[] { "product-lines", "weapons" }));
|
||||
Assert.Equal(1, Program.Main(new[] { "product-lines", "nonesuch" }));
|
||||
}
|
||||
}
|
||||
64
tests/Aetheria.Shared.Tests/RigBrandTests.cs
Normal file
64
tests/Aetheria.Shared.Tests/RigBrandTests.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
// Ruling loadout-product-optional-context-chooses: a rig whose test reads stats names the brand it was measured on, and
|
||||
// staging builds exactly that brand whatever the stage's seed draws for open designs. The fixture is RunStartTests'.
|
||||
public sealed partial class RunStartTests
|
||||
{
|
||||
private string ProductOf(EquippableItem item) => _items.Brand(item).Product.Name;
|
||||
|
||||
private IEnumerable<string> FittedBrands(Entity entity, string design) =>
|
||||
entity.Equipment.Concat(entity.CargoBays).Where(item => _items.GetData(item.EquippableItem).Name == design).Select(item => ProductOf(item.EquippableItem));
|
||||
|
||||
// Every product a scenario's fits name. A rig whose fits name a design where a brand stood would draw its brand from
|
||||
// the stage's seed; so each scenario is staged under eight seeds, and the design of every pinned product must come out
|
||||
// as exactly that product on every ship the scenario fits. A hull that is not a fit's (a generated or bare ship)
|
||||
// is left out: its gear is not pinned.
|
||||
public static IEnumerable<object[]> PinnedRigs() => new[]
|
||||
{
|
||||
Rig(new AiFusedDiscipline(), "not if i see you first", "Panopticon Prime"),
|
||||
Rig(new Arcs(), "GT 3K", "FastBlast+-", "🫳🔫", "ChargeBlast SG", "ClearPath", "not if i see you first", "Panopticon Prime"),
|
||||
Rig(new Belt(), "GT 3K", "DragOnBreath", "FastBlast+-", "not if i see you first", "Panopticon Prime"),
|
||||
Rig(new Crossing(), "GT 3K", "DragOnBreath", "FastBlast+-", "not if i see you first", "Panopticon Prime"),
|
||||
Rig(new Duel(), "GT 3K", "FastBlast+-", "Store-All Plus", "not if i see you first", "Panopticon Prime"),
|
||||
Rig(new FusedRounds(), "DeathCluster", "not if i see you first", "Panopticon Prime"),
|
||||
Rig(new LauncherAngles(), "GT 3K", "Leonid", "FastBlast+-", "not if i see you first", "Panopticon Prime"),
|
||||
Rig(new LockOn(), "GT 3K", "FastBlast+-", "Store-All Plus", "not if i see you first", "Panopticon Prime"),
|
||||
Rig(new Pursuit(), "parting gift", "Leonid", "GT 3K", "FastBlast+-", "Store-All Plus", "not if i see you first", "Panopticon Prime"),
|
||||
Rig(new RefusedRounds(), "🫳🔫", "not if i see you first", "Panopticon Prime"),
|
||||
Rig(new StarvedReactor(), "Spectra", "not if i see you first", "Panopticon Prime"),
|
||||
Rig(new WeaponFeel(), "ChargeBlast SG", "🫳🔫", "FastBlast+-", "not if i see you first", "Panopticon Prime"),
|
||||
};
|
||||
|
||||
private static object[] Rig(Scenario scenario, params string[] brands) => new object[] { scenario, brands };
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(PinnedRigs))]
|
||||
public void TheRigsKeepTheirBrands(Scenario scenario, string[] brands)
|
||||
{
|
||||
for (uint seed = 1; seed <= 8; seed++)
|
||||
{
|
||||
var (_, _, staged, failures) = Launch(new Scripted(false, scenario.Stage, seed * 7919, scenario.Generate));
|
||||
Assert.Empty(failures);
|
||||
var ships = staged.Entities.Append(staged.Player).Where(ship => _items.GetData(ship.Hull).Name is "Longinus" or "Smoke Test Hull" or "Turret");
|
||||
foreach (var brand in brands)
|
||||
{
|
||||
var product = _items.ItemData.GetAll<FactionProductData>().Single(candidate => candidate.Name == brand);
|
||||
var design = _items.ItemData.Get(product.Design).Name;
|
||||
var built = ships.SelectMany(ship => FittedBrands(ship, design)).Distinct().ToArray();
|
||||
Assert.True(built.SequenceEqual(new[] { brand }), $"{scenario.Name} seed {seed}: {design} is built as [{string.Join(", ", built)}], not {brand}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Duel's rival holds Spectra in its cargo.
|
||||
[Fact]
|
||||
public void TheDuelRivalHoldsItsBrand()
|
||||
{
|
||||
var duel = Launch(new Scripted(false, new Duel().Stage, 7919));
|
||||
Assert.Empty(duel.failures);
|
||||
var rival = Assert.Single(duel.staged.Entities);
|
||||
Assert.Equal(new[] { "Spectra" }, Held(rival).Select(item => _items.Brand(item).Product.Name));
|
||||
}
|
||||
}
|
||||
|
|
@ -662,7 +662,7 @@ public sealed partial class RunStartTests : IDisposable
|
|||
foreach (var hull in new[] { Hull("Djinni"), Hull("Longinus"), TurretHull() })
|
||||
{
|
||||
var failures = new List<string>();
|
||||
var entity = Loadouts.Materialize(_items, null, BareLoadout(hull), available.IsAvailable, failures);
|
||||
var entity = Loadouts.Materialize(_items, null, BareLoadout(hull), available.ChooseProduct, failures);
|
||||
Assert.Empty(failures);
|
||||
Assert.NotNull(_items.Brand(entity.Hull).Maker);
|
||||
}
|
||||
|
|
@ -681,7 +681,7 @@ public sealed partial class RunStartTests : IDisposable
|
|||
Assert.True(ships.Count > 1);
|
||||
foreach (var ship in ships) Assert.Equal(ship.IsPlayerShip ? 0 : 1, AgentsOf(zone, ship));
|
||||
|
||||
var arriving = (Ship) Loadouts.Materialize(_items, zone, BareLoadout(Hull("Djinni")), _ => true, new List<string>());
|
||||
var arriving = (Ship) Loadouts.Materialize(_items, zone, BareLoadout(Hull("Djinni")), new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, null, 2).ChooseProduct, new List<string>());
|
||||
var agents = zone.Agents.Count;
|
||||
zone.Admit(arriving, piloted: false);
|
||||
SafeAssert.In(zone, arriving, "the arrival is admitted");
|
||||
|
|
|
|||
|
|
@ -14,5 +14,6 @@
|
|||
<ProjectReference Include="..\..\Aetheria.Shared\Aetheria.Shared.csproj" />
|
||||
<!-- Unity asset files are YAML; parsing them is a solved problem, not one to hand roll -->
|
||||
<PackageReference Include="YamlDotNet" Version="16.2.1" />
|
||||
<InternalsVisibleTo Include="Aetheria.Shared.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
139
tools/AetherDb/ProductLinesCommand.cs
Normal file
139
tools/AetherDb/ProductLinesCommand.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameCult.Caching;
|
||||
|
||||
// product-lines <line> [apply] (aetheria-release, cut product-lines-weapons; rulings catalog-grows-generic-designs-branded-products,
|
||||
// maker-design-tiers-allowed, miss-terris-belongs-to-emily-r3, pirates-record-content): authors the branded products of one
|
||||
// product line into GameData/Aetheria.cc. A line is a table of (design, product, maker, text); each row becomes one
|
||||
// FactionProductData with one ProductRole per role of its design, at the maker's profile in Program.MakerProfiles, so the table
|
||||
// carries no quality numbers. A row already in the catalog (same name, design and maker) is skipped, so the same command is the
|
||||
// first apply, the second apply and the replay onto a merged catalog. A maker that already sells the design is not refused: a
|
||||
// tier is allowed. Dry run unless passed "apply".
|
||||
public static class ProductLinesCommand
|
||||
{
|
||||
internal sealed record Row(string Design, string Name, string Maker, string Description);
|
||||
|
||||
private static readonly Row[] Weapons =
|
||||
{
|
||||
new("Light Machine Gun", "Rattler", "AU", "Cheap, loud, and there when the rocks start moving."),
|
||||
new("Slug Rifle", "SM-6 Marksman", "R&D", "Standardised, certified, guaranteed to hit the target.*"),
|
||||
new("Long Slug Rifle", "Longshot", "Finch", "Light, quiet and dead accurate, when it feels like it."),
|
||||
new("Autocannon", "Workhorse", "AU", "Feeds from anything, fires at anything."),
|
||||
new("Cluster Cannon", "everything at once", "DME", "why aim/ when you can be everywhere"),
|
||||
new("Charged Scattergun", "Grapeshot", "Zhestokost", "The old answer to boarders, sized for the void."),
|
||||
new("Light Laser", "Sparrow", "Finch", "Barely registers on the scale, or on their sensors."),
|
||||
new("Light Laser", "Spotlight", "Lucent", "Make sure they know who got them."),
|
||||
new("Cell Blaster", "Strobe", "Lucent", "Fast, bright, and very hard to ignore."),
|
||||
new("Long Laser", "Merlin", "Finch", "Hovers at the edge of range, then stoops."),
|
||||
new("Heavy Beam Laser", "Afterglow", "NiteLife", "Keeps glowing long after the fight is over."),
|
||||
new("Charged Blaster", "Encore", "Lucent", "One more, for the fans."),
|
||||
new("Charged Scatter Blaster", "nobody leaves early", "DME", "doors are locked/ lights are low/ stay for the finale"),
|
||||
new("Lightning Projector", "Static Shock", "NiteLife", "A jolt for every ship in reach."),
|
||||
new("Guided Missile Launcher", "Homer", "Finch", "Finds its way, mostly."),
|
||||
new("Long-Range Missile Rack", "Long Haul", "AU", "Delivers to the far side of the zone."),
|
||||
new("Short-Range Missile Rack", "Short Fuse", "AU", "For problems already too close."),
|
||||
new("Swarm Rocket Pod", "Confetti", "Lucent", "Every launch is a celebration."),
|
||||
new("Incendiary Rocket Pod", "Trebuchet", "Zhestokost", "Siege fire for a column that does not stop."),
|
||||
new("Mine Launcher", "Tripwire", "Zhestokost", "Closes the road behind the column."),
|
||||
};
|
||||
|
||||
// Every line the command knows, by the word that follows "product-lines".
|
||||
private static readonly Dictionary<string, Row[]> Lines = new() { ["weapons"] = Weapons };
|
||||
|
||||
// A line's rows, for the catalog tests that check the shipped records against the table the command authors from.
|
||||
internal static IReadOnlyList<Row> Table(string line) => Lines[line];
|
||||
|
||||
// A product's record key is derived from its name, so the same table lands the same records every time it is applied: the
|
||||
// replay at a merge reproduces the branch's blob, and nothing that orders products by key reads a different order after it.
|
||||
private static CultRecordKey KeyOf(Row row) =>
|
||||
new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("product-line:" + row.Name)).AsSpan(0, 16)).ToString("N"));
|
||||
|
||||
public static int Run(string[] args, string root = null)
|
||||
{
|
||||
var apply = args.Contains("apply");
|
||||
var line = args.FirstOrDefault(a => a != "apply");
|
||||
if (line == null || !Lines.TryGetValue(line, out var rows))
|
||||
{
|
||||
Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)} [apply]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var db = AetherDb.Open(catalogWritable: apply, root: root);
|
||||
var designs = db.Cache.GetAll<CraftedItemData>().ToArray();
|
||||
var factions = db.Cache.GetAll<Faction>().ToArray();
|
||||
var products = db.Cache.GetAll<FactionProductData>().ToArray();
|
||||
|
||||
// Resolve every row before anything is written: a missing or ambiguous design or maker, a name that belongs to another
|
||||
// product or to a design, and a design with no roles each stop the command with the catalog untouched.
|
||||
var resolved = new List<(Row Row, CraftedItemData Design, Faction Maker, bool Present)>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var design = designs.Where(d => d.Name == row.Design).ToArray();
|
||||
if (design.Length != 1) throw new InvalidOperationException($"Expected exactly one design named \"{row.Design}\", found {design.Length}.");
|
||||
var maker = factions.Where(f => f.ShortName == row.Maker).ToArray();
|
||||
if (maker.Length != 1) throw new InvalidOperationException($"Expected exactly one faction with short name \"{row.Maker}\", found {maker.Length}.");
|
||||
if (!Program.MakerProfiles.ContainsKey(row.Maker)) throw new InvalidOperationException($"No maker profile for \"{row.Maker}\".");
|
||||
if (design[0].Roles.Count == 0) throw new InvalidOperationException($"The design \"{row.Design}\" has no roles.");
|
||||
|
||||
var designKey = db.Cache.RefOf(design[0]).Key;
|
||||
var makerKey = db.Cache.RefOf(maker[0]).Key;
|
||||
var named = products.Where(p => string.Equals(p.Name, row.Name, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
var present = named.Length == 1 && named[0].Name == row.Name && named[0].Design.Key.Equals(designKey) && named[0].Manufacturer.Key.Equals(makerKey);
|
||||
if (named.Length != 0 && !present) throw new InvalidOperationException($"The product name \"{row.Name}\" already belongs to another product.");
|
||||
if (!present && designs.Any(d => string.Equals(d.Name, row.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"The product name \"{row.Name}\" is the name of a design.");
|
||||
resolved.Add((row, design[0], maker[0], present));
|
||||
}
|
||||
|
||||
var missing = resolved.Where(r => !r.Present).ToArray();
|
||||
if (missing.Length == 0)
|
||||
{
|
||||
Console.WriteLine($"The {line} product line is already authored; nothing to do.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Product line {line}:");
|
||||
foreach (var (row, design, _, present) in resolved)
|
||||
{
|
||||
var (mean, dev) = Program.MakerProfiles[row.Maker];
|
||||
Console.WriteLine($" {(present ? "present" : "new ")} {row.Design} -> \"{row.Name}\" by {row.Maker}, mean {mean:0.##} dev {dev:0.##} on [{string.Join(", ", design.Roles.Select(r => r.Name))}]: {row.Description}");
|
||||
}
|
||||
|
||||
if (!apply)
|
||||
{
|
||||
Console.WriteLine($"\nDry run. Pass \"apply\" to author {missing.Length} products.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The batch lands onto the file as it is: every other record keeps the bytes it has, where a plain commit would
|
||||
// re-encode the whole catalog through today's types. What it expects is that each key it authors is still absent, the
|
||||
// one condition the command's own validation rests on. ExpectUnchanged would compare the file's schema ids with the
|
||||
// loaded ones and refuse any catalog that holds a record written on an older schema, whatever the batch is.
|
||||
if (!db.Cache.Commit(batch =>
|
||||
{
|
||||
foreach (var (row, _, _, _) in missing) batch.Expect(KeyOf(row), null);
|
||||
foreach (var (row, design, maker, _) in missing)
|
||||
{
|
||||
var (mean, dev) = Program.MakerProfiles[row.Maker];
|
||||
batch.Upsert(typeof(FactionProductData), new FactionProductData
|
||||
{
|
||||
Name = row.Name,
|
||||
Description = row.Description,
|
||||
Design = db.Cache.RefOf(design),
|
||||
Manufacturer = db.Cache.RefOf(maker),
|
||||
Roles = design.Roles.Select(r => new ProductRole { Role = r.Name, Mean = mean, StandardDeviation = dev }).ToList(),
|
||||
}, KeyOf(row));
|
||||
}
|
||||
}))
|
||||
throw new InvalidOperationException("The catalog changed while the product line was being authored; nothing was written. Run it again.");
|
||||
Console.WriteLine($"\nAuthored {missing.Length} products in Aetheria.cc");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -55,8 +55,9 @@ public static class Program
|
|||
case "sensor-stat-set": return SensorStatSet(args.Contains("apply"));
|
||||
case "generic-designs": return GenericDesigns(args.Contains("apply"));
|
||||
case "inspection-scanner": return InspectionScannerCatalog(args.Contains("apply"));
|
||||
case "product-lines": return ProductLinesCommand.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], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply]");
|
||||
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], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], product-lines weapons [apply]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -250,8 +251,8 @@ public static class Program
|
|||
};
|
||||
|
||||
// One Mean / StandardDeviation per maker, applied to every role of a design the maker sells (catalog-growth
|
||||
// addenda, "Maker profiles"); the Pirates are middling and inconsistent (pirates-record).
|
||||
private static readonly Dictionary<string, (float Mean, float Dev)> MakerProfiles = new Dictionary<string, (float Mean, float Dev)>
|
||||
// addenda, "Maker profiles"); the Pirates are middling and inconsistent (pirates-record). Read by ProductLinesCommand too.
|
||||
internal static readonly Dictionary<string, (float Mean, float Dev)> MakerProfiles = new Dictionary<string, (float Mean, float Dev)>
|
||||
{
|
||||
["Zhestokost"] = (.50f, .08f), ["AU"] = (.50f, .12f), ["Lightsail"] = (.55f, .10f), ["R&D"] = (.60f, .15f),
|
||||
["Lucent"] = (.60f, .15f), ["NiteLife"] = (.55f, .12f), ["Finch"] = (.65f, .20f), ["Alakrita"] = (.60f, .14f),
|
||||
|
|
|
|||
Loading…
Reference in a new issue