diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index 46bee7272..c12d32c99 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f09c54dfdb87dffd76fa92365d62c53ce333457b89ccf8ead09093ffffedc42 -size 12525 +oid sha256:711ded55b95c512e3803df7682e44f0a9bb83ad36a384432781dc12dc5dadf0a +size 12630 diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 6cd860ab3..3df60cf50 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -7,6 +7,7 @@ using System.Buffers; using System.Collections.Generic; using System.Linq; using CultMath; +using GameCult.Caching; using static CultMath.math; using float2 = CultMath.float2; using float3 = CultMath.float3; @@ -1234,7 +1235,7 @@ public static class FireControl // Detonate is still the one blast path. if (shot.Outcome.Result == ShotResult.Burst || shot.Fuse == WeaponFuse.Proximity) { - Detonate(zone, shot.Outcome.BurstPoint, shot.BlastRadius, shot.Damage, shot.DamageType); + Detonate(zone, shot.Outcome.BurstPoint, shot.BlastRadius, shot.Damage, shot.DamageType, shot); return; } @@ -1270,11 +1271,12 @@ public static class FireControl var direction = float3(shot.TravelDirection.x, 0, shot.TravelDirection.y); var surface = centre - shot.TravelDirection * zone.ChunkRadius(rock); Publish(zone.ChunkCapabilities, AbsorbLayer.Hull, float3(surface.x, 0f, surface.y), direction, shot.Damage, shot.DamageType); - zone.Wear(rock, shot.Damage); + var absorbed = zone.Wear(rock, shot.Damage); + Extract(zone, rock, absorbed, shot.Source, SimulationDice.For(zone.DropSeed, SimulationDice.OreStream, (uint) shot.ShotId)); return; } var point = Armed(shot.FireOrigin, shot.TravelDirection, shot.BlastRadius, float3(centre.x, shot.FireOrigin.y, centre.y)).xz; - Detonate(zone, point, shot.BlastRadius, shot.Damage, shot.DamageType); + Detonate(zone, point, shot.BlastRadius, shot.Damage, shot.DamageType, shot); } // Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step @@ -1413,7 +1415,7 @@ public static class FireControl // the muzzle than the blast radius is detonated on at the radius, along the round's own flight line. var hullPoint = target.ToWorldPoint(schematicPoint); var worldPoint = Armed(shot.FireOrigin, shot.TravelDirection, shot.BlastRadius, float3(hullPoint.x, shot.FireOrigin.y, hullPoint.y)).xz; - Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType); + Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType, shot); } // Cut 12.4(b) (docs/fire-control-cut.md, "The penetrator", Q12-8): the one penetration-reach owner, moved @@ -1715,7 +1717,7 @@ public static class FireControl // call: doing so would buy no behaviour, per ProbeC in the cut doc). Leftovers -- unoccupied-cell shares, // and whatever an item's pool does not absorb -- join one entity-wide hull total, applied through one // DamageHull call. - public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType) + public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType, PendingShot? shot = null) { // No distance prefilter: RectDiskOverlap is the one decider of whether the disc touches a hull. A // prefilter measured from Position (the centre of mass) with the bounding box's half-diagonal is not a @@ -1804,6 +1806,9 @@ public static class FireControl return byField != 0 ? byField : a.Target.Chunk.Value.Index.CompareTo(b.Target.Chunk.Value.Index); }); var discArea = PI * radius * radius; + // Mining Cut 5a: a shot's blast pays its Source for the rocks it wears, from the shot's own dice, once for the + // blast, drawn in the rock order above. A mine (no shot) wears rocks and yields nothing. + var dice = shot != null ? SimulationDice.For(zone.DropSeed, SimulationDice.OreStream, (uint) shot.Value.ShotId) : default; foreach (var rock in rocks) { var chunk = rock.Target.Chunk.Value; @@ -1812,7 +1817,66 @@ public static class FireControl var share = damage * CircleCircleOverlap(length(centre - worldPlanar), radius, zone.ChunkRadius(chunk)) / discArea; if (!(share > 0f)) continue; PublishBlast(zone.ChunkCapabilities, centre, 0f, worldPlanar, AbsorbLayer.Hull, share, damageType); - zone.Wear(chunk, share); + var absorbed = zone.Wear(chunk, share); + if (shot != null) Extract(zone, chunk, absorbed, shot.Value.Source, dice); + } + } + + // Mining Cut 5a: the one loot owner of a rock. A worn rock pays the shooter at the wear site, at arrival or detonation, + // from the shot's own dice: the damage it absorbed is that fraction of its hitpoints, and the same fraction of the + // units its size authors (AsteroidYield), so a rock pays its whole yield however many hits break it. Whole units, and + // one Bernoulli draw on the fraction. Each unit draws an ore by weight: the belt's yields for this province, scaled by + // exp(variance x g), g a stable hash of (field, index, ore) in [-1, 1) -- a rock's composition, derived here every call + // and never stored. A shot with no Source (a mine) yields nothing. The units go to the shooter's cargo bays in + // stacks; what does not fit, or everything when the shooter has left the zone, floats at the rock (ruling + // mining-overflow-floats). + // Draw order: the Bernoulli, then one draw per unit. + public static void Extract(Zone zone, ChunkId chunk, float absorbed, Entity shooter, Random dice) + { + if (shooter == null || !(absorbed > 0f)) return; + var settings = zone.Settings; + var belt = zone.AsteroidBelts[chunk.Field]; + var catalog = shooter.ItemManager.ItemData; + var ores = FieldKinds.Yield(belt.Data.Kind, zone.GalaxyZone?.Province?.Type ?? default, catalog); + if (ores.Length == 0) return; + + var size = belt.Data.Asteroids[chunk.Index].Size; + var owed = absorbed / settings.AsteroidHitpoints.Evaluate(size) * settings.AsteroidYield.Evaluate(size); + var units = (int) floor(owed); + if (dice.NextFloat() < owed - units) units++; + if (units == 0) return; + + var weights = new float[ores.Length]; + var total = 0f; + for (var i = 0; i < ores.Length; i++) + { + var g = $"{chunk.Field.Value}:{chunk.Index}:{ores[i].Commodity.Key.Value}".StableHash() / 2147483648.0 - 1.0; + total += weights[i] = ores[i].Amount * (float) Math.Exp(settings.ChunkCompositionVariance * g); + } + var counts = new int[ores.Length]; + for (var unit = 0; unit < units; unit++) + { + var pick = dice.NextFloat() * total; + var ore = 0; + while (ore < ores.Length - 1 && pick >= weights[ore]) pick -= weights[ore++]; + counts[ore]++; + } + + var at = zone.ChunkPose(chunk.Field, chunk.Index).xy; + var velocity = zone.ChunkVelocity(chunk); + var present = zone.Entities.Contains(shooter); + for (var i = 0; i < ores.Length; i++) + { + var key = ores[i].Commodity.Key; + var maxStack = Math.Max(1, ((SimpleCommodityData) catalog.Get(ores[i].Commodity)).MaxStack); + for (var left = counts[i]; left > 0; left -= maxStack) + { + var stack = new SimpleCommodity { Data = new CultRecordRef(key), Quantity = Math.Min(left, maxStack) }; + // A partial store leaves the remainder in Quantity and returns false, so the next bay is offered the rest. + var stored = false; + if (present) foreach (var bay in shooter.CargoBays) if (stored = bay.TryStore(stack)) break; + if (!stored) zone.Release(stack, at, velocity); + } } } diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 8326f76da..1d83bd692 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -29,6 +29,11 @@ public class PlanetSettings public ExponentialLerp AsteroidSize; public ExponentialLerp AsteroidHitpoints; public ExponentialLerp AsteroidRespawnTime; + // Units of ore a whole rock of normalized size s gives (FireControl.Extract), however many hits break it. + public ExponentialLerp AsteroidYield; + // How far a rock's mix of its belt's ores strays from the kind's ratio: each ore's weight is scaled by + // exp(variance x g), g in [-1, 1) a stable hash of (rock, ore). Zero reads the kind's ratio. + public float ChunkCompositionVariance; public float SecureAreaRadiusMultiplier = .45f; diff --git a/Assets/Scripts/ServerShared/SimulationDice.cs b/Assets/Scripts/ServerShared/SimulationDice.cs index adc440299..c8cb9e759 100644 --- a/Assets/Scripts/ServerShared/SimulationDice.cs +++ b/Assets/Scripts/ServerShared/SimulationDice.cs @@ -18,6 +18,9 @@ public static class SimulationDice // seed (CombatSeed): the run seed feeds drops and mines only (ruling run-seed-drops-only), and placement never rides // ItemManager.Random. public const uint ScatterStream = 5; + // The ore a shot's wear of rocks pays, ordinal the shot's ShotId, on the zone's DropSeed (mined ore is a drop): + // FireControl.Extract is the one reader. + public const uint OreStream = 6; // 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply, // xor-shift, multiply, xor-shift. Diffuses a structured seed (a zone seed XORed with a small, diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index 153fc595d..be15fc74e 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -94,15 +94,22 @@ public static class StationServices public static int BuyableQuantity(ItemManager items, SimpleCommodity stock, int typed, int credits) => Math.Max(1, Math.Min(Math.Min(typed, credits / BuyPrice(items, stock)), stock.Quantity)); - // The sale: the item leaves the bay for one of the station's cargo bays and the station pays SellPrice. False, - // with the item in its bay and the credits unchanged, when no station bay can take it. + // The sale: the item leaves the bay and the station pays SellPrice. A crafted item goes to one of the station's + // cargo bays; false, with the item in its bay and the credits unchanged, when no bay can take it. A simple + // commodity always sells (aetheria-release:ruling:station-market-absorbs-sold-commodity): the bays take as much of + // the stack as fits and the market absorbs the rest, at the full price. public static bool TrySell(EquippedCargoBay from, Entity station, ItemInstance item, ref int credits) { var price = SellPrice(station.ItemManager, item); - var moved = item is SimpleCommodity commodity - ? station.CargoBays.Any(bay => from.TryTransferItem(bay, commodity, commodity.Quantity)) - : station.CargoBays.Any(bay => from.TryTransferItem(bay, (CraftedItemInstance) item)); - if (!moved) return false; + if (item is SimpleCommodity commodity) + { + if (!from.Cargo.ContainsKey(commodity)) return false; + var offered = commodity.Quantity; + for (var quantity = offered; quantity >= 1; quantity--) + if (station.CargoBays.Any(bay => from.TryTransferItem(bay, commodity, quantity))) break; + if (from.Cargo.ContainsKey(commodity)) from.Remove(commodity, commodity.Quantity); + } + else if (!station.CargoBays.Any(bay => from.TryTransferItem(bay, (CraftedItemInstance) item))) return false; credits += price; return true; } diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index d0c5b5330..7cbde82ef 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -685,16 +685,17 @@ public class Zone return size.Exponent > 0 ? max(abs(size.Minimum), abs(size.Maximum)) : float.PositiveInfinity; } - // Cut 2: the only writer of chunk wear. Returns whether this hit broke the chunk. The break threshold is + // Cut 2: the only writer of chunk wear. Returns the damage the rock absorbed: the damage, or the hitpoints it had + // left when the hit broke it; 0 on a chunk that is already broken. Whether it broke is ChunkExists. The break threshold is // strictly greater-than, matching the deleted per-tick miner's own comparison against accumulated damage: // damage exactly equal to hitpoints does not yet break the chunk, only damage that exceeds them does. - // A chunk that is currently broken (zone time < BrokenUntil) absorbs nothing: this hit did not break it - // (it was already broken), and its wear stays zeroed until it respawns and starts fresh. - public bool Wear(ChunkId chunk, float damage) + // A chunk that is currently broken (zone time < BrokenUntil) absorbs nothing, and its wear stays zeroed until it + // respawns and starts fresh. + public float Wear(ChunkId chunk, float damage) { _wear.TryGetValue(chunk, out var wear); if (wear.BrokenUntil.HasValue && wear.BrokenUntil.Value > _time) - return false; + return 0f; var beltData = (AsteroidBeltData) Planets[chunk.Field]; var size = beltData.Asteroids[chunk.Index].Size; @@ -706,11 +707,11 @@ public class Zone { var respawnTime = Settings.AsteroidRespawnTime.Evaluate(size); _wear[chunk] = new ChunkWear { Damage = 0f, BrokenUntil = _time + respawnTime }; - return true; + return max(hitpoints - wear.Damage, 0f); } _wear[chunk] = new ChunkWear { Damage = newDamage, BrokenUntil = null }; - return false; + return damage; } // Cut 1 (docs/mining-cut.md): a chunk's pose is a pure function of zone time, computed when asked. No stored diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index f4cfdc4a3..ef463cde8 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9edc8434524f43f751c89416479cf07fba03b424f266cb8bfc1870bd46eef6a7 -size 220537 +oid sha256:62a6678e741e296449b6ac8cd8698e071a873ea39bc9c0303abe68887310dceb +size 220731 diff --git a/tests/Aetheria.Shared.Tests/DemoLoopTests.cs b/tests/Aetheria.Shared.Tests/DemoLoopTests.cs new file mode 100644 index 000000000..7e765174c --- /dev/null +++ b/tests/Aetheria.Shared.Tests/DemoLoopTests.cs @@ -0,0 +1,79 @@ +/* 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.Linq; +using CultMath; +using Xunit; + +// The demo loop on the shipped catalog (aetheria-release, mining 5a and the ore sheet): the player's own gun breaks a rock +// through the trigger, the ore lands in the hold, the Pirates station Zenith buys it, repairs the hull and sells a unit back. +public sealed partial class RunStartTests +{ + [Fact] + public void DemoLoop_mine_sell_repair_buy() + { + var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + var player = staged.Player; + var belt = arena.Planets.Single(body => body.Value is AsteroidBeltData); + var rock = new ChunkId(belt.Key, 0); + var laser = player.Equipment.Select(equipped => equipped.Behaviors.OfType().FirstOrDefault()) + .First(weapon => weapon != null && weapon.Item.EquippableItem != null && _items.GetData(weapon.Item.EquippableItem).Name == "Light Laser"); + + // Once the ship's stats have settled. + for (var tick = 0; tick < 50; tick++) arena.Update(.02f); + // The belt drifts and the sun's well pulls, so the ship is held where it was put: forty units off the rock. + void Hold() + { + var at = arena.ChunkPose(rock.Field, rock.Index).xy; + player.Position = new float3(at.x, player.Position.y, at.y - 40f); + player.Velocity = float2.zero; + player.Direction = new float2(0, 1); + player.Aim = new float3(0, 0, 1); + } + Hold(); + arena.Update(.02f); + Hold(); + Assert.True(player.SetTarget(rock), "the rock is a target"); + laser.Activate(); + for (var elapsed = 0f; elapsed < 20f && arena.ChunkExists(rock); elapsed += .02f) { Hold(); arena.Update(.02f); } + var info = player.ChunkInfo(rock); + Assert.False(arena.ChunkExists(rock), $"the laser broke the rock: designated {FireControl.Designated(laser, player, rock, out var range)} range {range} info {info} target {player.Target} pos {player.Position} rockpos {arena.ChunkPose(rock.Field, rock.Index)} hp {arena.Settings.AsteroidHitpoints.Evaluate(arena.AsteroidBelts[rock.Field].Data.Asteroids[0].Size)}"); + + var ores = new[] { "Rock", "New Metals", "Gold" }; + var held = player.CargoBays.SelectMany(bay => bay.Cargo.Keys.OfType().Select(stack => (bay, stack))) + .Where(h => ores.Contains(_items.GetData(h.stack).Name)).ToArray(); + Assert.NotEmpty(held); + Assert.Empty(arena.FloatingItems); + + var zenith = arena.Entities.Single(entity => entity.HullData.Name == "Zenith"); + var credits = 0; + // Every ore stack goes: the market takes it all, whatever the full hold of Zenith has room for. + foreach (var (from, sold) in held) Assert.True(StationServices.TrySell(from, zenith, sold, ref credits), "the market takes the ore"); + Assert.DoesNotContain(player.CargoBays.SelectMany(bay => bay.Cargo.Keys.OfType()), c => ores.Contains(_items.GetData(c).Name)); + Assert.True(credits > 0, "the station pays for the ore"); + + var cell = Enumerable.Range(0, player.MaxArmor.GetLength(0)).SelectMany(x => Enumerable.Range(0, player.MaxArmor.GetLength(1)).Select(y => (x, y))) + .First(c => player.MaxArmor[c.x, c.y] > 0); + player.Armor[cell.x, cell.y] = 0; + var cost = StationServices.RepairCost(_items, player); + Assert.True(cost > 0); + var paid = credits; + credits += cost; + Assert.True(StationServices.TryRepair(player, ref credits)); + Assert.Equal(paid, credits); + Assert.Equal(player.MaxArmor[cell.x, cell.y], player.Armor[cell.x, cell.y]); + + // Zenith's whole stock is one 16-cell reactor, which fits only an empty 4x4: the player jettisons what the hold + // still carries, then buys it. + var hold = player.CargoBays[0]; + foreach (var leftover in hold.Cargo.Keys.ToArray()) hold.Remove(leftover); + var stock = zenith.CargoBays.SelectMany(bay => bay.Cargo.Keys.OfType().Select(piece => (bay, piece))).Single(); + credits = StationServices.BuyPrice(_items, stock.piece); + Assert.Equal(BuyResult.Bought, StationServices.TryBuy(_items, stock.piece, 1, ref credits, + () => stock.bay.TryTransferItem(hold, stock.piece))); + Assert.Equal(0, credits); + Assert.Contains(stock.piece, hold.Cargo.Keys); + } +} diff --git a/tests/Aetheria.Shared.Tests/DemoOreSheetTests.cs b/tests/Aetheria.Shared.Tests/DemoOreSheetTests.cs new file mode 100644 index 000000000..24ef521eb --- /dev/null +++ b/tests/Aetheria.Shared.Tests/DemoOreSheetTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// The interim ore sheet (rulings mining-demo-ore-minimal-yields-now, mining-demo-ore-prices-gear-scale): what the shipped catalog's +// Asteroid belt holds can be carried and sold, so mine, sell, repair closes on the existing stations. +public sealed class DemoOreSheetTests : IDisposable +{ + private static readonly string[] Ores = { "Rock", "New Metals", "Gold" }; + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-demoore-" + Guid.NewGuid().ToString("N")); + + public DemoOreSheetTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private CultCache Shipped() + { + var path = Path.Combine(_root, "Aetheria.cc"); + File.Copy(TestCatalog.Repo, path, true); + return AetheriaStores.Open(path, catalogWritable: true, registry: TestCatalog.Registry()); + } + + private static CultRecordRef Asteroid(CultCache cache) => + cache.RefOf(cache.GetAll().Single(k => k.Name == "Asteroid")); + + [Fact] + public void AsteroidYieldsThreeOresInKeyOrder() + { + using var cache = Shipped(); + var yield = FieldKinds.Yield(Asteroid(cache), default, cache); + var amounts = yield.ToDictionary(y => cache.Get(y.Commodity).Name, y => y.Amount); + Assert.Equal(Ores.OrderBy(n => n).ToArray(), amounts.Keys.OrderBy(n => n).ToArray()); + Assert.Equal(6f, amounts["Rock"]); + Assert.Equal(3f, amounts["New Metals"]); + Assert.Equal(1f, amounts["Gold"]); + Assert.Equal(yield.Select(y => y.Commodity.Key.Value).OrderBy(k => k, StringComparer.Ordinal), yield.Select(y => y.Commodity.Key.Value)); + } + + [Fact] + public void EveryYieldedOreCanBeHeldAndSold() + { + using var cache = Shipped(); + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var bay = new Shape(4, 4); + foreach (var cell in bay.AllCoordinates) bay[cell] = true; + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new HullData { Name = "OreSkiff", HullType = HullType.Ship, Shape = shape, Price = 1000, Durability = 10, Armor = 10 }); + cache.Upsert(new CargoBayData { Name = "OreBay", Shape = new Shape(), InteriorShape = bay, Price = 20, Durability = 4 }); + cache.FlushAsync().GetAwaiter().GetResult(); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + EquippableItem Mint(string design) + { + var data = cache.GetByName(design); + return new EquippableItem + { + Data = cache.RefOf(data), Durability = data.Durability, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(data), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + } + var ship = new Ship(items, zone, Mint("OreSkiff"), new EntitySettings()); + Assert.True(ship.TryEquip(Mint("OreBay"), new int2(1, 1))); + + foreach (var (commodity, _) in FieldKinds.Yield(Asteroid(cache), default, cache)) + { + var data = cache.Get(commodity); + Assert.NotNull(data.Shape); + Assert.True(data.Price > 0, data.Name); + Assert.True(data.Mass > 0, data.Name); + Assert.True(ship.CargoBays.Single().TryStore(new SimpleCommodity { Data = cache.RefOf(data), Quantity = 1 }), data.Name); + } + } + + [Fact] + public void TheSheetConverges() + { + var path = Path.Combine(_root, "Converge.cc"); + File.Copy(Path.Combine(RestoredHullsTests.FindRepoRoot(), "GameData", "Aetheria.cc"), path); + var before = File.ReadAllBytes(path); + var output = new StringWriter(); + Assert.Equal(0, EconomyCommand.Author(path, new List { new DemoOre() }, true, output, TestCatalog.Registry(), _root)); + Assert.Equal(before, File.ReadAllBytes(path)); + + using var cache = AetheriaStores.Open(path, registry: TestCatalog.Registry()); + var ids = cache.GetAll().Single(k => k.Name == "Asteroid").Yields.Select(y => y.Id).ToArray(); + Assert.Equal(new[] { "a57e0c000001", "a57e0c000002", "a57e0c000003" }, ids); + } +} diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index 37111b114..e7254fac6 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -542,7 +542,7 @@ public sealed class DrawnPoseTests : IDisposable var chunk = new ChunkId(w.Belt, 5); var hitpoints = w.Zone.Settings.AsteroidHitpoints.Evaluate(w.Asteroids[5].Size); var unworn = w.Zone.ChunkRadius(chunk); - Assert.False(w.Zone.Wear(chunk, hitpoints * .9f)); + Assert.False(w.Zone.Breaks(chunk, hitpoints * .9f)); Assert.True(w.Zone.ChunkRadius(chunk) < unworn * .5f, "the rock has shrunk to less than half its size"); Assert.Equal(before, Meshes()); } diff --git a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs index 54fc99972..7716e3c9a 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs @@ -134,9 +134,9 @@ public sealed class MiningCut2Tests : IDisposable var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); // Several partial hits under the threshold: still there, still shrinking. - Assert.False(f.Zone.Wear(chunk, hp * 0.3f)); + Assert.False(f.Zone.Breaks(chunk, hp * 0.3f)); Assert.True(f.Zone.ChunkExists(chunk)); - Assert.False(f.Zone.Wear(chunk, hp * 0.3f)); + Assert.False(f.Zone.Breaks(chunk, hp * 0.3f)); Assert.True(f.Zone.ChunkExists(chunk)); var before = f.Zone.ChunkRadius(chunk); @@ -147,11 +147,11 @@ public sealed class MiningCut2Tests : IDisposable // break condition as strictly greater-than, not greater-or-equal (today's behaviour, kept deliberately // -- see Zone.Wear's own comment). Equal-to-hitpoints must not break the chunk. var remaining = hp - hp * 0.6f; - Assert.False(f.Zone.Wear(chunk, remaining)); + Assert.False(f.Zone.Breaks(chunk, remaining)); Assert.True(f.Zone.ChunkExists(chunk), "damage exactly equal to hitpoints must not break the chunk"); // One more point of damage tips it over. - Assert.True(f.Zone.Wear(chunk, 0.01f)); + Assert.True(f.Zone.Breaks(chunk, 0.01f)); Assert.False(f.Zone.ChunkExists(chunk)); Assert.Equal(0f, f.Zone.ChunkRadius(chunk)); } @@ -165,7 +165,7 @@ public sealed class MiningCut2Tests : IDisposable var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); - Assert.True(f.Zone.Wear(chunk, hp + 1f)); + Assert.True(f.Zone.Breaks(chunk, hp + 1f)); Assert.False(f.Zone.ChunkExists(chunk)); // Uneven steps that sum to just under the authored respawn time: still broken. @@ -188,13 +188,13 @@ public sealed class MiningCut2Tests : IDisposable var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsB[index].Size); var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsB[index].Size); - Assert.True(f.Zone.Wear(chunk, hp * 1.5f)); + Assert.True(f.Zone.Breaks(chunk, hp * 1.5f)); f.Zone.Update(respawn + 1f); Assert.True(f.Zone.ChunkExists(chunk)); // A hit well under hitpoints, on the freshly respawned chunk, must not break it -- if the old damage // (or the old broken state) leaked through, this hit alone would already be over any stale threshold. - Assert.False(f.Zone.Wear(chunk, hp * 0.4f)); + Assert.False(f.Zone.Breaks(chunk, hp * 0.4f)); Assert.True(f.Zone.ChunkExists(chunk)); var radius = f.Zone.ChunkRadius(chunk); Assert.True(radius > 0f && radius < f.Zone.AsteroidBelts[f.BeltB].UndamagedSize(index, f.Settings)); @@ -211,7 +211,7 @@ public sealed class MiningCut2Tests : IDisposable var chunkA1 = new ChunkId(f.BeltA, 1); var hpA0 = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[0].Size); - Assert.True(f.Zone.Wear(chunkA0, hpA0 + 1f)); + Assert.True(f.Zone.Breaks(chunkA0, hpA0 + 1f)); Assert.False(f.Zone.ChunkExists(chunkA0)); Assert.True(f.Zone.ChunkExists(chunkB0), "a different field at the same index must be unaffected"); @@ -230,8 +230,8 @@ public sealed class MiningCut2Tests : IDisposable var hpDamaged = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[4].Size); var hpBroken = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsB[3].Size); - Assert.False(f.Zone.Wear(damaged, hpDamaged * 0.45f)); - Assert.True(f.Zone.Wear(broken, hpBroken + 5f)); + Assert.False(f.Zone.Breaks(damaged, hpDamaged * 0.45f)); + Assert.True(f.Zone.Breaks(broken, hpBroken + 5f)); f.Zone.Update(1.3f); var damagedRadiusBefore = f.Zone.ChunkRadius(damaged); @@ -255,7 +255,7 @@ public sealed class MiningCut2Tests : IDisposable var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[6].Size); var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[6].Size); - Assert.True(f.Zone.Wear(chunk, hp + 1f)); + Assert.True(f.Zone.Breaks(chunk, hp + 1f)); f.Zone.Update(respawn + 2f); Assert.True(f.Zone.ChunkExists(chunk), "sanity: this chunk must have respawned before packing"); @@ -333,20 +333,20 @@ public sealed class MiningCut2Tests : IDisposable var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); - Assert.True(f.Zone.Wear(chunk, hp + 1f)); + Assert.True(f.Zone.Breaks(chunk, hp + 1f)); Assert.False(f.Zone.ChunkExists(chunk)); // A hit well under hitpoints, inside the respawn window: must not break it (it is already broken) and // must not touch BrokenUntil. f.Zone.Update(respawn * 0.2f); - Assert.False(f.Zone.Wear(chunk, hp * 0.1f), "a hit on an already-broken chunk did not break it"); + Assert.False(f.Zone.Breaks(chunk, hp * 0.1f), "a hit on an already-broken chunk did not break it"); Assert.False(f.Zone.ChunkExists(chunk), "must still be absent -- the old bug un-broke it here"); Assert.Equal(0f, f.Zone.ChunkRadius(chunk)); // A second hit, this one alone well over hitpoints, inside the same window: still must not break it // (it is already broken) and must not push BrokenUntil further out. f.Zone.Update(respawn * 0.3f); - Assert.False(f.Zone.Wear(chunk, hp + 50f), "a hit on an already-broken chunk did not break it, even when large"); + Assert.False(f.Zone.Breaks(chunk, hp + 50f), "a hit on an already-broken chunk did not break it, even when large"); Assert.False(f.Zone.ChunkExists(chunk)); // Just under the original respawn time (measured from the original break, not from either re-hit): @@ -360,9 +360,9 @@ public sealed class MiningCut2Tests : IDisposable Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), f.Zone.ChunkRadius(chunk), 4); // Worn again from fresh: breaks on the normal threshold, not one warped by the earlier no-op hits. - Assert.False(f.Zone.Wear(chunk, hp * 0.9f)); + Assert.False(f.Zone.Breaks(chunk, hp * 0.9f)); Assert.True(f.Zone.ChunkExists(chunk)); - Assert.True(f.Zone.Wear(chunk, hp * 0.2f)); + Assert.True(f.Zone.Breaks(chunk, hp * 0.2f)); Assert.False(f.Zone.ChunkExists(chunk)); } @@ -377,9 +377,9 @@ public sealed class MiningCut2Tests : IDisposable var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsB[idx].Size); var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsB[idx].Size); - Assert.True(f.Zone.Wear(chunk, hp + 1f)); + Assert.True(f.Zone.Breaks(chunk, hp + 1f)); f.Zone.Update(respawn * 0.4f); - Assert.False(f.Zone.Wear(chunk, hp * 0.5f), "re-hit inside the window did not break an already-broken chunk"); + Assert.False(f.Zone.Breaks(chunk, hp * 0.5f), "re-hit inside the window did not break an already-broken chunk"); var pack = f.Zone.PackZone(); var reloaded = new Zone(f.Items, f.Settings, pack, new GalaxyZone { Name = "MiningCut2ReHitReload", Owner = null }, null); @@ -424,7 +424,7 @@ public sealed class MiningCut2Tests : IDisposable var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); - Assert.True(f.Zone.Wear(chunk, hp + 1f)); + Assert.True(f.Zone.Breaks(chunk, hp + 1f)); f.Zone.Update(respawn); // zone time now equals BrokenUntil exactly, not "respawn plus a margin". Assert.True(f.Zone.ChunkExists(chunk), "must read as respawned the instant zone time reaches BrokenUntil, not strictly after it"); @@ -458,11 +458,11 @@ public sealed class MiningCut2Tests : IDisposable var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); - Assert.True(f.Zone.Wear(chunk, hp + 1f)); + Assert.True(f.Zone.Breaks(chunk, hp + 1f)); f.Zone.Update(respawn); Assert.True(f.Zone.ChunkExists(chunk)); - Assert.True(f.Zone.Wear(chunk, hp + 1f), "a hit on the instant of respawn breaks the respawned chunk again"); + Assert.True(f.Zone.Breaks(chunk, hp + 1f), "a hit on the instant of respawn breaks the respawned chunk again"); Assert.False(f.Zone.ChunkExists(chunk)); } @@ -477,9 +477,9 @@ public sealed class MiningCut2Tests : IDisposable var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); - Assert.True(f.Zone.Wear(chunk, hp + 1f)); + Assert.True(f.Zone.Breaks(chunk, hp + 1f)); f.Zone.Update(respawn + 1f); - Assert.False(f.Zone.Wear(chunk, hp * 0.3f)); + Assert.False(f.Zone.Breaks(chunk, hp * 0.3f)); var packed = f.Zone.PackZone().ChunkWear.Find(w => w.Field.Equals(chunk.Field) && w.Index == index); Assert.NotNull(packed); @@ -501,12 +501,12 @@ public sealed class MiningCut2Tests : IDisposable var undamaged = f.Zone.ChunkPose(f.BeltA, index).w; Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), undamaged, 4); - Assert.False(f.Zone.Wear(chunk, hp * 0.6f)); + Assert.False(f.Zone.Breaks(chunk, hp * 0.6f)); var worn = f.Zone.ChunkPose(f.BeltA, index).w; Assert.Equal(f.Zone.ChunkRadius(chunk), worn, 4); Assert.True(worn < undamaged, "a worn chunk is smaller than an untouched one"); - Assert.True(f.Zone.Wear(chunk, hp)); + Assert.True(f.Zone.Breaks(chunk, hp)); Assert.Equal(0f, f.Zone.ChunkPose(f.BeltA, index).w); } } @@ -520,3 +520,15 @@ internal static class MiningCut2TestExtensions return list; } } + +internal static class ChunkWearTestExtensions +{ + // Wears the chunk and reports whether that hit broke it: Zone.Wear returns the damage absorbed, and a break is + // ChunkExists going from true to false. + public static bool Breaks(this Zone zone, ChunkId chunk, float damage) + { + var there = zone.ChunkExists(chunk); + zone.Wear(chunk, damage); + return there && !zone.ChunkExists(chunk); + } +} diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 76a67472a..95dbacd1f 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -24,6 +24,9 @@ public sealed partial class MiningCut3Tests : IDisposable private readonly List _caches = new List(); private int _fixtureCount; + // A run seed for the scenes built next: nonzero gives the zone a galaxy carrying it (so Zone.DropSeed differs by it). + internal uint GalaxySeed; + public MiningCut3Tests() => Directory.CreateDirectory(_root); public void Dispose() @@ -56,6 +59,8 @@ public sealed partial class MiningCut3Tests : IDisposable AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, AsteroidHitpoints = new ExponentialLerp { Minimum = 20f, Maximum = 220f, Exponent = 1.6f }, AsteroidRespawnTime = new ExponentialLerp { Minimum = 8f, Maximum = 96f, Exponent = 1.2f }, + AsteroidYield = new ExponentialLerp { Minimum = 2f, Maximum = 6f, Exponent = 1f }, + ChunkCompositionVariance = .5f, BodyRadius = new ExponentialCurve(), LightRadius = new ExponentialCurve { Constant = lightRadius } }; @@ -161,7 +166,8 @@ public sealed partial class MiningCut3Tests : IDisposable pack.Planets.Add(new CultRecordRef(key)); scene.Belts.Add(key); } - scene.Zone = new Zone(items, PlanetSettings(lightRadius), pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, null); + scene.Zone = new Zone(items, PlanetSettings(lightRadius), pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, + GalaxySeed == 0 ? null : RunSeedFixture.Continue(cache, "MiningCut3", GalaxySeed)); return scene; } @@ -416,7 +422,7 @@ public sealed partial class MiningCut3Tests : IDisposable var whole = s.Zone.ChunkVisibility(chunk); var hitpoints = PlanetSettings().AsteroidHitpoints.Evaluate(.5f); - Assert.False(s.Zone.Wear(chunk, hitpoints * .5f)); + Assert.False(s.Zone.Breaks(chunk, hitpoints * .5f)); var worn = s.Zone.ChunkVisibility(chunk); Assert.InRange(worn, whole * .05f, whole * .95f); Assert.Equal(ExpectedVisibility(s, chunk, kind), worn, 4); @@ -433,7 +439,7 @@ public sealed partial class MiningCut3Tests : IDisposable Tick((observer, eyeAt)); Assert.True(observer.ChunkVisible(chunk)); - Assert.True(s.Zone.Wear(chunk, PlanetSettings().AsteroidHitpoints.Evaluate(.5f) * 2f)); + Assert.True(s.Zone.Breaks(chunk, PlanetSettings().AsteroidHitpoints.Evaluate(.5f) * 2f)); Assert.Equal(0f, s.Zone.ChunkVisibility(chunk)); Assert.Equal(0f, observer.ChunkInfo(chunk)); Assert.False(observer.SetTarget(chunk)); @@ -487,7 +493,7 @@ public sealed partial class MiningCut3Tests : IDisposable var centre = float2(400, -250); // off the origin, so a query that ignored the belt centre would disagree var s = BuildSceneAt(centre, new[] { kind }, Belt(kind, rocks)); var belt = s.Belts[0]; - Assert.True(s.Zone.Wear(new ChunkId(belt, 5), 1e6f)); // broken + Assert.True(s.Zone.Breaks(new ChunkId(belt, 5), 1e6f)); // broken var found = new List(); var sawSome = false; @@ -555,7 +561,7 @@ public sealed partial class MiningCut3Tests : IDisposable Assert.True(watcher.SetTarget(second)); Tick((watcher, eyeAt)); Assert.False(watcher.Target.Value.IsNone); - Assert.True(s.Zone.Wear(second, 1e6f)); + Assert.True(s.Zone.Breaks(second, 1e6f)); Tick((watcher, eyeAt)); Assert.True(watcher.Target.Value.IsNone); @@ -565,7 +571,7 @@ public sealed partial class MiningCut3Tests : IDisposable var third = new ChunkId(s.Belts[0], 2); Assert.True(docked.SetTarget(third)); docked.Deactivate(); - Assert.True(s.Zone.Wear(third, 1e6f)); + Assert.True(s.Zone.Breaks(third, 1e6f)); docked.Activate(); Assert.True(docked.Target.Value.IsNone); } diff --git a/tests/Aetheria.Shared.Tests/MiningCut4aTests.cs b/tests/Aetheria.Shared.Tests/MiningCut4aTests.cs index 7d43c86d3..49fc75886 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut4aTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut4aTests.cs @@ -181,7 +181,7 @@ public sealed partial class MiningCut3Tests { var (s, rock) = OneRock(); var at = At(s, rock); - Assert.True(s.Zone.Wear(rock, 1e6f)); + Assert.True(s.Zone.Breaks(rock, 1e6f)); var subs = new List(); var absorbs = Absorbs(s, subs); FireControl.Detonate(s.Zone, at, 8f, 10f, DamageType.Kinetic); diff --git a/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs b/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs index 351e875a2..abf4f0dc1 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs @@ -22,7 +22,7 @@ public sealed class MiningCut4bTests : IDisposable public void Dispose() => _fixtures.Dispose(); - private sealed class Rig + internal sealed class Rig { public MiningCut3Tests.Scene Scene; public Zone Zone => Scene.Zone; @@ -56,11 +56,12 @@ public sealed class MiningCut4bTests : IDisposable } // `precision` 1 gives sigma 1 cell; accuracy 1 and a resolution that asks little of the info make PSensor 1. - private Rig Build(bool sensor = true, bool launcher = false, float damage = 10f, float velocity = 0f, float spread = 0f, + internal Rig Build(bool sensor = true, bool launcher = false, float damage = 10f, float velocity = 0f, float spread = 0f, float precision = 1f, float effective = float.PositiveInfinity, WeaponFuse? fuse = null, float blastRadius = 0f, - float rockSize = 0f, float crossSection = 20f, bool target = true, float tracking = 1000000f) + float rockSize = 0f, float crossSection = 20f, bool target = true, float tracking = 1000000f, FieldKindData kind = null, uint galaxySeed = 0) { - var kind = MiningCut3Tests.Kind("Asteroid", crossSection); + _fixtures.GalaxySeed = galaxySeed; + kind ??= MiningCut3Tests.Kind("Asteroid", crossSection); var scene = _fixtures.BuildScene(new[] { kind }, MiningCut3Tests.Belt(kind, MiningCut3Tests.Rock(Distance, .25f, rockSize))); var gear = MiningCut3Tests.Gear("Targeting", new TargetingSystemData { @@ -209,7 +210,7 @@ public sealed class MiningCut4bTests : IDisposable // ==== resolution ==== - private static void Run(Rig rig, float seconds) + internal static void Run(Rig rig, float seconds) { for (var elapsed = 0f; elapsed < seconds; elapsed += .01f) rig.Zone.Update(.01f); } @@ -291,7 +292,7 @@ public sealed class MiningCut4bTests : IDisposable FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter); Assert.Empty(rig.Resolved); - Assert.True(rig.Zone.Wear(rig.Rock, 1e6f)); + Assert.True(rig.Zone.Breaks(rig.Rock, 1e6f)); Run(rig, 2.5f); var outcome = Assert.Single(rig.Resolved); @@ -383,7 +384,7 @@ public sealed class MiningCut4bTests : IDisposable Assert.True(frozen.Hit, "fixture: the commit rolled a hit"); Assert.Empty(rig.Resolved); - Assert.True(rig.Zone.Wear(rig.Rock, 1e6f)); + Assert.True(rig.Zone.Breaks(rig.Rock, 1e6f)); Run(rig, 2.5f); var outcome = Assert.Single(rig.Resolved); diff --git a/tests/Aetheria.Shared.Tests/MiningCut5aTests.cs b/tests/Aetheria.Shared.Tests/MiningCut5aTests.cs new file mode 100644 index 000000000..d71607031 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut5aTests.cs @@ -0,0 +1,311 @@ +/* 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 CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; + +// Mining Cut 5a (docs/mining-cut-refresh.md): a worn rock pays the shooter in ore at the wear site, from the shot's own +// dice; what does not fit in the hold floats at the rock. The fixtures are Cut 3's belt scene and Cut 4b's rig. +public sealed class MiningCut5aTests : IDisposable +{ + private readonly MiningCut4bTests _rigs = new MiningCut4bTests(); + private readonly MiningCut3Tests _scenes = new MiningCut3Tests(); + + public void Dispose() + { + _rigs.Dispose(); + _scenes.Dispose(); + } + + private static SimpleCommodityData Commodity(string name, int maxStack = 100) => + new SimpleCommodityData { Name = name, Shape = new Shape(), Price = 5, MaxStack = maxStack }; + + // Registers the ores and the kind's yields in the scene's catalog (the kind refs need the stored commodities). + private static void Stock(MiningCut3Tests.Scene scene, FieldKindData kind, params (SimpleCommodityData Ore, float Abundance)[] yields) + { + kind.Yields.Clear(); + foreach (var (ore, abundance) in yields) + { + scene.Cache.Upsert(ore); + kind.Yields.Add(new FieldYield { Commodity = scene.Cache.RefOf(ore), Abundance = abundance }); + } + scene.Cache.Upsert(kind); + } + + private EquippedCargoBay Bay(MiningCut3Tests.Scene scene, Ship ship, int width, int height, string name) + { + var shape = new Shape(width, height); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var data = new CargoBayData { Name = name, Shape = new Shape(), InteriorShape = shape, Price = 1, Durability = 4 }; + scene.Cache.Upsert(data); + var bay = new EquippedCargoBay(scene.Items, MiningCut3Tests.Mint(scene, data), new int2(1, 1), ship, name); + ship.CargoBays.Add(bay); + return bay; + } + + private static int Units(IEnumerable items, string ore, MiningCut3Tests.Scene scene) => + items.OfType().Where(c => scene.Items.GetData(c).Name == ore).Sum(c => c.Quantity); + + private static int Units(EquippedCargoBay bay, string ore, MiningCut3Tests.Scene scene) => Units(bay.Cargo.Keys, ore, scene); + + // A rig whose rock (size .5: 4 units, whole) yields `ores`, shooter holding one bay. + private (MiningCut4bTests.Rig Rig, EquippedCargoBay Bay) Mining(int bayWidth = 4, int bayHeight = 4, float damage = 1000f, + WeaponFuse? fuse = null, float velocity = 0f, uint galaxySeed = 0, float rockSize = .5f, params (SimpleCommodityData, float)[] ores) + { + var kind = MiningCut3Tests.Kind("Asteroid", 20f); + var rig = _rigs.Build(damage: damage, fuse: fuse, blastRadius: fuse == null ? 0f : 6f, precision: 1000f, rockSize: rockSize, velocity: velocity, kind: kind, galaxySeed: galaxySeed); + Stock(rig.Scene, kind, ores.Length > 0 ? ores : new[] { (Commodity("Rock"), 1f) }); + return (rig, Bay(rig.Scene, rig.Shooter, bayWidth, bayHeight, "Hold")); + } + + private static int Hits(MiningCut4bTests.Rig rig, int cap = 40) + { + for (var shot = 0; shot < cap && !rig.Resolved.Any(o => o.Hit); shot++) + { + // A broken rock cannot be targeted again: the first hit ends the loop. + FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter); + MiningCut4bTests.Run(rig, .05f); + } + return rig.Resolved.Count(o => o.Hit); + } + + private static float Hitpoints(MiningCut4bTests.Rig rig) => + rig.Zone.Settings.AsteroidHitpoints.Evaluate(rig.Zone.AsteroidBelts[rig.Rock.Field].Data.Asteroids[rig.Rock.Index].Size); + + [Fact] + public void WearReturnsWhatTheRockAbsorbed() + { + var (rig, _) = Mining(); + var hp = Hitpoints(rig); + Assert.Equal(hp * .3f, rig.Zone.Wear(rig.Rock, hp * .3f), 3); + // The breaking hit absorbs only what the rock had left. + Assert.Equal(hp * .7f, rig.Zone.Wear(rig.Rock, hp * 5f), 3); + Assert.False(rig.Zone.ChunkExists(rig.Rock)); + Assert.Equal(0f, rig.Zone.Wear(rig.Rock, hp)); + } + + // Units owed are absorbed/hitpoints x AsteroidYield(size): a rock pays its whole yield however many hits break it, so + // the mean over many rocks does not depend on how the damage arrives. + [Fact] + public void ARockYieldsItsAuthoredTotalWhateverBreaksIt() + { + const int rocks = 300; + var ore = Commodity("Rock"); + var kind = MiningCut3Tests.Kind("Asteroid", 4f); + var scene = _scenes.BuildScene(new[] { kind }, MiningCut3Tests.Belt(kind, + Enumerable.Range(0, rocks).Select(i => MiningCut3Tests.Rock(150f + i * .1f, i / (float) rocks, .3f)).ToArray())); + Stock(scene, kind, (ore, 1f)); + var ship = new Ship(scene.Items, scene.Zone, MiningCut3Tests.Mint(scene, scene.Hull), new EntitySettings()); + scene.Zone.Admit(ship, piloted: false); + var bay = Bay(scene, ship, 8, 8, "Hold"); + var settings = scene.Zone.Settings; + var hp = settings.AsteroidHitpoints.Evaluate(.3f); + var owed = settings.AsteroidYield.Evaluate(.3f); + + uint ordinal = 0; + void Hit(int rock, float fraction) => + FireControl.Extract(scene.Zone, new ChunkId(scene.Belts[0], rock), scene.Zone.Wear(new ChunkId(scene.Belts[0], rock), hp * fraction), + ship, SimulationDice.For(scene.Zone.DropSeed, SimulationDice.OreStream, ++ordinal)); + + for (var rock = 0; rock < rocks / 2; rock++) Hit(rock, 2f); + var wholeHits = Units(bay.Cargo.Keys.Concat(scene.Zone.FloatingItems.Select(f => f.Item)), "Rock", scene); + for (var rock = rocks / 2; rock < rocks; rock++) for (var part = 0; part < 4; part++) Hit(rock, .25f); + var total = Units(bay.Cargo.Keys.Concat(scene.Zone.FloatingItems.Select(f => f.Item)), "Rock", scene); + var quartered = total - wholeHits; + + // owed = 3.2 per rock; 150 rocks: mean 480, sigma about 4.9 whole, 4.9 quartered. + Assert.InRange(wholeHits, 480 - 30, 480 + 30); + Assert.InRange(quartered, 480 - 30, 480 + 30); + Assert.Equal(3.2f, owed, 3); + } + + [Fact] + public void ADirectHitPaysTheShooter() + { + var (rig, bay) = Mining(ores: new[] { (Commodity("Rock"), 1f), (Commodity("Gold"), 1f) }); + var yields = FieldKinds.Yield(rig.Zone.AsteroidBelts[rig.Rock.Field].Data.Kind, default, rig.Scene.Cache); + Assert.Equal(2, yields.Length); + Assert.Equal(1, Hits(rig)); + Assert.False(rig.Zone.ChunkExists(rig.Rock)); + Assert.Equal(4, Units(bay, "Rock", rig.Scene) + Units(bay, "Gold", rig.Scene)); + Assert.Empty(rig.Zone.FloatingItems); + } + + [Fact] + public void ABlastPaysTheShooter() + { + var (rig, bay) = Mining(fuse: WeaponFuse.Contact, ores: new[] { (Commodity("Rock"), 1f) }); + Hits(rig); + Assert.False(rig.Zone.ChunkExists(rig.Rock)); + Assert.Equal(4, Units(bay, "Rock", rig.Scene)); + } + + [Fact] + public void AMineWearsButYieldsNothing() + { + var (rig, bay) = Mining(); + var at = rig.Zone.ChunkPose(rig.Rock.Field, rig.Rock.Index).xy; + FireControl.Detonate(rig.Zone, at, 6f, 1000f, DamageType.Kinetic); + Assert.False(rig.Zone.ChunkExists(rig.Rock)); + Assert.Empty(bay.Cargo); + Assert.Empty(rig.Zone.FloatingItems); + } + + // Ruling mining-overflow-floats: what the hold cannot take floats at the rock, where another ship can pick it up. + [Fact] + public void AFullHoldReleasesTheOverflow() + { + var grit = Commodity("Grit", maxStack: 2); + var (rig, bay) = Mining(bayWidth: 1, bayHeight: 1, ores: new[] { (grit, 1f) }); + Hits(rig); + + Assert.Equal(2, Units(bay, "Grit", rig.Scene)); + var floating = Assert.Single(rig.Zone.FloatingItems); + Assert.Equal(2, Assert.IsType(floating.Item).Quantity); + var rock = rig.Zone.ChunkPose(rig.Rock.Field, rig.Rock.Index).xy; + Assert.True(length(floating.Body.Position - rock) < 1f, "the overflow leaves from the rock"); + + var taker = new Ship(rig.Scene.Items, rig.Zone, MiningCut3Tests.Mint(rig.Scene, rig.Scene.Hull), new EntitySettings()); + rig.Zone.Admit(taker, piloted: false); + var takerBay = Bay(rig.Scene, taker, 4, 4, "Taker"); + Assert.True(rig.Zone.TryPickUp(taker, floating.Id)); + Assert.Equal(2, Units(takerBay, "Grit", rig.Scene)); + } + + [Fact] + public void AGoneShooterReleasesEverything() + { + var (rig, bay) = Mining(velocity: 50f); + FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter); + rig.Shooter.HeatstrokeDeath.OnNext(UniRx.Unit.Default); + MiningCut4bTests.Run(rig, 3f); + + Assert.Empty(bay.Cargo); + Assert.Equal(4, Units(rig.Zone.FloatingItems.Select(f => f.Item), "Rock", rig.Scene)); + } + + // Two zones of one pack share every key, so the same rocks wear in each: the ore is a function of the dice alone. + private (MiningCut3Tests.Scene Scene, Func NewZone, Ship Shooter) Field(int rocks, float size, float variance = .5f, float yield = 4f) + { + var kind = MiningCut3Tests.Kind("Asteroid", 4f); + var scene = _scenes.BuildScene(new[] { kind }, MiningCut3Tests.Belt(kind, + Enumerable.Range(0, rocks).Select(i => MiningCut3Tests.Rock(150f + i, i / (float) rocks, size)).ToArray())); + Stock(scene, kind, (Commodity("Rock"), 1f), (Commodity("Gold"), 1f)); + var ship = new Ship(scene.Items, scene.Zone, MiningCut3Tests.Mint(scene, scene.Hull), new EntitySettings()); + Zone NewZone() + { + var settings = MiningCut3Tests.PlanetSettings(); + settings.ChunkCompositionVariance = variance; + settings.AsteroidYield = new ExponentialLerp { Minimum = yield, Maximum = yield, Exponent = 1f }; + return new Zone(scene.Items, settings, scene.Pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, null); + } + return (scene, NewZone, ship); + } + + // Every rock broken once; the shooter is not in the zone, so everything floats and the floats are the record. + private static (int Rock, int Gold)[] Break(MiningCut3Tests.Scene scene, Zone zone, Ship ship, int rocks, uint seed) => + Enumerable.Range(0, rocks).Select(rock => + { + var chunk = new ChunkId(scene.Belts[0], rock); + var before = zone.FloatingItems.Select(f => f.Item).ToArray(); + FireControl.Extract(zone, chunk, zone.Wear(chunk, 1e6f), ship, SimulationDice.For(seed, SimulationDice.OreStream, (uint) rock)); + var added = zone.FloatingItems.Select(f => f.Item).Except(before).ToArray(); + return (Units(added, "Rock", scene), Units(added, "Gold", scene)); + }).ToArray(); + + [Fact] + public void TheSameShotGivesTheSameOre() + { + var (scene, newZone, ship) = Field(20, .5f); + var first = Break(scene, newZone(), ship, 20, 11); + Assert.Equal(first, Break(scene, newZone(), ship, 20, 11)); + Assert.NotEqual(first, Break(scene, newZone(), ship, 20, 12)); + } + + // Composition is derived from the rock's identity every call: variance 0 reads the kind's ratio, variance above 0 gives + // rocks different mixes, and nothing stores it (a second zone of the pack reads the same mixes from the dice and keys). + [Fact] + public void CompositionVariesByRockAndIsDerived() + { + var (scene, even, ship) = Field(12, 1f, variance: 0f, yield: 600f); + // Equal weights, 600 units a rock: even splits within sampling noise (sigma about 12). + Assert.All(Break(scene, even(), ship, 12, 5), m => Assert.InRange(m.Rock, 240, 360)); + + var (scene2, varied, ship2) = Field(12, 1f, variance: 2f, yield: 600f); + var mixes = Break(scene2, varied(), ship2, 12, 5).Select(m => m.Rock).ToArray(); + Assert.True(mixes.Max() - mixes.Min() > 100, "rocks differ in mix when variance is above zero"); + Assert.Equal(mixes, Break(scene2, varied(), ship2, 12, 5).Select(m => m.Rock)); + } + + // ==== the shot's own dice: ShotId and the zone's DropSeed key the ore ==== + + // A rig whose rock outlives many shots and pays hundreds of units a hit, three ores, so a changed key shows in the mix. The rock is + // wider than the blast, so every blast wears it by the same share and only the dice can tell two shots apart. + private (MiningCut4bTests.Rig Rig, EquippedCargoBay Bay) Rich(WeaponFuse? fuse, float damage, uint galaxySeed = 0) + { + var (rig, bay) = Mining(bayWidth: 8, bayHeight: 8, damage: damage, fuse: fuse, galaxySeed: galaxySeed, rockSize: 1f, + ores: new[] { (Commodity("Rock"), 1f), (Commodity("Gold"), 1f), (Commodity("Ice"), 1f) }); + rig.Zone.Settings.AsteroidYield = new ExponentialLerp { Minimum = 6000f, Maximum = 6000f, Exponent = 1f }; + // Composition hashes the belt's record key, which differs per scene; variance 0 leaves the dice alone to vary the ore. + rig.Zone.Settings.ChunkCompositionVariance = 0f; + return (rig, bay); + } + + // Fires until `hits` shots have landed, returning each landed shot's payout (bay and floats together) as (Rock, Gold, Ice). + private static (int, int, int)[] Payouts(MiningCut4bTests.Rig rig, EquippedCargoBay bay, int hits) + { + IEnumerable Held() => bay.Cargo.Keys.Concat(rig.Zone.FloatingItems.Select(f => f.Item)); + var paid = new List<(int, int, int)>(); + var before = (0, 0, 0); + for (var shot = 0; shot < 100 && paid.Count < hits; shot++) + { + var landed = rig.Resolved.Count(o => o.Hit); + FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter); + MiningCut4bTests.Run(rig, .05f); + if (rig.Resolved.Count(o => o.Hit) == landed) continue; + Assert.True(rig.Zone.ChunkExists(rig.Rock), "the rock must outlive the shots under test"); + var held = Held().ToArray(); + var now = (Units(held, "Rock", rig.Scene), Units(held, "Gold", rig.Scene), Units(held, "Ice", rig.Scene)); + paid.Add((now.Item1 - before.Item1, now.Item2 - before.Item2, now.Item3 - before.Item3)); + before = now; + } + Assert.Equal(hits, paid.Count); + return paid.ToArray(); + } + + // Which ore lands in which slot follows each scene's catalog keys, so scenes compare by the multiset of counts. + private static int[][] Mix(IEnumerable<(int, int, int)> paid) => paid.Select(p => new[] { p.Item1, p.Item2, p.Item3 }.OrderBy(n => n).ToArray()).ToArray(); + + [Theory] + [InlineData(null, 8f)] + [InlineData(WeaponFuse.Contact, 40f)] + public void TwoShotsInOneZonePayDifferentOre(WeaponFuse? fuse, float damage) + { + var (rig, bay) = Rich(fuse, damage); + var paid = Payouts(rig, bay, 2); + Assert.All(paid, p => Assert.True(p.Item1 + p.Item2 + p.Item3 > 100, "a shot pays hundreds of units")); + Assert.NotEqual(paid[0], paid[1]); + } + + [Theory] + [InlineData(null, 8f)] + [InlineData(WeaponFuse.Contact, 40f)] + public void ADifferentRunSeedPaysDifferentOreForTheSameShot(WeaponFuse? fuse, float damage) + { + var (a, bayA) = Rich(fuse, damage, galaxySeed: 7); + var (b, bayB) = Rich(fuse, damage, galaxySeed: 8); + Assert.NotEqual(a.Zone.DropSeed, b.Zone.DropSeed); + Assert.Equal(a.Zone.CombatSeed, b.Zone.CombatSeed); + Assert.NotEqual(Mix(Payouts(a, bayA, 1)), Mix(Payouts(b, bayB, 1))); + // The same seed repeats it. + var (c, bayC) = Rich(fuse, damage, galaxySeed: 7); + var (d, bayD) = Rich(fuse, damage, galaxySeed: 7); + Assert.Equal(Mix(Payouts(c, bayC, 1)), Mix(Payouts(d, bayD, 1))); + } +} diff --git a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs index e47f9a4a1..a90f8343b 100644 --- a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs @@ -70,7 +70,7 @@ public sealed partial class MiningCut3Tests var centre = float2(-300, 820); var s = BuildSceneAt(centre, 5000f, 1234.5, null, new[] { kind }, Belt(kind, Annulus(rng, 3000, 200f, 900f)), Belt(kind, Annulus(rng, 700, 1000f, 1060f))); - for (var i = 0; i < 40; i++) Assert.True(s.Zone.Wear(new ChunkId(s.Belts[0], i * 73), 1e6f)); + for (var i = 0; i < 40; i++) Assert.True(s.Zone.Breaks(new ChunkId(s.Belts[0], i * 73), 1e6f)); var found = new List(); var nonEmpty = 0; diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index 2bf4d3c2f..824ef7158 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -175,6 +175,46 @@ public sealed class StationServicesTests : IDisposable Assert.Contains(ore, open.Cargo.Keys); } + // The market absorbs a sold commodity (aetheria-release:ruling:station-market-absorbs-sold-commodity): a full hold + // stores what fits, the rest is taken, and the full price is paid; a hold with no room at all still pays. + [Fact] + public void TrySell_commodity_is_paid_in_full_when_the_station_hold_is_full() + { + var from = Ship().CargoBays.Single(); + var ore = Ore(10); + Assert.True(from.TryStore(ore)); + var station = Station(); + while (station.CargoBays[0].TryStore(Mint("Gun"))) { } + var credits = 0; + Assert.True(StationServices.TrySell(from, station, ore, ref credits)); + Assert.Equal(35, credits); + Assert.Empty(from.Cargo.Keys.OfType()); + Assert.Empty(station.CargoBays[0].Cargo.Keys.OfType()); + } + + // A commodity the seller does not hold sells for nothing: not one never stored, and not a stale copy of a stack the sale + // already took. The held stack is untouched, and no credit is paid. + [Fact] + public void TrySell_commodity_not_in_the_hold_pays_nothing() + { + var from = Ship().CargoBays.Single(); + var held = Ore(10); + Assert.True(from.TryStore(held)); + var station = Station(); + var credits = 500; + + Assert.False(StationServices.TrySell(from, station, Ore(10), ref credits)); + Assert.Equal(500, credits); + Assert.Empty(station.CargoBays.Single().Cargo.Keys.OfType()); + Assert.Equal(10, Assert.Single(from.Cargo.Keys.OfType()).Quantity); + + Assert.True(StationServices.TrySell(from, station, held, ref credits)); + var paid = credits; + Assert.False(StationServices.TrySell(from, station, held, ref credits)); + Assert.Equal(paid, credits); + Assert.Equal(10, station.CargoBays.Single().Cargo.Keys.OfType().Sum(c => c.Quantity)); + } + [Fact] public void Repair_restores_and_charges() { diff --git a/tools/AetherDb/Economy/DemoOre.cs b/tools/AetherDb/Economy/DemoOre.cs new file mode 100644 index 000000000..df2a2aff2 --- /dev/null +++ b/tools/AetherDb/Economy/DemoOre.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; + +// Interim ore sheet (ruling mining-demo-ore-minimal-yields-now): the Asteroid field kind yields Rock, New Metals and Gold, and those +// three can be held (a 1x1 shape, a mass) and are sold (a price). The economy ores batch may rewrite the yields by element id. +// Prices are ruling mining-demo-ore-prices-gear-scale, gear-scale so the mine, sell, repair loop closes: a retune is an edit here and a rerun. +// Munitions and MunitionsFeed never rewrite these three commodities, so rerunning them does not clobber this sheet. +public sealed class DemoOre : EconomyBatch +{ + private const int RockPrice = 200; + private const int NewMetalsPrice = 1000; + private const int GoldPrice = 5000; + + // Authored element ids (the cache takes a hand-written id only as 12 lowercase hex): asteroid-rock, asteroid-new-metals, asteroid-gold. + private const string RockId = "a57e0c000001"; + private const string NewMetalsId = "a57e0c000002"; + private const string GoldId = "a57e0c000003"; + + public override void Author(EconomyAuthor author) + { + author.Edit("Rock", o => { o.Shape = new Shape(); o.Price = RockPrice; }); + author.Edit("New Metals", o => { o.Shape = new Shape(); o.Price = NewMetalsPrice; o.Mass = 2; }); + author.Edit("Gold", o => { o.Shape = new Shape(); o.Price = GoldPrice; }); + + var kinds = author.Catalog.GetAll().Where(k => k.Name == "Asteroid").ToArray(); + if (kinds.Length != 1) throw new InvalidOperationException($"{kinds.Length} field kinds are named 'Asteroid'; the ore sheet needs exactly one."); + var asteroid = kinds[0]; + asteroid.Yields = new List + { + Yield(author, "Rock", 6, RockId), + Yield(author, "New Metals", 3, NewMetalsId), + Yield(author, "Gold", 1, GoldId) + }; + author.Put(asteroid, author.Catalog.RefOf(asteroid).Key); + } + + private static FieldYield Yield(EconomyAuthor author, string name, float abundance, string id) + { + var item = author.Catalog.GetAll().Single(i => i.Name == name); + return new FieldYield { Commodity = new CultRecordRef(author.Catalog.RefOf(item).Key), Abundance = abundance, Regional = false, Id = id }; + } +}