diff --git a/Assets/Scripts/Editor/CultCacheDrawers.cs b/Assets/Scripts/Editor/CultCacheDrawers.cs index c446212db..818746e0f 100644 --- a/Assets/Scripts/Editor/CultCacheDrawers.cs +++ b/Assets/Scripts/Editor/CultCacheDrawers.cs @@ -132,8 +132,7 @@ public sealed class InspectableSchematicShapeDrawer : ICultInspectorDrawer using (new EditorGUILayout.HorizontalScope()) for (var x = 0; x < shape.Width; x++) { - var hardpoint = hull?.Hardpoints.FirstOrDefault(hp => hp.Shape.Coordinates - .Any(v => v.x + hp.Position.x == x && v.y + hp.Position.y == y)); + var hardpoint = hull?.Hardpoints.FirstOrDefault(hp => HullFootprint.Cells(hp).Contains(int2(x, y))); var previous = GUI.backgroundColor; if (hardpoint != null) GUI.backgroundColor = hardpoint.TintColor.ToColor(); shape[int2(x, y)] = GUILayout.Toggle(shape[int2(x, y)], GUIContent.none, GUILayout.Width(16)); @@ -145,3 +144,47 @@ public sealed class InspectableSchematicShapeDrawer : ICultInspectorDrawer return shape; } } + +// A hardpoint's schematic footprint over its hull's cells: the hull's occupied cells shaded, this hardpoint's cells lit +// in its tint, other hardpoints' cells tinted and disabled. A click edits Position and Shape together through +// HullFootprint.Toggle, which owns the rule so a Thing host reads the same one. The footprint is authored for gameplay, +// never derived from the model. Read-only on inspector.Record; only the HardpointData it is given changes. +[CultInspectorDrawer(typeof(HardpointData))] +public sealed class HardpointFootprintDrawer : ICultInspectorDrawer +{ + public object Draw(CultInspector inspector, string label, Type type, object value, MemberInfo member) + { + var hardpoint = value as HardpointData; + if (type != typeof(HardpointData) || hardpoint == null) return inspector.DrawDefault(label, type, value, member); + var hull = inspector.Record as HullData; + var own = hull?.Hardpoints.FirstOrDefault(hp => hp.Type == hardpoint.Type && hp.Position.Equals(hardpoint.Position) + && HullFootprint.Cells(hp).SequenceEqual(HullFootprint.Cells(hardpoint))); + var drawn = (HardpointData) inspector.DrawDefault(label, type, value, member); + if (hull?.Shape == null) return drawn; + + var others = hull.Hardpoints.Where(hp => hp != own).ToArray(); + EditorGUILayout.LabelField("Footprint"); + for (var y = hull.Shape.Height - 1; y >= 0; y--) + using (new EditorGUILayout.HorizontalScope()) + for (var x = 0; x < hull.Shape.Width; x++) + { + var cell = int2(x, y); + var other = others.FirstOrDefault(hp => HullFootprint.Cells(hp).Contains(cell)); + var on = HullFootprint.Cells(drawn).Contains(cell); + var previous = GUI.backgroundColor; + GUI.backgroundColor = on ? drawn.TintColor.ToColor() : other != null ? other.TintColor.ToColor() + : hull.Shape[cell] ? Color.gray : previous; + EditorGUI.BeginChangeCheck(); + using (new EditorGUI.DisabledScope(other != null && !on)) + GUILayout.Toggle(on || other != null, GUIContent.none, GUILayout.Width(16)); + if (EditorGUI.EndChangeCheck() && HullFootprint.Toggle(drawn.Shape, drawn.Position, cell, out var shape, out var position)) + { + drawn.Shape = shape; + drawn.Position = position; + GUI.changed = true; + } + GUI.backgroundColor = previous; + } + return drawn; + } +} diff --git a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs index 8a170dbde..c1994ebeb 100644 --- a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs +++ b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs @@ -10,8 +10,8 @@ using float2 = CultMath.float2; using float3 = CultMath.float3; // Batch proof that the presenters read the drawn pose through the clock: DrawnPoseTests pins the pose maths headlessly, -// and this is what pins that each presenter passes ActionGameManager.Clock.Lead into it. A zone is loaded into a fresh -// ZoneRenderer with the pickup and mine prefabs ARPG.unity's ZoneRenderer names; a loose Projectile is launched on the +// and this is what pins that each presenter passes ActionGameManager.Clock.Lead into it. A zone with a sun and a planet on a moving rail +// is built and the pickup and mine prefabs ARPG.unity's ZoneRenderer names are bound to it as the renderer binds them; a loose Projectile is launched on the // zone. The zone is stepped once, then the clock is set to leads 0, 0.008 and 0.015 s with no step, and each presenter's // own Update places its transform. A body is drawn at its last step plus its step velocity times the lead, spelled here // from the body's fields, so a presenter that reads lead 0 (or half the lead) sits on the step and fails. @@ -110,32 +110,34 @@ namespace Aetheria.EditorTools var oneCell = new Shape(1, 1); oneCell[new CultMath.int2(0, 0)] = true; - var cache = AetheriaStores.Open(Path.Combine(_work, "wiring.cc"), catalogWritable: true); + var cache = AetheriaStores.Open(Path.Combine(_work, "wiring.cc"), Path.Combine(_work, "wiring-run.cc"), catalogWritable: true); cache.Upsert(new VerseGrammar { Revision = 1 }); cache.Upsert(new SimpleCommodityData { Name = "Wiring Ore", Shape = oneCell, MaxStack = 100 }); cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); var items = new ItemManager(cache, ledger, settings, _ => { }); - var zone = new Zone(items, new PlanetSettings(), new ZonePack(), null, null); + + // A sun and a planet on a moving rail round it, both near the bodies below: the field under a floater changes with the + // drawn time, so a presenter that samples it at the step's time, drops it, or reads lead 0 for the height draws a different y. + var sunOrbit = cache.Upsert(new OrbitData { FixedPosition = new float2(-55, -.5f) }).Key; + var planetOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef(sunOrbit), Distance = 40f, Phase = 0f }).Key; + var pack = new ZonePack { Radius = 5000f, Mass = 10000f }; + pack.Orbits.Add(new CultRecordRef(sunOrbit)); + pack.Orbits.Add(new CultRecordRef(planetOrbit)); + pack.Planets.Add(new CultRecordRef(cache.Upsert(new PlanetData { Name = "Wiring Sun", Orbit = new CultRecordRef(sunOrbit), Mass = 400f }).Key)); + pack.Planets.Add(new CultRecordRef(cache.Upsert(new PlanetData { Name = "Wiring Planet", Orbit = new CultRecordRef(planetOrbit), Mass = 400f }).Key)); + var curves = new PlanetSettings + { + BodyRadius = new ExponentialCurve(), + WellStrength = new ExponentialCurve { Multiplier = 1f, Exponent = 1f }, + RocheLimit = new ExponentialCurve { Constant = 10f } + }; + var zone = new Zone(items, curves, pack, null, null); var rootObject = new GameObject("Wiring Zone Root"); - var rendererObject = new GameObject("Wiring Zone Renderer"); - rendererObject.SetActive(false); // inactive: ZoneRenderer.Update needs a GPU and is not part of this check - var renderer = rendererObject.AddComponent(); - renderer.ZoneRoot = rootObject.transform; - renderer.SimpleCommodityPickup = pickupPrefab; - renderer.MinePrefab = minePrefab; - renderer.ItemManager = items; - renderer.SectorBrushes = new GameObject("Wiring Brushes").transform; - renderer.AmbientLight = AmbientEdgeVerify.NewAmbientLight(); - renderer.SlimeGravityCamera = new GameObject("Wiring Gravity Camera").AddComponent(); - var slimeObject = new GameObject("Wiring Slime"); - slimeObject.SetActive(false); - renderer.SlimeRenderer = slimeObject.AddComponent(); - renderer.LoadZone(zone); var ore = new SimpleCommodity { Data = items.ItemData.RefOf(items.ItemData.GetByName("Wiring Ore")), Quantity = 7 }; - var floating = zone.Release(ore, new float2(3, 4), new float2(10, 0)); + var floating = zone.Release(ore, new float2(-17, 4), new float2(10, 0)); var mine = new Mine { Body = new KinematicBody { Position = new float2(-20, 8), Velocity = new float2(0, 14) }, @@ -143,6 +145,13 @@ namespace Aetheria.EditorTools }; zone.Lay(mine); + // Bound the way ZoneRenderer.LoadFloatingItem and LoadMine bind them: the prefab ARPG.unity names, Bind(zone, body, settings). + // (A ZoneRenderer would load this zone's sun and planet too, and that needs the planet prefab, the map and a GPU.) + var pickup = UnityEngine.Object.Instantiate(pickupPrefab, rootObject.transform); + pickup.Bind(zone, floating, settings); + var mineInstance = UnityEngine.Object.Instantiate(minePrefab, rootObject.transform); + mineInstance.Bind(zone, mine, settings); + // A round on its frozen line, no target and no outcome known: drawn at FireOrigin + direction * speed * (T - FireTime). var shot = new PendingShot { @@ -155,13 +164,17 @@ namespace Aetheria.EditorTools zone.Update(.5f); - var pickup = rootObject.GetComponentsInChildren(true).Single(p => p.Item == ore); - var mineInstance = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).Single(); var pickupVelocity = floating.Body.TotalVelocity; var mineVelocity = mine.Body.TotalVelocity; Require(Mathf.Abs(pickupVelocity.x) > 1f && Mathf.Abs(mineVelocity.y) > 1f, "The fixture's bodies stopped: a lead would not move them."); + // The field under each floater really is a function of the drawn time here, by more than the tolerance at the largest lead. + const float LargestLead = .015f; + foreach (var at in new[] { floating.Body.Position + pickupVelocity * LargestLead, mine.Body.Position + mineVelocity * LargestLead }) + Require(Mathf.Abs(DrawAhead.Height(zone, at, LargestLead) - zone.GetHeight(at, zone.ExactTime)) > 5 * Tolerance, + $"The fixture's field is flat under a floater: height {DrawAhead.Height(zone, at, LargestLead)} at the drawn time, {zone.GetHeight(at, zone.ExactTime)} at the step, planet at {zone.OrbitPositionAt(planetOrbit, zone.ExactTime)}, floater at {at}."); + Vector2 baseline = default; foreach (var leadSeconds in new[] { 0f, .008f, .015f }) { @@ -176,6 +189,9 @@ namespace Aetheria.EditorTools Require(Mathf.Abs(seen.x - want.x) < Tolerance && Mathf.Abs(seen.z - want.y) < Tolerance, $"ItemPickup at lead {lead}: drawn x/z {seen.x},{seen.z}, the step plus velocity times lead {want.x},{want.y}."); + Require(Mathf.Abs(seen.y - DrawAhead.FloaterHeight(zone, settings, new float2(want.x, want.y), lead)) < Tolerance, + $"ItemPickup y {seen.y} is not on the floater layer."); + seen = mineInstance.transform.position; want = mine.Body.Position + mineVelocity * lead; Require(Mathf.Abs(seen.x - want.x) < Tolerance && Mathf.Abs(seen.z - want.y) < Tolerance, diff --git a/Assets/Scripts/ServerShared/HullFootprint.cs b/Assets/Scripts/ServerShared/HullFootprint.cs new file mode 100644 index 000000000..9c3c6c3be --- /dev/null +++ b/Assets/Scripts/ServerShared/HullFootprint.cs @@ -0,0 +1,43 @@ +/* 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.Collections.Generic; +using System.Linq; +using CultMath; + +// A hardpoint's schematic footprint: its Shape laid over the hull's cells at its Position, in hull orientation. +// The footprint is authored for gameplay and never derived from the model or the Blender add-on +// (schematic-and-model-are-separate-spaces). This is the one reading of footprint cells in hull space and the one +// edit rule, so any host (the Studio drawer now, a Thing host later) edits footprints the same way. +public static class HullFootprint +{ + public static IEnumerable Cells(HardpointData hardpoint) => + hardpoint.Shape.Coordinates.Select(c => c + hardpoint.Position); + + // Toggles one hull cell of the footprint at `position`. A cell outside the footprint grows it toward any edge; + // a cell inside is removed and empty border rows and columns are trimmed; every other cell keeps its hull + // coordinate. Removing the last cell is refused (false, outputs are the inputs' values). Builds a new Shape and + // never edits the one it is given. + public static bool Toggle(Shape footprint, int2 position, int2 hullCell, out Shape shape, out int2 newPosition) + { + var cells = Cells(footprint, position); + if (!cells.Remove(hullCell)) cells.Add(hullCell); + else if (cells.Count == 0) + { + shape = footprint; + newPosition = position; + return false; + } + + var min = cells.Aggregate((a, b) => new int2(math.min(a.x, b.x), math.min(a.y, b.y))); + var max = cells.Aggregate((a, b) => new int2(math.max(a.x, b.x), math.max(a.y, b.y))); + shape = new Shape(max.x - min.x + 1, max.y - min.y + 1); + foreach (var cell in cells) shape[cell - min] = true; + newPosition = min; + return true; + } + + private static HashSet Cells(Shape footprint, int2 position) => + new HashSet(footprint.Coordinates.Select(c => c + position)); +} diff --git a/Assets/Scripts/ServerShared/HullFootprint.cs.meta b/Assets/Scripts/ServerShared/HullFootprint.cs.meta new file mode 100644 index 000000000..60a662fa0 --- /dev/null +++ b/Assets/Scripts/ServerShared/HullFootprint.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5e2b8c4a7d1f4a39b6c0e83f91d7a254 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 9fad4cfd0..d7b2ee9b3 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -60,6 +60,7 @@ public class Shape } Cells = newCells; + _dirty = true; } private static int mod(int x, int m) { @@ -85,11 +86,7 @@ public class Shape { get { - if (_dirty) - { - _cachedShapeCoordinates = EnumerateShapeCoordinates().ToArray(); - _dirty = false; - } + Refresh(); return _cachedShapeCoordinates; } } @@ -108,7 +105,14 @@ public class Shape private int2[] _cachedAllShapeCoordinates; [IgnoreMember] - public int2[] AllCoordinates => _cachedAllShapeCoordinates ?? (_cachedAllShapeCoordinates = EnumerateAllShapeCoordinates().ToArray()); + public int2[] AllCoordinates + { + get + { + Refresh(); + return _cachedAllShapeCoordinates; + } + } private IEnumerable EnumerateAllShapeCoordinates() { @@ -121,11 +125,27 @@ public class Shape } } - private float2? _centerOfMass; + private float2 _centerOfMass; [IgnoreMember] - public float2 CenterOfMass => _centerOfMass ?? (_centerOfMass = Coordinates - .Aggregate(float2.zero, (total, coord) => total + coord) / Coordinates.Length).Value; + public float2 CenterOfMass + { + get + { + Refresh(); + return _centerOfMass; + } + } + + // One dirty flag: Coordinates, AllCoordinates and CenterOfMass are caches of Cells and recompute together. + private void Refresh() + { + if (!_dirty) return; + _cachedShapeCoordinates = EnumerateShapeCoordinates().ToArray(); + _cachedAllShapeCoordinates = EnumerateAllShapeCoordinates().ToArray(); + _centerOfMass = _cachedShapeCoordinates.Aggregate(float2.zero, (total, coord) => total + coord) / _cachedShapeCoordinates.Length; + _dirty = false; + } public bool this[int2 pos] { get { return pos.x >= 0 && pos.y >= 0 && pos.x < Width && pos.y < Height && Cells[pos.x, pos.y]; } @@ -256,7 +276,7 @@ public class Shape { if (x >= 0 && x < Width && y >= 0 && y < Height) { - Cells[steep ? y : x, steep ? x : y] = true; + this[int2(steep ? y : x, steep ? x : y)] = true; } error += derr; if (error >= 0.5f) @@ -798,8 +818,8 @@ public static class StatValidation // ApplyModifier threw an unhelpful NullReferenceException at random equip time. Resolving it here, once, // memoized, and failing loudly at catalog load or Upsert (through ValidateStatModifiers below) names the // record instead of leaving the bug for whoever equips the item first. - private static readonly Dictionary<(string Target, string Stat), (Type Type, FieldInfo Field)> _resolvedStatFields = - new Dictionary<(string, string), (Type, FieldInfo)>(); + private static readonly System.Collections.Concurrent.ConcurrentDictionary<(string Target, string Stat), (Type Type, FieldInfo Field)> _resolvedStatFields = + new System.Collections.Concurrent.ConcurrentDictionary<(string, string), (Type, FieldInfo)>(); private static Type[] _statReferenceTypes; private static Type[] StatReferenceTypes => _statReferenceTypes ??= typeof(BehaviorData).GetAllChildClasses() diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index ba5bd1466..a53215eec 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:191864d6c0c3bb533015c3c036632a172bed34d432bfa0be79d8bdeb456ad18b -size 12745613 +oid sha256:a4ff0effc5b80bbd4edea4c9f3855555d622e6939edfb720d08c4603b61ebee9 +size 12760370 diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 344357a99..7a745329b 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -22,7 +22,8 @@ Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec - material-library-export: Blender material export: a faithful node-group dumper, Export and Check Material Graph calling AetherDb, the library. Waits on: material-graph (held post-demo by ruling demo-material-scope; blocked on material-graph). - hull-bakes: MaterialBakes: content-addressed pair and wear bakes dispatched from generated kernels, refcounted, read back and. Waits on: material-lowering (held post-demo by ruling demo-material-scope; blocked on material-lowering). - material-lowering: Material lowering: MaterialGraph to CultMath expression IR, generated compute kernels per graph (surface, worn. Waits on: material-graph, material-library-export (held post-demo by ruling demo-material-scope; blocked on material-graph). -- ships-studio-schematic: Studio draws a mod hull's shipped schematic strokes under its cell grid and paints hardpoint footprints, with the rules. Waits on: nothing in the mind (ready but not queued). +- ships-studio-schematic: Studio draws a mod hull's shipped schematic path (ShipAuthoring.SchematicLines) under its cell grid, display-only. Its footprint half was split out as spec cut-ships-studio-footprint.r1 (2026-10-17). r1's rule, 2 m per cell from Ship Root, claims a model-to-cell scale that ruling schematic-and-model-are-separate-spaces rejects. Waits on: question studio-underlay-registration (rec fit-to-cells) and ships-studio-footprint (same drawer file). +- ships-hull-livery: Faction.Livery, resolved over the hull's factory paint for that faction's ships. Key 18 is free at master 1d4aa626 (16 is reserved for Relations, 17 is Doctrine), and the unmerged assembly-from-maker branch reserves 18 for it in a comment and takes 19 and 20. r1 was withdrawn 2026-10-12: its question hull-livery-scope fell to ruling mask-channels-place-materials, so liveries set parameters on mask-placed materials. No HullPaint, LiveryRules or Livery type exists on master. Carries follow-up livery-key-collision. Waits on: a demo-shaped ships-hull-material (ruling demo-material-scope: baked maps, a mask and the one hull shader), which has no spec in force. - controls-ai-bearing: Controls 5: AI ships turn to bring their chosen weapon group to bear (broadsides for side mounts, the nose for forward. Waits on: controls-helm, controls-mount-aim (refresh needed (Broadside scenario gone)). - controls-hud: Controls 3: the HUD shows each weapon group's solution state on its crosshair (on solution, free, holding), arcs in the. Waits on: controls-mount-aim (refresh needed (HUD overlap, 48-commit drift)). - ai-autofire: Missiles 4: the AI fires through the same chooser. Combat and TurretController take each weapon's subject from. Waits on: autofire-threshold, controls-ai-bearing (blocked on autofire-threshold and controls-ai-bearing). @@ -40,8 +41,8 @@ Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec - autofire-controls: Missiles 3b (r2): the player sets autofire; the HUD marks munitions and autofire groups; the Point Defense scenario. Waits on: autofire-threshold, controls-hud, pd-gear (blocked on autofire-threshold and controls-hud). - broadside-gunboat: Broadside returns on a real gunboat: the operator's Zhestokost line-gunboat package (side-rotated Ballistic hardpoints). Waits on: mods-in-tree, package-product (blocked on package-product and the operator package). - demo-cast-hulls: Demo cast hulls land (r3): the operator's five packages under GameData/Mods, each its maker's ship under its own name. Waits on: mods-in-tree, pirates-record, package-product, boss-hull-derived (blocked on package-product, boss-hull-derived and operator packages). -- minor-power-makers: Aya Collective, Megiddo and Orbital Forge in the catalog: an AetherDb one-shot authors three manufacturer-only. Waits on: maker-only-faction, pd-gear, pirates-record, gear-without-presence, generic-design-names (blocked on maker-only-faction merge and generic-design-names; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first). -- ships-addon-mesh-mounts: Blender thruster and radiator mounts (from mounts r3): meshes from selected faces or a tagged mesh, each with its row. Waits on: ships-addon-mounts (blocked on ships-addon-mounts). +- minor-power-factions (spec cut-minor-power-factions.r1, remaps withdrawn minor-power-makers): Aya Collective, Megiddo and Orbital Forge as ordinary factions (ruling no-territory-ban): MinorPowerFactionsCommand authors the three records and allegiance gains, 'product-lines minor-powers' their ten PD and standard-gun products, and the Pirates' allegiance is topped up by 'pirates-faction apply' (port of maker-only-faction-r3's 57083412, the only part of that branch still wanted). Waits on: nothing; runs now beside gear-lines. Replay: minor-power-factions, pirates-faction, product-lines minor-powers, then assembly-from-maker last. Question minor-power-geonames (spec follows reuse-nearest). Forge's branded rounds stay follow-up branded-ammo-calibre. Body facts: Products PL10-PL13. +- ships-addon-mesh-mounts: Blender thruster and radiator mounts (from mounts r3): meshes from selected faces or a tagged mesh, each with its row. ships-addon-mounts merged at 26cdd578, so that is no longer what blocks it. Mesh-mounts would extend the add-on's derivation of a row's Position from the model (mounted_position(cell_at(...)), tools/blender/aetheria_ships/__init__.py, CELL_SIZE 2.0 in hull_grid.py) to thrusters and radiators. Ruling schematic-and-model-are-separate-spaces forbids that derivation, pending the operator's confirmation. Waits on: the operator walkthrough on Starfire (rulings hardpoint-work-held-for-walkthrough and thrusters-radiators-are-meshes; follow-up addon-derives-schematic-from-model). Re-map it after that walkthrough, with mount-by-name as the link. - ships-addon-mask-bakes: Blender mask helpers (from paint r2): Suggest Regions by weighted k-means in CIELAB, Bake Starting Mask, Bake Wear from. Waits on: ships-addon-paint (blocked on ships-addon-paint). - ships-addon-paint: Blender hull mask record (r3): mask slot, New Mask, factory livery, Package UV0 and mask checks, ship.mask.png, region. Waits on: ships-hull-material, ships-addon-frame, ships-addon-guards (blocked on ships-hull-material (premise obsolete under mask-channels-place-materials)). - matrix-coilguns: Weapon matrix, EMPS row: Light Coilgun, Coilgun, Railgun and Heavy Railgun at Small to ExtraLarge, with roles and two. Waits on: nothing in the mind (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first). @@ -52,7 +53,7 @@ Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec - caliber-bands: Caliber is the size: five records recalibered, every weapon footprint held in its caliber's band, and every matrix cell. Waits on: matrix-coilguns, matrix-cannons, matrix-beams, matrix-lightning, matrix-missiles, matrix-mines, matrix-flamers (blocked on the seven matrix cuts; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first). - matrix-flamers: Weapon matrix, Jet row: Light Flamethrower, Flamethrower, Heavy Flamethrower and Inferno Projector, thermal pellet guns. Waits on: nothing in the mind (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first). - matrix-mines: Weapon matrix, Mine row: Mine Rack, Heavy Mine Layer and Minefield Projector beside the Mine Launcher, with warhead and. Waits on: roles-backfill (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first). -- product-lines-gear: Gear product lines: the twenty-two second-maker products of follow-up gear-product-lines, with role quality from maker. Waits on: product-lines-weapons (same catalog writer: it adds 'product-lines gear' to that cut's ProductLinesCommand.cs); generic-design-names and roles-backfill are merged. Twenty products, not twenty-two: Compact Sensor already has Finch and Lucent products. Ruling maker-design-tiers-allowed (2026-10-16) changes nothing in its table, which keeps one product per maker per design. product-lines-weapons r4 deletes NoMakerSellsTwoProductsOfOneDesign and drops the command's matching refusal, so this cut's rows face no such refusal. Body facts: Products PL3. +- gear-lines (spec cut-gear-lines.r1, remaps withdrawn product-lines-gear): 22 second-maker gear products as 'product-lines gear' in ProductLinesCommand. The follow-up's rows less its two Compact Sensor rows, plus Medium Drive, Large Drive and Industrial Thermostatic Heater, so every role-bearing ship gear design that is sold has two makers (Muon Fusion and the four station reactors exempt). The command's no-roles refusal exempts cargo bays. Waits on: nothing; runs now. Replay: 'product-lines gear apply', either side of minor-power-factions, before assembly-from-maker. Body facts: Products PL3, PL10-PL12. - product-tiers: A maker sells several products of one design at different performance (ruling maker-design-tiers-allowed, 2026-10-16). In the operator's words, "segmentation via binning and sub-assembly selection": no new innovation or component roles. Maps per-tier ProductRole quality and a Price that follows quality, which today comes from the design (question maker-design-tiers, depends). The tier rows go through product-lines' ProductLinesCommand. Product names stay unique (NoTwoProductsShareAName). ProductRole's mean and deviation come from the maker's one MakerProfiles row, so two tiers at one maker need a per-tier offset. That offset is this cut's design, not a second profile table. Ship products are keyed one per maker per hull (ShipModCatalog.ProductKey(id, maker)); whether hull tiers widen that key is this cut's to map, not assumed. Waits on: product-lines-weapons (its command and the deleted test); product-lines-gear (same catalog writer). - run-seed: Drops mix a per-game seed stored in the save (ruling loot-seed-in-save, 2026-10-16). A new game rolls different drops from other games, reloading one save repeats its drops, and tests pass a fixed seed. Today Zone.CombatSeed is the zone name's StableHash (Zone.cs:144). It feeds three dice: the loot dice (Zone.cs:414, LootStream, by death ordinal), the mine lay scatter (FireControl.cs:791, MineStream) and the shot dice (FireControl.cs:930). The ruling covers drops, and the question named loot and mine fragments, so the seed mixes into the loot and mine streams. Whether hit rolls also take it is not ruled: the cut raises it. Do not change CombatSeed itself and carry that along silently. The seed lives on SavedGame, minted by the new-run branch. The run lifecycle is record-level (ActionGameManager.cs:58-60), so the seed must reach Zone at construction from the run, not from the catalog. Owns follow-up loot-dice-run-seed (its old owners, loot-1 r2 and sim-mines, are merged). Waits on: nothing in the mind. - mining-yield: Mining Cut 5 (docs/mining-cut-refresh.md 'Cut 5. Composition, the loot roll, the mined lot, and the same-path test agent'). Ruling mining-overflow-floats (2026-10-16) replaces that plan's deposit rule: units a shot extracts go into the shooter's cargo through TryStore, and what the hold cannot take is released through Zone.Release (Zone.cs:356) as floating items, which the player and NPCs collect through the one pickup path. The plan's AFullHoldLosesTheOverflow test becomes AFullHoldReleasesTheOverflow. Mining Cuts 4 and 7 stay with follow-up mining-cuts-4-5-7, which this cut's spec resolves with them. Waits on: mining Cut 4 (FireControl shots at chunks), full game (demo scope table: no buyer for ore in the demo). @@ -471,7 +472,8 @@ record. | spec | `material-library-export` | full game | Same. | | spec | `material-lowering` | full game | Same. | | spec | `hull-bakes` | full game | Same. | -| spec | `ships-studio-schematic` | full game | Authoring comfort in Studio; the demo's few hulls do not need it. | +| spec | `ships-studio-schematic` | full game | The line-art underlay is authoring comfort; the demo's few hulls do not need it. | +| spec | `ships-studio-footprint` | demo | Under `schematic-and-model-are-separate-spaces`, hardpoint footprints are authored, not derived. Every demo hull (`demo-content-bar`, `demo-cast`) needs them painted somewhere, and Studio is that pane. | | spec | `ships-addon-gizmos` | full game | Same. | | spec | `mining-index-tree` | full game | Sublinear index: wanted, not critical (ruling). | | spec | `mining-belt-cells` | full game | Same. | @@ -10195,6 +10197,26 @@ blob at `cee8cd6e`) and the branch's (oid `ba783127`, at `ae290654`). Scripts `p `ManufacturerPreference` (`:258-270`), and the run's Random. No caller has an outfitting budget. `ScenarioStage.Credits` is the player's starting purse. +Probes PL10-PL13 by imagination-ag4-gear-lines (session self-2026-10-16-ag4), 2026-10-17, on +origin/master `7dddcc47`: AetherDb `factions`, `census` and a temporary read-only probe command +(deleted) over the shipped catalog, built against the CultLib/CultMath pins. + +- **PL10, catalog:** 13 factions, 121 products, 72 designs. Single-maker role-bearing ship gear: + the follow-up's designs plus Medium Drive, Large Drive and Industrial Thermostatic Heater + (Lightsail), Muon Fusion Reactor (NiteLife) and the five station reactors. Ewan Hart Inc sells + the Tractor Beam and has no MakerProfiles row; no product line names it. +- **PL11, cargo bays:** all four have no roles, and `ProductLinesCommand` refuses a design with no + roles (`ProductLinesCommand.cs:83`); the gear line's three cargo rows need the exemption. +- **PL12, names:** none of the 22 gear names or the ten minor-power names is a product, design or + faction name or appears in a design text. SafeStuff is the base of R&D's SafeStuff II. +- **PL13, minor powers:** none of the three is in the catalog. The main galaxy draws factions with + no filter (`Galaxy.cs:109-112`) and names zones from `GeonameFile` (`:364`). No faction's + Allegiance names the Pirates; the Pirates name all twelve others at 1.0, and + `PiratesAreInTheShippedCatalog` requires every faction. Master's `PiratesFaction` does nothing + when the record exists. One ammunition item, Ammo, serves every ballistic gun. The only + unmerged branch that rewrites the catalog is assembly-from-maker (`AssemblyFromMakerCommand`, + derives each maker's assembly figures from its products: replays last). + Rationale for r5: the ruling's open case is a faction outfitting, and `LoadoutGenerator` already is that outfitter (allegiance, home-zone distance, seed). So the chooser lives there and reuses its availability rule and weight rather than a second economy. Rigs are pinned diff --git a/tests/Aetheria.Shared.Tests/HullFootprintTests.cs b/tests/Aetheria.Shared.Tests/HullFootprintTests.cs new file mode 100644 index 000000000..2baa5d422 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/HullFootprintTests.cs @@ -0,0 +1,126 @@ +using System.Linq; +using CultMath; +using Xunit; + +// HullFootprint: the one rule for editing a hardpoint's schematic footprint (Position + Shape together) and the +// one reading of its cells in hull space. +public sealed class HullFootprintTests +{ + private static HardpointData Hardpoint(int2 position, params int2[] cellsInShapeSpace) + { + var max = cellsInShapeSpace.Aggregate(new int2(0, 0), (a, c) => new int2(CultMath.math.max(a.x, c.x), CultMath.math.max(a.y, c.y))); + var shape = new Shape(max.x + 1, max.y + 1); + foreach (var c in cellsInShapeSpace) shape[c] = true; + return new HardpointData { Position = position, Shape = shape }; + } + + private static int2[] Hull(HardpointData hp) => HullFootprint.Cells(hp).OrderBy(c => c.y).ThenBy(c => c.x).ToArray(); + + private static HardpointData Apply(HardpointData hp, int2 hullCell) + { + Assert.True(HullFootprint.Toggle(hp.Shape, hp.Position, hullCell, out var shape, out var position)); + return new HardpointData { Position = position, Shape = shape }; + } + + [Fact] + public void CellsAreTheShapeLaidAtPosition() + { + var hp = Hardpoint(new int2(3, 3), new int2(0, 0), new int2(1, 0)); + Assert.Equal(new[] { new int2(3, 3), new int2(4, 3) }, Hull(hp)); + } + + [Fact] + public void FootprintGrowsTowardAnyEdgeAndKeepsItsCells() + { + var hp = Hardpoint(new int2(3, 3), new int2(0, 0)); + + var grown = Apply(Apply(hp, new int2(2, 3)), new int2(3, 2)); + + Assert.Equal(new int2(2, 2), grown.Position); + Assert.Equal(2, grown.Shape.Width); + Assert.Equal(2, grown.Shape.Height); + Assert.Equal(new[] { new int2(3, 2), new int2(2, 3), new int2(3, 3) }, Hull(grown)); + + var right = Apply(Apply(hp, new int2(4, 3)), new int2(3, 4)); + Assert.Equal(new int2(3, 3), right.Position); + Assert.Equal(new[] { new int2(3, 3), new int2(4, 3), new int2(3, 4) }, Hull(right)); + } + + [Fact] + public void FootprintTrimsBackToItsOriginal() + { + var hp = Hardpoint(new int2(3, 3), new int2(0, 0)); + var grown = Apply(Apply(hp, new int2(2, 3)), new int2(3, 2)); + + var trimmed = Apply(Apply(grown, new int2(2, 3)), new int2(3, 2)); + + Assert.Equal(hp.Position, trimmed.Position); + Assert.Equal(1, trimmed.Shape.Width); + Assert.Equal(1, trimmed.Shape.Height); + Assert.Equal(Hull(hp), Hull(trimmed)); + } + + [Fact] + public void FootprintRefusesItsLastCell() + { + var hp = Hardpoint(new int2(3, 3), new int2(0, 0)); + + Assert.False(HullFootprint.Toggle(hp.Shape, hp.Position, new int2(3, 3), out var shape, out var position)); + + Assert.Equal(hp.Position, position); + Assert.Same(hp.Shape, shape); + Assert.Equal(new[] { new int2(3, 3) }, Hull(hp)); + } + + [Fact] + public void RefusedClickLeavesPositionWhereItWasWhenTheLastCellIsNotAtPosition() + { + var hp = Hardpoint(new int2(3, 3), new int2(1, 0)); + Assert.Equal(new[] { new int2(4, 3) }, Hull(hp)); + + Assert.False(HullFootprint.Toggle(hp.Shape, hp.Position, new int2(4, 3), out var shape, out var position)); + + Assert.Equal(new int2(3, 3), position); + Assert.Same(hp.Shape, shape); + } + + [Fact] + public void WideFootprintLaysEveryCellAtPositionAndEditsAtItsFarEnd() + { + var hp = Hardpoint(new int2(2, 2), new int2(0, 0), new int2(1, 0), new int2(2, 0), new int2(3, 0), new int2(4, 0)); + Assert.Equal(new[] { new int2(2, 2), new int2(3, 2), new int2(4, 2), new int2(5, 2), new int2(6, 2) }, Hull(hp)); + + var grown = Apply(hp, new int2(7, 2)); + Assert.Equal(new int2(2, 2), grown.Position); + Assert.Equal(new[] { new int2(2, 2), new int2(3, 2), new int2(4, 2), new int2(5, 2), new int2(6, 2), new int2(7, 2) }, Hull(grown)); + + var trimmed = Apply(hp, new int2(6, 2)); + Assert.Equal(new int2(2, 2), trimmed.Position); + Assert.Equal(new[] { new int2(2, 2), new int2(3, 2), new int2(4, 2), new int2(5, 2) }, Hull(trimmed)); + } + + [Fact] + public void FootprintToggleLeavesItsInputUnchanged() + { + var hp = Hardpoint(new int2(3, 3), new int2(0, 0), new int2(1, 0)); + + HullFootprint.Toggle(hp.Shape, hp.Position, new int2(2, 3), out _, out _); + HullFootprint.Toggle(hp.Shape, hp.Position, new int2(4, 3), out _, out _); + + Assert.Equal(new int2(3, 3), hp.Position); + Assert.Equal(2, hp.Shape.Width); + Assert.Equal(1, hp.Shape.Height); + Assert.Equal(new[] { new int2(3, 3), new int2(4, 3) }, Hull(hp)); + } + + [Fact] + public void TrimmingAnInteriorBorderMovesPositionWithoutMovingCells() + { + var hp = Hardpoint(new int2(5, 5), new int2(0, 0), new int2(1, 0), new int2(2, 0)); + + var trimmed = Apply(hp, new int2(5, 5)); + + Assert.Equal(new int2(6, 5), trimmed.Position); + Assert.Equal(new[] { new int2(6, 5), new int2(7, 5) }, Hull(trimmed)); + } +} diff --git a/tests/Aetheria.Shared.Tests/MinorPowersTests.cs b/tests/Aetheria.Shared.Tests/MinorPowersTests.cs new file mode 100644 index 000000000..7416f08df --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MinorPowersTests.cs @@ -0,0 +1,146 @@ +/* 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.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// The Aya Collective, Megiddo and Orbital Forge (aetheria-release, cut minor-power-factions; ruling no-territory-ban): three +// ordinary factions in the shipped catalog, authored by AetherDb's minor-power-factions command, which a scratch copy of the +// catalog without them proves idempotent and conflict-refusing. +public sealed class MinorPowersTests : IDisposable +{ + private static readonly string[] Minor = { "Aya", "Megiddo", "Forge" }; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "minor-powers-" + Guid.NewGuid().ToString("N")); + private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + + public void Dispose() + { + if (Directory.Exists(_root)) Directory.Delete(_root, true); + } + + // A scratch copy of the shipped catalog with the three, their products and every allegiance toward them removed, through a + // registry scoped to the shipped assembly (as PiratesFactionCommandTests does), so the command has all of it to author. + private void StripTheMinorPowers() + { + Directory.CreateDirectory(Path.Combine(_root, "GameData")); + File.Copy(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), CatalogPath); + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes); + var gone = cache.GetAll().Where(f => Minor.Contains(f.ShortName)).Select(f => cache.RefOf(f)).ToArray(); + Assert.Equal(3, gone.Length); + foreach (var product in cache.GetAll().Where(p => gone.Any(g => g.Key.Equals(p.Manufacturer.Key))).ToArray()) + Assert.True(cache.Remove(cache.RefOf(product).Key)); + foreach (var reference in gone) Assert.True(cache.Remove(reference.Key)); + foreach (var faction in cache.GetAll()) + foreach (var key in faction.Allegiance.Keys.Where(k => gone.Any(g => g.Key.Equals(k.Key))).ToArray()) + faction.Allegiance.Remove(key); + cache.Commit(batch => + { + foreach (var faction in cache.GetAll()) batch.Upsert(typeof(Faction), faction, cache.RefOf(faction).Key); + }); + cache.FlushAsync().Wait(); + } + + [Fact] + public void TheMinorPowersAreOrdinaryFactions() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var factions = cache.GetAll().ToArray(); + foreach (var shortName in Minor) + { + var faction = Assert.Single(factions, f => f.ShortName == shortName); + Assert.NotNull(cache.Get(faction.GeonameFile)); + Assert.True(faction.InfluenceDistance > 0, shortName); + var named = faction.Allegiance.Keys.Select(k => k.Key).ToArray(); + var expected = factions.Where(f => f != faction && f.ShortName != "Pirates").Select(f => cache.RefOf(f).Key).ToArray(); + Assert.Equal(expected.Length, named.Length); + Assert.All(expected, key => Assert.Contains(named, n => n.Equals(key))); + } + // Territory is nothing a faction can say of itself: no member of Faction marks a maker-only or landless power. + Assert.DoesNotContain(typeof(Faction).GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic), + m => m.Name.Contains("ManufacturerOnly") || m.Name.Contains("Territory")); + } + + [Fact] + public void TheCommandIsIdempotentAndRefusesAConflict() + { + StripTheMinorPowers(); + var before = File.ReadAllBytes(CatalogPath); + Assert.Equal(0, MinorPowerFactionsCommand.Run(new string[0], _root)); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + + Assert.Equal(0, MinorPowerFactionsCommand.Run(new[] { "apply" }, _root)); + var landed = File.ReadAllBytes(CatalogPath); + Assert.NotEqual(before, landed); + var db = AetherDb.Open(root: _root); + try + { + var factions = db.Cache.GetAll().ToArray(); + foreach (var shortName in Minor) Assert.Single(factions, f => f.ShortName == shortName); + foreach (var shortName in Minor) + { + var own = factions.Single(f => f.ShortName == shortName); + var expected = factions.Where(f => f != own && f.ShortName != "Pirates").Select(f => db.Cache.RefOf(f).Key).ToArray(); + Assert.Equal(expected.Length, own.Allegiance.Count); + Assert.All(expected, key => Assert.Contains(own.Allegiance.Keys, k => k.Key.Equals(key))); + } + var forge = db.Cache.RefOf(factions.Single(f => f.ShortName == "Forge")); + Assert.Equal(.5f, factions.Single(f => f.ShortName == "AU").Allegiance[forge]); + // The Pirates' allegiance is pirates-faction's; Miss Terri's is her own. + Assert.All(factions.Where(f => f.ShortName is "Pirates" or "Miss Terri's"), f => Assert.DoesNotContain(f.Allegiance.Keys, k => k.Equals(forge))); + } + finally { db.Cache.Dispose(); } + + Assert.Equal(0, MinorPowerFactionsCommand.Run(new[] { "apply" }, _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + } + + [Fact] + public void ANameHeldUnderAnotherKeyIsRefusedWithNothingWritten() + { + StripTheMinorPowers(); + var squatter = AetherDb.Open(catalogWritable: true, root: _root); + squatter.Cache.Commit(batch => batch.Upsert(typeof(Faction), new Faction { Name = "Megiddo", ShortName = "Megiddo" })); + squatter.Cache.Dispose(); + var before = File.ReadAllBytes(CatalogPath); + Assert.Throws(() => MinorPowerFactionsCommand.Run(new[] { "apply" }, _root)); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + } +} + +public sealed partial class RunStartTests +{ + // Forge holds no zone in the prelude, so its gear reaches AU only through allegiance (ruling gear-without-presence); a + // design-only slot draws among the makers AU can reach (ruling design-only-weighted-draw). Miss Terri's names none of them. + [Fact] + public void TheirGearSellsWhereTheyAreAbsent() + { + var factions = _cache.GetAll().ToArray(); + var forge = factions.Single(f => f.ShortName == "Forge"); + Assert.DoesNotContain(_galaxy.Factions, f => f == forge); + var autocannon = _cache.GetAll().Single(d => d.Name == "Autocannon"); + var pattern30 = _cache.GetAll().Single(p => p.Name == "Pattern 30"); + + LoadoutGenerator For(string shortName, uint seed) + { + _items.Random = new CultMath.Random(seed); + return new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, factions.Single(f => f.ShortName == shortName), .5f); + } + Assert.True(For("AU", 1).IsAvailable(pattern30)); + // Seeds 1-64 spread by a multiplicative hash: the generator's first draws of adjacent small seeds are close together. + Assert.Contains(Enumerable.Range(1, 64), seed => For("AU", unchecked((uint) seed * 2654435761u)).ChooseProduct(autocannon)?.Name == pattern30.Name); + + var terri = For("Miss Terri's", 1); + var theirs = _cache.GetAll().Where(p => new[] { "Aya", "Megiddo", "Forge" }.Contains(_cache.Get(p.Manufacturer).ShortName)).ToArray(); + Assert.Equal(10, theirs.Length); + Assert.All(theirs, product => Assert.False(terri.IsAvailable(product), product.Name)); + } +} diff --git a/tests/Aetheria.Shared.Tests/PickupTests.cs b/tests/Aetheria.Shared.Tests/PickupTests.cs index 78e1d478a..af979eea5 100644 --- a/tests/Aetheria.Shared.Tests/PickupTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupTests.cs @@ -64,7 +64,9 @@ public sealed class PickupTests : IDisposable }; } - private Lab Build(PickupData beam, float lifetime = 30f) + // With `wells`, the zone has a sun (a parentless rail off the ship's side, strength 400) and a planet on a moving rail round + // it (strength 25), the field a floater layer is read against; without, the zone is flat and nothing pulls on anything. + private Lab Build(PickupData beam, float lifetime = 30f, bool wells = false) { var settings = new GameplaySettings { @@ -77,7 +79,8 @@ public sealed class PickupTests : IDisposable LootDropProbability = 0, LootDropVelocity = 25, FloatingItemLifetime = lifetime, ShipLayerHeight = 3f, FloaterLayerHeight = -1.5f }; - var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); + var id = Guid.NewGuid().ToString("N"); + var cache = AetheriaStores.Open(Path.Combine(_root, id + ".cc"), Path.Combine(_root, id + "-run.cc"), catalogWritable: true); _caches.Add(cache); cache.Upsert(new VerseGrammar { Revision = 1 }); cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = Solid(5, 5), Durability = 1000000, Mass = 1000 }); @@ -104,7 +107,26 @@ public sealed class PickupTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 9000; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; var items = new ItemManager(cache, ledger, settings, _ => { }); - var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 0 }, new GalaxyZone { Name = "Pickup", Owner = null }, null); + var pack = new ZonePack { Radius = 0 }; + var curves = new PlanetSettings(); + if (wells) + { + curves = new PlanetSettings + { + BodyRadius = new ExponentialCurve(), + WellStrength = new ExponentialCurve { Multiplier = 1f, Exponent = 1f }, + RocheLimit = new ExponentialCurve { Constant = 2f } + }; + var sunOrbit = cache.Upsert(new OrbitData { FixedPosition = float2(-50, 0) }).Key; + var planetOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef(sunOrbit), Distance = 90f, Phase = .9f }).Key; + pack.Radius = 5000f; + pack.Mass = 10000f; + pack.Orbits.Add(new CultRecordRef(sunOrbit)); + pack.Orbits.Add(new CultRecordRef(planetOrbit)); + pack.Planets.Add(new CultRecordRef(cache.Upsert(new PlanetData { Name = "Sun", Orbit = new CultRecordRef(sunOrbit), Mass = 400f }).Key)); + pack.Planets.Add(new CultRecordRef(cache.Upsert(new PlanetData { Name = "Planet", Orbit = new CultRecordRef(planetOrbit), Mass = 25f }).Key)); + } + var zone = new Zone(items, curves, pack, new GalaxyZone { Name = "Pickup", Owner = null }, null); return new Lab { Items = items, Zone = zone }; } @@ -251,16 +273,29 @@ public sealed class PickupTests : IDisposable public void AGrabPointRidesTheFloaterLayerNotTheShips() { using var bag = new CompositeDisposable(); - var lab = Build(Beam()); + var lab = Build(Beam(), wells: true); var ship = Spawn(lab, float3(0, 0, 0)); var item = Drift(lab, 40, 0); - var events = Watch(ship, bag); + + // The fixture has a field to ride: it differs between the ship's point and the item's, and between the start and + // the end of the run, so a grab point read at the ship's xz or at the wrong time cannot agree with it. + var atShip = lab.Zone.GetHeight(float2(0, 0), lab.Zone.ExactTime); + var atItem = lab.Zone.GetHeight(float2(40, 0), lab.Zone.ExactTime); + Assert.True(abs(atItem - atShip) > 1f, $"the field is flat under the grab: ship {atShip}, item {atItem}"); + var start = lab.Zone.ExactTime; + + // Each event is checked at the zone time it was published, where the sim read the field. + var seen = new List<(GrabEvent Event, double Time)>(); + ship.Capabilities.Grab.Subscribe(e => seen.Add((e, lab.Zone.ExactTime))).AddTo(bag); ship.TractorCommand = 1; for (var tick = 1; tick <= 150; tick++) lab.Zone.Update(1 / 60f); - Assert.NotEmpty(events); - Assert.All(events, e => Assert.Equal(lab.Zone.GetHeight(e.TargetPosition.xz, lab.Zone.ExactTime) - 1.5f, e.TargetPosition.y, 3)); + Assert.NotEmpty(seen); + Assert.True(abs(lab.Zone.GetHeight(float2(40, 0), lab.Zone.ExactTime) - lab.Zone.GetHeight(float2(40, 0), start)) > .01f, "the wells did not move"); + Assert.All(seen, s => Assert.Equal(lab.Zone.GetHeight(s.Event.TargetPosition.xz, s.Time) - 1.5f, s.Event.TargetPosition.y, 3)); + Assert.All(seen, s => Assert.True(abs(s.Event.TargetPosition.y - (-1.5f)) > 1f, "the grab point sits on the bare layer, the field dropped")); } + [Fact] public void GrabEventsNameTheFloatingItem() { diff --git a/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs b/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs index 7ccce7cc8..0a3cb161d 100644 --- a/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/PiratesFactionCommandTests.cs @@ -39,6 +39,13 @@ public sealed class PiratesFactionCommandTests : IDisposable public void Dispose() => Directory.Delete(_root, true); + private int AllFactionsButThePirates() + { + var db = AetherDb.Open(root: _root); + try { return db.Cache.GetAll().Count(f => f.Name != "Pirates"); } + finally { db.Cache.Dispose(); } + } + private (Faction[] Pirates, FactionProductData[] Products) Read() { var db = AetherDb.Open(root: _root); @@ -69,7 +76,7 @@ public sealed class PiratesFactionCommandTests : IDisposable Assert.Equal(3, faction.InfluenceDistance); Assert.Equal(new CultMath.float3(.42f, .40f, .37f), faction.PrimaryColor); Assert.Equal(new CultMath.float3(1f, .45f, .05f), faction.SecondaryColor); - Assert.Equal(12, faction.Allegiance.Count); + Assert.Equal(AllFactionsButThePirates(), faction.Allegiance.Count); Assert.All(faction.Allegiance.Values, value => Assert.Equal(1f, value)); var db = AetherDb.Open(root: _root); @@ -105,17 +112,52 @@ public sealed class PiratesFactionCommandTests : IDisposable Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); } + // An existing hand-authored Pirates record is topped up, never rewritten (follow-up pirates-allegiance-and-identity): it keeps + // its description, short name and record key and gains no products; after a faction is added, apply gives allegiance 1.0 to + // that faction alone; a second apply leaves the file bytes unchanged and a dry run writes nothing. [Fact] - public void An_existing_Pirates_faction_is_left_as_it_is() + public void An_existing_Pirates_faction_is_topped_up_with_allegiance_and_nothing_else() { var db = AetherDb.Open(catalogWritable: true, root: _root); db.Cache.Commit(batch => batch.Upsert(typeof(Faction), new Faction { Name = "Pirates", ShortName = "Mine", Description = "authored by hand" })); + var key = db.Cache.RefOf(db.Cache.GetAll().Single(f => f.Name == "Pirates")).Key; db.Cache.Dispose(); + var beforeDry = File.ReadAllBytes(CatalogPath); + Assert.Equal(0, Program.PiratesFaction(apply: false, root: _root)); + Assert.Equal(beforeDry, File.ReadAllBytes(CatalogPath)); + Assert.Equal(0, Program.PiratesFaction(apply: true, root: _root)); var (pirates, products) = Read(); - Assert.Equal("authored by hand", Assert.Single(pirates).Description); + var record = Assert.Single(pirates); + Assert.Equal("authored by hand", record.Description); + Assert.Equal("Mine", record.ShortName); Assert.Empty(products); + var topped = File.ReadAllBytes(CatalogPath); + + var late = AetherDb.Open(catalogWritable: true, root: _root); + late.Cache.Commit(batch => batch.Upsert(typeof(Faction), new Faction { Name = "Latecomer", ShortName = "Late" })); + var lateRef = late.Cache.RefOf(late.Cache.GetAll().Single(f => f.Name == "Latecomer")); + var others = late.Cache.GetAll().Count(f => f.Name != "Pirates"); + late.Cache.Dispose(); + + Assert.Equal(0, Program.PiratesFaction(apply: true, root: _root)); + var check = AetherDb.Open(root: _root); + try + { + var after = check.Cache.GetAll().Single(f => f.Name == "Pirates"); + Assert.Equal(key, check.Cache.RefOf(after).Key); + Assert.Equal("authored by hand", after.Description); + Assert.Equal(others, after.Allegiance.Count); + Assert.Equal(1f, after.Allegiance[lateRef]); + Assert.All(after.Allegiance.Values, value => Assert.Equal(1f, value)); + } + finally { check.Cache.Dispose(); } + + var settled = File.ReadAllBytes(CatalogPath); + Assert.NotEqual(topped, settled); + Assert.Equal(0, Program.PiratesFaction(apply: true, root: _root)); + Assert.Equal(settled, File.ReadAllBytes(CatalogPath)); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs index 756e22355..6a91bb457 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs @@ -45,12 +45,42 @@ public sealed class ProductLinesCatalogTests Assert.Empty(shared); } + // Ruling gear-without-presence: a sold ship gear design with roles has two makers. The six reactor designs named here are + // station gear or Faction Play's NiteLife-only quirk, and keep their one maker. + private static readonly string[] OneMakerGear = + { + "Muon Fusion Reactor", "Muon Fusion Station Reactor", "Station Reactor", "Heavy Station Reactor", + "High-Yield Station Reactor", "Ring Station Reactor", + }; + [Fact] - public void WeaponLineRowsAreShipped() + public void EveryRoleBearingGearDesignWithAProductHasTwoMakers() { using var cache = RestoredHullsTests.OpenCatalog(); - var table = ProductLinesCommand.Table("weapons"); - Assert.Equal(20, table.Count); + var products = cache.GetAll().ToArray(); + var gear = cache.GetAll().Where(d => d is not WeaponItemData && (d.Roles?.Count ?? 0) > 0).ToArray(); + Assert.All(OneMakerGear, name => Assert.Contains(gear, d => d.Name == name)); + var sold = 0; + foreach (var design in gear.Where(d => !OneMakerGear.Contains(d.Name))) + { + 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) continue; + sold++; + Assert.True(makers >= 2, $"the gear design \"{design.Name}\" is sold by {makers} maker"); + } + Assert.True(sold >= 20, "the gear designs with products should be many"); + } + + [Theory] + [InlineData("weapons", 20)] + [InlineData("gear", 22)] + [InlineData("minor-powers", 10)] + public void LineRowsAreShipped(string line, int count) + { + using var cache = RestoredHullsTests.OpenCatalog(); + var table = ProductLinesCommand.Table(line); + Assert.Equal(count, table.Count); foreach (var row in table) { var product = Assert.Single(cache.GetAll(), p => p.Name == row.Name); @@ -60,7 +90,7 @@ public sealed class ProductLinesCatalogTests 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), + (design.Roles ?? new()).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) { diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs index 9aa136e16..53b8fe773 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs @@ -6,28 +6,32 @@ 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 +// AetherDb's product-lines command (cuts product-lines-weapons and gear-lines), run per line on a scratch copy of the shipped +// catalog with that line's products removed from it. Apply must write what the shipped catalog holds, only its own records, +// land on an older-schema catalog, and accept a maker that already sells the design. The weapon line adds the tests that +// need weapon rows by name; the gear line adds the cargo-bay exemption from the no-roles refusal. +public abstract 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" }; + protected readonly string[] Names; + protected readonly string[] Line; + protected readonly string[] LineApply; - private readonly string _root = Path.Combine(Path.GetTempPath(), "product-lines-command-" + Guid.NewGuid().ToString("N")); - private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + protected readonly string _root = Path.Combine(Path.GetTempPath(), "product-lines-command-" + Guid.NewGuid().ToString("N")); + protected 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; + protected 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() + protected ProductLinesCommandTests(string line) { + Names = ProductLinesCommand.Table(line).Select(r => r.Name).ToArray(); + Line = new[] { line }; + LineApply = new[] { line, "apply" }; Directory.CreateDirectory(Path.Combine(_root, "GameData")); Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources")); var shipped = AetherDb.FindRoot(); @@ -44,14 +48,14 @@ public sealed class ProductLinesCommandTests : IDisposable public void Dispose() => Directory.Delete(_root, true); - private CultCache Open() + protected CultCache Open() { var cache = new CultCache(TestCatalog.Registry()); cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes); return cache; } - private string[] Line20() + protected 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(); @@ -127,38 +131,6 @@ public sealed class ProductLinesCommandTests : IDisposable } } - [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() @@ -215,6 +187,46 @@ public sealed class ProductLinesCommandTests : IDisposable Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); } +} + +// One collection: the lines open the same catalog records through shared state and must not run side by side. +[Collection("ProductLinesCommand")] +public sealed class WeaponLineCommandTests : ProductLinesCommandTests +{ + public WeaponLineCommandTests() : base("weapons") { } + + [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)); + } + private void AddProduct(string name, string design, string maker) { using var cache = Open(); @@ -260,3 +272,40 @@ public sealed class ProductLinesCommandTests : IDisposable Assert.Equal(1, Program.Main(new[] { "product-lines", "nonesuch" })); } } + +[Collection("ProductLinesCommand")] +public sealed class GearLineCommandTests : ProductLinesCommandTests +{ + public GearLineCommandTests() : base("gear") { } + + // Cargo bays carry no roles (ruling catalog-breadth-roles-and-components): their product lands with none. Any other design + // with no roles is still refused, with nothing written. + [Fact] + public void Apply_accepts_a_roleless_cargo_bay_and_refuses_a_roleless_gear_design() + { + using (var cache = Open()) + { + var radiator = cache.GetAll().Single(g => g.Name == "Radiator"); + radiator.Behaviors.Clear(); // the store refuses a behavior that names a role the design lacks + radiator.Roles.Clear(); + cache.Upsert(radiator); + cache.FlushAsync().Wait(); + } + var conflicted = File.ReadAllBytes(CatalogPath); + Assert.Throws(() => ProductLinesCommand.Run(LineApply, _root)); + Assert.Equal(conflicted, File.ReadAllBytes(CatalogPath)); + + using (var cache = Open()) + { + var radiator = cache.GetAll().Single(g => g.Name == "Radiator"); + radiator.Roles.Add(new ItemRole { Name = "Cooling" }); + cache.Upsert(radiator); + cache.FlushAsync().Wait(); + } + Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root)); + using var after = Open(); + var pithos = Assert.Single(after.GetAll(), p => p.Name == "Pithos"); + Assert.IsType(after.Get(pithos.Design)); + Assert.Empty(pithos.Roles); + } +} diff --git a/tests/Aetheria.Shared.Tests/ShapeCacheTests.cs b/tests/Aetheria.Shared.Tests/ShapeCacheTests.cs new file mode 100644 index 000000000..21fd6e914 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShapeCacheTests.cs @@ -0,0 +1,68 @@ +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; + +// Shape's derived reads (Coordinates, AllCoordinates, CenterOfMass) are caches of Cells and follow every edit. +public sealed class ShapeCacheTests +{ + [Fact] + public void CenterOfMassFollowsACellCleared() + { + var shape = new Shape(2, 1); + shape[int2(0, 0)] = true; + shape[int2(1, 0)] = true; + Assert.Equal(0.5f, shape.CenterOfMass.x); + + shape[int2(0, 0)] = false; + + Assert.Equal(1f, shape.CenterOfMass.x); + Assert.Equal(0f, shape.CenterOfMass.y); + } + + [Fact] + public void ResizeRecountsEveryCoordinateRead() + { + var shape = new Shape(2, 1); + shape[int2(0, 0)] = true; + shape[int2(1, 0)] = true; + Assert.Equal(2, shape.AllCoordinates.Length); + Assert.Equal(2, shape.Coordinates.Length); + var before = shape.CenterOfMass; + + shape.Resize(3, 2); + + Assert.Equal(6, shape.AllCoordinates.Length); + Assert.Equal(shape.Cells.Cast().Count(c => c), shape.Coordinates.Length); + Assert.NotEqual(before, shape.CenterOfMass); + } + + [Fact] + public void ResizeKeepsExistingCellsAndFillsNewOnes() + { + // The hull drawer's width grow relies on the fill (ships-studio-footprint r1 keeps it). + var shape = new Shape(2, 1); + shape[int2(0, 0)] = true; + + shape.Resize(3, 1); + + Assert.True(shape[int2(0, 0)]); + Assert.False(shape[int2(1, 0)]); + Assert.True(shape[int2(2, 0)]); + } + + [Fact] + public void SetLineRecountsEveryCoordinateRead() + { + var shape = new Shape(4, 1); + shape[int2(0, 0)] = true; + Assert.Single(shape.Coordinates); + Assert.Equal(0f, shape.CenterOfMass.x); + + shape.SetLine(float2(0f, 0f), float2(3f, 0f)); + + Assert.Equal(4, shape.Coordinates.Length); + Assert.Equal(4, shape.AllCoordinates.Length); + Assert.Equal(1.5f, shape.CenterOfMass.x); + } +} diff --git a/tools/AetherDb/MinorPowerFactionsCommand.cs b/tools/AetherDb/MinorPowerFactionsCommand.cs new file mode 100644 index 000000000..fbe0a39b6 --- /dev/null +++ b/tools/AetherDb/MinorPowerFactionsCommand.cs @@ -0,0 +1,122 @@ +/* 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 CultMath; +using GameCult.Caching; + +// minor-power-factions [apply] (aetheria-release, cut minor-power-factions; rulings no-territory-ban, miss-terris-belongs-to-emily-r3): +// authors the Aya Collective, Megiddo and Orbital Forge as ordinary factions, and the allegiance every existing faction but the +// Pirates and Miss Terri's holds to them. The Pirates' allegiance is pirates-faction's alone (it tops itself up on a re-run). +// A faction already in the catalog under its name-derived key is skipped, as is a gain already held, so the same command is the +// first apply, the second apply and the replay onto a merged catalog. Dry run unless passed "apply". +public static class MinorPowerFactionsCommand +{ + // Own: the weights this faction names for the other minor powers; every other faction but the Pirates gets Default. + // Gain: the weight every existing faction (bar the Pirates and Miss Terri's) gains toward this faction. + // Geonames is the name file the faction draws place names from (question minor-power-geonames: the nearest existing file). + internal sealed record Def(string Name, string ShortName, string Description, float3 Primary, float3 Secondary, + string Geonames, int Influence, float Gain, float Default, (string ShortName, float Weight)[] Own); + + internal static readonly Def[] Factions = + { + new("Aya Collective", "Aya", + "A federation of communes, clinics and repair crews. Its equipment comes with the drawings, so the people it protects can keep it running.", + new float3(.72f, .36f, .22f), new float3(.12f, .36f, .25f), "Congo", 3, .3f, .3f, + new[] { ("Forge", 1f), ("Megiddo", .5f) }), + new("Megiddo", "Megiddo", + "A defended fleet that holds its boundary until the question is answered. Others buy its protection without sharing its faith.", + new float3(.07f, .09f, .15f), new float3(.90f, .85f, .74f), "pleiades", 4, .3f, .2f, + new[] { ("Aya", .5f), ("Forge", .5f) }), + new("Orbital Forge", "Forge", + "Belt engineers who hollow asteroids into homes and yards. Their standards leave room to adapt them, and their drawings travel further than their ships.", + new float3(.42f, .30f, .22f), new float3(.30f, .38f, .52f), "pleiades", 3, .5f, .5f, + Array.Empty<(string, float)>()), + }; + + private const string Pirates = "Pirates"; + private const string MissTerris = "Miss Terri's"; + + // A faction's record key is derived from its name, so a replay reproduces the same key and the same bytes. + internal static CultRecordKey KeyOf(Def def) => + new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("faction:" + def.Name)).AsSpan(0, 16)).ToString("N")); + + public static int Run(string[] args, string root = null) + { + var apply = args.Contains("apply"); + var db = AetherDb.Open(catalogWritable: apply, root: root); + var existing = db.Cache.GetAll().ToArray(); + var nameFiles = db.Cache.GetAll().ToArray(); + + // Refuse before any write: a name or short name held by a faction under another key, and a missing name file. + foreach (var def in Factions) + { + var key = KeyOf(def); + foreach (var other in existing.Where(f => f.Name == def.Name || f.ShortName == def.ShortName)) + if (!db.Cache.RefOf(other).Key.Equals(key)) + throw new InvalidOperationException("Faction Name or ShortName: held by a faction under another key (conflict)."); + if (nameFiles.Count(n => n.Name == def.Geonames) != 1) + throw new InvalidOperationException("Faction GeonameFile: expected exactly one name file (missing)."); + } + + var minorKeys = Factions.ToDictionary(d => d.ShortName, KeyOf); + var created = Factions.Where(d => !existing.Any(f => db.Cache.RefOf(f).Key.Equals(KeyOf(d)))).ToArray(); + + // Every existing faction that is not a minor power, the Pirates or Miss Terri's holds each minor power it lacks. + var holders = existing.Where(f => f.ShortName != Pirates && f.ShortName != MissTerris && !minorKeys.ContainsKey(f.ShortName)).ToArray(); + var gains = holders + .Select(h => (Holder: h, Missing: Factions.Where(d => !h.Allegiance.ContainsKey(new CultRecordRef(minorKeys[d.ShortName]))).ToArray())) + .Where(g => g.Missing.Length != 0).ToArray(); + + if (created.Length == 0 && gains.Length == 0) + { + Console.WriteLine("The minor powers are already authored; nothing to do."); + return 0; + } + + foreach (var def in created) + Console.WriteLine($"New faction \"{def.Name}\" ({def.ShortName}): influence {def.Influence}, names \"{def.Geonames}\", colours ({def.Primary.x:0.00}, {def.Primary.y:0.00}, {def.Primary.z:0.00}) / ({def.Secondary.x:0.00}, {def.Secondary.y:0.00}, {def.Secondary.z:0.00})\n {def.Description}\n allegiance: {string.Join(", ", def.Own.Select(o => $"{o.ShortName} {o.Weight:0.##}"))} others {def.Default:0.##}"); + foreach (var (holder, missing) in gains) + Console.WriteLine($" {holder.ShortName} gains {string.Join(", ", missing.Select(d => $"{d.ShortName} {d.Gain:0.##}"))}"); + + if (!apply) + { + Console.WriteLine($"\nDry run. Pass \"apply\" to author {created.Length} factions and {gains.Sum(g => g.Missing.Length)} allegiance gains."); + return 0; + } + + // A new faction names every faction but the Pirates, the other minor powers included. + var everyone = existing.Where(f => f.ShortName != Pirates && !minorKeys.ContainsKey(f.ShortName)).Select(f => db.Cache.RefOf(f)).ToArray(); + if (!db.Cache.Commit(batch => + { + foreach (var def in created) batch.Expect(KeyOf(def), null); + foreach (var def in created) + { + var faction = new Faction + { + Name = def.Name, ShortName = def.ShortName, Description = def.Description, + PrimaryColor = def.Primary, SecondaryColor = def.Secondary, + GeonameFile = db.Cache.RefOf(nameFiles.Single(n => n.Name == def.Geonames)), InfluenceDistance = def.Influence, + }; + foreach (var reference in everyone) faction.Allegiance[reference] = def.Default; + foreach (var other in Factions.Where(o => o != def)) + faction.Allegiance[new CultRecordRef(minorKeys[other.ShortName])] = + def.Own.Where(o => o.ShortName == other.ShortName).Select(o => o.Weight).DefaultIfEmpty(def.Default).First(); + batch.Upsert(typeof(Faction), faction, KeyOf(def)); + } + foreach (var (holder, missing) in gains) + { + foreach (var def in missing) holder.Allegiance[new CultRecordRef(minorKeys[def.ShortName])] = def.Gain; + batch.Upsert(typeof(Faction), holder, db.Cache.RefOf(holder).Key); + } + })) + throw new InvalidOperationException("The catalog changed while the minor powers were being authored; nothing was written. Run it again."); + Console.WriteLine($"\nAuthored {created.Length} factions and {gains.Sum(g => g.Missing.Length)} allegiance gains in Aetheria.cc"); + return 0; + } +} diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs index d0fd7ce41..911f01ffb 100644 --- a/tools/AetherDb/ProductLinesCommand.cs +++ b/tools/AetherDb/ProductLinesCommand.cs @@ -9,7 +9,7 @@ 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, +// product-lines [apply] (aetheria-release, cuts product-lines-weapons and gear-lines; lines: weapons, gear; 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 @@ -44,8 +44,49 @@ public static class ProductLinesCommand new("Mine Launcher", "Tripwire", "Zhestokost", "Closes the road behind the column."), }; + // The Aya Collective, Megiddo and Orbital Forge (cut minor-power-factions): ordinary makers, applied after minor-power-factions. + private static readonly Row[] MinorPowers = + { + new("Point Defense Gun", "Common Guard", "Aya", "Standard mount, removable feed box, a barrel you can swap at the bench. Drawings included."), + new("Point Defense Laser", "Porch Light", "Aya", "Keeps the dark off the doorstep. The manual is longer than the warranty, on purpose."), + new("Flak Gun", "Canopy", "Aya", "Shade for the whole convoy. Every part replaceable by the people it covers."), + new("Point Defense Gun", "Watchman", "Megiddo", "Holds the line it is given. Every round accounted for."), + new("Point Defense Laser", "Vigil", "Megiddo", "It does not sleep while the boundary stands."), + new("Flak Gun", "Perimeter", "Megiddo", "Nothing crosses that we have not seen."), + new("Light Machine Gun", "Pattern 12", "Forge", "Drawings ship with the gun. Build the next one yourself."), + new("Autocannon", "Pattern 30", "Forge", "Standard bore, standard feed, standard anywhere a Belt yard can cut steel."), + new("Slug Rifle", "Pattern 6", "Forge", "Licensed or not, it fits the same mount."), + new("Point Defense Gun", "Pattern 2", "Forge", "Close-in defence to an open standard. No license check, ever."), + }; + + private static readonly Row[] Gear = + { + new("Adaptive Radiator", "Puffin", "Finch", "Fluffs up in the cold, slicks down in the heat. Mostly on cue."), + new("Radiator", "Shade Sail", "Lightsail", "Keeps the hold cool on the long run, wherever the run goes."), + new("Long Radiator", "Smokestack", "Zhestokost", "The heat goes up the stack. The column keeps marching."), + new("Heat Pump", "ChillOut+-", "NiteLife", "Cool down between sets."), + new("Cryogenic Heat Pump", "CryoVoid", "Adrasteia", "One half of the Santulanum Duality Thermal Dispersion System. The other half is sold separately."), + new("Compact Reactor", "Hestia", "AU", "A small fire that keeps the whole house warm."), + new("Compact Reactor", "Night Shift", "Lightsail", "Steady power for the hauls nobody else wants."), + new("Heavy Reactor", "HR-9 Bulwark", "R&D", "Rated for continuous output under all certified conditions.*"), + new("High-Yield Reactor", "PowerHour 2.0", "NiteLife", "Maximum output, for as long as it lasts."), + new("Ring Reactor", "Milk Run", "Lightsail", "Regular, reliable, and on schedule every time."), + new("High-Thrust Drive", "Battering Ram", "Zhestokost", "Straight ahead, through whatever is there. The brakes do, in fact, work."), + new("Burn Drive", "Headliner", "Lucent", "Top of the bill and the brightest exhaust in the zone."), + new("Rugged Drive", "Rough Road", "Lightsail", "Gets the freight through, whatever the road is made of."), + new("Racing Drive", "Prokope", "Finch", "67% fatal and 100% fun."), + new("Sensor Array", "Ranger", "AU", "Practically haunted by all the ghosts it picks up."), + new("Capacitor", "Powder Keg", "Zhestokost", "A broadside's worth of charge, all of it at once."), + new("Cargo Bay", "Pithos", "AU", "Holds everything. Do not open in transit."), + new("Cargo Bay", "SafeStuff", "R&D", "Safe for valuables. Excludes pets, children, cupcakes and friendship."), + new("Small Cargo Bay", "Amphora", "AU", "A jar for the long road."), + new("Medium Drive", "Stagecoach", "AU", "Steady miles, every day of the week."), + new("Large Drive", "Juggernaut", "Zhestokost", "Moves the column. Stopping it is someone else's problem."), + new("Industrial Thermostatic Heater", "WarmUp+-", "NiteLife", "Room temperature, whatever the room."), + }; + // Every line the command knows, by the word that follows "product-lines". - private static readonly Dictionary Lines = new() { ["weapons"] = Weapons }; + private static readonly Dictionary Lines = new() { ["weapons"] = Weapons, ["minor-powers"] = MinorPowers, ["gear"] = Gear }; // 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]; @@ -80,7 +121,8 @@ public static class ProductLinesCommand 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."); + // A cargo bay carries no roles (ruling catalog-breadth-roles-and-components); its product carries none either. + if (design[0] is not CargoBayData && (design[0].Roles?.Count ?? 0) == 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; @@ -103,7 +145,7 @@ public static class ProductLinesCommand 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}"); + Console.WriteLine($" {(present ? "present" : "new ")} {row.Design} -> \"{row.Name}\" by {row.Maker}, mean {mean:0.##} dev {dev:0.##} on [{string.Join(", ", (design.Roles ?? new()).Select(r => r.Name))}]: {row.Description}"); } if (!apply) @@ -128,7 +170,7 @@ public static class ProductLinesCommand 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(), + Roles = (design.Roles ?? new()).Select(r => new ProductRole { Role = r.Name, Mean = mean, StandardDeviation = dev }).ToList(), }, KeyOf(row)); } })) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 691fe3420..9d4fe5251 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -56,9 +56,10 @@ public static class Program 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()); + case "minor-power-factions": return MinorPowerFactionsCommand.Run(args.Skip(1).ToArray()); case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); 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], product-lines weapons [apply], doctrine-catalog [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], minor-power-factions [apply], product-lines weapons|minor-powers|gear [apply], doctrine-catalog [apply]"); return 1; } } @@ -258,6 +259,7 @@ public static class Program ["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), ["DME"] = (.55f, .20f), ["Adrasteia"] = (.60f, .10f), + ["Aya"] = (.50f, .08f), ["Megiddo"] = (.75f, .05f), ["Forge"] = (.45f, .15f), }; private static readonly (float Mean, float Dev) PirateProfile = (.45f, .22f); @@ -2259,6 +2261,11 @@ public static class Program // only, written here as \U escapes so no editor or shell mangles them; the dry run prints them raw and as code points. // // Dry run unless passed "apply", same contract as the other *-migrate commands. + // The factions the Pirates do not yet hold allegiance to: every faction but themselves and those already named. The one + // rule of both the first apply and the top-up, so they cannot diverge. + private static Faction[] AllegianceGaps(AetherDb db, Faction pirates, IEnumerable factions) => + factions.Where(f => !ReferenceEquals(f, pirates) && !pirates.Allegiance.ContainsKey(db.Cache.RefOf(f))).ToArray(); + public static int PiratesFaction(bool apply, string root = null) { var db = AetherDb.Open(catalogWritable: apply, root: root); @@ -2266,7 +2273,23 @@ public static class Program var existing = db.Cache.GetAll().FirstOrDefault(f => f.Name == "Pirates"); if (existing != null) { - Console.WriteLine("A faction named \"Pirates\" already exists; nothing to do."); + // A Pirates record already in the catalog is topped up, never rewritten: it gains allegiance 1.0 to each faction added + // since, under its existing key, and nothing else changes. + var gaps = AllegianceGaps(db, existing, db.Cache.GetAll()); + if (gaps.Length == 0) + { + Console.WriteLine("The Pirates' allegiance already covers every faction; nothing to do."); + return 0; + } + Console.WriteLine($"The Pirates lack allegiance to {gaps.Length} faction(s): {string.Join(", ", gaps.Select(f => f.Name))}."); + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to give them allegiance 1.0 to each."); + return 0; + } + foreach (var gap in gaps) existing.Allegiance[db.Cache.RefOf(gap)] = 1f; + db.Cache.Commit(batch => batch.Upsert(typeof(Faction), existing, db.Cache.RefOf(existing).Key)); + Console.WriteLine($"\nGave the Pirates allegiance 1.0 to {gaps.Length} faction(s) in Aetheria.cc"); return 0; } @@ -2293,7 +2316,7 @@ public static class Program GeonameFile = db.Cache.RefOf(nameFile), InfluenceDistance = 3, }; - foreach (var other in others) pirates.Allegiance[db.Cache.RefOf(other)] = 1f; + foreach (var other in AllegianceGaps(db, pirates, others)) pirates.Allegiance[db.Cache.RefOf(other)] = 1f; var rebrands = new (string Design, string Name, string Description)[] {