Mining cut 5a: a worn rock pays the shooter in ore; demo loop closes (#19) #19
18 changed files with 729 additions and 64 deletions
BIN
Assets/Resources/Settings.asset
(Stored with Git LFS)
BIN
Assets/Resources/Settings.asset
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
Binary file not shown.
79
tests/Aetheria.Shared.Tests/DemoLoopTests.cs
Normal file
79
tests/Aetheria.Shared.Tests/DemoLoopTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
92
tests/Aetheria.Shared.Tests/DemoOreSheetTests.cs
Normal file
92
tests/Aetheria.Shared.Tests/DemoOreSheetTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
311
tests/Aetheria.Shared.Tests/MiningCut5aTests.cs
Normal file
311
tests/Aetheria.Shared.Tests/MiningCut5aTests.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
44
tools/AetherDb/Economy/DemoOre.cs
Normal file
44
tools/AetherDb/Economy/DemoOre.cs
Normal 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 };
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue