Boss gear is never drawn (ruling boss-gear-is-a-loadout). GenerateShipLoadout gets its single body back. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
441 lines
22 KiB
C#
441 lines
22 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)
|
|
{
|
|
items.Random = new CultMath.Random(7);
|
|
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)
|
|
.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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public void Boss_flies_the_boss_hull_its_faction_makes()
|
|
{
|
|
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");
|
|
|
|
// The faction also makes the dearest ordinary hull, and three Boss hulls: B and A tie on price, C is cheaper. The boss
|
|
// is the dearest Boss hull, ties by name: not the dearest hull it makes, not the first product, not the cheapest.
|
|
_cache.Upsert(new FactionProductData { Name = "Decoy", Design = new CultRecordRef<CraftedItemData>(_cache.RefOf(priciest).Key), Manufacturer = _cache.RefOf(antagonist) });
|
|
MakeBoss("Boss B", cheapest + 10, antagonist);
|
|
MakeBoss("Boss C", cheapest, antagonist);
|
|
MakeBoss("Boss A", cheapest + 10, antagonist);
|
|
|
|
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"));
|
|
Assert.Equal(new[] { "Boss A", "Boss B", "Boss C" }, LoadoutGenerator.BossHullsOf(_cache, antagonist).Select(entry => entry.hull.Name));
|
|
}
|
|
|
|
[Fact]
|
|
public void Another_factions_boss_hull_is_never_used()
|
|
{
|
|
var galaxy = DemoGalaxy(1);
|
|
var antagonist = Zhestokost(galaxy);
|
|
var lucent = FactionNamed(galaxy, "Lucent");
|
|
var expected = PriciestSold().Name;
|
|
// Dearer than every ordinary hull, and Lucent's alone: Zhestokost neither derives it nor falls back to it.
|
|
var luxury = MakeBoss("Lucent Flagship", PriciestSold().Price + 1000, lucent);
|
|
|
|
var log = new List<string>();
|
|
var items = LoggingItems(log);
|
|
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
|
|
Assert.Equal(expected, HullName(items, boss));
|
|
Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("makes no boss hull"));
|
|
Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, antagonist));
|
|
Assert.Same(luxury, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull);
|
|
}
|
|
|
|
[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");
|
|
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);
|
|
|
|
Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, antagonist)).hull);
|
|
Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull);
|
|
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-stopgap: 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);
|
|
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");
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public void Main_galaxy_bosses_are_factions_that_make_boss_hulls()
|
|
{
|
|
Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones);
|
|
|
|
var maker = _cache.GetAll<Faction>().OrderBy(faction => faction.Name, StringComparer.Ordinal).First();
|
|
MakeBoss("Main Boss", 100, maker);
|
|
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
|
|
var bossFaction = Assert.Single(galaxy.BossZones.Keys);
|
|
Assert.Equal(maker.Name, bossFaction.Name);
|
|
Assert.NotEmpty(LoadoutGenerator.BossHullsOf(_cache, bossFaction));
|
|
}
|
|
|
|
[Fact]
|
|
public void Boss_spawns_once_in_boss_zone()
|
|
{
|
|
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));
|
|
// 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);
|
|
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);
|
|
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));
|
|
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();
|
|
MakeBoss("Gate Boss", 100, faction);
|
|
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 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);
|
|
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);
|
|
}
|
|
}
|