From 02e7fd4b7e308d1ac05fc436618d074886a04f28 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 22:01:17 +0200 Subject: [PATCH] A fixed seed fixes every fit: RunStart seeds the run's item draws from the galaxy's seed ScenarioStage.Generated picked from ItemManager.Random, which is clock-seeded per process and advances in play, so a relaunch of Djinni Shakedown or Long Haul in one session gave the player a different fit; every branded lot's workmanship and role fills came from the same clock stream, so arena gear and explicit fits varied in quality too. Galaxy now keeps the seed it was generated at, and RunStart.GenerateArena and Stage each reseed the item manager's random from it, one stream per step, so what runs between them cannot shift either. SeedRules relaunches every fixed-seed scenario around a clock-free Main Galaxy launch and a scrambled item random, and compares the galaxy (links, factions, homes, exit too), the arena down to each item's maker and workmanship, and the staged fits. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Galaxy.cs | 10 +++- Assets/Scripts/ServerShared/RunStart.cs | 15 +++++- Assets/Scripts/ServerShared/Scenario.cs | 3 +- Assets/Scripts/ServerShared/ScenarioStage.cs | 4 +- tests/Aetheria.Shared.Tests/ScenarioTests.cs | 55 +++++++++++++++----- 5 files changed, 68 insertions(+), 19 deletions(-) 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());