Mining cut 5a: a worn rock pays the shooter in ore; demo loop closes (#19) #19

Merged
nibu merged 5 commits from eureka/aetheria-release-mining-5a into master 2026-11-13 02:58:44 +00:00
18 changed files with 729 additions and 64 deletions

BIN
Assets/Resources/Settings.asset (Stored with Git LFS)

Binary file not shown.

View file

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

View file

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

View file

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

View file

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

View file

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

BIN
GameData/Aetheria.cc (Stored with Git LFS)

Binary file not shown.

View file

@ -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<Weapon>().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<SimpleCommodity>().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<SimpleCommodity>()), 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<CraftedItemInstance>().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);
}
}

View file

@ -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<FieldKindData> Asteroid(CultCache cache) =>
cache.RefOf(cache.GetAll<FieldKindData>().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>("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<EquippableItemData>(design);
return new EquippableItem
{
Data = cache.RefOf<ItemData>(data), Durability = data.Durability,
Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(data), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
};
}
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<ItemData>(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<EconomyBatch> { 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<FieldKindData>().Single(k => k.Name == "Asteroid").Yields.Select(y => y.Id).ToArray();
Assert.Equal(new[] { "a57e0c000001", "a57e0c000002", "a57e0c000003" }, ids);
}
}

View file

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

View file

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

View file

@ -24,6 +24,9 @@ public sealed partial class MiningCut3Tests : IDisposable
private readonly List<CultCache> _caches = new List<CultCache>();
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<BodyData>(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<ChunkId>();
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);
}

View file

@ -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<IDisposable>();
var absorbs = Absorbs(s, subs);
FireControl.Detonate(s.Zone, at, 8f, 10f, DamageType.Kinetic);

View file

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

View file

@ -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<ItemInstance> items, string ore, MiningCut3Tests.Scene scene) =>
items.OfType<SimpleCommodity>().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<SimpleCommodity>(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<Zone> 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<ItemInstance> 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)));
}
}

View file

@ -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<ChunkId>();
var nonEmpty = 0;

View file

@ -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<SimpleCommodity>());
Assert.Empty(station.CargoBays[0].Cargo.Keys.OfType<SimpleCommodity>());
}
// 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<SimpleCommodity>());
Assert.Equal(10, Assert.Single(from.Cargo.Keys.OfType<SimpleCommodity>()).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<SimpleCommodity>().Sum(c => c.Quantity));
}
[Fact]
public void Repair_restores_and_charges()
{

View file

@ -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<SimpleCommodityData>("Rock", o => { o.Shape = new Shape(); o.Price = RockPrice; });
author.Edit<SimpleCommodityData>("New Metals", o => { o.Shape = new Shape(); o.Price = NewMetalsPrice; o.Mass = 2; });
author.Edit<SimpleCommodityData>("Gold", o => { o.Shape = new Shape(); o.Price = GoldPrice; });
var kinds = author.Catalog.GetAll<FieldKindData>().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<FieldYield>
{
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<SimpleCommodityData>().Single(i => i.Name == name);
return new FieldYield { Commodity = new CultRecordRef<SimpleCommodityData>(author.Catalog.RefOf(item).Key), Abundance = abundance, Regional = false, Id = id };
}
}