Aetheria/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs
Meta Vens a59f1fa977 Tests: TutorialSeed1 follows the galaxy CultMath 0.3.0 rolls
The noise kernel changed at cultmath-unity-v0.3.0, so the fixed seed gives a
different tutorial. TutorialGalaxy_unchanged keeps its claim (a fixed seed
gives a fixed tutorial) against the regenerated shape. Does not pass the whole
suite: the RestoredHullsTests and the evasion test follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:54:42 +02:00

226 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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.Linq;
using CultMath;
using Xunit;
// The demo scenario (aetheria-release, cut demo-galaxy): a prelude galaxy of a fixed cast whose antagonist's home is the
// boss zone and the exit. The cast, the Pirates included, is in the shipped catalog.
public sealed partial class RunStartTests
{
private static readonly uint[] DemoSeeds = { 1, 2, 3, 4, 5 };
private Galaxy DemoGalaxy(uint seed) => RunStart.Generate(new DemoTerminus(), Inputs(() => seed));
[Fact]
public void DemoTerminus_fixed_cast()
{
foreach (var seed in DemoSeeds)
Assert.Equal(new[] { "Pirates", "Zhestokost", "Lucent Media", "Aeronautics Unlimited" },
DemoGalaxy(seed).Factions.Select(faction => faction.Name));
}
[Fact]
public void DemoTerminus_gate_at_antagonist_home()
{
foreach (var seed in DemoSeeds)
{
var galaxy = DemoGalaxy(seed);
// Zones are compared by index: xunit formats a failing zone into its message, and a zone drags the galaxy
// graph in with it until the test host dies.
int At(GalaxyZone zone) => Array.IndexOf(galaxy.Zones, zone);
var zhestokost = galaxy.Factions.Single(faction => faction.Name == "Zhestokost");
Assert.Equal(new[] { "Zhestokost" }, galaxy.BossZones.Keys.Select(faction => faction.Name));
Assert.Equal(At(galaxy.HomeZones[zhestokost]), At(galaxy.BossZones[zhestokost]));
Assert.Equal(At(galaxy.HomeZones[zhestokost]), At(galaxy.Exit));
Assert.NotEqual(At(galaxy.Entrance), At(galaxy.Exit));
}
}
// The player's standing is part of the run: Continue restores it, and the gate, through the saved game.
[Fact]
public void DemoTerminus_standing_survives_continue()
{
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1));
Assert.True(failures.Count == 0, string.Join("; ", failures));
galaxy.Entrance.Contents = arena;
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>(), _ => { });
Dictionary<string, FactionRelationship> Standing(Galaxy g) => g.Factions.ToDictionary(faction => faction.Name, faction => g.FactionRelationships[faction]);
Assert.Equal(new Dictionary<string, FactionRelationship>
{
["Pirates"] = FactionRelationship.Friendly,
["Zhestokost"] = FactionRelationship.Hated,
["Lucent Media"] = FactionRelationship.Neutral,
["Aeronautics Unlimited"] = FactionRelationship.Neutral
}, Standing(restored));
Assert.Equal(Standing(galaxy), Standing(restored));
Assert.Equal(Array.IndexOf(galaxy.Zones, galaxy.Exit), Array.IndexOf(restored.Zones, restored.Exit));
Assert.Equal(
galaxy.BossZones.Select(boss => (boss.Key.Name, Array.IndexOf(galaxy.Zones, boss.Value))),
restored.BossZones.Select(boss => (boss.Key.Name, Array.IndexOf(restored.Zones, boss.Value))));
}
// A catalog without the Pirates (the shipped record and its products removed from this scratch copy) does not start the
// run: the galaxy refuses, naming the cast field, and the scenario never reaches staging.
[Fact]
public void DemoTerminus_missing_cast_refused()
{
var pirates = _cache.GetAll<Faction>().Single(faction => faction.Name == "Pirates");
var piratesRef = _cache.RefOf(pirates);
foreach (var product in _cache.GetAll<FactionProductData>().Where(product => product.Manufacturer.Equals(piratesRef)).ToArray())
Assert.True(_cache.Remove(_cache.RefOf(product).Key));
Assert.True(_cache.Remove(piratesRef.Key));
Assert.DoesNotContain(_cache.GetAll<Faction>(), faction => faction.Name.StartsWith("Pirates", StringComparison.Ordinal));
var refusal = Assert.Throws<InvalidOperationException>(() => DemoGalaxy(1));
Assert.Contains("Pirates", refusal.Message);
Assert.Contains(nameof(TutorialGenerationSettings.ProtagonistFaction), refusal.Message);
}
// The operator's ruling is that the first release is a demo: the one scenario a release build lists says so by name.
[Fact]
public void DemoTerminus_is_named_a_demo()
{
var listed = Assert.Single(Scenarios.Modes);
Assert.IsType<DemoTerminus>(listed);
Assert.Equal("Terminus (Demo)", listed.Name);
}
// The run start as New Game runs it: the player is not a Pirate, so the hull is the base Longinus, made by Alakrita,
// not by the Pirates or any cast faction, with no faction on the ship, at the entrance zone's origin.
[Fact]
public void DemoTerminus_player_flies_a_non_pirates_brand_hull_the_longinus()
{
foreach (var seed in DemoSeeds)
{
var (galaxy, _, staged, failures) = Launch(new DemoTerminus(), Inputs(() => seed));
Assert.True(failures.Count == 0, string.Join("; ", failures));
Assert.Equal("Longinus", staged.Player.HullData.Name);
var design = _cache.RefOf(staged.Player.HullData).Key;
var product = Assert.Single(_cache.GetAll<FactionProductData>(), p => p.Design.Key.Equals(design));
var maker = _cache.Get(product.Manufacturer);
Assert.Equal("Alakrita", maker.Name);
Assert.DoesNotContain(galaxy.Factions, faction => faction.Name == maker.Name);
Assert.Null(staged.Player.Faction);
Assert.Equal(float3.zero, staged.Player.Position);
}
}
// The ruling says the Longinus with its default fit: the fit the game generates for that hull from the run's item
// draws, which Stage seeds from the galaxy's seed. The expected fit is that same generation replayed from the same
// seed, so no item list is spelled here; a bare hull, a preset, or one item swapped for another differs from it.
[Fact]
public void DemoTerminus_player_carries_the_longinus_default_fit()
{
string Fit(Entity ship) => string.Join(";", ship.Equipment
.Select(item => $"{item.Data.Name}@{item.Position}/{item.EquippableItem.Rotation}")
.OrderBy(entry => entry, StringComparer.Ordinal));
foreach (var seed in DemoSeeds)
{
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => seed));
Assert.True(failures.Count == 0, string.Join("; ", failures));
_items.Random = new CultMath.Random(galaxy.Seed * 0x9E3779B1u + 2);
var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, arena.GalaxyZone, null, 2);
var expected = EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull.Name == "Longinus"));
Assert.NotEmpty(expected.Equipment);
Assert.Equal(Fit(expected), Fit(staged.Player));
}
}
// The demo region's size and connectivity are authored: 64 zones, links thinned to half. The reference galaxy is the
// same cast with the thinning written out here, generated at the same seed, so any other density is a different graph.
[Fact]
public void DemoTerminus_region_is_sixty_four_zones_linked_at_half_density()
{
string Links(Galaxy g) => string.Join(";", g.Zones.Select(zone =>
string.Join(",", zone.AdjacentZones.Select(adjacent => Array.IndexOf(g.Zones, adjacent)).OrderBy(index => index))));
Galaxy Reference(uint seed, float density)
{
var cast = new TutorialGenerationSettings
{
ProtagonistFaction = "Pirates", AntagonistFaction = "Zhe", BufferFaction = "Luc",
NeutralFactions = new[] { "Aero" }, QuestFaction = null, ZoneCount = 64, LinkDensity = density
};
return RunStart.Generate(new Scripted(false, _ => { }, seed, galaxy => galaxy.Prelude(cast)), Inputs());
}
foreach (var seed in DemoSeeds)
{
var galaxy = DemoGalaxy(seed);
Assert.Equal(64, galaxy.Zones.Length);
Assert.Equal(Links(Reference(seed, .5f)), Links(galaxy));
Assert.NotEqual(Links(Reference(seed, 1f)), Links(galaxy));
}
}
// A cast that names no quest faction places no quest zone: one home for each of its four factions.
[Fact]
public void ACastNamingNoQuestFactionPlacesNoQuestZone()
{
var galaxy = DemoGalaxy(1);
Assert.Equal(4, galaxy.HomeZones.Count);
Assert.Equal(4, galaxy.Factions.Length);
}
// A name that resolves to nothing refuses the galaxy whichever cast field names it, and the refusal names the field.
[Theory]
[InlineData(nameof(TutorialGenerationSettings.ProtagonistFaction))]
[InlineData(nameof(TutorialGenerationSettings.AntagonistFaction))]
[InlineData(nameof(TutorialGenerationSettings.BufferFaction))]
[InlineData(nameof(TutorialGenerationSettings.QuestFaction))]
[InlineData(nameof(TutorialGenerationSettings.NeutralFactions))]
public void AnUnresolvedCastNameRefusesTheGalaxyNamingItsField(string field)
{
var cast = new TutorialGenerationSettings
{
ProtagonistFaction = _tutorialSettings.ProtagonistFaction,
AntagonistFaction = _tutorialSettings.AntagonistFaction,
BufferFaction = _tutorialSettings.BufferFaction,
QuestFaction = _tutorialSettings.QuestFaction,
NeutralFactions = _tutorialSettings.NeutralFactions,
ZoneCount = _tutorialSettings.ZoneCount,
LinkDensity = _tutorialSettings.LinkDensity
};
typeof(TutorialGenerationSettings).GetField(field).SetValue(cast, field == nameof(cast.NeutralFactions) ? new[] { "Nonesuch" } : "Nonesuch");
var scenario = new Scripted(false, _ => { }, generate: galaxy => galaxy.Prelude(cast));
var refusal = Assert.Throws<InvalidOperationException>(() => RunStart.Generate(scenario, Inputs()));
Assert.Contains(field, refusal.Message);
}
// What a release build lists under New Game is the demo alone; the tutorial and the main galaxy are listed only in
// editor and development builds (MainMenu.ShowScenarios), beside the test arenas.
[Fact]
public void ReleaseBuildsListOnlyTheDemo()
{
Assert.Equal(new[] { typeof(DemoTerminus) }, Scenarios.Modes.Select(scenario => scenario.GetType()));
Assert.Equal(new[] { typeof(TutorialGalaxy), typeof(MainGalaxy) }, Scenarios.Development.Select(scenario => scenario.GetType()));
}
// The tutorial is the galaxy it was before the demo: its cast, homes, entrance and ownership at a fixed seed, with
// no exit and no boss zone.
[Fact]
public void TutorialGalaxy_unchanged()
{
var galaxy = RunStart.Generate(new TutorialGalaxy(), Inputs(() => GalaxySeed));
Assert.Equal(TutorialSeed1, TutorialShape(galaxy));
Assert.True(galaxy.Exit == null, "the tutorial has no exit");
Assert.True(galaxy.BossZones.Count == 0, $"the tutorial has {galaxy.BossZones.Count} boss zones");
}
private static string TutorialShape(Galaxy g) =>
string.Join(",", g.Factions.Select(f => f.Name)) +
" | homes " + string.Join(",", g.Factions.Select(f => g.HomeZones.TryGetValue(f, out var z) ? Array.IndexOf(g.Zones, z) : -2)) +
" | entrance " + Array.IndexOf(g.Zones, g.Entrance) +
" | zones " + g.Zones.Length +
" | owners " + string.Join(",", g.Zones.Select(z => z.Owner == null ? "-" : z.Owner.ShortName));
// Measured at seed 1 under CultMath 0.3.0 (cultmath-unity-v0.3.0); the noise kernel changed there, which re-rolled the galaxy.
private const string TutorialSeed1 =
"Miss Terri’s Sugariffic Snack Company,Zhestokost,Lucent Media,Adrasteia,Aeronautics Unlimited,Finch Cybernetics | homes 3,44,40,19,0,30 | entrance 12 | zones 64 | owners AU,AU,Finch,Miss Terri's,Lucent,Zhestokost,-,Lucent,AU,AU,Lucent,Adrasteia,-,Finch,Zhestokost,-,Adrasteia,-,AU,Adrasteia,AU,Zhestokost,Miss Terri's,-,AU,Finch,Miss Terri's,-,Zhestokost,-,Finch,-,-,AU,-,-,-,-,Miss Terri's,AU,Lucent,-,Finch,-,Zhestokost,AU,-,Zhestokost,Adrasteia,Adrasteia,AU,Miss Terri's,Finch,AU,Finch,-,Lucent,Zhestokost,-,AU,Miss Terri's,Zhestokost,-,Lucent";
}