Consumable host: tests first (red at the base; they build, three fail)
Adds AConsumableModifierBoostsUntilExpiry, ABoughtConsumableActivates and ANonStackableConsumableRefusesASecondActivation. This commit builds; its tests fail until the host cut lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1b2e9c1258
commit
739eee4a8e
1 changed files with 152 additions and 0 deletions
152
tests/Aetheria.Shared.Tests/ConsumableTests.cs
Normal file
152
tests/Aetheria.Shared.Tests/ConsumableTests.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
|
||||
// A consumable is a cargo item with a timed effect (aetheria-release CS-R1). Its modifiers exist in the resolver
|
||||
// exactly while its effect is active: the host (Entity) initialises the effect's behaviours at activation and
|
||||
// disposes them at expiry, and ItemManager.CreateInstance is the one way a lot becomes a ConsumableItem.
|
||||
public sealed class ConsumableTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-consumable-" + Guid.NewGuid().ToString("N"));
|
||||
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
||||
private static readonly int2 EngineCell = new int2(0, 0);
|
||||
private static readonly int2 HoldCell = new int2(1, 1);
|
||||
|
||||
public ConsumableTests() => Directory.CreateDirectory(_root);
|
||||
public void Dispose() => Directory.Delete(_root, true);
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
public CultCache Cache;
|
||||
public ItemManager Items;
|
||||
public Ship Ship;
|
||||
public EquippedItem Engine;
|
||||
public PerformanceStat Thrust;
|
||||
public ConsumableItemData Consumable;
|
||||
public CultRecordKey Key;
|
||||
public CultRecordRef<Faction> Maker;
|
||||
|
||||
public ConsumableItem Mint() => Assert.IsType<ConsumableItem>(Items.CreateInstance(Items.CreateLot(Consumable, Maker, .5f)));
|
||||
public EquippedCargoBay Hold => Ship.CargoBays.Single();
|
||||
public int InCargo => Hold.Cargo.Keys.Count(i => i is ConsumableItem);
|
||||
}
|
||||
|
||||
// A ship with a thruster (Thrust 10) and a hold; the consumable doubles ThrusterData.Thrust for one second.
|
||||
private Rig Build(bool stackable = false)
|
||||
{
|
||||
var thrust = new PerformanceStat { Min = 10, Max = 10 };
|
||||
var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
||||
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
||||
cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" });
|
||||
var hullShape = new Shape(3, 3);
|
||||
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
|
||||
var hullRef = cache.Upsert(new HullData
|
||||
{
|
||||
Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10,
|
||||
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = EngineCell, Shape = new Shape() } }
|
||||
});
|
||||
var engineRef = cache.Upsert(new GearData
|
||||
{
|
||||
Name = "Engine", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10,
|
||||
Behaviors = { new ThrusterData { Thrust = thrust } }
|
||||
});
|
||||
var interior = new Shape(1, 1);
|
||||
foreach (var cell in interior.AllCoordinates) interior[cell] = true;
|
||||
var holdRef = cache.Upsert(new CargoBayData { Name = "Hold", Shape = new Shape(), InteriorShape = interior, Durability = 10 });
|
||||
var consumableRef = cache.Upsert(new ConsumableItemData
|
||||
{
|
||||
Name = "Overdrive", Duration = 1f, Stackable = stackable, Shape = new Shape(),
|
||||
Behaviors =
|
||||
{
|
||||
new StatModifierData
|
||||
{
|
||||
Stat = new StatReference { Target = nameof(ThrusterData), Stat = nameof(ThrusterData.Thrust) },
|
||||
Modifier = new PerformanceStat { Min = 2, Max = 2 },
|
||||
Type = StatModifierType.Multiplier
|
||||
}
|
||||
}
|
||||
});
|
||||
cache.FlushAsync().Wait();
|
||||
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
||||
var makerRef = cache.RefOf(cache.GetByName<Faction>("Maker"));
|
||||
EquippableItem Gear(EquippableItemData design) => (EquippableItem) items.CreateInstance(items.CreateLot(design, makerRef, .5f));
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
|
||||
var ship = new Ship(items, zone, Gear(cache.Get(hullRef)), new EntitySettings());
|
||||
Assert.True(ship.TryEquip(Gear(cache.Get(engineRef)), EngineCell));
|
||||
Assert.True(ship.TryEquip(Gear(cache.Get(holdRef)), HoldCell));
|
||||
zone.Entities.Add(ship);
|
||||
ship.Activate();
|
||||
var data = cache.Get(consumableRef);
|
||||
return new Rig
|
||||
{
|
||||
Cache = cache, Items = items, Ship = ship, Thrust = thrust, Consumable = data,
|
||||
Key = items.ItemData.RefOf<ItemData>(data).Key,
|
||||
Engine = ship.Equipment.Single(e => e.Data.Name == "Engine"),
|
||||
Maker = makerRef
|
||||
};
|
||||
}
|
||||
|
||||
// Two ticks after activation the modifier has attached (the first tick executes it, the second applies it).
|
||||
private static void Tick(Rig rig, int times, float dt)
|
||||
{
|
||||
for (var i = 0; i < times; i++) rig.Ship.Update(dt);
|
||||
}
|
||||
|
||||
// The rule: a consumable's modifiers exist in the resolver exactly while its effect is active, never after.
|
||||
// Observed at the resolved stat and at the resolver's modifier entry count, not at a flag.
|
||||
[Fact]
|
||||
public void AConsumableModifierBoostsUntilExpiry()
|
||||
{
|
||||
var rig = Build();
|
||||
using var _ = rig.Cache;
|
||||
var resolver = rig.Ship.Resolver;
|
||||
Assert.Equal(10f, rig.Engine.Evaluate(rig.Thrust), 3);
|
||||
var baseline = resolver.ModifierEntryCount;
|
||||
|
||||
rig.Ship.ActivateConsumable(rig.Mint());
|
||||
Tick(rig, 2, .3f); // 0.4s of the 1s remain; the modifier has attached
|
||||
Assert.Equal(20f, rig.Engine.Evaluate(rig.Thrust), 3);
|
||||
Assert.True(resolver.ModifierEntryCount > baseline);
|
||||
|
||||
Tick(rig, 3, .3f); // 0.9s elapsed in all: expired on the third of these ticks
|
||||
Assert.Equal(10f, rig.Engine.Evaluate(rig.Thrust), 3);
|
||||
Assert.Equal(baseline, resolver.ModifierEntryCount);
|
||||
}
|
||||
|
||||
// A lot minted through the one production path is a ConsumableItem, and a hold holding it activates it.
|
||||
[Fact]
|
||||
public void ABoughtConsumableActivates()
|
||||
{
|
||||
var rig = Build();
|
||||
using var _ = rig.Cache;
|
||||
Assert.True(rig.Hold.TryStore(rig.Mint()));
|
||||
Assert.Equal(1, rig.InCargo);
|
||||
|
||||
Assert.True(rig.Ship.TryActivateConsumable(rig.Consumable));
|
||||
Assert.Equal(0, rig.InCargo);
|
||||
Assert.NotNull(rig.Ship.FindActiveConsumable(rig.Consumable));
|
||||
}
|
||||
|
||||
// CS-R1: a non-stackable design allows one active effect at a time, so its Duration is its cooldown.
|
||||
[Fact]
|
||||
public void ANonStackableConsumableRefusesASecondActivation()
|
||||
{
|
||||
var rig = Build();
|
||||
using var _ = rig.Cache;
|
||||
Assert.True(rig.Hold.TryStore(rig.Mint()));
|
||||
Assert.True(rig.Hold.TryStore(rig.Mint()));
|
||||
|
||||
Assert.True(rig.Ship.TryActivateConsumable(rig.Consumable));
|
||||
Assert.False(rig.Ship.TryActivateConsumable(rig.Consumable));
|
||||
Assert.Equal(1, rig.InCargo);
|
||||
|
||||
Tick(rig, 5, .3f); // the first effect has run out
|
||||
Assert.True(rig.Ship.TryActivateConsumable(rig.Consumable));
|
||||
Assert.Equal(0, rig.InCargo);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue