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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 22:01:17 +02:00
commit 02e7fd4b7e
5 changed files with 69 additions and 20 deletions

View file

@ -28,6 +28,10 @@ public class Galaxy
public Dictionary<Faction, FactionRelationship> FactionRelationships { get; } = new Dictionary<Faction, FactionRelationship>();
private Action<string> 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<CultRecordKey> _containedFactions;
private GalaxyZone[] _exitPath;
@ -104,7 +108,8 @@ public class Galaxy
Background = background;
Log = log;
var factions = cache.GetAll<Faction>();
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<Faction>();

View file

@ -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<string> 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);
}

View file

@ -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

View file

@ -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);