Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
|
|
|
/* 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.Reflection;
|
|
|
|
|
using System.Text.RegularExpressions;
|
|
|
|
|
using CultMath;
|
|
|
|
|
using GameCult.Caching;
|
|
|
|
|
using GameCult.Caching.MessagePack;
|
|
|
|
|
using Xunit;
|
|
|
|
|
using static CultMath.math;
|
|
|
|
|
using float2 = CultMath.float2;
|
|
|
|
|
using float3 = CultMath.float3;
|
|
|
|
|
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
// RunStart owns what a new run starts with, and Zone.Admit is the one admission into a zone (docs/scenarios-cut.md,
|
|
|
|
|
// 2.2 and R). Every test stages into a real arena: the entrance zone of a prelude galaxy at a fixed seed, built from a
|
|
|
|
|
// scratch copy of the shipped catalog and the authored settings (Assets/Resources/Settings.asset).
|
2026-10-03 15:00:00 +02:00
|
|
|
public sealed partial class RunStartTests : IDisposable
|
Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
|
|
|
{
|
|
|
|
|
private const uint GalaxySeed = 1;
|
|
|
|
|
|
|
|
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-runstart-" + Guid.NewGuid().ToString("N"));
|
|
|
|
|
private readonly CultCache _cache;
|
|
|
|
|
private readonly ItemManager _items;
|
|
|
|
|
private readonly Galaxy _galaxy;
|
|
|
|
|
private readonly ZoneGenerationSettings _zoneSettings;
|
|
|
|
|
private readonly PlanetSettings _planetSettings;
|
|
|
|
|
private readonly string _startingHull;
|
|
|
|
|
private readonly Faction _protagonist;
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
private readonly TutorialGenerationSettings _tutorialSettings;
|
Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
|
|
|
|
|
|
|
|
public RunStartTests()
|
|
|
|
|
{
|
|
|
|
|
var repo = FindRepoRoot();
|
|
|
|
|
Directory.CreateDirectory(_root);
|
|
|
|
|
var catalog = Path.Combine(_root, "Aetheria.cc");
|
|
|
|
|
File.Copy(Path.Combine(repo, "GameData", "Aetheria.cc"), catalog);
|
|
|
|
|
|
|
|
|
|
// A registry scoped to the shipped assembly's own [CultDocument] types, as RestoredHullsTests composes it, so
|
|
|
|
|
// this test assembly's own documents never reach the real catalog's validation.
|
|
|
|
|
_cache = new CultCache(Registry());
|
|
|
|
|
_cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalog), AetheriaStores.CatalogTypes);
|
|
|
|
|
_cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(_root, "run.cc")), AetheriaStores.RunTypes);
|
|
|
|
|
|
|
|
|
|
var authored = AuthoredSettings.Load(repo);
|
|
|
|
|
_zoneSettings = authored.Read<ZoneGenerationSettings>("ZoneSettings");
|
|
|
|
|
_planetSettings = authored.Read<PlanetSettings>("PlanetSettings");
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var tutorial = _tutorialSettings = authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings");
|
Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
|
|
|
_galaxy = new Galaxy(tutorial, authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings"),
|
|
|
|
|
authored.Read<NameGeneratorSettings>("NameGeneratorSettings"), _cache, new PlayerSettings(),
|
|
|
|
|
Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, GalaxySeed);
|
|
|
|
|
_items = new ItemManager(_cache, new ProvenanceLedger(), authored.Read<GameplaySettings>("GameplaySettings"), _ => { });
|
|
|
|
|
_protagonist = _galaxy.ResolveFaction(tutorial.ProtagonistFaction);
|
|
|
|
|
|
|
|
|
|
// GameSettings.StartingHullName is a plain field of the Unity settings object, not a ServerShared subtree
|
|
|
|
|
_startingHull = File.ReadLines(Path.Combine(repo, "Assets", "Resources", "Settings.asset"))
|
|
|
|
|
.Select(line => Regex.Match(line, @"^ StartingHullName: (.+)$"))
|
|
|
|
|
.First(match => match.Success).Groups[1].Value.Trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
_cache.Dispose();
|
|
|
|
|
Directory.Delete(_root, true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
|
|
|
|
|
.Where(t => t is { IsAbstract: false, IsInterface: false })
|
|
|
|
|
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null));
|
|
|
|
|
|
|
|
|
|
private static string FindRepoRoot()
|
|
|
|
|
{
|
|
|
|
|
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
|
|
|
|
|
if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj")))
|
|
|
|
|
return dir.FullName;
|
|
|
|
|
throw new DirectoryNotFoundException("Run from inside the Aetheria repository.");
|
|
|
|
|
}
|
|
|
|
|
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
// The fixture galaxy's arena, with or without its generated ships and turrets.
|
|
|
|
|
private Zone Arena(bool ambient) =>
|
|
|
|
|
new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(ambient, _ => { })), _galaxy.Entrance, _galaxy);
|
Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
|
|
|
|
|
|
|
|
private HullData Hull(string name) => _cache.GetAll<HullData>().Single(hull => hull.Name == name);
|
|
|
|
|
|
|
|
|
|
// The first turret hull, by name, that a manufacturer makes: a real turret, not a fixture.
|
|
|
|
|
private HullData TurretHull() => _cache.GetAll<HullData>()
|
|
|
|
|
.Where(hull => hull.HullType == HullType.Turret)
|
|
|
|
|
.Where(hull => _cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(_cache.RefOf(hull).Key)))
|
|
|
|
|
.OrderBy(hull => hull.Name, StringComparer.Ordinal)
|
|
|
|
|
.First();
|
|
|
|
|
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
// A loadout with only its hull, in memory.
|
|
|
|
|
private Loadout BareLoadout(HullData hull) =>
|
|
|
|
|
new Loadout { Name = "bare " + hull.Name, Hull = _cache.RefOf(hull), WeaponGroups = new int[0][] };
|
Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
|
|
|
|
|
|
|
|
private static int AgentsOf(Zone zone, Entity entity) => zone.Agents.Count(agent => agent.Ship == entity);
|
|
|
|
|
|
2026-09-30 15:45:32 +02:00
|
|
|
// A heater is gear whose thermostat runs its heat only below a target: a low-pass thermotoggle, then heat.
|
|
|
|
|
private static bool IsHeater(EquippedItem item) =>
|
|
|
|
|
item.Behaviors.OfType<Thermotoggle>().Any(t => !t.ThermotoggleData.HighPass) && item.Behaviors.OfType<Heat>().Any();
|
|
|
|
|
|
Stations: a station reactor, so the heater has power
Operator ruling, 2026-09-30: "Yep, we need a station reactor." Zenith's 16-cell
Reactor hardpoint had no design to fill it, so generated stations had no power.
A transient `AetherDb station-reactor [apply]` authored it from Manhattan, the
largest and most industrial reactor, scaled by cells (16/9):
Station Reactor (GearData, Reactor hardpoint, full 4x4)
mass 500 -> 888.9, price 750,000 -> 1,350,000 (to the nearest 50,000),
charge 500..1000 -> 888.9..1777.8; every other value, the efficiency,
overload and throttling stats and all stat terms, the roles (core,
regulator), temperatures, durability and sound bank are Manhattan's.
Product "Station Reactor" by Zhestokost (Manhattan's maker), default role
quality.
Station generation fills the slot through its ordinary hardpoint pass. Tests:
every generated station carries one heater and a reactor; an idle station (a
quiet arena, nothing else running) holds its heater's cells above freezing over
the second five of ten minutes (unpowered they fell to ~247 K); a station hull
with no room for a heater fails generation by name instead of skipping it
(Soul's S11 survived before).
The station's border cells still sit near 265 K with the heater and reactor
running (the reactor core reaches ~433 K); how warm the whole hull should run
is left as a tuning question.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:32 +02:00
|
|
|
// Every generated station carries exactly one heater, and a reactor to power it.
|
2026-09-30 15:45:32 +02:00
|
|
|
[Fact]
|
Stations: a station reactor, so the heater has power
Operator ruling, 2026-09-30: "Yep, we need a station reactor." Zenith's 16-cell
Reactor hardpoint had no design to fill it, so generated stations had no power.
A transient `AetherDb station-reactor [apply]` authored it from Manhattan, the
largest and most industrial reactor, scaled by cells (16/9):
Station Reactor (GearData, Reactor hardpoint, full 4x4)
mass 500 -> 888.9, price 750,000 -> 1,350,000 (to the nearest 50,000),
charge 500..1000 -> 888.9..1777.8; every other value, the efficiency,
overload and throttling stats and all stat terms, the roles (core,
regulator), temperatures, durability and sound bank are Manhattan's.
Product "Station Reactor" by Zhestokost (Manhattan's maker), default role
quality.
Station generation fills the slot through its ordinary hardpoint pass. Tests:
every generated station carries one heater and a reactor; an idle station (a
quiet arena, nothing else running) holds its heater's cells above freezing over
the second five of ten minutes (unpowered they fell to ~247 K); a station hull
with no room for a heater fails generation by name instead of skipping it
(Soul's S11 survived before).
The station's border cells still sit near 265 K with the heater and reactor
running (the reactor core reaches ~433 K); how warm the whole hull should run
is left as a tuning question.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:32 +02:00
|
|
|
public void EveryGeneratedStationCarriesOneHeaterAndAReactor()
|
2026-09-30 15:45:32 +02:00
|
|
|
{
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var zone = Arena(true);
|
2026-09-30 15:45:32 +02:00
|
|
|
var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f);
|
|
|
|
|
for (var i = 0; i < 8; i++)
|
|
|
|
|
{
|
|
|
|
|
var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout());
|
|
|
|
|
Assert.True(station.Equipment.Count(IsHeater) == 1, $"station {i} carries {station.Equipment.Count(IsHeater)} heaters");
|
Stations: a station reactor, so the heater has power
Operator ruling, 2026-09-30: "Yep, we need a station reactor." Zenith's 16-cell
Reactor hardpoint had no design to fill it, so generated stations had no power.
A transient `AetherDb station-reactor [apply]` authored it from Manhattan, the
largest and most industrial reactor, scaled by cells (16/9):
Station Reactor (GearData, Reactor hardpoint, full 4x4)
mass 500 -> 888.9, price 750,000 -> 1,350,000 (to the nearest 50,000),
charge 500..1000 -> 888.9..1777.8; every other value, the efficiency,
overload and throttling stats and all stat terms, the roles (core,
regulator), temperatures, durability and sound bank are Manhattan's.
Product "Station Reactor" by Zhestokost (Manhattan's maker), default role
quality.
Station generation fills the slot through its ordinary hardpoint pass. Tests:
every generated station carries one heater and a reactor; an idle station (a
quiet arena, nothing else running) holds its heater's cells above freezing over
the second five of ten minutes (unpowered they fell to ~247 K); a station hull
with no room for a heater fails generation by name instead of skipping it
(Soul's S11 survived before).
The station's border cells still sit near 265 K with the heater and reactor
running (the reactor core reaches ~433 K); how warm the whole hull should run
is left as a tuning question.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:32 +02:00
|
|
|
Assert.True(station.Equipment.Any(item => item.Behaviors.OfType<Reactor>().Any()), $"station {i} has no reactor");
|
2026-09-30 15:45:32 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Stations: a station reactor, so the heater has power
Operator ruling, 2026-09-30: "Yep, we need a station reactor." Zenith's 16-cell
Reactor hardpoint had no design to fill it, so generated stations had no power.
A transient `AetherDb station-reactor [apply]` authored it from Manhattan, the
largest and most industrial reactor, scaled by cells (16/9):
Station Reactor (GearData, Reactor hardpoint, full 4x4)
mass 500 -> 888.9, price 750,000 -> 1,350,000 (to the nearest 50,000),
charge 500..1000 -> 888.9..1777.8; every other value, the efficiency,
overload and throttling stats and all stat terms, the roles (core,
regulator), temperatures, durability and sound bank are Manhattan's.
Product "Station Reactor" by Zhestokost (Manhattan's maker), default role
quality.
Station generation fills the slot through its ordinary hardpoint pass. Tests:
every generated station carries one heater and a reactor; an idle station (a
quiet arena, nothing else running) holds its heater's cells above freezing over
the second five of ten minutes (unpowered they fell to ~247 K); a station hull
with no room for a heater fails generation by name instead of skipping it
(Soul's S11 survived before).
The station's border cells still sit near 265 K with the heater and reactor
running (the reactor core reaches ~433 K); how warm the whole hull should run
is left as a tuning question.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:32 +02:00
|
|
|
private const float Freezing = 273.15f;
|
|
|
|
|
|
2026-10-08 14:10:58 +02:00
|
|
|
// A main-sector galaxy: each station's gear must come from manufacturers its faction can reach, the makers its
|
|
|
|
|
// allegiance names, present in the galaxy or not.
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
private Galaxy MainSectorGalaxy()
|
2026-09-30 20:46:32 +02:00
|
|
|
{
|
|
|
|
|
var authored = AuthoredSettings.Load(FindRepoRoot());
|
|
|
|
|
return new Galaxy(authored.Read<SectorGenerationSettings>("SectorGenerationSettings"),
|
|
|
|
|
authored.Read<SectorBackgroundSettings>("SectorBackgroundSettings"),
|
|
|
|
|
authored.Read<NameGeneratorSettings>("NameGeneratorSettings"), _cache, _ => { }, null, GalaxySeed);
|
|
|
|
|
}
|
|
|
|
|
|
Content: a station reactor for every reactor maker
Operator ruling, 2026-09-30: "Keep the variety, not every galaxy will have
Zhestokost in it." A transient `AetherDb station-reactors [apply]` gives each
manufacturer that makes reactors a design for Zenith's 16-cell Reactor
hardpoint, scaled by cells from its largest reactor with every other value,
stat term, role, temperature and sound bank its own, and a product by it:
Vulcan Station Reactor (AU, from Vulcan 12 cells x1.333) charge 66.7..266.7, mass 666.7, price 1,550,000
Notorious Station Reactor (Lucent, from Notorious 9 x1.778) charge 711.1..2133.3, mass 1333.3, price 1,800,000
MoveOnPro Station Reactor (NiteLife, from MoveOnPro 4 x4) charge 100..500, mass 1200, price 3,600,000
Core Power Station Reactor (R&D, from Core Power 4 x4) charge 400, mass 200, price 1,000,000
products: Vulcan Station, Notorious Station, MoveOnPro Station, Steadfast Station
The Manhattan-derived one is renamed "Manhattan Station Reactor" (product
"Manhattan Station").
Reach is read through the one rule, LoadoutGenerator.IsAvailable. Test: a
faction allied only to AU, Lightsail and NiteLife is offered none of
Zhestokost's products and AU's, its stations carry nothing it cannot reach and
nothing by Zhestokost, and they are powered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:13:31 +02:00
|
|
|
// Every item a station carries, equipped or stocked, with the product it was made as.
|
|
|
|
|
private IEnumerable<(string item, FactionProductData product)> Made(Entity station) =>
|
2026-09-30 20:46:32 +02:00
|
|
|
station.Equipment.Select(item => item.EquippableItem).Cast<CraftedItemInstance>()
|
|
|
|
|
.Concat(station.CargoBays.SelectMany(bay => bay.Cargo.Keys).OfType<CraftedItemInstance>())
|
Content: a station reactor for every reactor maker
Operator ruling, 2026-09-30: "Keep the variety, not every galaxy will have
Zhestokost in it." A transient `AetherDb station-reactors [apply]` gives each
manufacturer that makes reactors a design for Zenith's 16-cell Reactor
hardpoint, scaled by cells from its largest reactor with every other value,
stat term, role, temperature and sound bank its own, and a product by it:
Vulcan Station Reactor (AU, from Vulcan 12 cells x1.333) charge 66.7..266.7, mass 666.7, price 1,550,000
Notorious Station Reactor (Lucent, from Notorious 9 x1.778) charge 711.1..2133.3, mass 1333.3, price 1,800,000
MoveOnPro Station Reactor (NiteLife, from MoveOnPro 4 x4) charge 100..500, mass 1200, price 3,600,000
Core Power Station Reactor (R&D, from Core Power 4 x4) charge 400, mass 200, price 1,000,000
products: Vulcan Station, Notorious Station, MoveOnPro Station, Steadfast Station
The Manhattan-derived one is renamed "Manhattan Station Reactor" (product
"Manhattan Station").
Reach is read through the one rule, LoadoutGenerator.IsAvailable. Test: a
faction allied only to AU, Lightsail and NiteLife is offered none of
Zhestokost's products and AU's, its stations carry nothing it cannot reach and
nothing by Zhestokost, and they are powered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:13:31 +02:00
|
|
|
.Select(item => (_items.GetData(item).Name, _items.Brand(item).Product));
|
2026-09-30 20:46:32 +02:00
|
|
|
|
Content: a station reactor for every reactor maker
Operator ruling, 2026-09-30: "Keep the variety, not every galaxy will have
Zhestokost in it." A transient `AetherDb station-reactors [apply]` gives each
manufacturer that makes reactors a design for Zenith's 16-cell Reactor
hardpoint, scaled by cells from its largest reactor with every other value,
stat term, role, temperature and sound bank its own, and a product by it:
Vulcan Station Reactor (AU, from Vulcan 12 cells x1.333) charge 66.7..266.7, mass 666.7, price 1,550,000
Notorious Station Reactor (Lucent, from Notorious 9 x1.778) charge 711.1..2133.3, mass 1333.3, price 1,800,000
MoveOnPro Station Reactor (NiteLife, from MoveOnPro 4 x4) charge 100..500, mass 1200, price 3,600,000
Core Power Station Reactor (R&D, from Core Power 4 x4) charge 400, mass 200, price 1,000,000
products: Vulcan Station, Notorious Station, MoveOnPro Station, Steadfast Station
The Manhattan-derived one is renamed "Manhattan Station Reactor" (product
"Manhattan Station").
Reach is read through the one rule, LoadoutGenerator.IsAvailable. Test: a
faction allied only to AU, Lightsail and NiteLife is offered none of
Zhestokost's products and AU's, its stations carry nothing it cannot reach and
nothing by Zhestokost, and they are powered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:13:31 +02:00
|
|
|
private string Maker(FactionProductData product) => product == null ? "nobody" : _cache.Get(product.Manufacturer)?.Name ?? "nobody";
|
2026-09-30 20:46:32 +02:00
|
|
|
|
|
|
|
|
// Every faction's stations, in a main-sector galaxy, carry a heater and a reactor, and nothing, equipped or
|
Content: a station reactor for every reactor maker
Operator ruling, 2026-09-30: "Keep the variety, not every galaxy will have
Zhestokost in it." A transient `AetherDb station-reactors [apply]` gives each
manufacturer that makes reactors a design for Zenith's 16-cell Reactor
hardpoint, scaled by cells from its largest reactor with every other value,
stat term, role, temperature and sound bank its own, and a product by it:
Vulcan Station Reactor (AU, from Vulcan 12 cells x1.333) charge 66.7..266.7, mass 666.7, price 1,550,000
Notorious Station Reactor (Lucent, from Notorious 9 x1.778) charge 711.1..2133.3, mass 1333.3, price 1,800,000
MoveOnPro Station Reactor (NiteLife, from MoveOnPro 4 x4) charge 100..500, mass 1200, price 3,600,000
Core Power Station Reactor (R&D, from Core Power 4 x4) charge 400, mass 200, price 1,000,000
products: Vulcan Station, Notorious Station, MoveOnPro Station, Steadfast Station
The Manhattan-derived one is renamed "Manhattan Station Reactor" (product
"Manhattan Station").
Reach is read through the one rule, LoadoutGenerator.IsAvailable. Test: a
faction allied only to AU, Lightsail and NiteLife is offered none of
Zhestokost's products and AU's, its stations carry nothing it cannot reach and
nothing by Zhestokost, and they are powered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:13:31 +02:00
|
|
|
// stocked, from a manufacturer that faction cannot reach (LoadoutGenerator.IsAvailable, the one reach rule).
|
2026-09-30 20:46:32 +02:00
|
|
|
[Fact]
|
|
|
|
|
public void EveryFactionsStationsCarryOnlyGearItCanReachIncludingAHeaterAndAReactor()
|
|
|
|
|
{
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var galaxy = MainSectorGalaxy();
|
2026-09-30 20:46:32 +02:00
|
|
|
Assert.False(galaxy.IsPrelude);
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var zone = Arena(true);
|
2026-09-30 20:46:32 +02:00
|
|
|
foreach (var faction in galaxy.Factions)
|
|
|
|
|
{
|
|
|
|
|
var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, faction, .5f);
|
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
|
|
|
{
|
|
|
|
|
var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout());
|
|
|
|
|
Assert.True(station.Equipment.Count(IsHeater) == 1, $"{faction.Name} station {i}: no heater");
|
|
|
|
|
Assert.True(station.Equipment.Any(item => item.Behaviors.OfType<Reactor>().Any()), $"{faction.Name} station {i}: no reactor");
|
Content: a station reactor for every reactor maker
Operator ruling, 2026-09-30: "Keep the variety, not every galaxy will have
Zhestokost in it." A transient `AetherDb station-reactors [apply]` gives each
manufacturer that makes reactors a design for Zenith's 16-cell Reactor
hardpoint, scaled by cells from its largest reactor with every other value,
stat term, role, temperature and sound bank its own, and a product by it:
Vulcan Station Reactor (AU, from Vulcan 12 cells x1.333) charge 66.7..266.7, mass 666.7, price 1,550,000
Notorious Station Reactor (Lucent, from Notorious 9 x1.778) charge 711.1..2133.3, mass 1333.3, price 1,800,000
MoveOnPro Station Reactor (NiteLife, from MoveOnPro 4 x4) charge 100..500, mass 1200, price 3,600,000
Core Power Station Reactor (R&D, from Core Power 4 x4) charge 400, mass 200, price 1,000,000
products: Vulcan Station, Notorious Station, MoveOnPro Station, Steadfast Station
The Manhattan-derived one is renamed "Manhattan Station Reactor" (product
"Manhattan Station").
Reach is read through the one rule, LoadoutGenerator.IsAvailable. Test: a
faction allied only to AU, Lightsail and NiteLife is offered none of
Zhestokost's products and AU's, its stations carry nothing it cannot reach and
nothing by Zhestokost, and they are powered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:13:31 +02:00
|
|
|
var unreachable = Made(station).Where(made => made.product == null || !generator.IsAvailable(made.product)).ToList();
|
2026-09-30 20:46:32 +02:00
|
|
|
Assert.True(unreachable.Count == 0,
|
Content: a station reactor for every reactor maker
Operator ruling, 2026-09-30: "Keep the variety, not every galaxy will have
Zhestokost in it." A transient `AetherDb station-reactors [apply]` gives each
manufacturer that makes reactors a design for Zenith's 16-cell Reactor
hardpoint, scaled by cells from its largest reactor with every other value,
stat term, role, temperature and sound bank its own, and a product by it:
Vulcan Station Reactor (AU, from Vulcan 12 cells x1.333) charge 66.7..266.7, mass 666.7, price 1,550,000
Notorious Station Reactor (Lucent, from Notorious 9 x1.778) charge 711.1..2133.3, mass 1333.3, price 1,800,000
MoveOnPro Station Reactor (NiteLife, from MoveOnPro 4 x4) charge 100..500, mass 1200, price 3,600,000
Core Power Station Reactor (R&D, from Core Power 4 x4) charge 400, mass 200, price 1,000,000
products: Vulcan Station, Notorious Station, MoveOnPro Station, Steadfast Station
The Manhattan-derived one is renamed "Manhattan Station Reactor" (product
"Manhattan Station").
Reach is read through the one rule, LoadoutGenerator.IsAvailable. Test: a
faction allied only to AU, Lightsail and NiteLife is offered none of
Zhestokost's products and AU's, its stations carry nothing it cannot reach and
nothing by Zhestokost, and they are powered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:13:31 +02:00
|
|
|
$"{faction.Name} station {i} carries {string.Join(", ", unreachable.Select(u => u.item + " by " + Maker(u.product)))}");
|
2026-09-30 20:46:32 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 23:13:34 +02:00
|
|
|
// A faction always reaches its own manufacturer's gear: in a main-sector galaxy every product a faction makes is
|
|
|
|
|
// on offer to that faction, allegiance or not. And an allegiance map lists only other factions.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AFactionReachesItsOwnGearAndItsAllegianceNamesOnlyOthers()
|
|
|
|
|
{
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var galaxy = MainSectorGalaxy();
|
2026-09-30 23:13:34 +02:00
|
|
|
var products = _cache.GetAll<FactionProductData>().Where(p => p.Manufacturer.IsSet()).ToArray();
|
|
|
|
|
foreach (var faction in galaxy.Factions)
|
|
|
|
|
{
|
|
|
|
|
Assert.DoesNotContain(faction.Allegiance.Keys, key => key.Key.Equals(_cache.RefOf(faction).Key));
|
|
|
|
|
var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, faction, .5f);
|
|
|
|
|
var own = products.Where(p => p.Manufacturer.Key.Equals(_cache.RefOf(faction).Key)).ToList();
|
|
|
|
|
Assert.True(own.All(generator.IsAvailable), $"{faction.Name} cannot reach its own {string.Join(", ", own.Where(p => !generator.IsAvailable(p)).Select(p => p.Name))}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Content: a station reactor for every reactor maker
Operator ruling, 2026-09-30: "Keep the variety, not every galaxy will have
Zhestokost in it." A transient `AetherDb station-reactors [apply]` gives each
manufacturer that makes reactors a design for Zenith's 16-cell Reactor
hardpoint, scaled by cells from its largest reactor with every other value,
stat term, role, temperature and sound bank its own, and a product by it:
Vulcan Station Reactor (AU, from Vulcan 12 cells x1.333) charge 66.7..266.7, mass 666.7, price 1,550,000
Notorious Station Reactor (Lucent, from Notorious 9 x1.778) charge 711.1..2133.3, mass 1333.3, price 1,800,000
MoveOnPro Station Reactor (NiteLife, from MoveOnPro 4 x4) charge 100..500, mass 1200, price 3,600,000
Core Power Station Reactor (R&D, from Core Power 4 x4) charge 400, mass 200, price 1,000,000
products: Vulcan Station, Notorious Station, MoveOnPro Station, Steadfast Station
The Manhattan-derived one is renamed "Manhattan Station Reactor" (product
"Manhattan Station").
Reach is read through the one rule, LoadoutGenerator.IsAvailable. Test: a
faction allied only to AU, Lightsail and NiteLife is offered none of
Zhestokost's products and AU's, its stations carry nothing it cannot reach and
nothing by Zhestokost, and they are powered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:13:31 +02:00
|
|
|
// A faction with no access to Zhestokost (its allegiance names only AU, Lightsail and NiteLife) gets
|
|
|
|
|
// stations with no Zhestokost gear, powered by a reactor it can reach, and an idle one of them holds its heater's
|
|
|
|
|
// cells above freezing over the second five of ten minutes.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AStationWithoutZhestokostAccessIsPoweredAndStaysAboveFreezing()
|
|
|
|
|
{
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var galaxy = MainSectorGalaxy();
|
Content: a station reactor for every reactor maker
Operator ruling, 2026-09-30: "Keep the variety, not every galaxy will have
Zhestokost in it." A transient `AetherDb station-reactors [apply]` gives each
manufacturer that makes reactors a design for Zenith's 16-cell Reactor
hardpoint, scaled by cells from its largest reactor with every other value,
stat term, role, temperature and sound bank its own, and a product by it:
Vulcan Station Reactor (AU, from Vulcan 12 cells x1.333) charge 66.7..266.7, mass 666.7, price 1,550,000
Notorious Station Reactor (Lucent, from Notorious 9 x1.778) charge 711.1..2133.3, mass 1333.3, price 1,800,000
MoveOnPro Station Reactor (NiteLife, from MoveOnPro 4 x4) charge 100..500, mass 1200, price 3,600,000
Core Power Station Reactor (R&D, from Core Power 4 x4) charge 400, mass 200, price 1,000,000
products: Vulcan Station, Notorious Station, MoveOnPro Station, Steadfast Station
The Manhattan-derived one is renamed "Manhattan Station Reactor" (product
"Manhattan Station").
Reach is read through the one rule, LoadoutGenerator.IsAvailable. Test: a
faction allied only to AU, Lightsail and NiteLife is offered none of
Zhestokost's products and AU's, its stations carry nothing it cannot reach and
nothing by Zhestokost, and they are powered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:13:31 +02:00
|
|
|
Faction Named(string shortName) => _cache.GetAll<Faction>().Single(f => f.ShortName == shortName);
|
|
|
|
|
var coop = new Faction { Name = "Test Cooperative", ShortName = "Coop" };
|
|
|
|
|
_cache.Upsert(coop);
|
|
|
|
|
foreach (var ally in new[] { Named("AU"), Named("Lightsail"), Named("NiteLife") }) coop.Allegiance[_cache.RefOf(ally)] = 1;
|
|
|
|
|
var zhestokost = Named("Zhestokost");
|
|
|
|
|
var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, coop, .5f);
|
|
|
|
|
var products = _cache.GetAll<FactionProductData>().Where(p => p.Manufacturer.IsSet()).ToArray();
|
|
|
|
|
Assert.DoesNotContain(products.Where(p => p.Manufacturer.Key.Equals(_cache.RefOf(zhestokost).Key)), generator.IsAvailable);
|
|
|
|
|
Assert.Contains(products.Where(p => p.Manufacturer.Key.Equals(_cache.RefOf(Named("AU")).Key)), generator.IsAvailable);
|
2026-09-30 23:13:34 +02:00
|
|
|
// Its allegiance does not name itself, yet it reaches what it makes.
|
|
|
|
|
var own = new FactionProductData { Name = "Cooperative Capacitor", Design = products.First().Design, Manufacturer = _cache.RefOf(coop) };
|
|
|
|
|
_cache.Upsert(own);
|
|
|
|
|
Assert.True(generator.IsAvailable(own), "a faction reaches its own manufacturer's gear");
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var zone = Arena(false); // no ships: nothing but the zone runs
|
Content: a station reactor for every reactor maker
Operator ruling, 2026-09-30: "Keep the variety, not every galaxy will have
Zhestokost in it." A transient `AetherDb station-reactors [apply]` gives each
manufacturer that makes reactors a design for Zenith's 16-cell Reactor
hardpoint, scaled by cells from its largest reactor with every other value,
stat term, role, temperature and sound bank its own, and a product by it:
Vulcan Station Reactor (AU, from Vulcan 12 cells x1.333) charge 66.7..266.7, mass 666.7, price 1,550,000
Notorious Station Reactor (Lucent, from Notorious 9 x1.778) charge 711.1..2133.3, mass 1333.3, price 1,800,000
MoveOnPro Station Reactor (NiteLife, from MoveOnPro 4 x4) charge 100..500, mass 1200, price 3,600,000
Core Power Station Reactor (R&D, from Core Power 4 x4) charge 400, mass 200, price 1,000,000
products: Vulcan Station, Notorious Station, MoveOnPro Station, Steadfast Station
The Manhattan-derived one is renamed "Manhattan Station Reactor" (product
"Manhattan Station").
Reach is read through the one rule, LoadoutGenerator.IsAvailable. Test: a
faction allied only to AU, Lightsail and NiteLife is offered none of
Zhestokost's products and AU's, its stations carry nothing it cannot reach and
nothing by Zhestokost, and they are powered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:13:31 +02:00
|
|
|
Entity station = null;
|
|
|
|
|
for (var i = 0; i < 8; i++)
|
|
|
|
|
{
|
|
|
|
|
station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout());
|
|
|
|
|
Assert.True(station.Equipment.Any(item => item.Behaviors.OfType<Reactor>().Any()), $"station {i}: no reactor");
|
|
|
|
|
var foreign = Made(station).Where(made => made.product == null || !generator.IsAvailable(made.product) ||
|
|
|
|
|
made.product.Manufacturer.Key.Equals(_cache.RefOf(zhestokost).Key)).ToList();
|
|
|
|
|
Assert.True(foreign.Count == 0, $"station {i} carries {string.Join(", ", foreign.Select(u => u.item + " by " + Maker(u.product)))}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
zone.Admit(station, piloted: false);
|
|
|
|
|
var heater = station.Equipment.Single(IsHeater);
|
|
|
|
|
var coldest = float.MaxValue;
|
|
|
|
|
for (var tick = 0; tick < 6000; tick++)
|
|
|
|
|
{
|
|
|
|
|
zone.Update(.1f);
|
|
|
|
|
if (tick >= 3000) coldest = min(coldest, heater.Temperature);
|
|
|
|
|
}
|
|
|
|
|
Assert.True(coldest > Freezing, $"the heater's cells fell to {coldest} K (station now {station.MinTemp}..{station.MaxTemp} K)");
|
|
|
|
|
}
|
|
|
|
|
|
Stations: a station reactor, so the heater has power
Operator ruling, 2026-09-30: "Yep, we need a station reactor." Zenith's 16-cell
Reactor hardpoint had no design to fill it, so generated stations had no power.
A transient `AetherDb station-reactor [apply]` authored it from Manhattan, the
largest and most industrial reactor, scaled by cells (16/9):
Station Reactor (GearData, Reactor hardpoint, full 4x4)
mass 500 -> 888.9, price 750,000 -> 1,350,000 (to the nearest 50,000),
charge 500..1000 -> 888.9..1777.8; every other value, the efficiency,
overload and throttling stats and all stat terms, the roles (core,
regulator), temperatures, durability and sound bank are Manhattan's.
Product "Station Reactor" by Zhestokost (Manhattan's maker), default role
quality.
Station generation fills the slot through its ordinary hardpoint pass. Tests:
every generated station carries one heater and a reactor; an idle station (a
quiet arena, nothing else running) holds its heater's cells above freezing over
the second five of ten minutes (unpowered they fell to ~247 K); a station hull
with no room for a heater fails generation by name instead of skipping it
(Soul's S11 survived before).
The station's border cells still sit near 265 K with the heater and reactor
running (the reactor core reaches ~433 K); how warm the whole hull should run
is left as a tuning question.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:32 +02:00
|
|
|
// An idle station, powered by its reactor, holds its heater's cells (where the thermostat reads) above freezing
|
|
|
|
|
// over the second five of ten idle minutes. Unpowered, they cool from 280 K to about 247 K in that time. (The
|
|
|
|
|
// station's border cells still sit near 265 K; how warm the whole hull should run is a tuning question.)
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AnIdleStationStaysAboveFreezing()
|
|
|
|
|
{
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var zone = Arena(false); // no ships: nothing but the zone runs
|
Stations: a station reactor, so the heater has power
Operator ruling, 2026-09-30: "Yep, we need a station reactor." Zenith's 16-cell
Reactor hardpoint had no design to fill it, so generated stations had no power.
A transient `AetherDb station-reactor [apply]` authored it from Manhattan, the
largest and most industrial reactor, scaled by cells (16/9):
Station Reactor (GearData, Reactor hardpoint, full 4x4)
mass 500 -> 888.9, price 750,000 -> 1,350,000 (to the nearest 50,000),
charge 500..1000 -> 888.9..1777.8; every other value, the efficiency,
overload and throttling stats and all stat terms, the roles (core,
regulator), temperatures, durability and sound bank are Manhattan's.
Product "Station Reactor" by Zhestokost (Manhattan's maker), default role
quality.
Station generation fills the slot through its ordinary hardpoint pass. Tests:
every generated station carries one heater and a reactor; an idle station (a
quiet arena, nothing else running) holds its heater's cells above freezing over
the second five of ten minutes (unpowered they fell to ~247 K); a station hull
with no room for a heater fails generation by name instead of skipping it
(Soul's S11 survived before).
The station's border cells still sit near 265 K with the heater and reactor
running (the reactor core reaches ~433 K); how warm the whole hull should run
is left as a tuning question.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:32 +02:00
|
|
|
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
|
|
|
|
|
var heater = station.Equipment.Single(IsHeater);
|
|
|
|
|
var coldest = float.MaxValue;
|
|
|
|
|
for (var tick = 0; tick < 6000; tick++)
|
|
|
|
|
{
|
|
|
|
|
zone.Update(.1f);
|
|
|
|
|
if (tick >= 3000) coldest = min(coldest, heater.Temperature);
|
|
|
|
|
}
|
|
|
|
|
Assert.True(coldest > Freezing, $"the heater's cells fell to {coldest} K (station now {station.MinTemp}..{station.MaxTemp} K)");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 20:46:30 +02:00
|
|
|
// The heater itself warms the station: from a hull at one uniform temperature (so nothing conducts and interior
|
|
|
|
|
// cells radiate nothing), one tick below the heater's target raises its cells, and one tick above it does not.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AStationHeaterHeatsBelowItsTargetAndNotAbove()
|
|
|
|
|
{
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var zone = Arena(false); // no ships: nothing but the zone runs
|
2026-09-30 20:46:30 +02:00
|
|
|
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
|
|
|
|
|
var heater = station.Equipment.Single(IsHeater);
|
|
|
|
|
var target = heater.Behaviors.OfType<Thermotoggle>().Single().TargetTemperature;
|
|
|
|
|
for (var tick = 0; tick < 10; tick++) zone.Update(.1f); // the power bus is running
|
|
|
|
|
|
|
|
|
|
float Step(float start)
|
|
|
|
|
{
|
|
|
|
|
foreach (var v in station.HullData.Shape.Coordinates) station.Temperature[v.x, v.y] = start;
|
|
|
|
|
zone.Update(.1f);
|
|
|
|
|
return heater.Temperature - start;
|
|
|
|
|
}
|
|
|
|
|
var below = Step(target - 20);
|
|
|
|
|
var above = Step(target + 20);
|
|
|
|
|
Assert.True(below > 0, $"below its target the heater's cells moved {below} K");
|
|
|
|
|
Assert.True(above <= 0, $"above its target the heater's cells moved {above} K");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A consumer runs only when its group's thermostat is open, and is billed only then: a heater above its target
|
|
|
|
|
// puts no demand on the power bus, below it the bus bills its draw.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AHeaterAboveItsTargetDrawsNoPower()
|
|
|
|
|
{
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var zone = Arena(false); // no ships: nothing but the zone runs
|
2026-09-30 20:46:30 +02:00
|
|
|
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
|
|
|
|
|
var heater = station.Equipment.Single(IsHeater);
|
|
|
|
|
var target = heater.Behaviors.OfType<Thermotoggle>().Single().TargetTemperature;
|
|
|
|
|
var request = heater.Behaviors.OfType<EnergyDraw>().Single().PowerRequest(.1f);
|
|
|
|
|
Assert.True(request > 0, "the heater asks for power");
|
|
|
|
|
for (var tick = 0; tick < 10; tick++) zone.Update(.1f); // the heater is online
|
|
|
|
|
Assert.True(heater.Active.Value, "the heater is active");
|
|
|
|
|
|
|
|
|
|
float Demand(float temperature)
|
|
|
|
|
{
|
|
|
|
|
foreach (var v in station.HullData.Shape.Coordinates) station.Temperature[v.x, v.y] = temperature;
|
|
|
|
|
station.PowerBus.Step(.1f);
|
|
|
|
|
return station.PowerBus.TotalDemand;
|
|
|
|
|
}
|
|
|
|
|
var cold = Demand(target - 20);
|
|
|
|
|
var warm = Demand(target + 20);
|
|
|
|
|
Assert.True(cold - warm >= request * .99f, $"demand {cold} below the target, {warm} above; the heater asks {request}");
|
|
|
|
|
}
|
|
|
|
|
|
Stations: a station reactor, so the heater has power
Operator ruling, 2026-09-30: "Yep, we need a station reactor." Zenith's 16-cell
Reactor hardpoint had no design to fill it, so generated stations had no power.
A transient `AetherDb station-reactor [apply]` authored it from Manhattan, the
largest and most industrial reactor, scaled by cells (16/9):
Station Reactor (GearData, Reactor hardpoint, full 4x4)
mass 500 -> 888.9, price 750,000 -> 1,350,000 (to the nearest 50,000),
charge 500..1000 -> 888.9..1777.8; every other value, the efficiency,
overload and throttling stats and all stat terms, the roles (core,
regulator), temperatures, durability and sound bank are Manhattan's.
Product "Station Reactor" by Zhestokost (Manhattan's maker), default role
quality.
Station generation fills the slot through its ordinary hardpoint pass. Tests:
every generated station carries one heater and a reactor; an idle station (a
quiet arena, nothing else running) holds its heater's cells above freezing over
the second five of ten minutes (unpowered they fell to ~247 K); a station hull
with no room for a heater fails generation by name instead of skipping it
(Soul's S11 survived before).
The station's border cells still sit near 265 K with the heater and reactor
running (the reactor core reaches ~433 K); how warm the whole hull should run
is left as a tuning question.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:32 +02:00
|
|
|
// A station hull with no room left for a heater fails its generation by name, rather than spawning a station
|
|
|
|
|
// that will freeze. The only station hull on offer is one whose interior takes the smallest docking bay and no more.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AStationWithNoRoomForAHeaterFailsLoudly()
|
|
|
|
|
{
|
|
|
|
|
var sold = _cache.GetAll<FactionProductData>().Select(product => product.Design.Key).ToHashSet();
|
|
|
|
|
var bay = _cache.GetAll<DockingBayData>().Where(design => sold.Contains(_cache.RefOf(design).Key))
|
|
|
|
|
.OrderBy(design => design.Shape.Coordinates.Length).ThenBy(design => design.Name, StringComparer.Ordinal).First();
|
|
|
|
|
var shape = new Shape(bay.Shape.Width + 2, bay.Shape.Height + 2);
|
|
|
|
|
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
|
|
|
|
var cramped = new HullData { Name = "Cramped Station", HullType = HullType.Station, Shape = shape, Durability = 100, Mass = 1000, Price = 1 };
|
|
|
|
|
_cache.Upsert(cramped);
|
|
|
|
|
_cache.Upsert(new FactionProductData
|
|
|
|
|
{
|
|
|
|
|
Name = "Cramped Station", Design = new CultRecordRef<CraftedItemData>(_cache.RefOf(cramped).Key), Manufacturer = _cache.RefOf(_protagonist)
|
|
|
|
|
});
|
|
|
|
|
foreach (var hull in _cache.GetAll<HullData>().Where(hull => hull.HullType == HullType.Station && hull != cramped))
|
|
|
|
|
hull.Price = 0; // generation offers no zero-price design, so the cramped hull is the only station
|
|
|
|
|
var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f);
|
|
|
|
|
|
|
|
|
|
var failure = Assert.Throws<InvalidLoadoutException>(() => generator.GenerateStationLoadout());
|
|
|
|
|
Assert.Contains("heater", failure.Message);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 18:01:34 +02:00
|
|
|
// Orbit positions are computed once per tick, before the asteroid belts' tasks start, and those tasks finish
|
|
|
|
|
// before the next tick clears the cache. Were the cache cleared under a running belt task, an orbit could be
|
|
|
|
|
// served last tick's position. Over 3000 back-to-back ticks no orbit stands still against its parent. (Measured
|
|
|
|
|
// against the parent because a moon can genuinely pause in zone space, where its motion cancels its planet's.)
|
|
|
|
|
[Fact]
|
|
|
|
|
public void NoOrbitStandsStillAgainstItsParent()
|
|
|
|
|
{
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var zone = Arena(false); // no ships: nothing but the zone runs
|
2026-09-30 18:01:34 +02:00
|
|
|
Assert.True(zone.AsteroidBelts.Count > 0, "the arena has an asteroid belt to race against");
|
|
|
|
|
float2 Relative(Orbit orbit, bool previous) =>
|
|
|
|
|
(previous ? orbit.PreviousPosition : orbit.Position) -
|
|
|
|
|
(zone.Orbits.TryGetValue(orbit.Data.Parent.Key, out var parent) ? previous ? parent.PreviousPosition : parent.Position : orbit.Data.FixedPosition);
|
|
|
|
|
zone.Update(.02f);
|
|
|
|
|
var stalls = new List<string>();
|
|
|
|
|
for (var tick = 0; tick < 3000; tick++)
|
|
|
|
|
{
|
|
|
|
|
zone.Update(.02f);
|
|
|
|
|
stalls.AddRange(zone.Orbits.Values
|
|
|
|
|
.Where(orbit => orbit.Period > .01f && orbit.Data.Distance > 0 && lengthsq(Relative(orbit, false) - Relative(orbit, true)) == 0)
|
|
|
|
|
.Select(orbit => $"tick {tick}: period {orbit.Period}, distance {orbit.Data.Distance}, at {orbit.Position}"));
|
|
|
|
|
}
|
|
|
|
|
Assert.True(stalls.Count == 0, $"{stalls.Count} readings of an orbit standing still; first {stalls.FirstOrDefault()}");
|
|
|
|
|
}
|
|
|
|
|
|
One loose fit rule for hardpoint gear
Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you
from putting a small reactor in a large reactor's hardpoint." There is one rule,
HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the
hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same
rule at one offset, and Entity.ItemFits (equipping, save load, presets) now
asks it instead of repeating the containment test. Generation's hardpoint
picks, HasHome (so station stock and loadouts), product reuse and `AetherDb
hardpoint-fit` all read Takes.
Generation keeps its own placement preference, named IsFilledBy and not a fit
rule: it offers gear that fills the hardpoint when any is on offer and falls
back to anything that fits only when none is, so loadouts change only where a
hardpoint used to stay empty. RestoredHullsTests pick fillers through
IsFilledBy.
Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty
pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now
have a home and are offered. Still homeless: CShot RainbowLite Lazer,
ChargeBlast SG, ChargeBlast+-, OK Disperser.
Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7
design no hull fits is offered only once a hull that takes it exists; a 5x5
Reactor hardpoint nothing fills still gets a reactor; equipping and the rule
agree for every hull, hardpoint and design; the homeless list is stated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:28 +02:00
|
|
|
private LoadoutGenerator PreludeGenerator() =>
|
|
|
|
|
new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f);
|
|
|
|
|
|
|
|
|
|
private bool Offered(LoadoutGenerator generator, EquippableItemData design) =>
|
|
|
|
|
generator.RandomProducts<EquippableItemData>(1, 0, candidate => candidate == design).Length > 0;
|
|
|
|
|
|
|
|
|
|
private GearData ScratchGear(string name, HardpointType type, int width, int height)
|
|
|
|
|
{
|
|
|
|
|
var shape = new Shape(width, height);
|
|
|
|
|
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
|
|
|
|
var design = new GearData { Name = name, Hardpoint = type, Shape = shape, Durability = 10, Mass = 10, Price = 1000 };
|
|
|
|
|
_cache.Upsert(design);
|
|
|
|
|
_cache.Upsert(new FactionProductData
|
|
|
|
|
{
|
|
|
|
|
Name = name, Design = new CultRecordRef<CraftedItemData>(_cache.RefOf(design).Key), Manufacturer = _cache.RefOf(_protagonist)
|
|
|
|
|
});
|
|
|
|
|
return design;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hardpoint fit is loose (operator, 2026-09-30: "nobody's gonna stop you from putting a small reactor in a large
|
|
|
|
|
// reactor's hardpoint"), and there is one fit rule, HardpointData.Takes. Generation offers every sold hardpoint
|
|
|
|
|
// design that fits some hull's hardpoint of its type, and none that fits no hull.
|
Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.
Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
|
|
|
[Fact]
|
One loose fit rule for hardpoint gear
Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you
from putting a small reactor in a large reactor's hardpoint." There is one rule,
HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the
hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same
rule at one offset, and Entity.ItemFits (equipping, save load, presets) now
asks it instead of repeating the containment test. Generation's hardpoint
picks, HasHome (so station stock and loadouts), product reuse and `AetherDb
hardpoint-fit` all read Takes.
Generation keeps its own placement preference, named IsFilledBy and not a fit
rule: it offers gear that fills the hardpoint when any is on offer and falls
back to anything that fits only when none is, so loadouts change only where a
hardpoint used to stay empty. RestoredHullsTests pick fillers through
IsFilledBy.
Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty
pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now
have a home and are offered. Still homeless: CShot RainbowLite Lazer,
ChargeBlast SG, ChargeBlast+-, OK Disperser.
Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7
design no hull fits is offered only once a hull that takes it exists; a 5x5
Reactor hardpoint nothing fills still gets a reactor; equipping and the rule
agree for every hull, hardpoint and design; the homeless list is stated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:28 +02:00
|
|
|
public void GenerationOffersExactlyTheSoldGearSomeHullCanFit()
|
Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.
Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
|
|
|
{
|
One loose fit rule for hardpoint gear
Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you
from putting a small reactor in a large reactor's hardpoint." There is one rule,
HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the
hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same
rule at one offset, and Entity.ItemFits (equipping, save load, presets) now
asks it instead of repeating the containment test. Generation's hardpoint
picks, HasHome (so station stock and loadouts), product reuse and `AetherDb
hardpoint-fit` all read Takes.
Generation keeps its own placement preference, named IsFilledBy and not a fit
rule: it offers gear that fills the hardpoint when any is on offer and falls
back to anything that fits only when none is, so loadouts change only where a
hardpoint used to stay empty. RestoredHullsTests pick fillers through
IsFilledBy.
Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty
pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now
have a home and are offered. Still homeless: CShot RainbowLite Lazer,
ChargeBlast SG, ChargeBlast+-, OK Disperser.
Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7
design no hull fits is offered only once a hull that takes it exists; a 5x5
Reactor hardpoint nothing fills still gets a reactor; equipping and the rule
agree for every hull, hardpoint and design; the homeless list is stated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:28 +02:00
|
|
|
var hulls = _cache.GetAll<HullData>().ToArray();
|
|
|
|
|
var sold = _cache.GetAll<FactionProductData>().Where(p => p.Manufacturer.IsSet()).Select(p => p.Design.Key).ToHashSet();
|
|
|
|
|
var generator = PreludeGenerator();
|
|
|
|
|
var wrong = _cache.GetAll<EquippableItemData>()
|
|
|
|
|
.Where(design => sold.Contains(_cache.RefOf(design).Key) && design.Price > 0)
|
|
|
|
|
.Where(design => design.HardpointType != HardpointType.Tool && design.HardpointType != HardpointType.Hull)
|
|
|
|
|
.Where(design => Offered(generator, design) != hulls.Any(hull => hull.Hardpoints.Any(hardpoint => hardpoint.Takes(design))))
|
|
|
|
|
.Select(design => design.Name).ToList();
|
|
|
|
|
Assert.True(wrong.Count == 0, $"offered against the fit rule: {string.Join(", ", wrong)}");
|
|
|
|
|
Assert.True(Offered(generator, _cache.GetAll<EquippableItemData>().Single(d => d.Name == "Autocannon")),
|
|
|
|
|
"the Autocannon fits the turret's 8-cell Ballistic hardpoint, so it is offered");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Gear no hull can fit is never offered; authoring a hull whose hardpoint takes it makes it offered.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void GearNoHullCanFitIsOfferedOnlyOnceAHullTakesIt()
|
|
|
|
|
{
|
|
|
|
|
var huge = ScratchGear("Oversized Cannon", HardpointType.Ballistic, 7, 7);
|
|
|
|
|
var generator = PreludeGenerator();
|
|
|
|
|
Assert.False(Offered(generator, huge), "no hull fits a 7x7 Ballistic design");
|
Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.
Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
|
|
|
|
One loose fit rule for hardpoint gear
Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you
from putting a small reactor in a large reactor's hardpoint." There is one rule,
HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the
hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same
rule at one offset, and Entity.ItemFits (equipping, save load, presets) now
asks it instead of repeating the containment test. Generation's hardpoint
picks, HasHome (so station stock and loadouts), product reuse and `AetherDb
hardpoint-fit` all read Takes.
Generation keeps its own placement preference, named IsFilledBy and not a fit
rule: it offers gear that fills the hardpoint when any is on offer and falls
back to anything that fits only when none is, so loadouts change only where a
hardpoint used to stay empty. RestoredHullsTests pick fillers through
IsFilledBy.
Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty
pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now
have a home and are offered. Still homeless: CShot RainbowLite Lazer,
ChargeBlast SG, ChargeBlast+-, OK Disperser.
Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7
design no hull fits is offered only once a hull that takes it exists; a 5x5
Reactor hardpoint nothing fills still gets a reactor; equipping and the rule
agree for every hull, hardpoint and design; the homeless list is stated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:28 +02:00
|
|
|
var shape = new Shape(9, 9);
|
Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.
Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
|
|
|
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
One loose fit rule for hardpoint gear
Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you
from putting a small reactor in a large reactor's hardpoint." There is one rule,
HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the
hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same
rule at one offset, and Entity.ItemFits (equipping, save load, presets) now
asks it instead of repeating the containment test. Generation's hardpoint
picks, HasHome (so station stock and loadouts), product reuse and `AetherDb
hardpoint-fit` all read Takes.
Generation keeps its own placement preference, named IsFilledBy and not a fit
rule: it offers gear that fills the hardpoint when any is on offer and falls
back to anything that fits only when none is, so loadouts change only where a
hardpoint used to stay empty. RestoredHullsTests pick fillers through
IsFilledBy.
Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty
pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now
have a home and are offered. Still homeless: CShot RainbowLite Lazer,
ChargeBlast SG, ChargeBlast+-, OK Disperser.
Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7
design no hull fits is offered only once a hull that takes it exists; a 5x5
Reactor hardpoint nothing fills still gets a reactor; equipping and the rule
agree for every hull, hardpoint and design; the homeless list is stated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:28 +02:00
|
|
|
var hardpointShape = new Shape(8, 8);
|
|
|
|
|
foreach (var cell in hardpointShape.AllCoordinates) hardpointShape[cell] = true;
|
Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.
Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
|
|
|
_cache.Upsert(new HullData
|
|
|
|
|
{
|
One loose fit rule for hardpoint gear
Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you
from putting a small reactor in a large reactor's hardpoint." There is one rule,
HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the
hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same
rule at one offset, and Entity.ItemFits (equipping, save load, presets) now
asks it instead of repeating the containment test. Generation's hardpoint
picks, HasHome (so station stock and loadouts), product reuse and `AetherDb
hardpoint-fit` all read Takes.
Generation keeps its own placement preference, named IsFilledBy and not a fit
rule: it offers gear that fills the hardpoint when any is on offer and falls
back to anything that fits only when none is, so loadouts change only where a
hardpoint used to stay empty. RestoredHullsTests pick fillers through
IsFilledBy.
Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty
pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now
have a home and are offered. Still homeless: CShot RainbowLite Lazer,
ChargeBlast SG, ChargeBlast+-, OK Disperser.
Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7
design no hull fits is offered only once a hull that takes it exists; a 5x5
Reactor hardpoint nothing fills still gets a reactor; equipping and the rule
agree for every hull, hardpoint and design; the homeless list is stated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:28 +02:00
|
|
|
Name = "Big Mount", HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 1000, Price = 1,
|
|
|
|
|
Hardpoints = { new HardpointData { Type = HardpointType.Ballistic, Position = int2(0, 0), Shape = hardpointShape } }
|
Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.
Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
|
|
|
});
|
One loose fit rule for hardpoint gear
Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you
from putting a small reactor in a large reactor's hardpoint." There is one rule,
HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the
hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same
rule at one offset, and Entity.ItemFits (equipping, save load, presets) now
asks it instead of repeating the containment test. Generation's hardpoint
picks, HasHome (so station stock and loadouts), product reuse and `AetherDb
hardpoint-fit` all read Takes.
Generation keeps its own placement preference, named IsFilledBy and not a fit
rule: it offers gear that fills the hardpoint when any is on offer and falls
back to anything that fits only when none is, so loadouts change only where a
hardpoint used to stay empty. RestoredHullsTests pick fillers through
IsFilledBy.
Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty
pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now
have a home and are offered. Still homeless: CShot RainbowLite Lazer,
ChargeBlast SG, ChargeBlast+-, OK Disperser.
Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7
design no hull fits is offered only once a hull that takes it exists; a 5x5
Reactor hardpoint nothing fills still gets a reactor; equipping and the rule
agree for every hull, hardpoint and design; the homeless list is stated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:28 +02:00
|
|
|
Assert.True(Offered(generator, huge), "a hull whose hardpoint takes it gives it a home");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Where nothing fills a hardpoint, generation puts in something that fits: a hull whose only Reactor hardpoint is
|
|
|
|
|
// 5x5, which no reactor fills, still gets a reactor.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void GenerationFitsWhatItCannotFill()
|
|
|
|
|
{
|
|
|
|
|
var shape = new Shape(9, 9);
|
|
|
|
|
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
|
|
|
|
var hardpointShape = new Shape(5, 5);
|
|
|
|
|
foreach (var cell in hardpointShape.AllCoordinates) hardpointShape[cell] = true;
|
|
|
|
|
var hull = new HullData
|
|
|
|
|
{
|
|
|
|
|
Name = "Odd Reactor Bay", HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 1000, Price = 1,
|
|
|
|
|
Hardpoints = { new HardpointData { Type = HardpointType.Reactor, Position = int2(1, 1), Shape = hardpointShape } }
|
|
|
|
|
};
|
|
|
|
|
_cache.Upsert(hull);
|
|
|
|
|
_cache.Upsert(new FactionProductData
|
|
|
|
|
{
|
|
|
|
|
Name = "Odd Reactor Bay", Design = new CultRecordRef<CraftedItemData>(_cache.RefOf(hull).Key), Manufacturer = _cache.RefOf(_protagonist)
|
|
|
|
|
});
|
|
|
|
|
Assert.DoesNotContain(_cache.GetAll<GearData>(), design => hull.Hardpoints[0].IsFilledBy(design));
|
|
|
|
|
|
|
|
|
|
var ship = EntitySerializer.Unpack(_items, null, PreludeGenerator().GenerateShipLoadout(candidate => candidate == hull));
|
|
|
|
|
Assert.True(ship.Equipment.Any(item => item.Behaviors.OfType<Reactor>().Any()), "the 5x5 Reactor hardpoint got a reactor");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Equipping and generation read the same rule: for every hull, hardpoint and design of that hardpoint's type, the
|
|
|
|
|
// hardpoint takes the design exactly when an empty hull of that kind accepts it somewhere in that hardpoint.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void EquippingAndGenerationAgreeOnEveryDesignAndHardpoint()
|
|
|
|
|
{
|
|
|
|
|
var products = _cache.GetAll<FactionProductData>().Where(p => p.Manufacturer.IsSet()).ToArray();
|
|
|
|
|
var gear = _cache.GetAll<EquippableItemData>().Where(d => d.HardpointType != HardpointType.Tool && d.HardpointType != HardpointType.Hull).ToArray();
|
|
|
|
|
var disagreements = new List<string>();
|
|
|
|
|
foreach (var hull in _cache.GetAll<HullData>())
|
|
|
|
|
{
|
|
|
|
|
var product = products.FirstOrDefault(p => p.Design.Key.Equals(_cache.RefOf(hull).Key));
|
|
|
|
|
if (product == null) continue;
|
|
|
|
|
var entity = new Ship(_items, null, (EquippableItem) _items.CreateInstance(product), _items.GameplaySettings.DefaultEntitySettings);
|
|
|
|
|
foreach (var hardpoint in hull.Hardpoints)
|
|
|
|
|
foreach (var design in gear.Where(d => d.HardpointType == hardpoint.Type))
|
|
|
|
|
{
|
|
|
|
|
var item = new EquippableItem { Data = _cache.RefOf<ItemData>(design), Durability = design.Durability };
|
|
|
|
|
var accepted = false;
|
|
|
|
|
for (var x = 0; x < hardpoint.Shape.Width && !accepted; x++)
|
|
|
|
|
for (var y = 0; y < hardpoint.Shape.Height && !accepted; y++)
|
|
|
|
|
accepted = entity.ItemFits(item, hardpoint.Position + int2(x, y));
|
|
|
|
|
if (accepted != hardpoint.Takes(design))
|
|
|
|
|
disagreements.Add($"{hull.Name} {hardpoint.Type} at {hardpoint.Position}, {design.Name}: equip {accepted}, rule {!accepted}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Assert.True(disagreements.Count == 0, string.Join("; ", disagreements.Take(10)) + $" ({disagreements.Count} in all)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The sold hardpoint gear that no hull fits even loosely today, stated so that authoring a hull that takes one
|
|
|
|
|
// shows up here as a changed list.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void TheSoldHardpointGearWithNoHomeToday()
|
|
|
|
|
{
|
|
|
|
|
var hulls = _cache.GetAll<HullData>().ToArray();
|
|
|
|
|
var sold = _cache.GetAll<FactionProductData>().Select(product => product.Design.Key).ToHashSet();
|
|
|
|
|
var homeless = _cache.GetAll<EquippableItemData>()
|
|
|
|
|
.Where(design => sold.Contains(_cache.RefOf(design).Key) && design.Price > 0)
|
|
|
|
|
.Where(design => design.HardpointType != HardpointType.Tool && design.HardpointType != HardpointType.Hull)
|
|
|
|
|
.Where(design => !hulls.Any(hull => hull.Hardpoints.Any(hardpoint => hardpoint.Takes(design))))
|
|
|
|
|
.Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToArray();
|
|
|
|
|
Assert.Equal(new[] { "CShot RainbowLite Lazer", "ChargeBlast SG", "ChargeBlast+-", "OK Disperser" }, homeless);
|
Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.
Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
|
|
|
}
|
|
|
|
|
|
Content: damage curves for the five sold weapons that had none
Soul's F4: LRMM72, SRMM72, Autocannon, pswarm and plight had DamageCurve ==
null, and once they had products NPCs were generated with them and
CombatState.SampleDps threw at InstantWeapon.cs:64 / ChargedWeapon.cs:81.
A transient `AetherDb damage-curves [apply]` copies each one's curve from its
nearest comparable weapon, same class first, then closest range and damage:
Autocannon <- DeathCluster (auto-fire Ballistic, 4 cells, range reaching 1000)
LRMM72 <- GT 3K (the only other unguided launcher)
SRMM72 <- scorched void policy (guided; 750 range and 128 damage inside its 500-1250, 128-512)
pswarm <- scorched void policy (guided; identical damage, range, cooldown)
plight <- ChargeBlast+- (charged Energy; nearest range)
Every curve in the catalog is the same one, keys (0, 1, 0, 0) and
(1, 0, -1.7656243, -1.7656243): full damage at minimum range falling to none
at maximum. Test: every weapon a product sells has a damage curve.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:27 +02:00
|
|
|
// A sold weapon can be generated onto an NPC, whose combat state samples its damage at range, so every weapon a
|
|
|
|
|
// product sells carries a damage curve.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void EverySoldWeaponHasADamageCurve()
|
|
|
|
|
{
|
|
|
|
|
var sold = _cache.GetAll<FactionProductData>().Select(product => product.Design.Key).ToHashSet();
|
|
|
|
|
var missing = _cache.GetAll<EquippableItemData>()
|
|
|
|
|
.Where(design => sold.Contains(_cache.RefOf(design).Key))
|
|
|
|
|
.Where(design => design.Behaviors.OfType<WeaponData>().Any(weapon => weapon.DamageCurve?.Keys == null || weapon.DamageCurve.Keys.Length == 0))
|
|
|
|
|
.Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToList();
|
|
|
|
|
Assert.True(missing.Count == 0, $"sold weapons with no damage curve: {string.Join(", ", missing)}");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 23:13:37 +02:00
|
|
|
// An empty hull of this kind to equip into by hand.
|
|
|
|
|
private Entity BareHull(HullData hull) =>
|
|
|
|
|
new Ship(_items, null, (EquippableItem) _items.CreateInstance(_cache.GetAll<FactionProductData>().First(p => p.Design.Key.Equals(_cache.RefOf(hull).Key))),
|
|
|
|
|
_items.GameplaySettings.DefaultEntitySettings);
|
|
|
|
|
|
|
|
|
|
private HullData ScratchHull(string name, int size, params HardpointData[] hardpoints)
|
|
|
|
|
{
|
|
|
|
|
var shape = new Shape(size, size);
|
|
|
|
|
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
|
|
|
|
var hull = new HullData { Name = name, HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 1000, Price = 1 };
|
|
|
|
|
hull.Hardpoints.AddRange(hardpoints);
|
|
|
|
|
_cache.Upsert(hull);
|
|
|
|
|
_cache.Upsert(new FactionProductData
|
|
|
|
|
{
|
|
|
|
|
Name = name, Design = new CultRecordRef<CraftedItemData>(_cache.RefOf(hull).Key), Manufacturer = _cache.RefOf(_protagonist)
|
|
|
|
|
});
|
|
|
|
|
return hull;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private EquippableItem Instance(EquippableItemData design) =>
|
|
|
|
|
(EquippableItem) _items.CreateInstance(_cache.GetAll<FactionProductData>().First(p => p.Design.Key.Equals(_cache.RefOf(design).Key)));
|
|
|
|
|
|
|
|
|
|
// Placement searches every offset the fit rule does: an L-shaped hardpoint, whose origin cell is empty, takes the L
|
|
|
|
|
// design that fills it, by hand and in generation.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AnLShapedHardpointTakesItsLDesign()
|
|
|
|
|
{
|
|
|
|
|
var l = new Shape(2, 2);
|
|
|
|
|
l[int2(1, 0)] = true;
|
|
|
|
|
l[int2(0, 1)] = true;
|
|
|
|
|
l[int2(1, 1)] = true;
|
|
|
|
|
var hull = ScratchHull("L Mount", 9, new HardpointData { Type = HardpointType.Ballistic, Position = int2(1, 1), Shape = l });
|
|
|
|
|
var gun = ScratchGear("L Gun", HardpointType.Ballistic, 1, 1);
|
|
|
|
|
gun.Shape = l;
|
|
|
|
|
Assert.True(hull.Hardpoints[0].IsFilledBy(gun));
|
|
|
|
|
|
|
|
|
|
Assert.True(BareHull(hull).TryEquip(Instance(gun)), "equipping by hand");
|
|
|
|
|
var ship = EntitySerializer.Unpack(_items, null, PreludeGenerator().GenerateShipLoadout(candidate => candidate == hull));
|
|
|
|
|
Assert.True(ship.Equipment.Any(item => item.Data.HardpointType == HardpointType.Ballistic), "generation equips the L hardpoint");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generated entities whose hardpoints share leftover cells with general gear, found by generating until enough.
|
|
|
|
|
private List<(Entity entity, HardpointData hardpoint, EquippedItem held)> SharedHardpoints(int wanted)
|
|
|
|
|
{
|
|
|
|
|
var found = new List<(Entity, HardpointData, EquippedItem)>();
|
|
|
|
|
var generator = PreludeGenerator();
|
|
|
|
|
for (var i = 0; i < 400 && found.Count < wanted; i++)
|
|
|
|
|
{
|
|
|
|
|
var entity = EntitySerializer.Unpack(_items, null, generator.GenerateTurretLoadout());
|
|
|
|
|
foreach (var hardpoint in entity.HullData.Hardpoints)
|
|
|
|
|
{
|
|
|
|
|
var occupants = hardpoint.Shape.Coordinates.Select(c => entity.GearOccupancy[hardpoint.Position.x + c.x, hardpoint.Position.y + c.y])
|
|
|
|
|
.Where(o => o != null).Distinct().ToList();
|
|
|
|
|
var held = occupants.FirstOrDefault(o => o.Data.HardpointType == hardpoint.Type);
|
|
|
|
|
if (held != null && occupants.Any(o => o.Data.HardpointType == HardpointType.Tool)) found.Add((entity, hardpoint, held));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return found;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A hardpoint's leftover cells may hold general gear (operator, 2026-09-30, "B is the design intent"): a hardpoint
|
|
|
|
|
// item needs only its own cells free. So a generated turret whose spare gun-mount cells hold tool gear takes its own
|
|
|
|
|
// gun back after the gun is pulled.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AHardpointItemGoesBackBesideGearInItsLeftoverCells()
|
|
|
|
|
{
|
|
|
|
|
var shared = SharedHardpoints(10);
|
|
|
|
|
Assert.True(shared.Count > 0, "generation shares hardpoint cells with general gear");
|
|
|
|
|
foreach (var (entity, hardpoint, held) in shared)
|
|
|
|
|
{
|
|
|
|
|
var item = entity.TryUnequip(held);
|
|
|
|
|
Assert.True(item != null && entity.TryEquip(item), $"{entity.HullData.Name}: {held.Data.Name} back into its {hardpoint.Type} hardpoint");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A hardpoint holds at most one hardpoint item, however much room it has left.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AHardpointHoldsOneHardpointItem()
|
|
|
|
|
{
|
|
|
|
|
var mount = new Shape(8, 8);
|
|
|
|
|
foreach (var cell in mount.AllCoordinates) mount[cell] = true;
|
|
|
|
|
var hull = ScratchHull("Roomy Mount", 10, new HardpointData { Type = HardpointType.Ballistic, Position = int2(1, 1), Shape = mount });
|
|
|
|
|
var gun = ScratchGear("Small Gun", HardpointType.Ballistic, 1, 1);
|
|
|
|
|
var entity = BareHull(hull);
|
|
|
|
|
Assert.True(entity.TryEquip(Instance(gun)), "the first gun");
|
|
|
|
|
Assert.False(entity.TryEquip(Instance(gun)), "a second gun in the same hardpoint");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Loading a saved entity equips its items one by one; the result does not depend on their order, even where general
|
|
|
|
|
// gear sits in a hardpoint's leftover cells.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void SaveLoadGivesTheSameLoadoutInAnyOrder()
|
|
|
|
|
{
|
|
|
|
|
var shared = SharedHardpoints(3);
|
|
|
|
|
Assert.True(shared.Count > 0, "generation shares hardpoint cells with general gear");
|
|
|
|
|
string Loadout(Entity e) => string.Join(";", e.Equipment.Select(i => $"{i.Data.Name}@{i.Position}").OrderBy(s => s, StringComparer.Ordinal));
|
|
|
|
|
foreach (var entity in shared.Select(s => s.entity).Distinct())
|
|
|
|
|
{
|
|
|
|
|
var pack = EntitySerializer.Pack(entity);
|
|
|
|
|
var inOrder = Loadout(EntitySerializer.Unpack(_items, null, pack));
|
|
|
|
|
pack.Equipment = pack.Equipment.Reverse().ToArray();
|
|
|
|
|
var reversed = Loadout(EntitySerializer.Unpack(_items, null, pack));
|
|
|
|
|
Assert.Equal(Loadout(entity), inOrder);
|
|
|
|
|
Assert.Equal(inOrder, reversed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 23:13:38 +02:00
|
|
|
// Generation prefers gear that fills a hardpoint: a station's 16-cell Reactor hardpoint always gets a 16-cell
|
|
|
|
|
// reactor, though smaller ones fit it.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void GenerationFillsAHardpointWhenSomethingFillsIt()
|
|
|
|
|
{
|
|
|
|
|
var zenith = _cache.GetAll<HullData>().Single(h => h.Name == "Zenith");
|
|
|
|
|
var reactorPoint = zenith.Hardpoints.Single(h => h.Type == HardpointType.Reactor);
|
|
|
|
|
Assert.Contains(_cache.GetAll<GearData>(), design => reactorPoint.Takes(design) && !reactorPoint.IsFilledBy(design));
|
|
|
|
|
var generator = PreludeGenerator();
|
|
|
|
|
for (var i = 0; i < 50; i++)
|
|
|
|
|
{
|
|
|
|
|
var design = generator.RandomProduct<GearData>(reactorPoint, 2).design;
|
|
|
|
|
Assert.True(reactorPoint.IsFilledBy(design), $"draw {i}: {design.Name} does not fill the hardpoint");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Matching hardpoints carry matching units: a generated Djinni's eight thruster hardpoints hold one product's lot.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void MatchingHardpointsShareOneLot()
|
|
|
|
|
{
|
|
|
|
|
var ship = EntitySerializer.Unpack(_items, null, PreludeGenerator().GenerateShipLoadout(hull => hull.Name == "Djinni"));
|
|
|
|
|
var thrusters = ship.Equipment.Where(item => item.Data.HardpointType == HardpointType.Thruster).ToList();
|
|
|
|
|
Assert.Equal(8, thrusters.Count);
|
|
|
|
|
Assert.Single(thrusters.Select(item => item.EquippableItem.Lot).Distinct());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The bus bills a thermostat-gated consumer on exactly the ticks it runs: across 5 K either side of a heater's
|
|
|
|
|
// target, the heater's draw is billed exactly when its thermostat is open.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void TheBusBillsAHeaterExactlyWhenItsThermostatIsOpen()
|
|
|
|
|
{
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var zone = Arena(false); // no ships: nothing but the zone runs
|
2026-09-30 23:13:38 +02:00
|
|
|
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
|
|
|
|
|
var heater = station.Equipment.Single(IsHeater);
|
|
|
|
|
var thermostat = heater.Behaviors.OfType<Thermotoggle>().Single();
|
|
|
|
|
var request = heater.Behaviors.OfType<EnergyDraw>().Single().PowerRequest(.1f);
|
|
|
|
|
for (var tick = 0; tick < 10; tick++) zone.Update(.1f);
|
|
|
|
|
|
|
|
|
|
float Demand(float temperature)
|
|
|
|
|
{
|
|
|
|
|
foreach (var v in station.HullData.Shape.Coordinates) station.Temperature[v.x, v.y] = temperature;
|
|
|
|
|
station.PowerBus.Step(.1f);
|
|
|
|
|
return station.PowerBus.TotalDemand;
|
|
|
|
|
}
|
|
|
|
|
var unbilled = Demand(thermostat.TargetTemperature + 20); // well above: closed
|
|
|
|
|
var mismatches = new List<string>();
|
|
|
|
|
for (var delta = -5f; delta <= 5f; delta += .25f)
|
|
|
|
|
{
|
|
|
|
|
var billed = Demand(thermostat.TargetTemperature + delta) - unbilled > request * .5f;
|
|
|
|
|
if (billed != thermostat.Open) mismatches.Add($"{delta:+0.00;-0.00} K: open {thermostat.Open}, billed {billed}");
|
|
|
|
|
}
|
|
|
|
|
Assert.True(mismatches.Count == 0, string.Join("; ", mismatches));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A thermostat gates the rest of its own behaviour group, not the whole item: of two draws on one item, the one
|
|
|
|
|
// behind a closed thermostat is not billed and the one in another group is.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AThermostatGatesOnlyItsOwnGroup()
|
|
|
|
|
{
|
|
|
|
|
var gear = new GearData
|
|
|
|
|
{
|
|
|
|
|
Name = "Two Group Gear", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, Mass = 10, Price = 1000,
|
|
|
|
|
MinimumTemperature = 100, MaximumTemperature = 500, OptimalTemperature = 280, PlateauWidth = 200, // online at any hull temperature here
|
|
|
|
|
Behaviors =
|
|
|
|
|
{
|
|
|
|
|
new ThermotoggleData { Group = 0, TargetTemperature = 1 }, // low-pass below 1 K: always closed here
|
|
|
|
|
new EnergyDrawData { Group = 0, EnergyDraw = new PerformanceStat { Min = 10, Max = 10 }, PerSecond = true },
|
|
|
|
|
new EnergyDrawData { Group = 1, EnergyDraw = new PerformanceStat { Min = 7, Max = 7 }, PerSecond = true },
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
_cache.Upsert(gear);
|
|
|
|
|
_cache.Upsert(new FactionProductData
|
|
|
|
|
{
|
|
|
|
|
Name = "Two Group Gear", Design = new CultRecordRef<CraftedItemData>(_cache.RefOf(gear).Key), Manufacturer = _cache.RefOf(_protagonist)
|
|
|
|
|
});
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var zone = Arena(false);
|
2026-09-30 23:13:38 +02:00
|
|
|
var ship = BareHull(Hull("Djinni"));
|
|
|
|
|
ship.Zone = zone;
|
|
|
|
|
Assert.True(ship.TryEquip(Instance(gear)));
|
|
|
|
|
zone.Admit(ship, piloted: false);
|
|
|
|
|
for (var tick = 0; tick < 5; tick++) zone.Update(.1f);
|
|
|
|
|
Assert.True(ship.Equipment.Single(item => item.Data == gear).Active.Value, "the gear is active");
|
|
|
|
|
ship.PowerBus.Step(1f);
|
|
|
|
|
Assert.True(abs(ship.PowerBus.TotalDemand - 7) < .01f, $"billed {ship.PowerBus.TotalDemand} for a closed 10 and an ungated 7");
|
|
|
|
|
}
|
|
|
|
|
|
Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
|
|
|
// The Q3 rule must not reach a real hull: the hulls scenarios rely on each have a product, and materialize bare,
|
|
|
|
|
// branded, under the prelude's availability.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void TheRealHullsMaterializeBareFromABrandedProduct()
|
|
|
|
|
{
|
|
|
|
|
var available = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, null, 2);
|
2026-10-08 13:44:48 +02:00
|
|
|
foreach (var hull in new[] { Hull("Djinni"), Hull("Longinus"), TurretHull() })
|
Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
|
|
|
{
|
|
|
|
|
var failures = new List<string>();
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var entity = Loadouts.Materialize(_items, null, BareLoadout(hull), available.IsAvailable, failures);
|
Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
|
|
|
Assert.Empty(failures);
|
|
|
|
|
Assert.NotNull(_items.Brand(entity.Hull).Maker);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Zone.Admit is the only creator of agents: construction pilots every packed ship but the player's, and an
|
|
|
|
|
// unpiloted admission (a warp arrival, an undock, a spawned turret) gets none.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AdmitIsTheOnlyAgentCreator()
|
|
|
|
|
{
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var pack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(true, _ => { }));
|
Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
|
|
|
var packedPlayer = pack.Entities.OfType<ShipPack>().First();
|
|
|
|
|
packedPlayer.IsPlayerShip = true;
|
|
|
|
|
var zone = new Zone(_items, _planetSettings, pack, _galaxy.Entrance, _galaxy);
|
|
|
|
|
var ships = zone.Entities.OfType<Ship>().ToList();
|
|
|
|
|
Assert.True(ships.Count > 1);
|
|
|
|
|
foreach (var ship in ships) Assert.Equal(ship.IsPlayerShip ? 0 : 1, AgentsOf(zone, ship));
|
|
|
|
|
|
Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas
A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:55 +02:00
|
|
|
var arriving = (Ship) Loadouts.Materialize(_items, zone, BareLoadout(Hull("Djinni")), _ => true, new List<string>());
|
Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
|
|
|
var agents = zone.Agents.Count;
|
|
|
|
|
zone.Admit(arriving, piloted: false);
|
2026-09-30 14:59:50 +02:00
|
|
|
SafeAssert.In(zone, arriving, "the arrival is admitted");
|
Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
|
|
|
Assert.Equal(agents, zone.Agents.Count);
|
|
|
|
|
}
|
|
|
|
|
}
|