diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index bcdf7a2f8..a5660f52e 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -28,6 +28,10 @@ public class Galaxy public Dictionary FactionRelationships { get; } = new Dictionary(); private Action Log { get; } public bool IsPrelude { get; } + + // The seed this galaxy was generated at, which also seeds its run's item draws (RunStart); zero for a galaxy + // restored from a save. + public uint Seed { get; } private HashSet _containedFactions; private GalaxyZone[] _exitPath; @@ -104,7 +108,8 @@ public class Galaxy Background = background; Log = log; var factions = cache.GetAll(); - var random = new Random(seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed); + Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed; + var random = new Random(Seed); Factions = factions.OrderBy(x => random.NextFloat()).Take(settings.MegaCount).ToArray(); foreach (var f in Factions) FactionRelationships[f] = FactionRelationship.Neutral; @@ -159,7 +164,8 @@ public class Galaxy Background = background; Log = log; - var random = new Random(seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed); + Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed; + var random = new Random(Seed); var factions = new List(); diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index 1eab125bb..4397f4575 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Random = CultMath.Random; // The owner of a new run (docs/scenarios-cut.md, R.4). Every new run is a scenario: RunStart generates its galaxy, // generates its arena (the galaxy's entrance zone) under the scenario's Ambient rule, and admits what the scenario @@ -21,9 +22,12 @@ public static class RunStart return scenario.Generate(stage) ?? throw new InvalidOperationException($"Scenario {scenario.Name} generated no galaxy."); } - public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, Galaxy galaxy, Scenario scenario) => - galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone( + public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, Galaxy galaxy, Scenario scenario) + { + SeedItems(itemManager, galaxy, 1); + return galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone( itemManager, zoneSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario.Ambient); + } // Runs the scenario's Stage, then admits its player and everything it placed: each entity's stance set both ways // with the player, a pilot for each piloted one. Returns null, with every failure listed and the arena unchanged, @@ -31,6 +35,7 @@ public static class RunStart public static Staged Stage(ItemManager itemManager, Zone arena, Scenario scenario, string startingHull, TutorialGenerationSettings tutorialGenerationSettings, List failures) { + SeedItems(itemManager, arena.Galaxy, 2); var stage = new ScenarioStage(itemManager, arena, startingHull, tutorialGenerationSettings); scenario.Stage(stage); var reported = failures.Count; @@ -51,4 +56,10 @@ public static class RunStart } return new Staged { Player = player, Entities = stage.Placed.Select(placed => placed.Entity).ToList() }; } + + // A new run's item draws (each lot's quality, each generated fit) come from its galaxy's seed, not the process clock, + // so a fixed seed fixes the arena's gear and the player's fit. Each step draws its own stream, so whatever runs + // between them (populating the level) cannot shift it. + private static void SeedItems(ItemManager itemManager, Galaxy galaxy, uint step) => + itemManager.Random = new Random(galaxy.Seed * 0x9E3779B1u + step); } diff --git a/Assets/Scripts/ServerShared/Scenario.cs b/Assets/Scripts/ServerShared/Scenario.cs index ba7165a19..acd2f10b2 100644 --- a/Assets/Scripts/ServerShared/Scenario.cs +++ b/Assets/Scripts/ServerShared/Scenario.cs @@ -10,7 +10,8 @@ public abstract class Scenario public abstract string Brief { get; } // Zero draws the seed from the clock, so each launch is a new galaxy. Anything else fixes the galaxy, its - // background and its arena, so every launch has the same layout. + // background, its arena and every fit, each item's maker and workmanship included, so every launch has the same + // layout. public virtual uint Seed => 0; // False: the arena keeps its planets, orbits and stations but generates no ship or turret, so nothing wanders into diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index 241684a35..feac93e66 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -64,8 +64,8 @@ public sealed class ScenarioStage // A hull with nothing fitted. public ScenarioFit Bare(string hull) => Fit(hull); - // A fit generated as the game generates its ships, for the faction when one is given. An empty hull means any - // ship hull. + // A fit generated as the game generates its ships, for the faction when one is given, from the run's item draws, + // which RunStart seeds from the galaxy's seed. An empty hull means any ship hull. public ScenarioFit Generated(string hull, Faction faction = null) { var generator = new LoadoutGenerator(ref _items.Random, _items, Galaxy, Arena.GalaxyZone, faction, 2); diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index ac57abce9..9fbd9e7e6 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -132,24 +132,55 @@ public sealed partial class RunStartTests Assert.Contains(main.arena.Pack.Entities, entity => entity is ShipPack { IsPlayerShip: false }); } + // Everything a galaxy's layout fixes: each zone's name, position, owner, factions and links, the homes, the + // entrance, the exit and the background. private static string Layout(Galaxy galaxy) => - string.Join(";", galaxy.Zones.Select(zone => $"{zone.Name}@{zone.Position}:{zone.Owner?.Name}")) + - $" entrance {Array.IndexOf(galaxy.Zones, galaxy.Entrance)} noise {galaxy.Background.NoisePosition}"; + string.Join(";", galaxy.Zones.Select(zone => + $"{zone.Name}@{zone.Position}:{zone.Owner?.Name}:[{string.Join(",", zone.Factions.Select(faction => faction.Name))}]" + + $":[{string.Join(",", zone.AdjacentZones.Select(adjacent => Array.IndexOf(galaxy.Zones, adjacent)).OrderBy(i => i))}]")) + + $" homes {string.Join(",", galaxy.HomeZones.Select(home => $"{home.Key.Name}={Array.IndexOf(galaxy.Zones, home.Value)}").OrderBy(home => home))}" + + $" entrance {Array.IndexOf(galaxy.Zones, galaxy.Entrance)} exit {Array.IndexOf(galaxy.Zones, galaxy.Exit)} noise {galaxy.Background.NoisePosition}"; - private static string Layout(ZonePack arena) => - $"{arena.Radius} {arena.Mass} {arena.Planets.Count()} {arena.Orbits.Count()} {arena.Entities.Count()}"; + // An arena's bodies and every entity in it, down to each item's design, maker, workmanship and role fills. + private string Layout(ZonePack arena) => + $"{arena.Radius} {arena.Mass} planets [{string.Join(",", arena.Planets.Select(planet => _cache.Get(planet)).Select(body => $"{body.GetType().Name}:{body.Mass}"))}]" + + $" orbits [{string.Join(",", arena.Orbits.Select(orbit => _cache.Get(orbit).Distance))}]" + + $" entities [{string.Join(",", arena.Entities.Select(Gear))}]"; - // A fixed seed fixes the galaxy, its background and its arena, launch after launch in one session. Seed zero takes - // the clock, so two launches at two clock readings are two galaxies. + // What a launch staged: the player and everything placed, each where it is and with exactly what it carries. + private string Fits(RunStart.Staged staged) => + string.Join(" | ", staged.Entities.Prepend(staged.Player).Select(entity => $"{entity.Name}@{entity.Position}:{Gear(EntitySerializer.Pack(entity))}")); + + private string Gear(EntityPack pack) => + $"{pack.GetType().Name}{(pack is ShipPack ship ? "@" + ship.Position : "")}:{string.Join("/", EntitySerializer.Items(pack).Select(Item))}"; + + private string Item(ItemInstance item) + { + var name = _items.GetData(item)?.Name; + if (!(item is CraftedItemInstance crafted)) return name; + var lot = _items.GetLot(crafted); + return $"{name}<{(lot.Origin as Attributed)?.Faction.Key.Value}>~{lot.Quality}" + + string.Concat((lot.Roles ?? new List()).Select(fill => $"/{fill.Role}={fill.Quality}")); + } + + // A fixed seed fixes the galaxy, its background, its arena and every fit, each item's maker and workmanship + // included, launch after launch in one session, whatever ran in between. Seed zero takes the clock, so two launches + // at two clock readings are two galaxies. [Fact] public void SeedRules() { - var arena = new DjinniShakedown(); - Assert.NotEqual(0u, arena.Seed); - var first = Launch(arena); - var second = Launch(arena); - Assert.Equal(Layout(first.galaxy), Layout(second.galaxy)); - Assert.Equal(Layout(first.arena.Pack), Layout(second.arena.Pack)); + foreach (var scenario in Scenarios.Tests) + { + Assert.NotEqual(0u, scenario.Seed); + var first = Launch(scenario); + Launch(new MainGalaxy(), Inputs(() => 4242)); + _items.Random = new CultMath.Random(0xC0FFEE); // play draws from the run's item random + var second = Launch(scenario); + Assert.True(first.failures.Count == 0, $"{scenario.Name}: {string.Join("; ", first.failures)}"); + Assert.Equal(Layout(first.galaxy), Layout(second.galaxy)); + Assert.Equal(Layout(first.arena.Pack), Layout(second.arena.Pack)); + Assert.Equal(Fits(first.staged), Fits(second.staged)); + } var clock = new Queue(new[] { 1000u, 2000u }); var inputs = Inputs(() => clock.Dequeue());