Authored ships carry crates; ballistic guns and mines draw rounds; the munitions chain is sourced #9
14 changed files with 406 additions and 42 deletions
|
|
@ -133,13 +133,13 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
public InstantWeapon(InstantWeaponData data, EquippedItem item) : base(data, item)
|
||||
{
|
||||
_data = data;
|
||||
_ammo = data.MagazineSize;
|
||||
_ammo = Magazine;
|
||||
}
|
||||
|
||||
public InstantWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item)
|
||||
{
|
||||
_data = data;
|
||||
_ammo = data.MagazineSize;
|
||||
_ammo = Magazine;
|
||||
}
|
||||
|
||||
protected void Trigger()
|
||||
|
|
@ -229,9 +229,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
if (!_coolingDown || !(_cooldown < 0)) return false;
|
||||
_overshoot = min(-_cooldown * CountdownSpan, dt);
|
||||
_coolingDown = false;
|
||||
if (_data.MagazineSize > 0 && _ammo == 0 && TakeReloadRound())
|
||||
if (Magazine > 0 && _ammo == 0 && TakeReloadRound())
|
||||
{
|
||||
_ammo = _data.MagazineSize;
|
||||
_ammo = Magazine;
|
||||
OnReloadComplete?.Invoke();
|
||||
}
|
||||
else
|
||||
|
|
@ -250,7 +250,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
return true;
|
||||
}
|
||||
|
||||
private float CountdownSpan => max(ShortestCycle, _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown);
|
||||
private float CountdownSpan => max(ShortestCycle, Magazine > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown);
|
||||
|
||||
// A countdown that starts outside a round (a charge failing) carries the last one's overshoot like every restart.
|
||||
protected void StartCountdown()
|
||||
|
|
@ -266,9 +266,13 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
return overshoot;
|
||||
}
|
||||
|
||||
// A weapon that names an AmmoType is magazine-fed whatever MagazineSize says: its magazine is at least one round, every round
|
||||
// leaves it and the reload takes a crate. A weapon with no AmmoType and a magazine of one or none fires free.
|
||||
private int Magazine => _data.AmmoType.IsSet() ? max(1, _data.MagazineSize) : _data.MagazineSize;
|
||||
|
||||
private bool UseAmmo(float dt)
|
||||
{
|
||||
if (_data.MagazineSize <= 1) return true;
|
||||
if (!_data.AmmoType.IsSet() && _data.MagazineSize <= 1) return true;
|
||||
|
||||
if (_ammo > 0)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -157,11 +157,12 @@ public static class Loadouts
|
|||
// spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does, and so does the
|
||||
// boss in ZoneGenerator, which is built here too from its faction's Loadout (Loadouts.BossOf). The hull's type
|
||||
// picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is
|
||||
// put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is
|
||||
// put; a station hull is refused. The entity is stocked with the crates its weapons draw (StockAmmunition) unless the caller
|
||||
// authors the cargo itself and passes stockAmmunition false. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is
|
||||
// padded with empty groups. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states
|
||||
// one for a fit its checks rest on).
|
||||
public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
|
||||
Func<CraftedItemData, FactionProductData> choose, List<string> failures, float? quality = null)
|
||||
Func<CraftedItemData, FactionProductData> choose, List<string> failures, float? quality = null, bool stockAmmunition = true)
|
||||
{
|
||||
var cache = itemManager.ItemData;
|
||||
var reported = failures.Count;
|
||||
|
|
@ -202,9 +203,24 @@ public static class Loadouts
|
|||
built[g] = (items.Select(item => item.GetBehavior<Weapon>()).ToList(), items);
|
||||
}
|
||||
entity.WeaponGroups = built;
|
||||
if (stockAmmunition) StockAmmunition(itemManager, entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
// The crates an entity's weapons reload from: LoadoutGenerator.AmmunitionCrates of each distinct AmmoType, stopping when no hold takes
|
||||
// another. The one owner of ammunition stock: every path that fits weapons onto a new entity (the generator, a Loadout's Materialize,
|
||||
// so a scenario's Fit, a boss, a console-built ship) ends here. A saved or packed entity is never stocked: its cargo is its own.
|
||||
// Draws nothing from the random stream, so adding it moves no other roll.
|
||||
public static void StockAmmunition(ItemManager itemManager, Entity entity)
|
||||
{
|
||||
foreach (var key in entity.Weapons.Select(weapon => weapon.WeaponData.AmmoType).Where(type => type.IsSet()).Select(type => type.Key).Distinct())
|
||||
{
|
||||
var crates = new SimpleCommodity { Data = new CultRecordRef<ItemData>(key), Quantity = LoadoutGenerator.AmmunitionCrates };
|
||||
if (!entity.CargoBays.Any(bay => bay.TryStore(crates)))
|
||||
itemManager.Log($"{entity.Name}: no room for {LoadoutGenerator.AmmunitionCrates} crates of {key.Value}.");
|
||||
}
|
||||
}
|
||||
|
||||
// The hull, then each slot's gear, made by the given builders and equipped to the loadout's cells: the one placement of
|
||||
// a loadout, run once on the throwaway manager to decide the fit and once on the live one to build. Null with every
|
||||
// misfit listed when any cell does not take its gear.
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ public class LoadoutGenerator
|
|||
OutfitEntity(entity);
|
||||
EquipPickup(entity);
|
||||
StockConsumables(entity, ShipConsumableChance, ShipConsumableUnits);
|
||||
StockAmmunition(entity);
|
||||
Loadouts.StockAmmunition(ItemManager, entity);
|
||||
return EntitySerializer.Pack(entity);
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ public class LoadoutGenerator
|
|||
var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings);
|
||||
entity.Faction = Faction;
|
||||
OutfitEntity(entity);
|
||||
StockAmmunition(entity);
|
||||
Loadouts.StockAmmunition(ItemManager, entity);
|
||||
return EntitySerializer.Pack(entity) as OrbitalEntityPack;
|
||||
}
|
||||
|
||||
|
|
@ -168,18 +168,6 @@ public class LoadoutGenerator
|
|||
}
|
||||
}
|
||||
|
||||
// The crates the ship's weapons reload from: AmmunitionCrates of each distinct AmmoType, stopping when no hold takes another.
|
||||
// Draws nothing from the random stream, so adding it moves no other roll.
|
||||
private void StockAmmunition(Entity entity)
|
||||
{
|
||||
foreach (var key in entity.Weapons.Select(weapon => weapon.WeaponData.AmmoType).Where(type => type.IsSet()).Select(type => type.Key).Distinct())
|
||||
{
|
||||
var crates = new SimpleCommodity { Data = new CultRecordRef<ItemData>(key), Quantity = AmmunitionCrates };
|
||||
if (!entity.CargoBays.Any(bay => bay.TryStore(crates)))
|
||||
ItemManager.Log($"{entity.Name}: no room for {AmmunitionCrates} crates of {key.Value}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHeater(GearData item) =>
|
||||
item.HardpointType == HardpointType.Tool &&
|
||||
item.Behaviors.Any(b => b is ThermotoggleData { HighPass: false }) &&
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ public static class RunStart
|
|||
if (!stage.PlayerPlaced)
|
||||
failures.Add("player: the scenario places no player");
|
||||
if (failures.Count > reported) return null;
|
||||
stage.StockUnauthoredAmmunition();
|
||||
|
||||
var player = stage.PlayerShip;
|
||||
player.IsPlayerShip = true;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ public sealed class ScenarioStage
|
|||
private readonly List<string> _failures = new List<string>();
|
||||
private readonly List<Placement> _placed = new List<Placement>();
|
||||
private int _placements;
|
||||
// The entities this stage built from a fit (a packed fit is a saved ship, whose cargo is its own) and those whose cargo the scenario
|
||||
// authored with Cargo: the rest are stocked with the crates their weapons draw when staging ends (StockUnauthoredAmmunition).
|
||||
private readonly HashSet<Entity> _fitted = new HashSet<Entity>();
|
||||
private readonly HashSet<Entity> _cargoAuthored = new HashSet<Entity>();
|
||||
|
||||
internal ScenarioStage(ItemManager items, Zone arena, string startingHull, TutorialGenerationSettings tutorialGenerationSettings)
|
||||
{
|
||||
|
|
@ -40,6 +44,14 @@ public sealed class ScenarioStage
|
|||
public int Credits { get; set; }
|
||||
|
||||
internal IReadOnlyList<string> Failures => _failures;
|
||||
|
||||
// Staging's last step: a fitted entity whose cargo the scenario did not author gets the crates its weapons draw, like every other
|
||||
// newly built armed entity (Loadouts.StockAmmunition). Draws nothing, so it moves no item roll.
|
||||
internal void StockUnauthoredAmmunition()
|
||||
{
|
||||
foreach (var entity in _fitted.Where(entity => !_cargoAuthored.Contains(entity)))
|
||||
Loadouts.StockAmmunition(_items, entity);
|
||||
}
|
||||
internal bool PlayerPlaced { get; private set; }
|
||||
internal Ship PlayerShip { get; private set; }
|
||||
internal IReadOnlyList<Placement> Placed => _placed;
|
||||
|
|
@ -160,6 +172,7 @@ public sealed class ScenarioStage
|
|||
public void Cargo(Entity entity, params string[] names)
|
||||
{
|
||||
if (entity == null) return;
|
||||
_cargoAuthored.Add(entity);
|
||||
var where = $"cargo of {entity.Name}";
|
||||
var bay = entity.CargoBays.FirstOrDefault();
|
||||
foreach (var name in names)
|
||||
|
|
@ -183,7 +196,8 @@ public sealed class ScenarioStage
|
|||
else
|
||||
{
|
||||
var failures = new List<string>();
|
||||
entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _choose, failures, fit.Quality);
|
||||
entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _choose, failures, fit.Quality, stockAmmunition: false);
|
||||
if (entity != null) _fitted.Add(entity);
|
||||
_failures.AddRange(failures.Select(failure => $"{where} ({fit.Loadout.Name}): {failure}"));
|
||||
if (entity == null) return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,12 +234,7 @@ public class SchematicDisplay : MonoBehaviour
|
|||
x.ListElement.CooldownFill.anchorMax = new Vector2(x.Cooldown.Progress, 1);
|
||||
x.ListElement.DurabilityLabel.text = $"{(int)(x.Row.Item.EquippableItem.Durability / itemData.Durability * 100)}%";
|
||||
if (x.Weapon.WeaponData.AmmoType.IsSet())
|
||||
{
|
||||
if(x.Weapon.WeaponData.MagazineSize > 1)
|
||||
x.ListElement.AmmoLabel.text = $"{x.Weapon.Ammo}/{x.Weapon.Reserve}";
|
||||
else
|
||||
x.ListElement.AmmoLabel.text = _entity.CountItemsInCargo(x.Weapon.WeaponData.AmmoType.Key).ToString();
|
||||
}
|
||||
x.ListElement.AmmoLabel.text = $"{x.Weapon.Ammo}/{x.Weapon.Reserve}";
|
||||
}
|
||||
|
||||
if (x.ListElement.RangeLabel)
|
||||
|
|
|
|||
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,4 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using static CultMath.math;
|
||||
using Xunit;
|
||||
|
||||
// Ruling ballistics-real-ammo: generation stocks combatants with rounds, because a magazine weapon reloads from cargo and a ship with
|
||||
|
|
@ -54,4 +56,114 @@ public sealed partial class RunStartTests
|
|||
}
|
||||
Assert.True(fed > 0, "fixture: some generated turret carries an ammunition-fed weapon");
|
||||
}
|
||||
|
||||
private static int[] FedTypesStocked(Entity ship, out int fed)
|
||||
{
|
||||
var keys = ship.Weapons.Select(w => w.WeaponData.AmmoType).Where(t => t.IsSet()).Select(t => t.Key).Distinct().ToArray();
|
||||
fed = keys.Length;
|
||||
return keys.Select(key => ship.CountItemsInCargo(key)).ToArray();
|
||||
}
|
||||
|
||||
// A test hull, a one-cell ammunition-fed gun (a magazine of none, so the single-round rule is in play) and a bay,
|
||||
// in the shared catalog: the smallest authored ship that can carry crates.
|
||||
private (string Hull, (string, CultMath.int2, ItemRotation)[] Slots) FedFit(bool bay)
|
||||
{
|
||||
var shape = new Shape(7, 7);
|
||||
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
||||
var hullData = new HullData { Name = "Fed Hull", HullType = HullType.Ship, Shape = shape, Durability = 10, Mass = 100 };
|
||||
_cache.Upsert(hullData);
|
||||
var rounds = _cache.Upsert(new SimpleCommodityData { Name = "Fed Rounds", Shape = new Shape(), Price = 1, MaxStack = 100 });
|
||||
_cache.Upsert(new WeaponItemData
|
||||
{
|
||||
Name = "Fed Gun", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
|
||||
Behaviors = { new InstantWeaponData { AmmoType = rounds, MagazineSize = 0 } }
|
||||
});
|
||||
_cache.Upsert(new WeaponItemData
|
||||
{
|
||||
Name = "Plain Gun", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
|
||||
Behaviors = { new InstantWeaponData { MagazineSize = 0 } }
|
||||
});
|
||||
var interior = new Shape(4, 4);
|
||||
foreach (var cell in interior.AllCoordinates) interior[cell] = true;
|
||||
_cache.Upsert(new CargoBayData { Name = "Fed Bay", Shape = new Shape(), InteriorShape = interior, Price = 20, Durability = 4 });
|
||||
var slots = new List<(string, CultMath.int2, ItemRotation)> { ("Fed Gun", int2(3, 3), ItemRotation.None) };
|
||||
if (bay) slots.Add(("Fed Bay", int2(4, 3), ItemRotation.None));
|
||||
return ("Fed Hull", slots.ToArray());
|
||||
}
|
||||
|
||||
// A ship a scenario fits slot by slot (stage.Fit, no generator) that carries a bay and an ammunition-fed weapon ends with the crates that
|
||||
// weapon draws, and its own Reserve reads them. Kills: Materialize returning the entity unstocked (the authored ship fires one magazine).
|
||||
[Fact]
|
||||
public void AScenarioFitWithAFedWeaponAndABayCarriesItsCrates()
|
||||
{
|
||||
var (hull, slots) = FedFit(bay: true);
|
||||
var (_, _, staged, failures) = Launch(new Scripted(false, stage => stage.Player(stage.Fit(hull, slots), float2(0, 0))), Inputs(() => GalaxySeed));
|
||||
Assert.True(failures.Count == 0, string.Join("; ", failures));
|
||||
var weapon = Assert.Single(staged.Player.Weapons);
|
||||
Assert.True(weapon.WeaponData.AmmoType.IsSet(), "fixture: the gun draws ammunition");
|
||||
Assert.Equal(LoadoutGenerator.AmmunitionCrates, weapon.Reserve);
|
||||
}
|
||||
|
||||
// The Loadout path with the DEFAULT stocking argument (what the boss in ZoneGenerator and the console `fly` call): a ship built from a
|
||||
// Loadout holds the crates its fed weapon draws, and one whose only weapon draws nothing holds none. Kills: Materialize
|
||||
// dropping its default StockAmmunition (every Loadout-built armed ship fires one magazine).
|
||||
[Theory]
|
||||
[InlineData("Fed Gun", LoadoutGenerator.AmmunitionCrates)]
|
||||
[InlineData("Plain Gun", 0)]
|
||||
public void ALoadoutBuiltShipIsStockedByDefaultWithWhatItsWeaponsDraw(string gun, int crates)
|
||||
{
|
||||
var (hull, _) = FedFit(bay: true);
|
||||
EquippableItemData Design(string name) => _cache.GetAll<EquippableItemData>().Single(item => item.Name == name);
|
||||
var loadout = new Loadout { Name = "default stock", Hull = _cache.RefOf(_cache.GetAll<HullData>().Single(h => h.Name == hull)), WeaponGroups = new int[0][] };
|
||||
loadout.Slots.Add(new LoadoutSlot { Position = int2(3, 3), Rotation = ItemRotation.None, Design = _cache.RefOf(Design(gun)) });
|
||||
loadout.Slots.Add(new LoadoutSlot { Position = int2(4, 3), Rotation = ItemRotation.None, Design = _cache.RefOf(Design("Fed Bay")) });
|
||||
var failures = new List<string>();
|
||||
var ship = Loadouts.Materialize(_items, null, loadout, _ => null, failures);
|
||||
Assert.True(failures.Count == 0, string.Join("; ", failures));
|
||||
var weapon = Assert.Single(ship.Weapons);
|
||||
Assert.Equal(gun == "Fed Gun", weapon.WeaponData.AmmoType.IsSet());
|
||||
Assert.Equal(crates, ship.CargoBays.Sum(bay => bay.Cargo.Keys.OfType<SimpleCommodity>().Sum(c => c.Quantity)));
|
||||
}
|
||||
|
||||
// A scenario that authors a fit's cargo (stage.Cargo) gets exactly that cargo: its author named what the hold holds, so no crates are added.
|
||||
// Kills: stocking that ignores authored cargo and puts missiles into a scenario whose check counts what the hold carries.
|
||||
[Fact]
|
||||
public void AScenarioFitWhoseCargoIsAuthoredIsNotStocked()
|
||||
{
|
||||
var (hull, slots) = FedFit(bay: true);
|
||||
var (_, _, staged, failures) = Launch(new Scripted(false, stage =>
|
||||
stage.Cargo(stage.Player(stage.Fit(hull, slots), float2(0, 0)), "Fed Gun")), Inputs(() => GalaxySeed));
|
||||
Assert.True(failures.Count == 0, string.Join("; ", failures));
|
||||
Assert.Equal(0, Assert.Single(staged.Player.Weapons).Reserve);
|
||||
}
|
||||
|
||||
// With no hold to put crates in there are none, and the fit still builds: stocking is bounded by what the ship can carry, never a failure.
|
||||
[Fact]
|
||||
public void AScenarioFitWithNoBayCarriesNoCratesAndStillBuilds()
|
||||
{
|
||||
var (hull, slots) = FedFit(bay: false);
|
||||
var (_, _, staged, failures) = Launch(new Scripted(false, stage => stage.Player(stage.Fit(hull, slots), float2(0, 0))), Inputs(() => GalaxySeed));
|
||||
Assert.True(failures.Count == 0, string.Join("; ", failures));
|
||||
Assert.Equal(0, Assert.Single(staged.Player.Weapons).Reserve);
|
||||
}
|
||||
|
||||
// The new run's player ship (stage.Generated at the starting hull) and every ship the shipped main-galaxy scenario generates carry the crates
|
||||
// their weapons draw: nothing that reaches play with ammunition-fed weapons is left to one magazine.
|
||||
[Fact]
|
||||
public void ANewRunPlayerShipCarriesTheCratesItsWeaponsDraw()
|
||||
{
|
||||
var fed = 0;
|
||||
for (uint seed = GalaxySeed; seed < GalaxySeed + 8; seed++)
|
||||
{
|
||||
var (_, _, staged, failures) = Launch(new Scripted(false, stage => stage.Player(stage.Generated(stage.StartingHull), float2(0, 0)), seed),
|
||||
Inputs(() => seed));
|
||||
Assert.True(failures.Count == 0, string.Join("; ", failures));
|
||||
foreach (var count in FedTypesStocked(staged.Player, out var types))
|
||||
{
|
||||
Assert.Equal(LoadoutGenerator.AmmunitionCrates, count);
|
||||
fed += types;
|
||||
}
|
||||
}
|
||||
Assert.True(fed > 0, "fixture: some new-run player ship carries an ammunition-fed weapon");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ public sealed class CommodityRecordsTests
|
|||
public void ShippedCatalogOpensWithTheRetiredKeysGone()
|
||||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
Assert.Equal(23, cache.GetAll<SimpleCommodityData>().Count());
|
||||
Assert.Equal(24, cache.GetAll<SimpleCommodityData>().Count());
|
||||
Assert.Equal(63, cache.GetAll<CompoundCommodityData>().Count());
|
||||
Assert.Contains(cache.GetAll<SimpleCommodityData>(), c => c.Name == "Ammo");
|
||||
Assert.All(cache.GetAll<CompoundCommodityData>(), c => Assert.False(string.IsNullOrEmpty(c.Name)));
|
||||
|
|
|
|||
|
|
@ -1328,9 +1328,9 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
|
||||
// One launcher-shaped weapon (magazine 2, reload 3 s, Missiles) held on its trigger at no target, `crates` of Missiles in the
|
||||
// hold. Returns the rig and a counter of committed shots.
|
||||
private (Rig Rig, Func<int> Shots) AmmoRig(int crates)
|
||||
private (Rig Rig, Func<int> Shots) AmmoRig(int crates, int magazine = 2)
|
||||
{
|
||||
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 40f, magazine: 2, auto: true, crates: crates, reload: 3f);
|
||||
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 40f, magazine: magazine, auto: true, crates: crates, reload: 3f);
|
||||
r.Shooter.SetTarget(TargetRef.None);
|
||||
Aim(r, float2(0, 1));
|
||||
var shots = 0;
|
||||
|
|
@ -1406,4 +1406,48 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
Run(r, 30f);
|
||||
Assert.True(shots > 4, $"an unfed magazine weapon fires past its first magazine, fired {shots}");
|
||||
}
|
||||
|
||||
// A weapon that names an AmmoType is magazine-fed whatever MagazineSize says (0 or 1 is a magazine of one): it fires one round, every
|
||||
// reload takes a crate, and with none it fires no more. Kills: UseAmmo/reload ignoring AmmoType at MagazineSize <= 1 (free fire, or
|
||||
// fire that never touches the cargo).
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
public void ASingleRoundAmmunitionFedWeaponFiresOnePerCrateAndNoMore(int magazine)
|
||||
{
|
||||
var (r, shots) = AmmoRig(crates: 2, magazine: magazine);
|
||||
Assert.Equal(1, r.Gun.Weapon.Ammo);
|
||||
Run(r, 2f);
|
||||
Assert.Equal(1, shots());
|
||||
Assert.Equal(2, r.Gun.Weapon.Reserve);
|
||||
Run(r, 30f);
|
||||
Assert.Equal(3, shots());
|
||||
Assert.Equal(0, r.Gun.Weapon.Reserve);
|
||||
Assert.Equal(0, r.Gun.Weapon.Ammo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
public void ASingleRoundAmmunitionFedWeaponWithNoCrateFiresOnceAndNoMore(int magazine)
|
||||
{
|
||||
var (r, shots) = AmmoRig(crates: 0, magazine: magazine);
|
||||
Run(r, 30f);
|
||||
Assert.Equal(1, shots());
|
||||
}
|
||||
|
||||
// With no AmmoType a magazine of 0 or 1 is no magazine: the weapon fires free, as before. Kills: the new rule leaking onto unfed weapons.
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
public void AWeaponWithNoAmmunitionTypeAndNoRealMagazineFiresFree(int magazine)
|
||||
{
|
||||
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 40f, magazine: magazine, auto: true, reload: 3f);
|
||||
r.Shooter.SetTarget(TargetRef.None);
|
||||
Aim(r, float2(0, 1));
|
||||
var shots = 0;
|
||||
r.Zone.ShotCommitted.Subscribe(_ => shots++);
|
||||
Run(r, 30f);
|
||||
Assert.True(shots > 4, $"an unfed weapon fires on, fired {shots}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
79
tests/Aetheria.Shared.Tests/MunitionsSourcingCatalogTests.cs
Normal file
79
tests/Aetheria.Shared.Tests/MunitionsSourcingCatalogTests.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
|
||||
// Ruling ballistics-real-ammo, read from the shipped catalog: every gun that fires a round names the commodity it draws, and every
|
||||
// such commodity (and the chain behind it) is produced from extraction, not from nothing. A round with no source is a magazine
|
||||
// nobody can ever refill.
|
||||
public sealed class MunitionsSourcingCatalogTests
|
||||
{
|
||||
// The guns that fire real rounds: ballistic and launcher hardpoints, and the mine layer (a launcher in all but hardpoint).
|
||||
private static bool FiresRounds(WeaponItemData item, WeaponData data) =>
|
||||
item.HardpointType == HardpointType.Ballistic || item.HardpointType == HardpointType.Launcher || data is MineLayerData;
|
||||
|
||||
// Kills: a ballistic gun (or the Light Machine Gun's free hose) authored with AmmoType unset, which reloads for nothing; a
|
||||
// drawn commodity that is not a commodity record; a magazine too small for a reload to mean anything.
|
||||
[Fact]
|
||||
public void EveryRoundFiringGunNamesAnAmmunitionCommodityAndHoldsARealMagazine()
|
||||
{
|
||||
using var db = AetherDb.Open();
|
||||
var guns = db.Cache.GetAll<WeaponItemData>()
|
||||
.SelectMany(i => i.Behaviors.OfType<WeaponData>().Where(d => FiresRounds(i, d)).Select(d => (i.Name, d))).ToArray();
|
||||
Assert.True(guns.Length >= 14, $"fixture: the catalog holds its round-firing guns ({guns.Length})");
|
||||
foreach (var (name, data) in guns)
|
||||
{
|
||||
Assert.True(data.AmmoType.IsSet(), $"{name} draws no ammunition");
|
||||
Assert.NotNull(db.Cache.Get<SimpleCommodityData>(data.AmmoType.Key));
|
||||
Assert.True(data.MagazineSize >= 1, $"{name} has no magazine");
|
||||
}
|
||||
Assert.Equal(new[] { "Ammo", "Mines", "Missiles" }, guns.Select(g => db.Cache.Get<SimpleCommodityData>(g.d.AmmoType.Key).Name).Distinct().OrderBy(n => n).ToArray());
|
||||
}
|
||||
|
||||
// Kills: an ammunition commodity no chain of recipes reaches raw goods for (the Ammo, Missiles and Mines leaves had no recipe at all).
|
||||
[Fact]
|
||||
public void EveryDrawnAmmunitionHasAFiniteProductionDepth()
|
||||
{
|
||||
using var db = AetherDb.Open();
|
||||
var depth = EconomyChecks.Deep(db.Cache);
|
||||
var drawn = db.Cache.GetAll<WeaponItemData>().SelectMany(i => i.Behaviors.OfType<WeaponData>()).Where(d => d.AmmoType.IsSet())
|
||||
.Select(d => d.AmmoType.Key.Value).Distinct().ToArray();
|
||||
Assert.Equal(3, drawn.Length);
|
||||
foreach (var key in drawn)
|
||||
Assert.True(depth[key] != null, $"{db.Cache.Get<ItemData>(new CultRecordKey(key)).Name} has no finite production depth");
|
||||
}
|
||||
|
||||
// Kills: a leaf of the munitions chain left without a source or a sink (the 19 L-source-sink faults this chain shipped with).
|
||||
[Fact]
|
||||
public void TheMunitionsChainHasNoSourceSinkFault()
|
||||
{
|
||||
using var db = AetherDb.Open();
|
||||
var chain = new HashSet<string>(ChainOf(db.Cache), System.StringComparer.Ordinal);
|
||||
Assert.True(chain.Count >= 25, $"fixture: the chain is walked ({chain.Count} goods)");
|
||||
var faults = EconomyChecks.SourceSink(db.Cache).Where(f => chain.Contains(KeyOfRecipe(f.Record))).ToArray();
|
||||
Assert.True(faults.Length == 0, string.Join("\n", faults.Select(f => f.Message)));
|
||||
}
|
||||
|
||||
private static string KeyOfRecipe(string recipeKey) => recipeKey.StartsWith("recipe:") ? recipeKey.Substring("recipe:".Length) : recipeKey;
|
||||
|
||||
// Every good that feeds a drawn round, or fills a role of a round-firing gun, through the recipe graph; the rounds included.
|
||||
private static IEnumerable<string> ChainOf(CultCache catalog)
|
||||
{
|
||||
var recipes = catalog.GetAll<RecipeData>().ToDictionary(r => r.Output.Key.Value);
|
||||
var open = new Stack<string>(catalog.GetAll<WeaponItemData>().SelectMany(i => i.Behaviors.OfType<WeaponData>())
|
||||
.Where(d => d.AmmoType.IsSet()).Select(d => d.AmmoType.Key.Value).Distinct());
|
||||
foreach (var item in catalog.GetAll<WeaponItemData>().Where(i => i.Behaviors.OfType<WeaponData>().Any(d => FiresRounds(i, d))))
|
||||
{
|
||||
var recipe = Recipes.RecipeOf(catalog, item);
|
||||
if (recipe == null) continue;
|
||||
foreach (var role in Recipes.RoleInputsOf(catalog, recipe))
|
||||
foreach (var filler in Recipes.Fillers(catalog, role.Name)) open.Push(filler.Value);
|
||||
}
|
||||
var seen = new HashSet<string>(open);
|
||||
while (open.Count > 0)
|
||||
if (recipes.TryGetValue(open.Pop(), out var recipe))
|
||||
foreach (var input in recipe.Inputs.Select(c => c.Commodity.Key.Value))
|
||||
if (seen.Add(input)) open.Push(input);
|
||||
return seen;
|
||||
}
|
||||
}
|
||||
|
|
@ -248,17 +248,17 @@ public sealed class StationKindTests : IDisposable
|
|||
{
|
||||
var world = World.Make(this, zenithOffered: false);
|
||||
var hub = world.Sell(world.Zh, "trade-hub", "Zh Hub");
|
||||
world.StandIn("mining", world.Zh, "trade-hub");
|
||||
var mining = world.Cache.Get(Kind("mining"));
|
||||
world.StandIn("salvage", world.Zh, "trade-hub");
|
||||
var salvage = world.Cache.Get(Kind("salvage"));
|
||||
|
||||
Assert.Contains(Kind("mining").Key.Value, StationKinds.KindsOf(world.Cache, world.Zh).Select(kind => kind.Key.Value));
|
||||
Assert.Equal(Kind("trade-hub").Key, StationKinds.HullKindFor(world.Cache, world.Zh, Kind("mining")).Key);
|
||||
Assert.Contains(Kind("salvage").Key.Value, StationKinds.KindsOf(world.Cache, world.Zh).Select(kind => kind.Key.Value));
|
||||
Assert.Equal(Kind("trade-hub").Key, StationKinds.HullKindFor(world.Cache, world.Zh, Kind("salvage")).Key);
|
||||
for (uint seed = 1; seed <= 10; seed++)
|
||||
{
|
||||
var pack = world.Generator(world.Zh, seed).GenerateStationLoadout(Kind("mining"));
|
||||
var pack = world.Generator(world.Zh, seed).GenerateStationLoadout(Kind("salvage"));
|
||||
Assert.Equal("Zh Hub", world.HullName(pack));
|
||||
Assert.Equal(Kind("mining").Key, pack.Kind.Key);
|
||||
Assert.Equal(mining.Name, pack.Name);
|
||||
Assert.Equal(Kind("salvage").Key, pack.Kind.Key);
|
||||
Assert.Equal(salvage.Name, pack.Name);
|
||||
}
|
||||
var own = world.Generator(world.Zh).GenerateStationLoadout(Kind("trade-hub"));
|
||||
Assert.Equal(hub.Name, own.Name);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ public sealed class Munitions : EconomyBatch
|
|||
// Zhestokost has no station model of any kind yet. Its first planned model is the supply depot (ruling faction-station-models),
|
||||
// so its industrial kinds stand in on the depot until their own models land. One hop: depot itself has no Zhestokost stand-in.
|
||||
var zhestokost = FactionRef("Zhestokost");
|
||||
foreach (var kind in new[] { "processing", "fabrication", "munitions-works" })
|
||||
// The feed stock for the chain (MunitionsFeed) is extracted and grown where the arsenal's planners can reach it, so the mining,
|
||||
// volatiles and agriculture kinds stand in on the depot too until the factions that own those industries place them.
|
||||
foreach (var kind in new[] { "processing", "fabrication", "munitions-works", "mining", "volatiles", "agriculture" })
|
||||
StandIn(kind, zhestokost, "depot");
|
||||
|
||||
// Interface goods: named by this chain, owned by another.
|
||||
|
|
@ -153,6 +155,15 @@ public sealed class Munitions : EconomyBatch
|
|||
"A missile crate is a warhead and a motor in one box, and the box is the arsenal's most counted object: Zhestokost ties each launcher crew's allowance to a serial range, so a cache of loose crates is either a deserter's or a quartermaster's crime.",
|
||||
new string[0], (warhead, 1), (motor, 1));
|
||||
|
||||
// Mines: the crate the mine launcher reloads from, a warhead per mine laid. Shape and stack as Missiles.
|
||||
var mines = new SimpleCommodityData { Name = "Mines", Shape = new Shape(), MaxStack = 5, Mass = 7f, Price = 1400,
|
||||
Description = "Crated proximity mines: a high-explosive warhead in a steel drum with a fuzed trigger. The crate a mine launcher reloads from." };
|
||||
_a.Put(mines, CommodityKey("mines"));
|
||||
var minesRef = new CultRecordRef<ItemData>(CommodityKey("mines"));
|
||||
Recipe(minesRef, "munitions-works", 900, 20, 2,
|
||||
"A mine is a warhead with patience. Every blockade that holds a lane for a season is paid for in crates of these, and a convoy captain who finds a lane suddenly quiet counts the hours since the last tender came through.",
|
||||
new string[0], (warhead, 1), (steel, 2));
|
||||
|
||||
var dispenser = Compound("mine-dispenser", "Mine Dispenser", "A rack and ejector that lays mines in a pattern.", 20f, 1600);
|
||||
Recipe(dispenser, "fabrication", 900, 20, 1,
|
||||
"Little more than steel and actuators, which is why every convoy navy can build one and every blockade starts with one.",
|
||||
|
|
@ -171,6 +182,22 @@ public sealed class Munitions : EconomyBatch
|
|||
{
|
||||
foreach (var data in w.Behaviors.OfType<WeaponData>()) data.AmmoType = new CultRecordRef<SimpleCommodityData>(CommodityKey("missiles"));
|
||||
});
|
||||
|
||||
// Every ballistic gun draws a round (ruling ballistics-real-ammo). The Light Machine Gun had no magazine, a free hose; it gets a
|
||||
// real one so a single Ammo crate reloads it like the other guns. The mine launcher draws a Mines crate.
|
||||
foreach (var gun in new[] { "Slug Rifle", "Long Slug Rifle", "Light Machine Gun" })
|
||||
_a.Edit<WeaponItemData>(gun, w =>
|
||||
{
|
||||
foreach (var data in w.Behaviors.OfType<WeaponData>())
|
||||
{
|
||||
data.AmmoType = new CultRecordRef<SimpleCommodityData>(ammo.Key);
|
||||
if (gun == "Light Machine Gun") { data.MagazineSize = 100; data.ReloadTime = 3f; }
|
||||
}
|
||||
});
|
||||
_a.Edit<WeaponItemData>("Mine Launcher", w =>
|
||||
{
|
||||
foreach (var data in w.Behaviors.OfType<WeaponData>()) data.AmmoType = new CultRecordRef<SimpleCommodityData>(CommodityKey("mines"));
|
||||
});
|
||||
}
|
||||
|
||||
private CultRecordRef<Faction> FactionRef(string name)
|
||||
|
|
|
|||
84
tools/AetherDb/Economy/MunitionsFeed.cs
Normal file
84
tools/AetherDb/Economy/MunitionsFeed.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
|
||||
// Economy content, batch 1b: where the munitions chain's raw materials come from. Munitions writes the chain and names its leaves
|
||||
// (Water, Methane, Ammonia, Oxygen, Potassium, Diamond, Steel, Cellulose Pulp, Actuator, Circuit Board) without recipes; this batch
|
||||
// gives each a source at a station kind whose description already says it does that work: input-free extraction at mining (ore,
|
||||
// Gold, Potassium, Diamond) and volatiles (Water, Methane, Ammonia), and processing or fabrication from those goods for the rest.
|
||||
// Run after Munitions: it edits nothing the other batch writes and every output it names must exist.
|
||||
public sealed class MunitionsFeed : EconomyBatch
|
||||
{
|
||||
private EconomyAuthor _a;
|
||||
|
||||
public override void Author(EconomyAuthor author)
|
||||
{
|
||||
_a = author;
|
||||
|
||||
// Extraction: no inputs, so these are the chain's raw goods.
|
||||
var rock = Make("Rock", "mining", 300, 10, 8,
|
||||
"Nobody on a mining station calls it rock; it is the overburden, the slag, the thing the real cargo was buried in. It is also what every smelter, board shop and cement yard starts from, so the cheapest hold in the verse is the one that decides who can build.");
|
||||
var metals = Make("New Metals", "mining", 300, 12, 6,
|
||||
"The ore that built the cities: whoever holds the claim feeds every foundry downstream, which is why mining rights are the one commodity the garrisons will not leave unguarded.");
|
||||
var gold = Make("Gold", "mining", 600, 8, 2,
|
||||
"Gold has a use besides vanity: it does not corrode in a contact. Boards bound for a hull in a cold or wet corridor are gold-plated, and a prospector who finds a seam sells it to the board shops before the jewellers.");
|
||||
var potassium = Make("Potassium", "mining", 300, 8, 4,
|
||||
"Potash and saltpetre salts turn up in the same evaporite beds that mark old water. Farms want it for fertiliser and the arsenal wants it for powder; the survey crews tag the beds by which buyer reached them first.");
|
||||
var diamond = Make("Diamond", "mining", 1200, 12, 1,
|
||||
"Industrial stones from the deep kimberlite cores: grit for lining barrels and cutting tools rather than jewellery. A miner who sells one for a ring has wasted a barrel.");
|
||||
var water = Make("Water", "volatiles", 300, 8, 6,
|
||||
"Every station buys water first and everything else second. The ice haulers run on the knowledge that a station's water reserve is the one stock it cannot be talked out of counting.");
|
||||
var methane = Make("Methane", "volatiles", 300, 8, 4,
|
||||
"Skimmed from the cold ice where it keeps for ever. Hauled home it is cooking gas, a feedstock for the formaldehyde plants, and the first thing that burns when a tank is struck.");
|
||||
var ammonia = Make("Ammonia", "volatiles", 300, 8, 4,
|
||||
"Ammonia ice is the nitrogen the whole verse eats and shoots: the farms spread it and the arsenals nitrate it. The skimmers who work cold bodies know exactly which of the two their hold is bound for by the hauler waiting at the dock.");
|
||||
|
||||
// Processing from those goods.
|
||||
var oxygen = Make("Oxygen", "processing", 240, 10, 3,
|
||||
"Cracking water ice pays twice: the oxygen goes to lungs and acid towers, and the hydrogen is fuel. A station that sells its oxygen cheap is either swimming in ice or hiding a shortage.",
|
||||
(water, 3));
|
||||
|
||||
var steel = Make("Steel", "processing", 600, 20, 4,
|
||||
"Foundries on the ore belts pour bar, sheet and billet from alloy ore and slag flux. A pour is cheap; the rolling mills that finish it are the part a blockade starves, and every gun, hull plate and nail in the verse is waiting behind them.",
|
||||
(metals, 3), (rock, 1));
|
||||
|
||||
// Algae is a catalog commodity the agriculture station description says grows from Water and Potassium.
|
||||
var algae = Make("Algae", "agriculture", 600, 12, 6,
|
||||
"Vats of green water under lamp-light: the first thing a new habitat grows, because it eats ash and sunlight and gives back food, fibre and air. A farm that loses its algae loses its station within the season.",
|
||||
(water, 2), (potassium, 1));
|
||||
|
||||
var pulp = Make("Cellulose Pulp", "processing", 300, 10, 2,
|
||||
"Algae from the farm vats is washed, digested and pressed into pulp. The same vats make paper for a Zhestokost order book and guncotton stock for its arsenal, and the split is seen in how many hands the washing hall hires.",
|
||||
(algae, 3));
|
||||
|
||||
var board = Make("Circuit Board", "fabrication", 600, 14, 2,
|
||||
"Gold-plated contacts on a silicate board: the oldest reliable electronics. A region that cannot make its own boards imports a shortage in every device that has a fuze, a sensor or a lock.",
|
||||
(gold, 1), (rock, 2));
|
||||
|
||||
Make("Actuator", "fabrication", 600, 16, 1,
|
||||
"A steel housing, a motor and a controller board. It opens hatches and feeds guns, and a repair crew keeps a drawer of them because everything on a ship eventually needs to move on command.",
|
||||
(steel, 2), (board, 1));
|
||||
}
|
||||
|
||||
private CultRecordRef<ItemData> Make(string name, string kind, float cycle, int workforce, int count, string lore,
|
||||
params (CultRecordRef<ItemData> Commodity, int Count)[] inputs)
|
||||
{
|
||||
var named = _a.Catalog.GetAll<ItemData>().Where(i => i.Name == name).ToArray();
|
||||
if (named.Length != 1) throw new InvalidOperationException($"{named.Length} items are named '{name}'; the munitions feed needs exactly one.");
|
||||
var output = new CultRecordRef<ItemData>(_a.Catalog.RefOf(named[0]).Key);
|
||||
_a.Put(new RecipeData
|
||||
{
|
||||
Output = output,
|
||||
OutputCount = count,
|
||||
Inputs = inputs.Select(i => new CommodityCount { Commodity = i.Commodity, Count = i.Count }).ToList(),
|
||||
Cycle = cycle,
|
||||
Workforce = workforce,
|
||||
Upkeep = new List<CommodityCount>(),
|
||||
StationKind = new CultRecordRef<StationKindData>(StationKindData.KeyOf(kind)),
|
||||
LoreHook = lore,
|
||||
Fills = new List<string>()
|
||||
}, RecipeData.KeyOf(output.Key));
|
||||
return output;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue