Aetheria/tests/Aetheria.Shared.Tests/RunGoalTests.cs
2026-10-18 20:16:47 +02:00

693 lines
38 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. 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(_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 && !hull.Boss)
.Distinct().ToArray();
// A Boss hull a faction makes, by the production path: a copy of a sold ship hull (so it builds into a real ship) flagged
// Boss under its own name and price, and one copy of that hull's product per maker. A copy rather than the shipped
// hull itself, because flagging a shipped hull would make it a Boss hull of every maker the catalog gives it.
private HullData MakeBoss(string name, int price, params Faction[] makers) => MakeBoss(HullType.Ship, name, price, makers);
private HullData MakeBoss(HullType type, string name, int price, params Faction[] makers)
{
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
T Copy<T>(T document) => MessagePack.MessagePackSerializer.Deserialize<T>(MessagePack.MessagePackSerializer.Serialize(document, options), options);
var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First();
var likeProduct = _cache.GetAll<FactionProductData>().OrderBy(product => product.Name, StringComparer.Ordinal)
.First(product => _cache.Get(product.Design) == like);
var boss = Copy(like);
boss.Name = name;
boss.Price = price;
boss.HullType = type;
boss.Boss = true;
boss.Prefab = null;
boss.Visual = default;
var bossRef = _cache.Upsert(boss);
foreach (var maker in makers)
{
var product = Copy(likeProduct);
product.Name = $"{name} by {maker.Name}";
product.Design = new CultRecordRef<CraftedItemData>(bossRef.Key);
product.Manufacturer = _cache.RefOf(maker);
_cache.Upsert(product);
}
return _cache.Get(bossRef);
}
// A boss's fixed Loadout (ruling boss-gear-is-a-loadout), by the production capture: a generated fit of the hull MakeBoss
// copied, captured as a Loadout (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 boss hull and the maker's product of it, and stored at
// Loadouts.KeyOf(name), the key Loadouts.Commit writes. openRank leaves the slot whose design ranks highest (above 0) design-only.
// The shipped catalog has no Boss hull, so this and MakeBoss are fixtures.
private Loadout MakeBossLoadout(HullData boss, Faction maker, string name, Func<EquippableItemData, int> openRank = null)
{
var like = SoldShipHulls().OrderBy(hull => hull.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(hull => hull == 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();
if (openRank != null)
{
var open = fit.Slots.OrderByDescending(slot => openRank(_cache.Get(slot.Design))).First();
Assert.True(openRank(_cache.Get(open.Design)) > 0, "no weapon of the fit can be left design-only for this maker");
open.Product = default;
}
fit.Hull = _cache.RefOf(boss);
fit.HullProduct = _cache.RefOf(_cache.GetAll<FactionProductData>().Single(product => _cache.Get(product.Design) == boss && _cache.Get(product.Manufacturer) == maker));
StoreLoadout(fit);
return fit;
}
// 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))));
// The demo's antagonist gets a Boss hull and its Loadout: the shipped catalog has neither.
private HullData GiveAntagonistABoss(Galaxy galaxy)
{
var antagonist = Zhestokost(galaxy);
var boss = MakeBoss("Antagonist Boss", 100, antagonist);
MakeBossLoadout(boss, antagonist, "Antagonist Boss loadout");
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 static Faction FactionNamed(Galaxy galaxy, string name) => galaxy.Factions.Single(faction => faction.Name == name);
private HullData PriciestSold() =>
SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First();
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);
}
// BossOf owns the boss. Zhestokost makes Boss A, B and C and a decoy ordinary hull. Loadouts sit on the decoy's product,
// on Boss B's and C's, and two on Boss A's: only Boss A's, first by name, is the boss's, whatever order the names fall in.
[Fact]
public void Boss_flies_its_factions_boss_loadout()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var cheapest = SoldShipHulls().Min(hull => hull.Price);
var priciest = PriciestSold();
Assert.True(cheapest < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart");
// B and A tie on price, C is cheaper: the boss is the dearest Boss hull, ties by name.
var decoy = new FactionProductData { Name = "Decoy", Design = new CultRecordRef<CraftedItemData>(_cache.RefOf(priciest).Key), Manufacturer = _cache.RefOf(antagonist) };
var decoyRef = _cache.Upsert(decoy);
var b = MakeBoss("Boss B", cheapest + 10, antagonist);
var c = MakeBoss("Boss C", cheapest, antagonist);
var a = MakeBoss("Boss A", cheapest + 10, antagonist);
MakeBossLoadout(b, antagonist, "Aaa loadout of Boss B");
MakeBossLoadout(c, antagonist, "Aab loadout of Boss C");
MakeBossLoadout(a, antagonist, "Zz second loadout of Boss A");
MakeBossLoadout(a, antagonist, "Zz first loadout of Boss A");
StoreLoadout(new Loadout
{
Name = "Aac loadout of the decoy", Hull = _cache.RefOf(priciest),
HullProduct = new CultRecordRef<FactionProductData>(decoyRef.Key)
});
Assert.Equal(new[] { "Boss A", "Boss B", "Boss C" }, LoadoutGenerator.BossHullsOf(_cache, antagonist).Select(entry => entry.hull.Name));
var loadout = Loadouts.BossOf(_cache, antagonist, out var refusal);
Assert.Equal(BossRefusal.None, refusal);
Assert.Equal("Zz first loadout of Boss A", loadout.Name);
var log = new List<string>();
var items = LoggingItems(log);
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal("Boss A", HullName(items, boss));
Assert.DoesNotContain(log, line => line.Contains("makes no boss hull"));
}
// Equal-priced Boss hulls go by hull name, and a hull sold under two products by product name, whatever order the products
// were authored or are named in: the first Boss hull is the same hull for every reader.
[Fact]
public void Equal_priced_boss_hulls_go_by_hull_name_then_product_name()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var y = MakeBoss("Tie Y", 100, antagonist);
var z = MakeBoss("Tie Z", 100, antagonist);
void Rename(HullData hull, string name)
{
var product = _cache.GetAll<FactionProductData>().Single(p => _cache.Get(p.Design) == hull);
product.Name = name;
Assert.True(_cache.Commit(batch => batch.Upsert(typeof(FactionProductData), product, _cache.RefOf(product).Key)));
}
Rename(y, "zz product of Y");
Rename(z, "aa product of Z");
var yProduct = _cache.GetAll<FactionProductData>().Single(p => p.Name == "zz product of Y");
_cache.Upsert(new FactionProductData { Name = "a second product of Y", Design = yProduct.Design, Manufacturer = yProduct.Manufacturer });
var made = LoadoutGenerator.BossHullsOf(_cache, antagonist);
Assert.Equal(new[] { "Tie Y", "Tie Y", "Tie Z" }, made.Select(entry => entry.hull.Name));
Assert.Equal(new[] { "a second product of Y", "zz product of Y", "aa product of Z" }, made.Select(entry => entry.product.Name));
}
// Ruling boss-gear-is-a-loadout: the boss carries its Loadout's cells and nothing else. The generated fit of the same hull
// carries more (a tractor pickup, consumables); none of it is on the boss. No generator draw decides it: another seed gives
// the same cells. A design-only slot takes its brand from the context's ChooseProduct (ruling
// loadout-product-optional-context-chooses); a pinned one keeps the product it names.
[Fact]
public void Boss_carries_exactly_its_loadouts_cells()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var hull = MakeBoss("Boss A", 100, antagonist);
var loadout = MakeBossLoadout(hull, antagonist, "Boss A loadout",
openRank: design =>
{
// A design the antagonist's outfitter can get, best one that also has a product it cannot: its pick is then a constrained choice.
var outfitter = GeneratorFor(galaxy, _items, antagonist, 1);
var made = _cache.GetAll<FactionProductData>().Where(product => product.Design.Key.Equals(_cache.RefOf(design).Key)).ToArray();
return made.Any(outfitter.IsAvailable) ? (made.Any(product => !outfitter.IsAvailable(product)) ? 2 : 1) : 0;
});
Assert.True(loadout.Slots.Count > 1);
var open = Assert.Single(loadout.Slots, slot => !slot.Product.IsSet());
string Cell(LoadoutSlot slot, string brand) => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{_cache.Get(slot.Design).Name},{brand}";
var pinned = loadout.Slots.Where(slot => slot != open).Select(slot => Cell(slot, _cache.Get(slot.Product).Name)).OrderBy(x => x, StringComparer.Ordinal).ToArray();
// The brands the context can choose for it: the products of the design the antagonist's outfitter can get, never any maker's.
var outfitter = GeneratorFor(galaxy, _items, antagonist, 1);
var brands = _cache.GetAll<FactionProductData>().Where(product => product.Design.Key.Equals(open.Design.Key) && outfitter.IsAvailable(product)).Select(product => product.Name).ToArray();
Assert.NotEmpty(brands);
var items = LoggingItems(new List<string>());
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
var cells = Cells(items, boss);
Assert.Equal(loadout.Slots.Count, cells.Length);
Assert.Equal(pinned, cells.Where(cell => !cell.StartsWith($"{open.Position.x},{open.Position.y},")).OrderBy(x => x, StringComparer.Ordinal));
var openCell = Assert.Single(cells, cell => cell.StartsWith($"{open.Position.x},{open.Position.y},"));
Assert.StartsWith(Cell(open, ""), openCell);
Assert.Contains(openCell[(openCell.LastIndexOf(',') + 1)..], brands);
var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, boss), "probe");
Assert.Equal(_cache.Get(loadout.HullProduct).Name, _cache.Get(captured.HullProduct).Name);
Assert.Equal(hull.Name, _cache.Get(captured.Hull).Name);
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()).ToArray();
Assert.Equal(Groups(loadout).Where(group => group.Length > 0), Groups(captured).Where(group => group.Length > 0));
// No generator draw decides the gear: another seed builds the same pinned cells. Only the open slot's brand is the context's pick.
var otherItems = LoggingItems(new List<string>());
var other = Assert.Single(BossZonePack(galaxy, otherItems, galaxy.Exit, seed: 8).Entities, entity => entity.Boss);
var otherCells = Cells(otherItems, other);
Assert.Equal(pinned, otherCells.Where(cell => !cell.StartsWith($"{open.Position.x},{open.Position.y},")).OrderBy(x => x, StringComparer.Ordinal));
}
// No generated fallback: the shipped catalog has no Boss hull, so Zhestokost has no boss, however many ordinary hulls it can be offered.
[Fact]
public void Boss_without_a_boss_hull_is_refused()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
Assert.Equal(BossRefusal.NoBossHull, 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") && line.Contains("HullData.Boss"));
Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss"));
Assert.DoesNotContain(log, line => line.Contains("makes no boss hull"));
Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, null));
}
// A Boss hull whose product has no Loadout is refused typed at the derivation. The refusal names the missing field and
// echoes no input: the canary hull name, its product's name and the record keys reach no log line.
[Fact]
public void A_boss_hull_without_a_loadout_is_refused()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var hull = MakeBoss("Canary7f3a hull", 100, antagonist);
var product = _cache.GetAll<FactionProductData>().Single(p => _cache.Get(p.Design) == hull);
// A Loadout on another maker's boss product is not this boss's.
var lucent = FactionNamed(galaxy, "Lucent Media");
MakeBossLoadout(MakeBoss("Other boss", 50, lucent), lucent, "Other boss loadout");
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
Assert.Equal(BossRefusal.NoLoadout, refusal);
var described = Loadouts.Describe(refusal);
Assert.Contains("HullProduct", described);
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") && line.Contains("HullProduct"));
foreach (var canary in new[] { "Canary7f3a", product.Name, _cache.RefOf(product).Key.Value, _cache.RefOf(hull).Key.Value })
{
Assert.DoesNotContain(log, line => line.Contains(canary));
Assert.DoesNotContain(canary, described);
}
}
// The key is the product, not the hull: a Boss hull both makers sell, with a Loadout on Lucent Media's product only.
[Fact]
public void A_loadout_on_another_makers_product_is_not_this_bosses()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var lucent = FactionNamed(galaxy, "Lucent Media");
var shared = MakeBoss("Shared Boss", 100, antagonist, lucent);
var loadout = MakeBossLoadout(shared, lucent, "Aaa Lucent only");
Assert.Equal(loadout.Name, Loadouts.BossOf(_cache, lucent, out var lucentRefusal).Name);
Assert.Equal(BossRefusal.None, lucentRefusal);
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
Assert.Equal(BossRefusal.NoLoadout, refusal);
Assert.False(BossZonePack(galaxy, LoggingItems(new List<string>()), galaxy.Exit).BossSpawned);
}
[Fact]
public void Another_factions_boss_hull_is_never_used()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var lucent = FactionNamed(galaxy, "Lucent Media");
// Dearer than every ordinary hull, and Lucent's alone, with its own Loadout: Zhestokost neither derives it nor falls back to it.
var luxury = MakeBoss("Lucent Flagship", PriciestSold().Price + 1000, lucent);
MakeBossLoadout(luxury, lucent, "Lucent Flagship loadout");
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") && line.Contains("HullData.Boss"));
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
Assert.Equal(BossRefusal.NoBossHull, refusal);
Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, antagonist));
Assert.Same(luxury, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull);
Assert.Equal("Lucent Flagship loadout", Loadouts.BossOf(_cache, lucent, out _).Name);
}
[Fact]
public void A_boss_hull_two_makers_sell_is_a_boss_of_each()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var lucent = FactionNamed(galaxy, "Lucent Media");
var shared = MakeBoss("Shared Boss", PriciestSold().Price - 1, antagonist, lucent);
// A Boss station hull the antagonist also makes is no boss: a boss is a ship.
MakeBoss(HullType.Station, "Boss Station", PriciestSold().Price + 1, antagonist);
MakeBossLoadout(shared, lucent, "Aaa loadout of Lucent");
MakeBossLoadout(shared, antagonist, "Zzz loadout of Zhestokost");
Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, antagonist)).hull);
Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull);
Assert.Equal("Zzz loadout of Zhestokost", Loadouts.BossOf(_cache, antagonist, out _).Name);
Assert.Equal("Aaa loadout of Lucent", Loadouts.BossOf(_cache, lucent, out _).Name);
var items = LoggingItems(new List<string>());
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal("Shared Boss", HullName(items, boss));
}
// Ruling hull-programmer-art-reads-as-stand-in: a boss hull may be an agent's stopgap with no authored model.
[Fact]
public void A_boss_hull_needs_no_authored_model()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var bare = MakeBoss("Bare Boss", 1, antagonist);
MakeBossLoadout(bare, antagonist, "Bare Boss loadout");
Assert.Null(bare.Prefab);
Assert.False(bare.Visual.IsSet());
Assert.Same(bare, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, antagonist)).hull);
var items = LoggingItems(new List<string>());
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal("Bare Boss", HullName(items, boss));
}
// A Boss hull is flown only by bosses: with the cheapest price (the heaviest draw weight) and every relevant maker, it still
// never comes out of a generation draw, including the any-manufacturer fallback and a faction no maker is allied to.
[Fact]
public void Boss_hulls_are_never_stock_or_traffic()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var lucent = FactionNamed(galaxy, "Lucent Media");
var bossShip = MakeBoss("Traffic Boss", 1, antagonist, lucent);
var bossStation = MakeBoss(HullType.Station, "Stock Boss", 1, antagonist, lucent);
var stranger = new Faction { Name = "Stranger" };
_cache.Upsert(stranger);
stranger = _cache.GetAll<Faction>().Single(faction => faction.Name == "Stranger");
MakeBoss("Strangers Foe Boss", 1, lucent);
var log = new List<string>();
var items = LoggingItems(log);
var drawn = 0;
for (uint seed = 1; seed <= 20; seed++)
foreach (var faction in new[] { antagonist, lucent, stranger, null })
{
var generator = GeneratorFor(galaxy, items, faction, seed);
for (var i = 0; i < 3; i++)
{
var pack = generator.GenerateShipLoadout();
Assert.NotNull(pack);
drawn++;
Assert.DoesNotContain(HullName(items, pack), new[] { "Traffic Boss", "Strangers Foe Boss" });
Assert.NotEqual("Stock Boss", generator.RandomHull(HullType.Station).design?.Name);
}
// Asked for by name, with the boss the only candidate: nothing is on offer, however the fallback is reached.
Assert.Null(generator.GenerateShipLoadout(hull => hull == bossShip));
Assert.Null(generator.GenerateShipLoadout(hull => hull.Name == "Strangers Foe Boss"));
Assert.Null(generator.RandomHull(HullType.Station, hull => hull == bossStation).design);
}
Assert.Equal(20 * 4 * 3, drawn);
}
// PlaceFactionsMain picks through BossOf: a faction with a Boss hull and no Loadout places nothing, and the faction that
// also has the Loadout is the one entry, though another faction sorts first.
[Fact]
public void Main_galaxy_bosses_are_factions_with_a_boss_loadout()
{
Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones);
var makers = _cache.GetAll<Faction>().OrderBy(faction => faction.Name, StringComparer.Ordinal).Take(2).ToArray();
MakeBoss("Unarmed Boss", 100, makers[0]);
var armed = MakeBoss("Main Boss", 100, makers[1]);
Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones);
MakeBossLoadout(armed, makers[1], "Main Boss loadout");
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
var bossFaction = Assert.Single(galaxy.BossZones.Keys);
Assert.Equal(makers[1].Name, bossFaction.Name);
Assert.NotNull(Loadouts.BossOf(_cache, bossFaction, out _));
}
[Fact]
public void Boss_spawns_once_in_boss_zone()
{
var antagonistsBoss = GiveAntagonistABoss(DemoGalaxy(DemoSeeds.First()));
foreach (var seed in DemoSeeds.Take(3))
{
var galaxy = DemoGalaxy(seed);
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(antagonistsBoss.Name, 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);
}
[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()
{
GiveAntagonistABoss(DemoGalaxy(1));
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1));
Assert.True(failures.Count == 0, string.Join("; ", failures));
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 generated: 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));
// The shipped catalog has no Boss hull, so the main galaxy places no boss: give a faction one and a boss zone that is
// not the exit, as the generator would for a faction that makes a Boss hull.
var faction = galaxy.Factions.First();
MakeBossLoadout(MakeBoss("Gate Boss", 100, faction), faction, "Gate Boss loadout");
var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit);
galaxy.BossZones[faction] = bossZone;
var zone = ZoneOf(galaxy, bossZone);
Assert.True(zone.Pack.BossSpawned);
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 Loadout whose slot does not fit gives no boss: the log names the Loadout and the fit failure, and the gate stays sealed.
[Fact]
public void An_unbuildable_boss_loadout_spawns_no_boss()
{
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var hull = MakeBoss("Boss A", 100, antagonist);
var loadout = MakeBossLoadout(hull, antagonist, "Unfittable loadout");
loadout.Slots[0].Position = new int2(999, 999);
StoreLoadout(loadout);
Assert.Equal("Unfittable loadout", Loadouts.BossOf(_cache, antagonist, out _).Name);
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);
Assert.Contains(log, line => line.Contains("Unfittable loadout") && line.Contains("does not fit"));
Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy)));
}
// 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);
}
}