Merge remote-tracking branch 'origin/nibu/ammo-stock-code' into nibu/ammo-data

This commit is contained in:
Nibu 2026-11-11 03:54:40 +00:00
commit afa048641f
8 changed files with 179 additions and 31 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,88 @@ 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 } }
});
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);
}
// 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");
}
}

View file

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