Merge origin/master (arena cut 1: ItemManager takes a seed) into eureka/aetheria-gravity-laned-belts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 00:44:50 +02:00
commit 95903ab00e
118 changed files with 1157 additions and 297 deletions

View file

@ -38,6 +38,12 @@ public class FactionProductData
// no Assembly; the mint derives both from the line.
[Inspectable, JsonProperty("bin"), Key(7)]
public ProductBin Bin;
// Off the market (ruling off-market-is-unoffered-product): the product keeps its maker, brand, quality and price, but no
// station stocks it, no generation or chooser draws it, and its hull gives no gear a home. It reaches a ship only through a
// Loadout that pins it. LoadoutGenerator.IsOffered is its one reader.
[Inspectable, JsonProperty("notOffered"), Key(8)]
public bool NotOffered;
}
// How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is

View file

@ -17,7 +17,9 @@ using float4 = CultMath.float4;
public class ItemManager
{
public Random Random = new Random((uint) (DateTime.Now.Ticks%uint.MaxValue));
// The item stream: each lot's quality and each generated fit. Seeded by whoever constructs the manager and reseeded
// by RunStart at each step of a new run, never by the clock.
public Random Random;
private Action<string> _logger;
@ -25,8 +27,10 @@ public class ItemManager
public ProvenanceLedger Lots { get; }
public GameplaySettings GameplaySettings { get; }
public ItemManager(CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action<string> logger)
public ItemManager(uint seed, CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action<string> logger)
{
if (seed == 0) throw new ArgumentOutOfRangeException(nameof(seed), "an item stream needs a nonzero seed");
Random = new Random(seed);
ItemData = itemData;
Lots = lots;
GameplaySettings = settings;

View file

@ -127,6 +127,9 @@ public static class Loadouts
// The quality a design no manufacturer makes is built at: the console give's.
public const float UnbrandedQuality = .95f;
// The seed of the scratch item manager a fit is tried on. It draws nothing, so any nonzero constant does.
private const uint ScratchSeed = 1u;
// All-or-nothing: returns an entity only when every design resolved and fitted, the loadout has at most
// WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure of a stage is listed; on any
// failure nothing is returned and the zone and cache are unchanged. The stages run in the order that puts refusal
@ -166,7 +169,7 @@ public static class Loadouts
// The cells fit or they do not whatever brand a design-only slot takes: the shape is the design's. Tried on its own
// item manager, so nothing is minted into the live ledger and nothing is drawn.
var scratch = new ItemManager(cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { });
var scratch = new ItemManager(ScratchSeed, cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { });
Assemble(scratch, null, hullData, loadout, Unbranded(scratch, hullSettled), slotsSettled.Select(settled => Unbranded(scratch, settled)).ToArray(), failures, out _);
if (failures.Count > reported) return null;

View file

@ -197,6 +197,8 @@ public class LoadoutGenerator
entry.design != null &&
entry.design.Price > 0 &&
entry.product.Manufacturer.IsSet() &&
// A not-offered product is never a candidate, so the any-manufacturer fallback below cannot reach it either.
IsOffered(ItemManager.ItemData, entry.product) &&
(!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) &&
(filter?.Invoke(entry.design) ?? true))
.ToArray();
@ -216,9 +218,10 @@ public class LoadoutGenerator
// bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station
// stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior.
// Only a hull some product sells gives gear a home: a hull no faction builds (the Smoke Test Hull) is a test fixture, so its
// hardpoints never put gear on a shelf. The one definition of the hulls HasHome considers.
// hardpoints never put gear on a shelf. An off-market hull gives no gear a home either: only its pinned Loadout flies it.
// The one definition of the hulls HasHome considers.
public static HullData[] SoldHulls(CultCache items) =>
items.GetAll<FactionProductData>().Select(product => items.Get(product.Design)).OfType<HullData>().Distinct().ToArray();
items.GetAll<FactionProductData>().Where(product => IsOffered(items, product)).Select(product => items.Get(product.Design)).OfType<HullData>().Distinct().ToArray();
public static bool HasHome(EquippableItemData design, HullData[] hulls) =>
design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull ||
@ -231,9 +234,16 @@ public class LoadoutGenerator
// always reaches its own manufacturer's gear, and otherwise gear made by a manufacturer its allegiance names
// (operator, 2026-09-30: allegiance lists only other factions). Whether that manufacturer is present in the galaxy
// does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available).
// A not-offered product is available to nobody, a stage with no faction included.
public bool IsAvailable(FactionProductData product) =>
Galaxy == null || Faction == null ||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer);
IsOffered(ItemManager.ItemData, product) && (
Galaxy == null || Faction == null ||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer));
// Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered. A bin is
// offered only if both it and its line are (ruling bin-inherits-line-offered); the catalog read guarantees a bin's line exists.
public static bool IsOffered(CultCache items, FactionProductData product) =>
!product.NotOffered && (product.Bin == null || !items.Get(product.Bin.Line).NotOffered);
// How much this outfitter favors a product: its maker's preference times the product's share of it. The one weight of
// generation and of ChooseProduct. A bin takes the share of its maker's stock that would pass its floor and its line takes
@ -248,9 +258,10 @@ public class LoadoutGenerator
// 1 - Phi(floor): the fraction of a normal draw that lies at least floor deviations above its mean.
private static float PassFraction(float floor) => .5f * (1 - erf(floor / sqrt(2f)));
// Each line's key to the sum of its bins' pass fractions: read from the catalog once per weighting pass, never kept.
// Each line's key to the sum of its offered bins' pass fractions: read from the catalog once per weighting pass, never kept. An
// off-market bin takes no share, so its line keeps the whole weight.
private Dictionary<string, float> BinShares() => ItemManager.ItemData.GetAll<FactionProductData>()
.Where(product => product.Bin != null)
.Where(product => product.Bin != null && IsOffered(ItemManager.ItemData, product))
.GroupBy(product => product.Bin.Line.Key.Value)
.ToDictionary(group => group.Key, group => group.Sum(bin => PassFraction(bin.Bin.Floor)));

