diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 0e60a544e..7effbfe76 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -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 Hull; [Key(2)] public List Slots = new List(); [Key(3)] public int[][] WeaponGroups; // indices into Slots + [Key(4)] public CultRecordRef 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 Design; + [Key(3)] public CultRecordRef 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 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 isAvailable, List failures) + Func choose, List 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 Resolve(ItemManager itemManager, CultRecordRef design, - Predicate isAvailable, string where, List failures) where T : EquippableItemData => - Resolve(itemManager, ProductsInKeyOrder(itemManager.ItemData), design, isAvailable, where, failures); - - private static Func Resolve(ItemManager itemManager, FactionProductData[] products, - CultRecordRef design, Predicate isAvailable, string where, List 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 Resolve(ItemManager itemManager, CultRecordRef design, + CultRecordRef product, Func choose, string where, + List 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().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().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray(); - private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}"; } diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 46bd5bc33..55eea27f8 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -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() + .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. diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index 159504292..b32a4993c 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -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 _available; + private readonly Func _choose; private readonly List _failures = new List(); private readonly List _placed = new List(); 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(hull, where); + var (hullData, hullProduct) = NamedOrProduct(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(design, where); + var (data, product) = NamedOrProduct(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(design, where, found => found is EquippableItemData || found is ConsumableItemData); + var (data, product) = NamedOrProduct(name, where, found => found is EquippableItemData || found is ConsumableItemData); if (data == null) continue; - Func 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(); - 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(string name, string where, Func among = null) where T : CraftedItemData + { + var cache = _items.ItemData; + var products = cache.GetAll().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().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(string name, string where, Func among = null) where T : CraftedItemData { var named = _items.ItemData.GetAll().Where(data => data.Name == name && (among == null || among(data))).Take(2).ToArray(); diff --git a/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs index eb8ecab18..df33f5640 100644 --- a/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs +++ b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs @@ -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); diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index da2983267..b91fbc359 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -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); diff --git a/Assets/Scripts/ServerShared/Scenarios/Belt.cs b/Assets/Scripts/ServerShared/Scenarios/Belt.cs index efab34db7..cf0150ff8 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Belt.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Belt.cs @@ -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. diff --git a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs index f0e91be00..c90859439 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs @@ -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); diff --git a/Assets/Scripts/ServerShared/Scenarios/Duel.cs b/Assets/Scripts/ServerShared/Scenarios/Duel.cs index 510d60147..62dff34ae 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Duel.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Duel.cs @@ -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"); } } diff --git a/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs index ca9011bee..db2e18b65 100644 --- a/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs +++ b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs @@ -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); diff --git a/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs index 7c5fc669c..62258b9cb 100644 --- a/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs +++ b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs @@ -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); diff --git a/Assets/Scripts/ServerShared/Scenarios/LockOn.cs b/Assets/Scripts/ServerShared/Scenarios/LockOn.cs index 7dc850497..450020506 100644 --- a/Assets/Scripts/ServerShared/Scenarios/LockOn.cs +++ b/Assets/Scripts/ServerShared/Scenarios/LockOn.cs @@ -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); diff --git a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs index 75a803b18..bd067b96d 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs @@ -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); } } diff --git a/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs index 557812197..21208adf1 100644 --- a/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs +++ b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs @@ -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); diff --git a/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs index 3a23b164a..b04f260fc 100644 --- a/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs +++ b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs @@ -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)); } diff --git a/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs index 43d4097ae..329d0671e 100644 --- a/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs +++ b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs @@ -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); diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 321476801..7cc7c9d58 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed0500b462cb0a1dc93377e009c818e3f97c9612fd8f6fd02e17bfd6ea433350 -size 12738595 +oid sha256:ba78312744521762069315f8b8c491f7619f0410249abcfa90291a11b9e29d7b +size 12745154 diff --git a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs index c93b38d57..0a452491f 100644 --- a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs +++ b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs @@ -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().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(), 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(), p => p.Design.Key.Equals(cache.RefOf(mine).Key)); + var brand = Assert.Single(cache.GetAll(), 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 - { - ["Zhestokost"] = (.50f, .08f), ["R&D"] = (.60f, .15f), ["DME"] = (.55f, .20f), ["Lightsail"] = (.55f, .10f), ["Pirates"] = (.45f, .22f), - }; + var profiles = new Dictionary(Program.MakerProfiles) { ["Pirates"] = (.45f, .22f) }; foreach (var design in new[] { autocannon, lrm, srm, mine, drive }) { var sellers = cache.GetAll().Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray(); diff --git a/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs b/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs index 2f5a2a99b..096845380 100644 --- a/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs @@ -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 - { - ["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 => { diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index f95d67047..f1ae47b5d 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -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(cache.RefOf(cache.GetByName("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().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 available, List 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(); - 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(); - 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 SoleProduct(CultCache cache) => design => + { + var key = cache.RefOf(design).Key; + var made = cache.GetAll().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 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); diff --git a/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs b/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs index 67ae08d22..637b2e65e 100644 --- a/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs +++ b/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs @@ -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() - .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] diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs new file mode 100644 index 000000000..888634f8e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -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 _caches = new List(); + 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().Single(p => p.Name == name); + public CultRecordRef ProductRef(string name) => Cache.RefOf(Product(name)); + + public Loadout Fit(CultRecordRef lamp = default) => new Loadout + { + Name = "Lamp skiff", + Hull = Cache.RefOf(Cache.GetByName("Skiff")), + HullProduct = ProductRef("Skiff by Alpha"), + Slots = { new LoadoutSlot { Position = HardpointCell, Design = Cache.RefOf(Cache.GetByName("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 maker, string key) => + seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData + { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }, new CultRecordKey(key))); + Sell("Skiff by Alpha", skiff.Key, alpha, "skiff-a"); + var makers = new Dictionary> { ["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("OutA"); + var outZ = cache.GetByName("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("Alpha")), cache.RefOf(cache.GetByName("Zeta")) }, + Relationships = new[] { FactionRelationship.Neutral, FactionRelationship.Neutral, FactionRelationship.Neutral, FactionRelationship.Neutral }, + HomeZones = new Dictionary { { 0, 0 }, { 1, 0 }, { 2, 0 }, { 3, 1 } }, + BossZones = new Dictionary(), + 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("OutBoth"), OutEven = cache.GetByName("OutEven"), OutNone = cache.GetByName("OutNone"), + Items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), + Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }) + }; + } + + // 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 choose, List failures) => + Loadouts.Materialize(rig.Items, null, loadout, choose, failures); + + [Fact] + public void ADifferentOutfitterChoosesADifferentProduct() + { + var rig = Build("lamp-z", "lamp-a"); + var failures = new List(); + 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("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("Lamp"))); + var failures = new List(); + 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("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(); + 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[] { _ => 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("Lamp"))?.Name).Distinct().ToArray(); + Assert.Single(chosen); + } + } + + [Fact] + public void CaptureKeepsTheLotsProduct() + { + var rig = Build("lamp-z", "lamp-a"); + var failures = new List(); + 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(); + 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("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("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("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().IntKey); + Assert.Equal(3, typeof(LoadoutSlot).GetField(nameof(LoadoutSlot.Product)).GetCustomAttribute().IntKey); + } +} diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs new file mode 100644 index 000000000..756e22355 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs @@ -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().ToArray(); + var unsold = new List(); + foreach (var design in cache.GetAll()) + { + 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() + .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(), 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); + } + } + } +} diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs new file mode 100644 index 000000000..9aa136e16 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs @@ -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().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().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().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 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().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().First(w => w.Name == "Light Machine Gun"); + var maker = cache.GetAll().First(f => f.ShortName == "Zhestokost"); + cache.Upsert(new FactionProductData { Name = "Sparrow", Design = new CultRecordRef(cache.RefOf(other).Key), Manufacturer = cache.RefOf(maker) }); + cache.FlushAsync().Wait(); + } + var conflicted = File.ReadAllBytes(CatalogPath); + + Assert.Throws(() => 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().First(w => w.Name == "Light Laser"); + var lucent = cache.GetAll().First(f => f.ShortName == "Lucent"); + cache.Upsert(new FactionProductData { Name = "Scratch tier", Design = new CultRecordRef(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(), 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().First(w => w.Name == design); + var makerRecord = cache.GetAll().First(f => f.ShortName == maker); + cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(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(() => 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(() => 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" })); + } +} diff --git a/tests/Aetheria.Shared.Tests/RigBrandTests.cs b/tests/Aetheria.Shared.Tests/RigBrandTests.cs new file mode 100644 index 000000000..5c364fcd3 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RigBrandTests.cs @@ -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 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 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().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)); + } +} diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 5ad89c639..18286649d 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -662,7 +662,7 @@ public sealed partial class RunStartTests : IDisposable foreach (var hull in new[] { Hull("Djinni"), Hull("Longinus"), TurretHull() }) { var failures = new List(); - 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()); + var arriving = (Ship) Loadouts.Materialize(_items, zone, BareLoadout(Hull("Djinni")), new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, null, 2).ChooseProduct, new List()); var agents = zone.Agents.Count; zone.Admit(arriving, piloted: false); SafeAssert.In(zone, arriving, "the arrival is admitted"); diff --git a/tools/AetherDb/AetherDb.csproj b/tools/AetherDb/AetherDb.csproj index f2b488b9b..d0be0585a 100644 --- a/tools/AetherDb/AetherDb.csproj +++ b/tools/AetherDb/AetherDb.csproj @@ -14,5 +14,6 @@ + diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs new file mode 100644 index 000000000..d0fd7ce41 --- /dev/null +++ b/tools/AetherDb/ProductLinesCommand.cs @@ -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 [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 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 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().ToArray(); + var factions = db.Cache.GetAll().ToArray(); + var products = db.Cache.GetAll().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; + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index a99c7526c..b2af11686 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -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 ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], 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 ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], 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 MakerProfiles = new Dictionary + // addenda, "Maker profiles"); the Pirates are middling and inconsistent (pirates-record). Read by ProductLinesCommand too. + internal static readonly Dictionary MakerProfiles = new Dictionary { ["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),