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:
Meta Vens 2026-10-17 04:48:14 +02:00
commit 6179846249
28 changed files with 1032 additions and 216 deletions

View file

@ -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}";
}

View file

@ -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.

View file

@ -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();

View file

@ -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);

View file

@ -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);

View file

@ -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.

View file

@ -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);

View file

@ -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");
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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));
}

View file

@ -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)

Binary file not shown.

View file

@ -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();

View file

@ -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 =>
{

View file

@ -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);

View file

@ -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]

View 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);
}
}

View 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);
}
}
}
}

View 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" }));
}
}

View 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));
}
}

View file

@ -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");

View file

@ -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>

View 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;
}
}

View file

@ -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),