View file

@ -25,7 +25,7 @@ public static class RunStart
public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, PlanetSettings planetSettings, Galaxy galaxy, Scenario scenario)
{
SeedItems(itemManager, galaxy, 1);
SeedItems(itemManager, galaxy, ArenaStep);
return galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone(
itemManager, zoneSettings, planetSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario.Ambient);
}
@ -36,7 +36,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);
SeedItems(itemManager, arena.Galaxy, StageStep);
var stage = new ScenarioStage(itemManager, arena, startingHull, tutorialGenerationSettings);
scenario.Stage(stage);
var reported = failures.Count;
@ -62,5 +62,22 @@ public static class RunStart
// 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);
itemManager.Random = new Random(ItemSeed(galaxy, step));
// The steps of a run that draw items: generating its arena, staging its scenario, and continuing a saved run.
public const uint ArenaStep = 1;
public const uint StageStep = 2;
public const uint ContinueStep = 3;
// The item seed of an ActionGameManager scene opened with no galaxy. No run is loaded, so nothing a run decides
// draws from it.
public const uint NoRunSeed = 0x5EED0001u;
// The item stream's seed for one step of a run: the run's seed and the step. Never zero, which no stream accepts.
public static uint ItemSeed(Galaxy galaxy, uint step)
{
var seed = galaxy.Seed * 0x9E3779B1u + step;
if (seed == 0) throw new InvalidOperationException($"Item seed is zero for galaxy seed {galaxy.Seed} at step {step}.");
return seed;
}
}

View file

@ -6,6 +6,10 @@ public abstract class Scenario
{
public abstract string Name { get; }
// The scenario's stable identity: kebab-case, unique across Scenarios, never shown to the player, recorded by
// matches and replays. Name is display only.
public abstract string Id { get; }
// The conditions and what to verify in them, shown under the scenario's button.
public abstract string Brief { get; }

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class AiFusedDiscipline : Scenario
{
public override string Name => "AI Fused Discipline";
public override string Id => "ai-fused-discipline";
public override string Brief => "A Djinni; a piloted neutral Longinus carrying a Smoke Proximity and a piloted hostile Longinus whose only weapon is " +
"Smoke Refused. Verify: the hostile closes on you and never fires its refused weapon; the neutral never fires its " +
"fused one.";

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class Arcs : Scenario
{
public override string Name => "Arcs";
public override string Id => "arcs";
public override string Brief => "The Smoke Test Hull with forward mounts and a side mount on each flank; a bare hull off the bow, one off " +
"the stern, one abeam to port, one abeam to starboard, and a hostile turret. Verify: the hull keeps its " +
"heading while you aim at the stern hull, and the forward guns hold. Designate the bow hull and fire with the " +

View file

@ -5,6 +5,7 @@ using static ItemRotation;
public sealed class Belt : Scenario
{
public override string Name => "Belt";
public override string Id => "belt";
public override string Brief => "The biggest asteroid belt the generator made, with the Longinus parked 100 m outside its ring, facing along it, " +
"and nothing else in the arena. Verify: next and previous target step through the rocks and the reticle sweeps " +
"across the belt without a hitch; Previous with nothing targeted takes the farthest rock; the target indicator " +

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class Crossing : Scenario
{
public override string Name => "Crossing";
public override string Id => "crossing";
public override string Brief => "A Lightning Projector at the starboard gun and a Cell Blaster at the port gun, an unpiloted Djinni coasting across the bow " +
"close ahead and an unpiloted one waiting at range. The crosser is launched to port at 50 m/s and only coasts: nothing steers it, " +
"its hull drag slows it to about 25 m/s at the bow line, and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " +

View file

@ -17,6 +17,7 @@ public sealed class DemoTerminus : Scenario
};
public override string Name => "Terminus (Demo)";
public override string Id => "demo-terminus";
public override string Brief => "One gate, one region, one boss. Allied: the Pirates. Opposed: Zhestokost. " +
"Neutral: Lucent Media and Aeronautics Unlimited.";

View file

@ -3,6 +3,7 @@ using float2 = CultMath.float2;
public sealed class DjinniShakedown : Scenario
{
public override string Name => "Djinni Shakedown";
public override string Id => "djinni-shakedown";
public override string Brief => "A fitted Djinni in empty space, facing +z, with a bare hull dead ahead and one abeam. Verify: its thrusters " +
"move and turn it; with the hull offline and thrusters firing, its speed does not run away. Its " +
"articulated ballistic barrel points at the hull ahead when it bears, and rests at the arc edge while you " +

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class Duel : Scenario
{
public override string Name => "Duel";
public override string Id => "duel";
public override string Brief => "One piloted, hostile Longinus with slow guided missiles and a Light Laser in its hold. Verify: rolled " +
"hits and near-misses, subsystem aim, beam reads above bow, armour takes the missile, loot keeps its brand.";
public override uint Seed => 104;

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class FusedRounds : Scenario
{
public override string Name => "Fused Rounds";
public override string Id => "fused-rounds";
public override string Brief => "The Smoke Test Hull with Smoke Proximity, Smoke Delayed and a Cluster Cannon; a hostile bare Longinus at mid range, a bare " +
"hull dead ahead, a fragile one to starboard, a close one inside the proximity blast radius, and one astern. " +
"Verify with nothing selected: aim past every hull and the round bursts at max range along the aim; aim at the hull " +

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class LauncherAngles : Scenario
{
public override string Name => "Launcher Angles";
public override string Id => "launcher-angles";
public override string Brief => "A Guided Missile Launcher, a Swarm Rocket Pod and two Cell Blasters; a hostile bare Longinus broadside on, another beside it. " +
"Verify: hits pulse only the facing edge; a Swarm Rocket Pod round whose target dies fades unexploded.";
public override uint Seed => 105;

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class LockOn : Scenario
{
public override string Name => "Lock On";
public override string Id => "lock-on";
public override string Brief => "A hostile closes from out of sight, finds you and locks. Verify: no warning at first; once it locks, one " +
"indicator appears toward it and tightens as the lock grows; break line of sight or range and it vanishes; " +
"your own ping shows no incoming paint on its side, only the lock does.";

View file

@ -3,6 +3,7 @@ using float2 = CultMath.float2;
public sealed class LongHaul : Scenario
{
public override string Name => "Long Haul";
public override string Id => "long-haul";
public override string Brief => "A standard ship in a populated galaxy. Verify: memory stays flat across zones with kills; " +
"warp, quit and Continue keep items, tier and brand; death disables Continue.";
public override uint Seed => 106;

View file

@ -4,6 +4,7 @@ using float2 = CultMath.float2;
public sealed class MainGalaxy : Scenario
{
public override string Name => "Main Galaxy";
public override string Id => "main-galaxy";
public override string Brief => "The full galaxy: factions drawn at random, bosses guarding the road to the exit.";
public override Galaxy Generate(GalaxyStage stage) => stage.Main();

View file

@ -9,6 +9,7 @@ public sealed class Pursuit : Scenario
private const float Astern = 300;
public override string Name => "Pursuit";
public override string Id => "pursuit";
public override string Brief => "A hostile Longinus on your tail; a Mine Launcher fitted, three Coolant Vents and two Thruster " +
"Overdrives aboard. Verify: lay mines in its path, the mine pulses slowly after 2 s, fast when " +
"the hostile is near, explodes 2 s later; vent and coast to lose it; overdrive away.";

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class RefusedRounds : Scenario
{
public override string Name => "Refused Rounds";
public override string Id => "refused-rounds";
public override string Brief => "The Smoke Test Hull with Smoke Refused, Smoke Refused Beam, Smoke Refused Charge and an Autocannon; a bare Longinus " +
"dead ahead. Each smoke weapon's blast radius is wider than its range, so it can never be fired. Verify: pull each " +
"trigger and a refused shot costs no ammo, energy, heat, sound, visibility or cooldown, the beam never starts and " +

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class StarvedReactor : Scenario
{
public override string Name => "Starved Reactor";
public override string Id => "starved-reactor";
public override string Brief => "Two Light Lasers on a Muon Fusion Reactor, which can fall to a quarter of the Compact Reactor's charge, and a bare hull " +
"to shoot. Verify: starvation reads as starvation, brownout degrades, refills stutter.";
public override uint Seed => 102;

View file

@ -4,6 +4,7 @@ using float2 = CultMath.float2;
public sealed class TutorialGalaxy : Scenario
{
public override string Name => "Tutorial Galaxy";
public override string Id => "tutorial-galaxy";
public override string Brief => "A small galaxy of fixed factions, entered in a ship of the protagonist faction.";
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class WeaponFeel : Scenario
{
public override string Name => "Weapon Feel";
public override string Id => "weapon-feel";
public override string Brief => "The Smoke Test Hull with a Charged Scatter Blaster, an Autocannon and a Cell Blaster burst weapon; a bare Longinus dead ahead. " +
"Verify: Charged Scatter Blaster and Autocannon fire with effects, particles and action-bar icons; the burst weapon's " +
"cadence is as before; a fast release-and-press on the Charged Scatter Blaster starts no second charge.";

View file

@ -14,6 +14,10 @@ public static class SimulationDice
// never rides ItemManager.Random, so minting items cannot change how a pilot fights.
public const uint CombatSampleStream = 3;
public const uint PatrolStream = 4;
// Generation placement of an unpacked ship packed at the origin, ordinal its 1-based index in the pack, on the zone's
// seed (CombatSeed): the run seed feeds drops and mines only (ruling run-seed-drops-only), and placement never rides
// ItemManager.Random.
public const uint ScatterStream = 5;
// 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply,
// xor-shift, multiply, xor-shift. Diffuses a structured seed (a zone seed XORed with a small,

View file

@ -266,14 +266,18 @@ public class Zone
foreach (var wear in pack.ChunkWear)
_wear[new ChunkId(wear.Field, wear.Index)] = new ChunkWear { Damage = wear.Damage, BrokenUntil = wear.BrokenUntil };
var ordinal = 0u;
foreach (var entityPack in pack.Entities)
{
ordinal++;
var entity = EntitySerializer.Unpack(_itemManager, this, entityPack);
var ship = entity as Ship;
Admit(entity, piloted: ship is {IsPlayerShip: false});
// Generation placement, not admission: a ship packed at the origin is scattered across the zone.
// Generation placement, not admission: a ship packed at the origin is scattered across the zone, on the
// zone's own dice, so nothing drawn from the item stream can move it.
if (ship is {IsPlayerShip: false} && lengthsq(ship.Position) < 1)
ship.Position = _itemManager.Random.NextFloat3(float3(-pack.Radius * .5f), float3(pack.Radius * .5f));
ship.Position = SimulationDice.For(CombatSeed, SimulationDice.ScatterStream, ordinal)
.NextFloat3(float3(-pack.Radius * .5f), float3(pack.Radius * .5f));
}
// TODO: Associate planets with stored entities for planetary colonies