- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it. - RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed. - Continue seeds the item stream from the saved run's seed (ContinueStep); a scene with no galaxy uses NoRunSeed. - Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream, 1-based pack ordinal), never the item stream. - Loadout's scratch manager and AetherDb's catalog command take fixed seeds; every test and Editor construction gains 1u (mechanical rewrite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
12 KiB
C#
265 lines
12 KiB
C#
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 ConsumableItemData Other; // a second, unrelated non-stackable design
|
|
public Func<EquippableItem> NewEngine;
|
|
public CultRecordKey Key;
|
|
public CultRecordRef<Faction> Maker;
|
|
|
|
public ConsumableItem Mint(ConsumableItemData design = null) =>
|
|
Assert.IsType<ConsumableItem>(Items.CreateInstance(Items.CreateLot(design ?? 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 VerseGrammar { Revision = 1 });
|
|
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(2, 1); // room for two consumables
|
|
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
|
|
}
|
|
}
|
|
});
|
|
var otherRef = cache.Upsert(new ConsumableItemData { Name = "Flare", Duration = 1f, Shape = new Shape() });
|
|
cache.FlushAsync().Wait();
|
|
|
|
var items = new ItemManager(1u, 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.Admit(ship, piloted: false);
|
|
var data = cache.Get(consumableRef);
|
|
return new Rig
|
|
{
|
|
Cache = cache, Items = items, Ship = ship, Thrust = thrust, Consumable = data, Other = cache.Get(otherRef),
|
|
NewEngine = () => Gear(cache.Get(engineRef)),
|
|
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); // 1.5s elapsed in all: the effect ran out on the fourth tick
|
|
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);
|
|
}
|
|
|
|
// The expiry boundary: Duration 1 with 0.3 s ticks leaves 0.1 s after the third tick (still active) and
|
|
// -0.2 s after the fourth (gone). An expiry one tick early or one tick late changes one of these two reads.
|
|
[Fact]
|
|
public void AConsumableLivesForItsDurationAndNoLonger()
|
|
{
|
|
var rig = Build();
|
|
using var _ = rig.Cache;
|
|
rig.Ship.ActivateConsumable(rig.Mint());
|
|
Tick(rig, 3, .3f);
|
|
Assert.Equal(20f, rig.Engine.Evaluate(rig.Thrust), 3);
|
|
Tick(rig, 1, .3f);
|
|
Assert.Equal(10f, rig.Engine.Evaluate(rig.Thrust), 3);
|
|
}
|
|
|
|
// An effect runs for exactly its Duration: Duration 1 with 0.25 s ticks (exact in floats) executes its
|
|
// behaviours on four ticks and is gone when the fourth ends, not on a fifth.
|
|
[Fact]
|
|
public void AConsumableRunsForExactlyItsDurationInTicks()
|
|
{
|
|
var rig = Build();
|
|
using var _ = rig.Cache;
|
|
rig.Ship.ActivateConsumable(rig.Mint());
|
|
Tick(rig, 3, .25f);
|
|
Assert.NotNull(rig.Ship.FindActiveConsumable(rig.Consumable));
|
|
Assert.Equal(20f, rig.Engine.Evaluate(rig.Thrust), 3);
|
|
Tick(rig, 1, .25f); // the fourth tick spends the last of the Duration
|
|
Assert.Null(rig.Ship.FindActiveConsumable(rig.Consumable));
|
|
Assert.Equal(10f, rig.Engine.Evaluate(rig.Thrust), 3);
|
|
}
|
|
|
|
// CS-R1, the stackable side: a stackable design activates again while its first effect runs, and every
|
|
// multiplier on the stat multiplies.
|
|
[Fact]
|
|
public void TwoStackableConsumablesOverlapAndMultiply()
|
|
{
|
|
var rig = Build(stackable: true);
|
|
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.True(rig.Ship.TryActivateConsumable(rig.Consumable));
|
|
Assert.Equal(0, rig.InCargo);
|
|
Tick(rig, 2, .3f);
|
|
Assert.Equal(40f, rig.Engine.Evaluate(rig.Thrust), 3);
|
|
}
|
|
|
|
// Two modifiers share one resolver entry, both attached. The first expires while the second still runs: the
|
|
// second's boost stays, and when it expires too nothing is left behind.
|
|
[Fact]
|
|
public void AnExpiringConsumableLeavesAnOverlappingOnesBoost()
|
|
{
|
|
var rig = Build(stackable: true);
|
|
using var _ = rig.Cache;
|
|
var resolver = rig.Ship.Resolver;
|
|
var baseline = resolver.ModifierEntryCount;
|
|
Assert.True(rig.Hold.TryStore(rig.Mint()));
|
|
Assert.True(rig.Hold.TryStore(rig.Mint()));
|
|
|
|
Assert.True(rig.Ship.TryActivateConsumable(rig.Consumable));
|
|
Tick(rig, 1, .3f);
|
|
Assert.True(rig.Ship.TryActivateConsumable(rig.Consumable));
|
|
Tick(rig, 2, .3f); // both attached: the first has 0.1 s left, the second 0.4 s
|
|
Assert.Equal(40f, rig.Engine.Evaluate(rig.Thrust), 3);
|
|
|
|
Tick(rig, 1, .3f); // the first ran out; the second's boost is still on the shared entry
|
|
Assert.Equal(20f, rig.Engine.Evaluate(rig.Thrust), 3);
|
|
Assert.True(resolver.ModifierEntryCount > baseline);
|
|
|
|
Tick(rig, 1, .3f); // and now the second
|
|
Assert.Equal(10f, rig.Engine.Evaluate(rig.Thrust), 3);
|
|
Assert.Equal(baseline, resolver.ModifierEntryCount);
|
|
}
|
|
|
|
// CS-R1: the cooldown is per design. A running effect of one design does not refuse another design.
|
|
[Fact]
|
|
public void ADesignsCooldownDoesNotBlockAnotherDesign()
|
|
{
|
|
var rig = Build();
|
|
using var _ = rig.Cache;
|
|
Assert.True(rig.Hold.TryStore(rig.Mint()));
|
|
Assert.True(rig.Hold.TryStore(rig.Mint(rig.Other)));
|
|
|
|
Assert.True(rig.Ship.TryActivateConsumable(rig.Consumable));
|
|
Assert.True(rig.Ship.TryActivateConsumable(rig.Other));
|
|
Assert.Equal(0, rig.InCargo);
|
|
}
|
|
|
|
// Docking: an effect still running when the entity deactivates, is refitted and activates again boosts the
|
|
// items now fitted, as gear modifiers do, not the thruster that was swapped out.
|
|
[Fact]
|
|
public void AConsumableActiveAcrossARefitBoostsTheNewItems()
|
|
{
|
|
var rig = Build();
|
|
using var _ = rig.Cache;
|
|
rig.Ship.ActivateConsumable(rig.Mint());
|
|
Tick(rig, 2, .3f);
|
|
Assert.Equal(20f, rig.Engine.Evaluate(rig.Thrust), 3);
|
|
|
|
rig.Ship.Deactivate();
|
|
Assert.NotNull(rig.Ship.TryUnequip(rig.Engine));
|
|
Assert.True(rig.Ship.TryEquip(rig.NewEngine(), EngineCell));
|
|
rig.Ship.Activate();
|
|
Tick(rig, 2, .1f); // 0.2 s more: the effect still has 0.2 s of its second
|
|
var fitted = rig.Ship.Equipment.Single(e => e.Data.Name == "Engine");
|
|
Assert.NotSame(rig.Engine, fitted);
|
|
Assert.Equal(20f, fitted.Evaluate(rig.Thrust), 3);
|
|
}
|
|
}
|