790 lines
43 KiB
C#
790 lines
43 KiB
C#
/* 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.IO;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using CultMath;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using UniRx;
|
|
using Xunit;
|
|
|
|
// The demo's goal (aetheria-release, cut boss-gate): the boss zone spawns its faction's boss, and the exit gate opens
|
|
// only when that boss is dead. The boss flies its faction's boss Loadout (cut boss-loadout). Every input is the production one: the demo galaxy, GenerateZone, the Zone death path,
|
|
// and the run save.
|
|
public sealed partial class RunStartTests
|
|
{
|
|
private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true, uint seed = 7)
|
|
{
|
|
items.Random = new CultMath.Random(seed);
|
|
return ZoneGenerator.GenerateZone(items, _zoneSettings, _planetSettings, galaxy, zone, galaxy.IsPrelude, ambient);
|
|
}
|
|
|
|
private Zone ZoneOf(Galaxy galaxy, GalaxyZone zone) =>
|
|
new Zone(_items, _planetSettings, BossZonePack(galaxy, _items, zone), zone, galaxy);
|
|
|
|
private static Faction Zhestokost(Galaxy galaxy) => galaxy.Factions.Single(faction => faction.Name == "Zhestokost");
|
|
|
|
private ItemManager LoggingItems(List<string> log) =>
|
|
new ItemManager(1u, _cache, new ProvenanceLedger(), AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("GameplaySettings"), log.Add);
|
|
|
|
private static string HullName(ItemManager items, EntityPack pack) => items.GetData(pack.Hull).Name;
|
|
|
|
private HullData[] SoldShipHulls() => _cache.GetAll<FactionProductData>()
|
|
.Where(product => product.Manufacturer.IsSet())
|
|
.Select(product => _cache.Get(product.Design) as HullData)
|
|
.Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0)
|
|
.Distinct().ToArray();
|
|
|
|
// A hull no product makes, so the market never offers it: a MessagePack copy of the first sold ship hull by name, renamed. It has
|
|
// no authored model, which a boss does not need. Stored once; asking again returns the same record.
|
|
private HullData BossTestHull()
|
|
{
|
|
var existing = _cache.GetAll<HullData>().FirstOrDefault(hull => hull.Name == "Boss Test Hull");
|
|
if (existing != null) return existing;
|
|
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
|
|
var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First();
|
|
var boss = MessagePack.MessagePackSerializer.Deserialize<HullData>(MessagePack.MessagePackSerializer.Serialize(like, options), options);
|
|
boss.Name = "Boss Test Hull";
|
|
boss.Prefab = null;
|
|
boss.Visual = default;
|
|
return _cache.Get(_cache.Upsert(boss));
|
|
}
|
|
|
|
// A Loadout record at Loadouts.KeyOf(Name), the key Loadouts.Commit writes it under.
|
|
private void StoreLoadout(Loadout loadout) =>
|
|
Assert.True(_cache.Commit(batch => batch.Upsert(typeof(Loadout), loadout, Loadouts.KeyOf(loadout.Name))));
|
|
|
|
// A boss's fixed Loadout (ruling boss-gear-is-a-loadout), by the production capture: a seed-11 generated fit of the first sold
|
|
// ship hull by name, captured (Loadouts.Capture), cut down to its weapons so it is smaller than any generated fit (the tractor
|
|
// pickup and the rest are not in it), moved onto the given hull with no hull product, and stored under its name.
|
|
private Loadout MakeBossLoadout(HullData hull, string name)
|
|
{
|
|
var like = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First();
|
|
var random = new CultMath.Random(11);
|
|
var generator = new LoadoutGenerator(ref random, _items, null, null, null, .5f);
|
|
var ship = (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(h => h == like));
|
|
var fit = Loadouts.Capture(_items, ship, name);
|
|
var armed = fit.Slots.Select((slot, index) => (slot, index))
|
|
.Where(entry => _cache.Get(entry.slot.Design).Behaviors.Any(behavior => behavior is WeaponData)).ToArray();
|
|
Assert.NotEmpty(armed);
|
|
Assert.True(armed.Length < fit.Slots.Count, "the generated fit needs gear besides weapons for this fixture to tell a Loadout from a fit");
|
|
var renumbered = armed.Select((entry, number) => (entry.index, number)).ToDictionary(entry => entry.index, entry => entry.number);
|
|
fit.WeaponGroups = fit.WeaponGroups.Select(group => group.Where(renumbered.ContainsKey).Select(index => renumbered[index]).ToArray()).ToArray();
|
|
fit.Slots = armed.Select(entry => entry.slot).ToList();
|
|
fit.Hull = _cache.RefOf(hull);
|
|
fit.HullProduct = default;
|
|
StoreLoadout(fit);
|
|
return fit;
|
|
}
|
|
|
|
private static void SetBoss(Faction faction, Loadout loadout) => faction.BossLoadout = new CultRecordRef<Loadout>(Loadouts.KeyOf(loadout.Name));
|
|
|
|
// The demo's antagonist gets a boss Loadout on a hull nothing sells: the shipped catalog authors none.
|
|
private Loadout GiveAntagonistABoss(Galaxy galaxy)
|
|
{
|
|
var boss = MakeBossLoadout(BossTestHull(), "Antagonist Boss");
|
|
SetBoss(Zhestokost(galaxy), boss);
|
|
return boss;
|
|
}
|
|
|
|
// What a ship carries, cell by cell, as a Loadout would name it: position, rotation, design, product.
|
|
private static string[] Cells(ItemManager items, EntityPack pack)
|
|
{
|
|
var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, pack), "probe");
|
|
return captured.Slots.OrderBy(slot => slot.Position.x).ThenBy(slot => slot.Position.y)
|
|
.Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{items.ItemData.Get(slot.Design).Name},{(slot.Product.IsSet() ? items.ItemData.Get(slot.Product).Name : "-")}").ToArray();
|
|
}
|
|
|
|
private string[] LoadoutCells(Loadout loadout) =>
|
|
loadout.Slots.OrderBy(slot => slot.Position.x).ThenBy(slot => slot.Position.y)
|
|
.Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{_cache.Get(slot.Design).Name},{(slot.Product.IsSet() ? _cache.Get(slot.Product).Name : "-")}").ToArray();
|
|
|
|
private LoadoutGenerator GeneratorFor(Galaxy galaxy, ItemManager items, Faction faction, uint seed)
|
|
{
|
|
var random = new CultMath.Random(seed);
|
|
return new LoadoutGenerator(ref random, items, galaxy, galaxy.Exit, faction, .5f);
|
|
}
|
|
|
|
// What the pack holds, in order, by hull and faction: two zone packs that drew the same stream hold the same list.
|
|
private static string[] Roster(ItemManager items, ZonePack pack) =>
|
|
pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}").ToArray();
|
|
|
|
[Fact]
|
|
public void Boss_spawns_once_in_boss_zone()
|
|
{
|
|
foreach (var seed in DemoSeeds.Take(3))
|
|
{
|
|
var galaxy = DemoGalaxy(seed);
|
|
GiveAntagonistABoss(galaxy);
|
|
var antagonist = Zhestokost(galaxy);
|
|
foreach (var zone in galaxy.Zones)
|
|
{
|
|
var bosses = BossZonePack(galaxy, _items, zone).Entities.Where(entity => entity.Boss).ToArray();
|
|
if (zone != galaxy.Exit) { Assert.Empty(bosses); continue; }
|
|
|
|
var boss = Assert.Single(bosses);
|
|
Assert.IsType<ShipPack>(boss);
|
|
Assert.Same(antagonist, _cache.Get(boss.Faction));
|
|
Assert.Equal("Boss Test Hull", HullName(_items, boss));
|
|
// Generation is repeatable per zone: the same zone yields the same boss hull.
|
|
var again = Assert.Single(BossZonePack(galaxy, _items, zone).Entities, entity => entity.Boss);
|
|
Assert.Equal(HullName(_items, boss), HullName(_items, again));
|
|
}
|
|
}
|
|
}
|
|
|
|
// A zone that is not ambient still has its boss: the boss is the goal, not traffic.
|
|
[Fact]
|
|
public void Boss_spawns_in_a_zone_that_is_not_ambient()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
GiveAntagonistABoss(galaxy);
|
|
var pack = BossZonePack(galaxy, _items, galaxy.Exit, ambient: false);
|
|
Assert.Single(pack.Entities, entity => entity.Boss);
|
|
Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack);
|
|
}
|
|
|
|
// Faction.BossLoadout owns the boss. A decoy Loadout sorts before it by name and by key, so a resolver that takes the first
|
|
// Loadout in the cache, or any Loadout but the referenced one, flies the wrong hull.
|
|
[Fact]
|
|
public void Boss_flies_its_factions_boss_loadout()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var antagonist = Zhestokost(galaxy);
|
|
var sold = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First();
|
|
StoreLoadout(new Loadout { Name = "Aaa Decoy", Hull = _cache.RefOf(sold) });
|
|
var loadout = GiveAntagonistABoss(galaxy);
|
|
|
|
Assert.Same(_cache.Get(antagonist.BossLoadout), Loadouts.BossOf(_cache, antagonist, out var refusal));
|
|
Assert.Equal(BossRefusal.None, refusal);
|
|
Assert.Equal("Antagonist Boss", loadout.Name);
|
|
var boss = Assert.Single(BossZonePack(galaxy, _items, galaxy.Exit).Entities, entity => entity.Boss);
|
|
Assert.IsType<ShipPack>(boss);
|
|
Assert.Same(antagonist, _cache.Get(boss.Faction));
|
|
Assert.Equal("Boss Test Hull", HullName(_items, boss));
|
|
var again = Assert.Single(BossZonePack(galaxy, _items, galaxy.Exit).Entities, entity => entity.Boss);
|
|
Assert.Equal(Cells(_items, boss), Cells(_items, again));
|
|
}
|
|
|
|
// Ruling boss-gear-is-a-loadout: the boss carries exactly its Loadout, nothing drawn. The Loadout is weapons only, so any
|
|
// pickup, extra gear, cargo or consumable on the boss is a generator pass that should not run.
|
|
[Fact]
|
|
public void Boss_carries_exactly_its_loadouts_cells()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var loadout = GiveAntagonistABoss(galaxy);
|
|
Assert.True(loadout.Slots.Count > 1);
|
|
Assert.All(loadout.Slots, slot => Assert.True(slot.Product.IsSet(), "a captured fit pins every product"));
|
|
|
|
foreach (var seed in new uint[] { 7, 8 })
|
|
{
|
|
var items = LoggingItems(new List<string>());
|
|
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit, seed: seed).Entities, entity => entity.Boss);
|
|
Assert.Equal(LoadoutCells(loadout), Cells(items, boss));
|
|
Assert.All(boss.Equipment, entry => Assert.DoesNotContain(items.GetData(entry.item).Behaviors, behavior => behavior is PickupData));
|
|
Assert.Empty(boss.CargoContents.SelectMany(bay => bay));
|
|
Assert.Empty(boss.DockingBayContents.SelectMany(bay => bay));
|
|
|
|
var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, boss), "probe");
|
|
string[][] Groups(Loadout l) => l.WeaponGroups.Select(group => group.Select(i => $"{l.Slots[i].Position.x},{l.Slots[i].Position.y}").OrderBy(x => x, StringComparer.Ordinal).ToArray()).Where(group => group.Length > 0).ToArray();
|
|
Assert.Equal(Groups(loadout), Groups(captured));
|
|
}
|
|
}
|
|
|
|
// Ruling loadout-product-optional-context-chooses: a design-only slot takes the brand the boss's faction can get. The design has
|
|
// an outsider's product that sorts first by name, so a chooser that takes the first product by name builds the wrong brand.
|
|
[Fact]
|
|
public void A_design_only_boss_slot_takes_a_brand_its_faction_can_get()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var antagonist = Zhestokost(galaxy);
|
|
var loadout = GiveAntagonistABoss(galaxy);
|
|
var outfitter = GeneratorFor(galaxy, _items, antagonist, 1);
|
|
var open = loadout.Slots.First(slot => _cache.GetAll<FactionProductData>().Any(product => product.Design.Key.Equals(slot.Design.Key) && outfitter.IsAvailable(product)));
|
|
var template = _cache.GetAll<FactionProductData>().First(product => product.Design.Key.Equals(open.Design.Key));
|
|
open.Product = default;
|
|
StoreLoadout(loadout);
|
|
|
|
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
|
|
var outsider = MessagePack.MessagePackSerializer.Deserialize<FactionProductData>(MessagePack.MessagePackSerializer.Serialize(template, options), options);
|
|
outsider.Name = "Aaa Outsider";
|
|
foreach (var faction in _cache.GetAll<Faction>().OrderBy(faction => faction.Name, StringComparer.Ordinal))
|
|
{
|
|
outsider.Manufacturer = _cache.RefOf(faction);
|
|
if (!outfitter.IsAvailable(outsider)) break;
|
|
}
|
|
Assert.False(outfitter.IsAvailable(outsider), "the outsider's maker is neither Zhestokost nor in its allegiance");
|
|
_cache.Upsert(outsider);
|
|
|
|
var gettable = _cache.GetAll<FactionProductData>().Where(product => product.Design.Key.Equals(open.Design.Key) && outfitter.IsAvailable(product)).Select(product => product.Name).ToArray();
|
|
Assert.NotEmpty(gettable);
|
|
foreach (var seed in new uint[] { 1, 2, 3, 4, 5 })
|
|
{
|
|
var items = LoggingItems(new List<string>());
|
|
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit, seed: seed).Entities, entity => entity.Boss);
|
|
var cell = Assert.Single(Cells(items, boss), c => c.StartsWith($"{open.Position.x},{open.Position.y},"));
|
|
var brand = cell.Split(',').Last();
|
|
Assert.NotEqual("Aaa Outsider", brand);
|
|
Assert.Contains(brand, gettable);
|
|
}
|
|
}
|
|
|
|
// The demo as shipped (follow-up zhestokost-boss-loadout): Zhestokost's Faction.BossLoadout names the committed Nakovalnya
|
|
// Loadout, and a demo run reaches a built Nakovalnya carrying exactly that Loadout's cells, nothing drawn from the market.
|
|
// The gate stays shut while it lives and opens when it dies.
|
|
[Fact]
|
|
public void The_shipped_demo_boss_is_Zhestokosts_Nakovalnya_loadout()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var antagonist = Zhestokost(galaxy);
|
|
var loadout = Loadouts.BossOf(_cache, antagonist, out var refusal);
|
|
Assert.Equal(BossRefusal.None, refusal);
|
|
Assert.Equal("Nakovalnya Boss", loadout.Name);
|
|
Assert.Equal("Nakovalnya", _cache.Get(loadout.Hull).Name);
|
|
Assert.Equal(26, loadout.Slots.Count);
|
|
|
|
var hull = _cache.Get(loadout.Hull);
|
|
Assert.DoesNotContain(hull, LoadoutGenerator.SoldHulls(_cache));
|
|
|
|
foreach (var seed in new uint[] { 7, 8, 9 })
|
|
{
|
|
var log = new List<string>();
|
|
var items = LoggingItems(log);
|
|
var pack = BossZonePack(galaxy, items, galaxy.Exit, seed: seed);
|
|
Assert.True(pack.BossSpawned, string.Join("; ", log));
|
|
var boss = Assert.Single(pack.Entities, entity => entity.Boss);
|
|
Assert.Same(antagonist, _cache.Get(boss.Faction));
|
|
Assert.Equal("Nakovalnya", HullName(items, boss));
|
|
// A design-only slot takes the brand its faction can get (ruling loadout-product-optional-context-chooses), so the
|
|
// cell is compared by position, rotation and design; a slot that pins a product must be built with that product.
|
|
static string Placed(string cell) => string.Join(",", cell.Split(',').Take(4));
|
|
Assert.Equal(LoadoutCells(loadout).Select(Placed), Cells(items, boss).Select(Placed));
|
|
foreach (var pinned in LoadoutCells(loadout).Where(cell => !cell.EndsWith(",-")))
|
|
Assert.Contains(pinned, Cells(items, boss));
|
|
// No other ship in the zone flies the boss hull: the market never draws it.
|
|
Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack && HullName(items, entity) == "Nakovalnya");
|
|
}
|
|
|
|
var exit = ZoneOf(galaxy, galaxy.Exit);
|
|
var nakovalnya = Assert.Single(exit.Entities, entity => entity.IsBoss);
|
|
Assert.True(RunGoal.BossAlive(exit));
|
|
Assert.False(RunGoal.ExitOpen(exit));
|
|
nakovalnya.HeatstrokeDeath.OnNext(Unit.Default);
|
|
Assert.True(RunGoal.ExitOpen(exit));
|
|
}
|
|
|
|
// No generated fallback (rulings boss-gear-is-a-loadout, demo-boss-hull-invented): a faction that sets no boss Loadout has no
|
|
// boss. The refusal happens before anything is drawn.
|
|
[Fact]
|
|
public void Boss_without_a_boss_loadout_is_refused()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var antagonist = Zhestokost(galaxy);
|
|
antagonist.BossLoadout = default;
|
|
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
|
|
Assert.Equal(BossRefusal.Unset, refusal);
|
|
|
|
var log = new List<string>();
|
|
var items = LoggingItems(log);
|
|
var pack = BossZonePack(galaxy, items, galaxy.Exit);
|
|
Assert.False(pack.BossSpawned);
|
|
Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
|
|
Assert.Contains(log, line => line.Contains("Zhestokost has no boss") && line.Contains("sets no Faction.BossLoadout"));
|
|
Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss"));
|
|
Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy)));
|
|
|
|
// Nothing was drawn for the refused boss: the zone is the zone with no boss mapped at all.
|
|
galaxy.BossZones.Clear();
|
|
var bare = BossZonePack(galaxy, LoggingItems(new List<string>()), galaxy.Exit);
|
|
Assert.NotEmpty(bare.Entities);
|
|
Assert.Equal(Roster(items, bare), Roster(items, pack));
|
|
}
|
|
|
|
// A reference to a Loadout the catalog does not hold is refused like no reference, and the log names the field, not the value.
|
|
[Fact]
|
|
public void A_missing_boss_loadout_is_refused()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var antagonist = Zhestokost(galaxy);
|
|
antagonist.BossLoadout = new CultRecordRef<Loadout>(Loadouts.KeyOf("No Such Loadout"));
|
|
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
|
|
Assert.Equal(BossRefusal.Missing, refusal);
|
|
|
|
var log = new List<string>();
|
|
var items = LoggingItems(log);
|
|
var pack = BossZonePack(galaxy, items, galaxy.Exit);
|
|
Assert.False(pack.BossSpawned);
|
|
Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
|
|
Assert.Contains(log, line => line.Contains("Zhestokost has no boss") && line.Contains("names a Faction.BossLoadout that is not in the catalog"));
|
|
Assert.DoesNotContain(log, line => line.Contains("No Such Loadout") || line.Contains("sets no"));
|
|
Assert.DoesNotContain(log, line => line.Contains("Zhestokost has no boss") && line.Contains("No Such"));
|
|
}
|
|
|
|
// A boss Loadout whose slots do not fit gives no boss: the log names the Loadout and every fit failure, and the gate stays sealed.
|
|
[Fact]
|
|
public void An_unbuildable_boss_loadout_spawns_no_boss()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var loadout = GiveAntagonistABoss(galaxy);
|
|
loadout.Slots[0].Position = new int2(999, 999);
|
|
loadout.Slots[1].Position = new int2(998, 998);
|
|
StoreLoadout(loadout);
|
|
|
|
var log = new List<string>();
|
|
var items = LoggingItems(log);
|
|
var pack = BossZonePack(galaxy, items, galaxy.Exit, ambient: false);
|
|
Assert.False(pack.BossSpawned);
|
|
Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
|
|
var refusal = Assert.Single(log, line => line.Contains("Antagonist Boss") && line.Contains("does not fit"));
|
|
Assert.Equal(2, Regex.Matches(refusal, "does not fit").Count);
|
|
Assert.Contains("; ", refusal);
|
|
Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy)));
|
|
}
|
|
|
|
// The existing market rule (ruling off-market-is-general): an item is on offer iff a product of it is. A hull no product makes
|
|
// is never drawn, stocked or chosen, yet the boss whose Loadout names it flies it.
|
|
[Fact]
|
|
public void A_hull_no_product_makes_is_never_on_offer()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var hull = BossTestHull();
|
|
GiveAntagonistABoss(galaxy);
|
|
Assert.DoesNotContain(hull, LoadoutGenerator.SoldHulls(_cache));
|
|
var items = LoggingItems(new List<string>());
|
|
|
|
for (uint seed = 1; seed <= 20; seed++)
|
|
{
|
|
var generator = GeneratorFor(galaxy, items, Zhestokost(galaxy), seed);
|
|
var ship = generator.GenerateShipLoadout();
|
|
Assert.NotNull(ship);
|
|
Assert.NotEqual(hull.Name, HullName(items, ship));
|
|
var station = generator.GenerateStationLoadout(TestStationKinds.TradeHub);
|
|
Assert.DoesNotContain(EntitySerializer.Items(station), item => items.GetData(item).Name == hull.Name);
|
|
Assert.Null(generator.ChooseProduct(hull));
|
|
Assert.Null(generator.GenerateShipLoadout(candidate => candidate == hull));
|
|
}
|
|
|
|
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
|
|
Assert.Equal(hull.Name, HullName(items, boss));
|
|
}
|
|
|
|
// PlaceFactionsMain reads BossOf: with no reference set there is no boss zone, a reference to nothing places none, and the one
|
|
// faction whose boss Loadout resolves is the one entry though another faction sorts first.
|
|
[Fact]
|
|
public void Main_galaxy_bosses_are_factions_with_a_boss_loadout()
|
|
{
|
|
var factions = _cache.GetAll<Faction>().OrderBy(faction => faction.Name, StringComparer.Ordinal).ToArray();
|
|
foreach (var faction in factions) faction.BossLoadout = default;
|
|
Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones);
|
|
|
|
factions[0].BossLoadout = new CultRecordRef<Loadout>(Loadouts.KeyOf("No Such Loadout"));
|
|
Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones);
|
|
|
|
var armed = MakeBossLoadout(BossTestHull(), "Main Boss");
|
|
SetBoss(factions[1], armed);
|
|
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
|
|
var bossFaction = Assert.Single(galaxy.BossZones.Keys);
|
|
Assert.Equal(factions[1].Name, bossFaction.Name);
|
|
Assert.NotNull(Loadouts.BossOf(_cache, bossFaction, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void Exit_sealed_until_boss_dies()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
GiveAntagonistABoss(galaxy);
|
|
var exit = ZoneOf(galaxy, galaxy.Exit);
|
|
var boss = Assert.Single(exit.Entities, entity => entity.IsBoss);
|
|
Assert.True(RunGoal.BossAlive(exit));
|
|
Assert.False(RunGoal.ExitOpen(exit));
|
|
|
|
// Death removes the boss from the zone (the one death path), and that alone opens the gate.
|
|
boss.HeatstrokeDeath.OnNext(Unit.Default);
|
|
Assert.False(exit.Entities.Any(x => ReferenceEquals(x, boss)), "the boss is removed from the exit");
|
|
Assert.False(RunGoal.BossAlive(exit));
|
|
Assert.True(RunGoal.ExitOpen(exit));
|
|
|
|
// No other zone is ever an exit, boss or no boss.
|
|
foreach (var zone in galaxy.Zones.Where(zone => zone != galaxy.Exit))
|
|
Assert.False(RunGoal.ExitOpen(ZoneOf(galaxy, zone)));
|
|
}
|
|
|
|
// Continue: the mark is saved with the zone and restored, so a boss alive when the run was saved is alive after.
|
|
[Fact]
|
|
public void Boss_mark_survives_continue()
|
|
{
|
|
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1));
|
|
Assert.True(failures.Count == 0, string.Join("; ", failures));
|
|
GiveAntagonistABoss(galaxy);
|
|
galaxy.Entrance.Contents = arena;
|
|
galaxy.Exit.Contents = ZoneOf(galaxy, galaxy.Exit);
|
|
var before = Assert.Single(galaxy.Exit.Contents.Entities, entity => entity.IsBoss);
|
|
Assert.False(RunGoal.ExitOpen(galaxy.Exit.Contents));
|
|
|
|
var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0], 0);
|
|
RunSave.Commit(_cache, game, zones, _items.Lots);
|
|
var restored = new Galaxy(_cache, _cache.GetGlobal<SavedGame>(), _ => { });
|
|
var exit = new Zone(_items, _planetSettings, restored.Exit.PackedContents, restored.Exit, restored);
|
|
|
|
var after = Assert.Single(exit.Entities, entity => entity.IsBoss);
|
|
Assert.Equal(before.Name, after.Name);
|
|
Assert.Equal(before.Faction.Name, after.Faction.Name);
|
|
Assert.False(RunGoal.ExitOpen(exit));
|
|
after.HeatstrokeDeath.OnNext(Unit.Default);
|
|
Assert.True(RunGoal.ExitOpen(exit));
|
|
}
|
|
|
|
// The gate fails closed. The main galaxy puts its bosses on choke points, not at its exit, so its exit zone never gets
|
|
// one: the gate stays sealed on arrival and when every boss in the galaxy dies, because a boss that never existed is
|
|
// not a dead one.
|
|
[Fact]
|
|
public void Exit_sealed_where_no_boss_was_spawned_main_galaxy()
|
|
{
|
|
foreach (var seed in new uint[] { 1, 2, 3, 4, 5, 6, 7, 8 })
|
|
{
|
|
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => seed));
|
|
var exit = ZoneOf(galaxy, galaxy.Exit);
|
|
Assert.False(RunGoal.ExitOpen(exit), $"seed {seed}: the gate is open on arrival");
|
|
foreach (var boss in exit.Entities.Where(entity => entity.IsBoss).ToArray())
|
|
boss.HeatstrokeDeath.OnNext(Unit.Default);
|
|
Assert.False(RunGoal.ExitOpen(exit), $"seed {seed}: the gate is open with no boss spawned");
|
|
}
|
|
}
|
|
|
|
// The same for a demo exit whose boss could not be built: the zone logs the gap by name and the gate stays sealed.
|
|
[Fact]
|
|
public void Exit_sealed_and_logged_when_the_boss_cannot_be_generated()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
galaxy.BossZones.Clear();
|
|
var log = new List<string>();
|
|
var items = LoggingItems(log);
|
|
var pack = BossZonePack(galaxy, items, galaxy.Exit);
|
|
Assert.False(pack.BossSpawned);
|
|
Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss"));
|
|
var exit = new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy);
|
|
Assert.False(RunGoal.ExitOpen(exit));
|
|
}
|
|
|
|
// The gate is the exit zone's alone. The main galaxy puts bosses on choke points that are not its exit: such a zone's
|
|
// boss spawns and dies, and no gate opens, because the exit-zone condition is what keeps a slain boss from winning.
|
|
[Fact]
|
|
public void Exit_open_only_in_the_exit_zone()
|
|
{
|
|
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
|
|
// Give a faction a boss Loadout and a boss zone that is
|
|
// not the exit, as the generator would for a faction with a boss Loadout. This Loadout pins its hull's brand, so any faction
|
|
// builds it whatever it can get.
|
|
var faction = galaxy.Factions.First();
|
|
var hull = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First();
|
|
var gate = MakeBossLoadout(hull, "Gate Boss");
|
|
gate.HullProduct = _cache.RefOf(_cache.GetAll<FactionProductData>().OrderBy(product => product.Name, StringComparer.Ordinal).First(product => _cache.Get(product.Design) == hull));
|
|
StoreLoadout(gate);
|
|
SetBoss(faction, gate);
|
|
var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit);
|
|
galaxy.BossZones[faction] = bossZone;
|
|
var log = new List<string>();
|
|
var items = LoggingItems(log);
|
|
var zone = new Zone(items, _planetSettings, BossZonePack(galaxy, items, bossZone), bossZone, galaxy);
|
|
Assert.True(zone.Pack.BossSpawned, string.Join("; ", log));
|
|
Assert.NotEmpty(zone.Entities.Where(entity => entity.IsBoss));
|
|
foreach (var boss in zone.Entities.Where(entity => entity.IsBoss).ToArray())
|
|
boss.HeatstrokeDeath.OnNext(Unit.Default);
|
|
Assert.False(RunGoal.BossAlive(zone));
|
|
Assert.False(RunGoal.ExitOpen(zone));
|
|
}
|
|
|
|
// A boss killed before the save stays dead after it, and the gate stays open: the spawn fact travels with the zone.
|
|
[Fact]
|
|
public void Exit_open_after_save_of_a_slain_boss()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
GiveAntagonistABoss(galaxy);
|
|
var exit = ZoneOf(galaxy, galaxy.Exit);
|
|
Assert.Single(exit.Entities, entity => entity.IsBoss).HeatstrokeDeath.OnNext(Unit.Default);
|
|
var repacked = exit.PackZone();
|
|
Assert.True(repacked.BossSpawned);
|
|
Assert.DoesNotContain(repacked.Entities, entity => entity.Boss);
|
|
Assert.True(RunGoal.ExitOpen(new Zone(_items, _planetSettings, repacked, galaxy.Exit, galaxy)));
|
|
}
|
|
|
|
// The gate shares no point with an adjacency wormhole: every one is farther from it than a ship's wormhole reach.
|
|
[Fact]
|
|
public void Exit_gate_shares_no_point_with_a_wormhole()
|
|
{
|
|
var ratio = float.Parse(File.ReadLines(Path.Combine(FindRepoRoot(), "Assets", "Resources", "Settings.asset"))
|
|
.Select(line => Regex.Match(line, @"^ WormholeDistanceRatio: (.+)$")).First(match => match.Success).Groups[1].Value.Trim(),
|
|
System.Globalization.CultureInfo.InvariantCulture);
|
|
var reach = AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("GameplaySettings").WormholeExitRadius;
|
|
foreach (var seed in DemoSeeds)
|
|
{
|
|
var galaxy = DemoGalaxy(seed);
|
|
var zone = ZoneOf(galaxy, galaxy.Exit);
|
|
var gate = RunGoal.ExitGatePosition(zone, ratio);
|
|
foreach (var adjacent in galaxy.Exit.AdjacentZones)
|
|
{
|
|
var wormhole = math.normalize(adjacent.Position - galaxy.Exit.Position) * zone.Pack.Radius * ratio;
|
|
Assert.True(math.length(gate - wormhole) > 2 * reach, $"seed {seed}: the exit gate sits {math.length(gate - wormhole)} from a wormhole");
|
|
}
|
|
}
|
|
}
|
|
|
|
// The gate sits on the wormholes' radius, in the middle of the widest gap between neighbours, whatever the
|
|
// neighbours' number and order: an empty ring, one, two straddling the +-pi seam, and eight spread evenly (every gap
|
|
// under one radian). Checked against a brute-force sweep of every direction.
|
|
[Theory]
|
|
[InlineData(new double[0])]
|
|
[InlineData(new[] { 40.0 })]
|
|
[InlineData(new[] { 170.0, -170.0 })]
|
|
[InlineData(new[] { 100.0, 10.0, 20.0, 350.0, -90.0 })]
|
|
[InlineData(new[] { 0.0, 45.0, 90.0, 135.0, 180.0, -135.0, -90.0, -45.0 })]
|
|
public void Exit_gate_sits_in_the_widest_gap_at_the_wormhole_radius(double[] neighbourDegrees)
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var zone = ZoneOf(galaxy, galaxy.Exit);
|
|
const float ratio = 0.75f;
|
|
var radius = zone.Pack.Radius * ratio;
|
|
// Away from the origin and with close neighbours, so a direction taken from raw positions instead of the
|
|
// offset from the zone cannot pass.
|
|
var originalPosition = galaxy.Exit.Position;
|
|
var origin = galaxy.Exit.Position = math.float2(500, -300);
|
|
var original = galaxy.Exit.AdjacentZones;
|
|
galaxy.Exit.AdjacentZones = neighbourDegrees
|
|
.Select(degrees => new GalaxyZone
|
|
{
|
|
Position = origin + math.float2((float) Math.Cos(degrees * Math.PI / 180), (float) Math.Sin(degrees * Math.PI / 180)) * 10
|
|
}).ToList();
|
|
float2 gate;
|
|
try { gate = RunGoal.ExitGatePosition(zone, ratio); }
|
|
finally { galaxy.Exit.AdjacentZones = original; galaxy.Exit.Position = originalPosition; }
|
|
|
|
Assert.Equal(radius, math.length(gate), 2);
|
|
if (neighbourDegrees.Length == 0) { Assert.Equal(radius, gate.x, 2); Assert.Equal(0, gate.y, 2); return; }
|
|
|
|
double Separation(double angle) => neighbourDegrees.Min(degrees =>
|
|
{
|
|
var difference = Math.Abs(angle - degrees * Math.PI / 180) % (2 * Math.PI);
|
|
return difference > Math.PI ? 2 * Math.PI - difference : difference;
|
|
});
|
|
var best = Enumerable.Range(0, 7200).Max(step => Separation(step * Math.PI / 3600));
|
|
Assert.True(Separation(Math.Atan2(gate.y, gate.x)) >= best - 0.002,
|
|
$"the gate's nearest wormhole is {Separation(Math.Atan2(gate.y, gate.x))} rad away; the widest gap's middle is {best}");
|
|
}
|
|
|
|
// Two neighbours straight across leave two gaps of exactly pi: the first in ascending angle (from -pi) wins, so the
|
|
// gate sits at +radius on y, not -radius.
|
|
[Fact]
|
|
public void Exit_gate_takes_the_first_of_equal_gaps()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var zone = ZoneOf(galaxy, galaxy.Exit);
|
|
const float ratio = 0.75f;
|
|
var originalPosition = galaxy.Exit.Position;
|
|
var origin = galaxy.Exit.Position = math.float2(500, -300);
|
|
var original = galaxy.Exit.AdjacentZones;
|
|
galaxy.Exit.AdjacentZones = new List<GalaxyZone>
|
|
{
|
|
new GalaxyZone { Position = math.float2(510, -300) },
|
|
new GalaxyZone { Position = math.float2(490, -300) },
|
|
};
|
|
float2 gate;
|
|
try { gate = RunGoal.ExitGatePosition(zone, ratio); }
|
|
finally { galaxy.Exit.AdjacentZones = original; galaxy.Exit.Position = originalPosition; }
|
|
|
|
Assert.Equal(0, gate.x, 3);
|
|
Assert.Equal(zone.Pack.Radius * ratio, gate.y, 2);
|
|
}
|
|
|
|
// Soul's probes of cut boss-loadout pass s1, kept as behaviour: the rules the first eight tests let a mutant past.
|
|
|
|
[Fact]
|
|
public void The_boss_is_the_loadout_its_faction_names_even_when_another_loadout_shares_its_hull()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var decoy = MakeBossLoadout(BossTestHull(), "Aaa Same Hull");
|
|
decoy.Slots = decoy.Slots.Take(1).ToList();
|
|
decoy.WeaponGroups = new int[0][];
|
|
StoreLoadout(decoy);
|
|
var boss = GiveAntagonistABoss(galaxy);
|
|
Assert.True(boss.Slots.Count > decoy.Slots.Count);
|
|
var pack = Assert.Single(BossZonePack(galaxy, _items, galaxy.Exit).Entities, e => e.Boss);
|
|
Assert.Equal(LoadoutCells(boss), Cells(_items, pack));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void A_refused_boss_flies_no_catalog_loadout_and_draws_nothing(bool dangling)
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var hull = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First();
|
|
StoreLoadout(new Loadout { Name = "Aaa Decoy", Hull = _cache.RefOf(hull) });
|
|
Zhestokost(galaxy).BossLoadout = dangling ? new CultRecordRef<Loadout>(Loadouts.KeyOf("CANARY-7f3a")) : default;
|
|
var log = new List<string>();
|
|
var items = LoggingItems(log);
|
|
var pack = BossZonePack(galaxy, items, galaxy.Exit);
|
|
var next = items.Random.NextFloat();
|
|
Assert.False(pack.BossSpawned);
|
|
Assert.DoesNotContain(pack.Entities, e => e.Boss);
|
|
Assert.DoesNotContain(log, l => l.Contains("CANARY"));
|
|
galaxy.BossZones.Clear();
|
|
var bareItems = LoggingItems(new List<string>());
|
|
var bare = BossZonePack(galaxy, bareItems, galaxy.Exit);
|
|
Assert.Equal(Roster(bareItems, bare), Roster(items, pack));
|
|
Assert.Equal(bareItems.Random.NextFloat(), next);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_design_only_boss_slot_whose_design_only_outsiders_make_refuses_the_boss()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var faction = Zhestokost(galaxy);
|
|
var loadout = GiveAntagonistABoss(galaxy);
|
|
var outfitter = GeneratorFor(galaxy, _items, faction, 1);
|
|
var open = loadout.Slots.First(slot => _cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(slot.Design.Key) && outfitter.IsAvailable(p)));
|
|
var outsider = _cache.GetAll<Faction>().OrderBy(f => f.Name, StringComparer.Ordinal).First(f => f.Name != faction.Name && !faction.Allegiance.ContainsKey(_cache.RefOf(f)));
|
|
foreach (var p in _cache.GetAll<FactionProductData>().Where(p => p.Design.Key.Equals(open.Design.Key))) p.Manufacturer = _cache.RefOf(outsider);
|
|
Assert.Null(outfitter.ChooseProduct(_cache.Get(open.Design)));
|
|
open.Product = default;
|
|
StoreLoadout(loadout);
|
|
var log = new List<string>();
|
|
var pack = BossZonePack(galaxy, LoggingItems(log), galaxy.Exit, ambient: false);
|
|
Assert.False(pack.BossSpawned);
|
|
Assert.DoesNotContain(pack.Entities, e => e.Boss);
|
|
Assert.Contains(log, l => l.Contains("Antagonist Boss") && l.Contains("no available product"));
|
|
}
|
|
|
|
[Fact]
|
|
public void The_boss_path_draws_only_what_building_its_loadout_draws()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var loadout = GiveAntagonistABoss(galaxy);
|
|
var withBoss = LoggingItems(new List<string>());
|
|
Assert.True(BossZonePack(galaxy, withBoss, galaxy.Exit, ambient: false).BossSpawned);
|
|
var next = withBoss.Random.NextFloat();
|
|
galaxy.BossZones.Clear();
|
|
var expected = LoggingItems(new List<string>());
|
|
BossZonePack(galaxy, expected, galaxy.Exit, ambient: false);
|
|
var failures = new List<string>();
|
|
Assert.NotNull(Loadouts.Materialize(expected, null, loadout, GeneratorFor(galaxy, expected, Zhestokost(galaxy), 7).ChooseProduct, failures));
|
|
Assert.Empty(failures);
|
|
Assert.Equal(expected.Random.NextFloat(), next);
|
|
}
|
|
|
|
// Everything a generated zone exposes afterwards: the serialized ledger, the roster and the gear of its ships, and
|
|
// the next draw of the item stream. A faction's generator stream shows in the ships it outfitted, so it is in the pack.
|
|
private string ZoneFacts(ItemManager items, ZonePack pack)
|
|
{
|
|
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
|
|
var bytes = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(MessagePack.MessagePackSerializer.Serialize(items.Lots, options)));
|
|
var gear = pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}|{(EntitySerializer.Unpack(items, null, entity) is Ship ? string.Join(";", Cells(items, entity)) : "-")}");
|
|
return $"ledger {bytes} NextLot={items.Lots.NextLot} lots={items.Lots.Lots.Count}\n{string.Join("\n", gear)}\nnext item draw {items.Random.NextFloat():R}";
|
|
}
|
|
|
|
// The boss of the named faction, whose Loadout is made unbuildable by unbuild, refuses before anything is minted or drawn: the
|
|
// zone is the zone with no boss in every state it exposes. The faction need not be the zone's nearest.
|
|
private void AssertUnbuildableBossIsAsIfAbsent(string bossFaction, Action<Loadout> unbuild)
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var loadout = GiveAntagonistABoss(galaxy);
|
|
unbuild(loadout);
|
|
StoreLoadout(loadout);
|
|
var boss = galaxy.Factions.Single(faction => faction.Name == bossFaction);
|
|
Zhestokost(galaxy).BossLoadout = default;
|
|
SetBoss(boss, loadout);
|
|
galaxy.BossZones.Clear();
|
|
galaxy.BossZones[boss] = galaxy.Exit;
|
|
var log = new List<string>();
|
|
var items = LoggingItems(log);
|
|
var pack = BossZonePack(galaxy, items, galaxy.Exit);
|
|
Assert.False(pack.BossSpawned, bossFaction);
|
|
Assert.Contains(log, line => line.Contains("Antagonist Boss") && line.Contains("cannot be built"));
|
|
|
|
var bare = DemoGalaxy(1);
|
|
bare.BossZones.Clear();
|
|
var bareItems = LoggingItems(new List<string>());
|
|
var barePack = BossZonePack(bare, bareItems, bare.Exit);
|
|
var expected = ZoneFacts(bareItems, barePack);
|
|
var actual = ZoneFacts(items, pack);
|
|
Assert.True(expected == actual, $"{bossFaction}: the zone with an unbuildable boss differs from the zone with none.\nno boss:\n{expected}\nunbuildable boss:\n{actual}\nlog: {string.Join(" / ", log)}");
|
|
}
|
|
|
|
private IEnumerable<string> AllFactionNames() => DemoGalaxy(1).Factions.Select(faction => faction.Name).OrderBy(name => name, StringComparer.Ordinal);
|
|
|
|
private static void MoveOut(Loadout loadout, int slots)
|
|
{
|
|
for (var i = 0; i < slots; i++) loadout.Slots[i].Position = new int2(999 - i, 999 - i);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unbuildable_boss_loadout_with_one_failure_leaves_the_zone_as_if_it_had_no_boss()
|
|
{
|
|
foreach (var faction in AllFactionNames()) AssertUnbuildableBossIsAsIfAbsent(faction, loadout => MoveOut(loadout, 1));
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unbuildable_boss_loadout_with_several_failures_leaves_the_zone_as_if_it_had_no_boss()
|
|
{
|
|
foreach (var faction in AllFactionNames()) AssertUnbuildableBossIsAsIfAbsent(faction, loadout => MoveOut(loadout, 3));
|
|
}
|
|
|
|
// Ruling catalog-is-a-package-stack: a boss Loadout committed on a package's hull, and the package then removed (the one
|
|
// uninstall). The catalog still resolves (the shipped file, nothing thrown), so the game boots; the Loadout and the
|
|
// faction's reference to it survive in the shipped file, and the boss zone fails closed with the reason ZoneGenerator logs.
|
|
[Fact]
|
|
public void A_boss_loadout_on_a_removed_package_hull_still_boots_and_leaves_the_zone_without_a_boss()
|
|
{
|
|
const string id = "mod.removedboss";
|
|
var work = Path.Combine(_root, "removed-package");
|
|
var shipped = Path.Combine(work, "GameData", "Aetheria.cc");
|
|
var mods = Path.Combine(work, "GameData", "Mods");
|
|
Directory.CreateDirectory(mods);
|
|
File.Copy(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), shipped);
|
|
ShipFixture.WritePackage(mods, id, "Removed Boss Hull");
|
|
var installed = TestCatalog.Resolve(shipped, mods, Directory.CreateDirectory(Path.Combine(work, "installed")).FullName);
|
|
Assert.NotEqual(shipped, installed);
|
|
|
|
var galaxy = DemoGalaxy(1);
|
|
var antagonist = Zhestokost(galaxy);
|
|
var boss = new Loadout { Name = "Removed Package Boss", Hull = new CultRecordRef<HullData>(ShipModCatalog.HullKey(id)), WeaponGroups = new int[0][] };
|
|
Assert.True(Loadouts.Commit(shipped, boss, replace: false, _cache.RefOf(antagonist)));
|
|
ShipModCatalog.RemovePackage(mods, id);
|
|
|
|
var booted = TestCatalog.Resolve(shipped, mods, Directory.CreateDirectory(Path.Combine(work, "booted")).FullName);
|
|
Assert.Equal(shipped, booted);
|
|
Loadout survived;
|
|
using (var catalog = AetheriaStores.Open(booted))
|
|
{
|
|
survived = Loadouts.BossOf(catalog, catalog.GetAll<Faction>().Single(f => f.Name == antagonist.Name), out var refusal);
|
|
Assert.Equal(BossRefusal.None, refusal);
|
|
Assert.Null(catalog.Get(survived.Hull));
|
|
}
|
|
|
|
StoreLoadout(survived);
|
|
SetBoss(antagonist, survived);
|
|
var log = new List<string>();
|
|
var pack = BossZonePack(galaxy, LoggingItems(log), galaxy.Exit);
|
|
Assert.False(pack.BossSpawned);
|
|
Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
|
|
Assert.Contains(log, line => line.Contains("boss Loadout Removed Package Boss cannot be built") && line.Contains($"design {ShipModCatalog.HullKey(id)} is not in the catalog"));
|
|
}
|
|
|
|
// A design-only slot asks the chooser, which draws its faction's stream; the fit is decided before it is asked.
|
|
[Fact]
|
|
public void An_unbuildable_boss_loadout_with_design_only_slots_leaves_the_zone_as_if_it_had_no_boss()
|
|
{
|
|
foreach (var faction in AllFactionNames())
|
|
AssertUnbuildableBossIsAsIfAbsent(faction, loadout =>
|
|
{
|
|
foreach (var slot in loadout.Slots) slot.Product = default;
|
|
Assert.Contains(loadout.Slots, slot => _cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(slot.Design.Key)));
|
|
MoveOut(loadout, 1);
|
|
loadout.Slots[loadout.Slots.Count - 1].Position = new int2(900, 900);
|
|
});
|
|
}
|
|
}
|