/* 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.IO; using System.Linq; using System.Reflection; using CultMath; using GameCult.Caching; using GameCult.Caching.MessagePack; using Xunit; using static CultMath.math; using float3 = CultMath.float3; using int2 = CultMath.int2; using Random = CultMath.Random; // Cut 7 (docs/fire-control-cut.md): 7.1, the fifth mutation Soul found survived 197 green tests -- // FireControl.Fire freezing the raw TargetItem.Value instead of the reveal-re-checked ResolvedTargetItem. // Builds its own shooter/target fixture, the same shape FireAuthorityTests uses for Cut 3, rather than // reusing that file's private Build helper (the established convention in this campaign's test files). public sealed class FireControlCut7Tests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut7-" + Guid.NewGuid().ToString("N")); private string Catalog => Path.Combine(_root, "Aetheria.cc"); public FireControlCut7Tests() => Directory.CreateDirectory(_root); private readonly List _openCaches = new List(); public void Dispose() { foreach (var c in _openCaches) c.Dispose(); Directory.Delete(_root, true); } // Detection well below the armor tier, which is well below the gear tier, so an aimed interior item can // decay out of its own reveal while the weapon itself (index 0, the armor tier) and general detection // both stay satisfied -- the shot still fires, only the aim point should be lost. private static GameplaySettings TestSettings() => new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp(), TargetDetectionInfoThreshold = .1f, TargetArmorInfoThreshold = .3f, TargetGearInfoThreshold = .9f, FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 1f, UnaidedAccuracy = .05f, AgentMinHitProbability = 0f }; private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; private sealed class Engagement { public ItemManager Items; public Zone Zone; public Ship Shooter; public Ship Target; public EquippedItem WeaponItem; public InstantWeapon Weapon; } // A trimmed copy of FireAuthorityTests.Build: one Sensors-hardpoint weapon plus a Tool-slot targeting // system on the shooter, a target ship with a matching weapon (index 0 in IsRevealed's ranking) and, // through beforeActivate, a second Tool item to aim at (index 1 -- the gear tier). private Engagement Build(GameplaySettings settings, Action beforeActivate) { var hullShape = new Shape(5, 5); foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; var hullData = new HullData { Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 1000, Mass = 1000, Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } }; var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); _openCaches.Add(cache); cache.Upsert(new VerseGrammar { Revision = 1 }); cache.Upsert(hullData); cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new InstantWeaponData { Damage = Constant(50), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new TargetingSystemData { Accuracy = Constant(1), Resolution = Constant(1), Precision = Constant(1), Tracking = Constant(1000) } } }); cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= 10; 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(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }; var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; Assert.True(shooter.TryEquip(gun, new int2(0, 0))); var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 }; Assert.True(shooter.TryFindSpace(targeting, out var tpos)); Assert.True(shooter.TryEquip(targeting, tpos)); var targetHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 4 }; var target = new Ship(items, zone, targetHull, new EntitySettings()); var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); beforeActivate?.Invoke(items, shooter, target); zone.Entities.Add(shooter); zone.Entities.Add(target); shooter.Activate(); target.Activate(); shooter.Position = float3.zero; target.Position = float3(0, 0, 100); shooter.SetTarget(target); shooter.SetIff(target, true); zone.Update(0f); // warm-up: resolves weapon/targeting stats before Fire reads them return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon }; } // 7.1 (Soul's fifth survivor): FireControl.Fire must freeze source.ResolvedTargetItem, not the raw // source.TargetItem.Value -- the aim point is re-checked against reveal at the instant the trigger is // pulled, same as every other read of it. Mutation: read TargetItem.Value directly. Must die. // // The target's non-hull equipment ranks the hardpoint weapon first (armor tier, index 0 of 2) and the // interior "Targeting" item last (gear tier, index 1 of 2, i.e. exactly TargetGearInfoThreshold). Info is // dropped to a level between the two tiers: the weapon stays revealed (so the shot still fires, PBase > // 0) but the aimed item's own tier is no longer met -- the reveal re-check, not a global blackout, is // what must drop the aim point. [Fact] public void AimedItemDropsOutWhenRevealDecaysBeforeFire() { EquippableItem aimedGear = null; var settings = TestSettings(); var e = Build(settings, (items, shooter, target) => { var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); aimedGear = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 6 }; Assert.True(target.TryFindSpace(aimedGear, out var pos)); Assert.True(target.TryEquip(aimedGear, pos)); }); var aimedItem = e.Target.Equipment.Single(x => x.EquippableItem == aimedGear); // Fully reveal and select the interior item as the aim point. e.Shooter.EntityInfoGathered[e.Target] = 1f; Assert.True(e.Shooter.TrySelectTargetItem(aimedItem)); Assert.Same(aimedItem, e.Shooter.ResolvedTargetItem); // Decay to halfway between the armor and gear tiers: the weapon (armor tier) stays revealed, the // aimed item (gear tier) does not. The raw field still holds the item -- nothing clears it -- but the // read accessor must now return null. e.Shooter.EntityInfoGathered[e.Target] = (settings.TargetArmorInfoThreshold + settings.TargetGearInfoThreshold) / 2f; Assert.Same(aimedItem, e.Shooter.TargetItem.Value); // raw field: untouched by decay Assert.Null(e.Shooter.ResolvedTargetItem); // read path: re-checked, and drops it var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); Assert.True(shot.PFire > 0f); // the shot is still worth taking -- this isn't a blackout, just a lost aim point Assert.Null(shot.Aimed); // the frozen snapshot must not carry an aim point reveal no longer supports } // 7.3/7.4 (docs/fire-control-cut.md): the campaign's only assertion that the shipped catalog is // readable by the shipped code at all, not only by a synthetic fixture. Cut 2 shipped a targeting-system // requirement with no data to satisfy it and every green synthetic-fixture suite missed it; merging Cuts // 6b and 6c separately shipped a non-nullable field no existing record carried, and neither cut's own // synthetic-fixture tests ever opened real data, so AetheriaStores.Open throwing outright on the shipped // catalog (fixed at c896489b) passed every gate until an operator opened it by hand. Any exception here // -- deserialization or generation -- is a failure; this is not scoped to "did a design exist." [Fact] public void ShippedCatalogOpensAndGeneratesAnArmedHull() { var gameData = TestCatalog.Repo; using var cache = OpenReadOnlyRealCatalog(gameData); // Force every EquippableItemData -- WeaponItemData included, the type the broken merge could not // read -- to actually be enumerated. AetheriaStores.Open already deserializes every record eagerly // at attach time (CultCache.PullAll), so this line is what turns that into a promise this test // enforces rather than an accident of the current backing-store implementation: a future lazy-read // change that skipped WeaponItemData specifically would still be caught here. var equippable = cache.GetAll().ToList(); Assert.NotEmpty(equippable); Assert.Contains(equippable, e => e is WeaponItemData); var hull = cache.GetByName("Longinus"); Assert.NotNull(hull); var settings = new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp() }; var log = new List(); var items = new ItemManager(cache, new ProvenanceLedger(), settings, log.Add); var random = new Random(1u); var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); var pack = generator.GenerateShipLoadout(candidate => candidate == hull); Assert.NotNull(pack); // Cut 2's own mutation (drop the targeting designs' products): this goes null Assert.Contains(pack.Equipment, e => (cache.Get(e.item.Data) as GearData)?.Behaviors.Any(b => b is TargetingSystemData) ?? false); } private static string FindRepoRoot() { for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) return dir.FullName; throw new DirectoryNotFoundException("Run from inside the Aetheria repository -- built test output was not under a recognizable repo root."); } // AetheriaStores.Open uses `new CultCache()`, whose default registry (CultDocumentRegistry.Shared) discovers every // [CultDocument] type loaded anywhere in the current process by scanning the whole AppDomain. // // This reproduces AetheriaStores.Open's own composition and validation exactly, scoped to a registry // built only from the shipped assembly's own [CultDocument] types (CultDocumentRegistry.ForTypes does // not re-scan the AppDomain the way the default constructor's Refresh() does), so this test still opens // the catalog the same way the game does -- same backing store, same missing-global check, same R-heat // validation loop -- without scanning the whole AppDomain. private static CultCache OpenReadOnlyRealCatalog(string catalogPath) { var registry = TestCatalog.Registry(); var cache = new CultCache(registry); try { cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, true), AetheriaStores.CatalogTypes); var missing = cache.Registry.AllDescriptors.FirstOrDefault(descriptor => descriptor.IsGlobal && AetheriaStores.CatalogTypes.Any(home => home.IsAssignableFrom(descriptor.DocumentType)) && cache.AllStoredDocuments.All(stored => stored.Descriptor != descriptor)); if (missing != null) throw new InvalidOperationException($"Catalog {catalogPath} has no {missing.SchemaName} record; catalog globals are authored, never invented."); foreach (var data in cache.GetAll()) { StatValidation.ValidateHeatResponse(data); StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); } foreach (var data in cache.GetAll()) { StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); } return cache; } catch { cache.Dispose(); throw; } } }