Migrate tutorial galaxy topology into daemon

This commit is contained in:
Meta Vens 2026-07-17 07:49:54 +01:00
commit 1e02aee692
5 changed files with 688 additions and 1 deletions

View file

@ -29,6 +29,11 @@ else if (args.Contains("--payload", StringComparer.Ordinal))
checks.RunPayload();
Console.WriteLine("Daemon retained Ymir payload smoke passed.");
}
else if (args.Contains("--tutorial-topology", StringComparer.Ordinal))
{
checks.RunTutorialTopology();
Console.WriteLine("Daemon tutorial topology smoke passed.");
}
else
{
checks.Run();
@ -62,6 +67,60 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
RunCheck(OverlappingMineRemainsQueryableAfterArming);
}
public void RunTutorialTopology() => RunCheck(TutorialTopologyIsDeterministicConnectedAndRoleOwned);
private static void TutorialTopologyIsDeterministicConnectedAndRoleOwned()
{
AetheriaDaemonTutorialFactionInput Faction(string shortName, int influence = 2) =>
new($"faction.{shortName.ToLowerInvariant()}", shortName, shortName, influence);
var factions = new[]
{
Faction("Miss"),
Faction("Zhe"),
Faction("Luc"),
Faction("Aero"),
Faction("Finch"),
Faction("Adras")
};
var first = AetheriaDaemonTutorialTopologyGenerator.GenerateFossil(factions, 0xA37E_2026u);
var second = AetheriaDaemonTutorialTopologyGenerator.GenerateFossil(factions, 0xA37E_2026u);
RequireEqual(64, first.Zones.Count,
"the daemon tutorial must retain the authored 64-zone topology size");
Require(first.Zones.Select(zone => (zone.X, zone.Y, string.Join(',', zone.AdjacentZoneIndices)))
.SequenceEqual(second.Zones.Select(zone => (zone.X, zone.Y, string.Join(',', zone.AdjacentZoneIndices)))),
"tutorial topology must be deterministic for a fixed daemon seed and density field");
RequireNear(first.NoisePosition, second.NoisePosition, 0,
"the daemon seed must reproduce the authored tutorial cloud field position");
Require(AetheriaDaemonTutorialTopologyGenerator.TutorialCloudDensity(0.5f, 0.5f, first.NoisePosition) >= 0.5f,
"tutorial cloud selection must retain the fossil's dense-center admission gate");
RequireEqual(6, first.HomeZoneByFactionKey.Count,
"every authored tutorial faction role must receive one daemon-owned home zone");
Require(first.HomeZoneByFactionKey["faction.miss"] != first.HomeZoneByFactionKey["faction.zhe"],
"tutorial protagonist and antagonist homes must not collapse onto one zone");
Require(first.Zones[first.EntranceZoneIndex].OwnerFactionKey.Length == 0,
"the tutorial entrance must retain the fossil's unowned-zone rule");
Require(first.DiscoveredZoneIndices.SequenceEqual(
new[] { first.EntranceZoneIndex }
.Concat(first.Zones[first.EntranceZoneIndex].AdjacentZoneIndices)
.Distinct()
.OrderBy(value => value)),
"initial tutorial discovery must contain exactly the entrance and its adjacent zones");
Require(first.Zones.All(zone => zone.AdjacentZoneIndices.Count > 0) &&
first.Zones.All(zone => zone.AdjacentZoneIndices.All(neighbor =>
first.Zones[neighbor].AdjacentZoneIndices.Contains(zone.ZoneIndex))),
"tutorial link pruning must preserve one connected undirected graph");
var reachable = new HashSet<int> { first.EntranceZoneIndex };
var queue = new Queue<int>();
queue.Enqueue(first.EntranceZoneIndex);
while (queue.Count > 0)
{
foreach (var neighbor in first.Zones[queue.Dequeue()].AdjacentZoneIndices)
if (reachable.Add(neighbor)) queue.Enqueue(neighbor);
}
RequireEqual(first.Zones.Count, reachable.Count,
"tutorial link elimination must never strand a zone outside the playable graph");
}
private static void RetainedWorldAdvancesEveryFixedSubstep()
{
var ship = Entity(0, 0, "player");
@ -152,6 +211,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
Action[] checks =
[
PositiveGravityDepthAttractsAndProjectsAsAWell,
TutorialTopologyIsDeterministicConnectedAndRoleOwned,
VolumeSurfaceKeepsNativeShaderAbiInAssetVariant,
YmirMovesProjectileAndReportsStableContact,
CompressedTerminusStopsAtAttentionBoundary,

View file

@ -0,0 +1,583 @@
using GameCult.Aetheria.State.Verse;
public sealed record AetheriaDaemonTutorialFactionInput(
string CorporationKey,
string Name,
string ShortName,
int InfluenceDistance);
public sealed record AetheriaDaemonTutorialTopologySettings(
int ZoneCount,
float LinkDensity,
string ProtagonistFaction,
string AntagonistFaction,
string BufferFaction,
IReadOnlyList<string> NeutralFactions,
string QuestFaction)
{
public static AetheriaDaemonTutorialTopologySettings Fossil { get; } = new(
64,
0.5f,
"Miss",
"Zhe",
"Luc",
new[] { "Aero", "Finch" },
"Adras");
}
public sealed record AetheriaDaemonTutorialZoneTopology(
int ZoneIndex,
float X,
float Y,
IReadOnlyList<int> AdjacentZoneIndices,
IReadOnlyList<string> FactionKeys,
string OwnerFactionKey);
public sealed record AetheriaDaemonTutorialTopology(
uint GenerationSeed,
float NoisePosition,
int EntranceZoneIndex,
IReadOnlyList<int> DiscoveredZoneIndices,
IReadOnlyDictionary<string, int> HomeZoneByFactionKey,
IReadOnlyList<AetheriaDaemonTutorialZoneTopology> Zones);
/// <summary>
/// Daemon-owned migration of the fossil tutorial galaxy topology. This organ
/// deliberately stops before zone contents: materialization consumes this
/// result, but cannot change its graph, faction placement, or discovery truth.
/// </summary>
public static class AetheriaDaemonTutorialTopologyGenerator
{
private const int CandidateMultiplier = 8;
public static AetheriaDaemonTutorialTopology GenerateFossil(
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
uint seed,
AetheriaDaemonTutorialTopologySettings? settings = null)
{
var noiseRandom = new CultMath.Random(seed ^ 0x74A7_91D3u);
float noisePosition;
var attempts = 0;
do
{
if (++attempts > 1_000_000)
throw new InvalidOperationException("Tutorial cloud field did not produce an admissible entrance region.");
noisePosition = noiseRandom.NextFloat(0, 1000);
} while (TutorialCloudDensity(0.5f, 0.5f, noisePosition) < 0.5f);
return Generate(
factions,
seed,
(x, y) => TutorialCloudDensity(x, y, noisePosition),
settings) with
{
NoisePosition = noisePosition
};
}
public static float TutorialCloudDensity(float x, float y, float noisePosition)
{
var point = new CultMath.float2(x + noisePosition, y + noisePosition);
var frequency = 0.1f;
var amplitude = 0.5f;
var sum = 0f;
for (var octave = 0; octave < 10; octave++)
{
var noise = MathF.Abs(CultMath.math.simplex_noise(point * frequency));
sum += (octave < 4 ? 1f - noise : noise) * amplitude;
frequency *= 2f;
amplitude *= 0.7f;
}
return MathF.Pow(sum + 0.3f, 10f) * 0.01f;
}
public static IReadOnlyList<AetheriaDaemonTutorialFactionInput> ResolveFossilFactions(
AetheriaRuntimeCatalogSnapshot catalog,
AetheriaDaemonTutorialTopologySettings? settings = null)
{
settings ??= AetheriaDaemonTutorialTopologySettings.Fossil;
var requested = new[]
{
settings.ProtagonistFaction,
settings.AntagonistFaction,
settings.BufferFaction
}
.Concat(settings.NeutralFactions)
.Append(settings.QuestFaction)
.ToArray();
return requested.Select(name =>
{
var match = (catalog.Corporations ?? Array.Empty<AetheriaRuntimeCorporation>())
.FirstOrDefault(value =>
string.Equals(value.ShortName, name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(value.Name, name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(value.CorporationKey, name, StringComparison.OrdinalIgnoreCase));
if (match == null || string.IsNullOrWhiteSpace(match.CorporationKey))
throw new InvalidDataException($"Tutorial generation requires the authored faction '{name}'.");
return new AetheriaDaemonTutorialFactionInput(
match.CorporationKey,
match.Name ?? "",
match.ShortName ?? "",
match.InfluenceDistance);
}).ToArray();
}
public static AetheriaDaemonTutorialTopology Generate(
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
uint seed,
Func<float, float, float> cloudDensity,
AetheriaDaemonTutorialTopologySettings? settings = null)
{
settings ??= AetheriaDaemonTutorialTopologySettings.Fossil;
if (settings.ZoneCount < 3)
throw new ArgumentOutOfRangeException(nameof(settings), "Tutorial topology requires at least three zones.");
if (cloudDensity == null)
throw new ArgumentNullException(nameof(cloudDensity));
var roles = ResolveRoles(factions, settings);
var random = new CultMath.Random(seed);
var points = GeneratePoints(settings.ZoneCount, ref random, cloudDensity);
var links = DelaunayLinks(points);
PruneLinks(points, links, settings.LinkDensity, cloudDensity);
var distances = BuildDistances(points.Length, links);
var home = PlaceFactions(points.Length, links, distances, roles);
var influence = roles.All.ToDictionary(
faction => faction.CorporationKey,
faction => Math.Max(0, (faction.InfluenceDistance + 1) / 2),
StringComparer.Ordinal);
var zoneFactions = new string[points.Length][];
var owners = new string[points.Length];
for (var zoneIndex = 0; zoneIndex < points.Length; zoneIndex++)
{
zoneFactions[zoneIndex] = roles.All
.Where(faction => distances[zoneIndex, home[faction.CorporationKey]] <= influence[faction.CorporationKey])
.Select(faction => faction.CorporationKey)
.ToArray();
owners[zoneIndex] = roles.All
.Where(faction => distances[zoneIndex, home[faction.CorporationKey]] <= influence[faction.CorporationKey])
.OrderBy(faction => distances[zoneIndex, home[faction.CorporationKey]])
.ThenBy(faction => Array.IndexOf(roles.All, faction))
.Select(faction => faction.CorporationKey)
.FirstOrDefault() ?? "";
}
var entranceCandidates = Enumerable.Range(0, points.Length)
.Where(index => owners[index].Length == 0)
.ToArray();
if (entranceCandidates.Length == 0)
throw new InvalidOperationException("Tutorial topology produced no unowned entrance zone.");
var entrance = entranceCandidates
.OrderBy(index => distances[index, home[roles.Protagonist.CorporationKey]])
.ThenBy(index => index)
.First();
var discovered = new[] { entrance }
.Concat(Neighbors(entrance, links))
.Distinct()
.OrderBy(index => index)
.ToArray();
var zones = Enumerable.Range(0, points.Length)
.Select(index => new AetheriaDaemonTutorialZoneTopology(
index,
points[index].X,
points[index].Y,
Neighbors(index, links).OrderBy(value => value).ToArray(),
zoneFactions[index],
owners[index]))
.ToArray();
return new AetheriaDaemonTutorialTopology(seed, float.NaN, entrance, discovered, home, zones);
}
private static TutorialRoles ResolveRoles(
IReadOnlyList<AetheriaDaemonTutorialFactionInput> factions,
AetheriaDaemonTutorialTopologySettings settings)
{
AetheriaDaemonTutorialFactionInput Resolve(string name)
{
return factions.FirstOrDefault(value =>
string.Equals(value.ShortName, name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(value.Name, name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(value.CorporationKey, name, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidDataException($"Tutorial topology is missing faction '{name}'.");
}
var protagonist = Resolve(settings.ProtagonistFaction);
var antagonist = Resolve(settings.AntagonistFaction);
var buffer = Resolve(settings.BufferFaction);
var neutrals = settings.NeutralFactions.Select(Resolve).ToArray();
var quest = Resolve(settings.QuestFaction);
var all = new[] { protagonist, antagonist, buffer }
.Concat(neutrals)
.Append(quest)
.DistinctBy(value => value.CorporationKey, StringComparer.Ordinal)
.ToArray();
if (all.Length != 4 + neutrals.Length)
throw new InvalidDataException("Tutorial faction roles must resolve to distinct corporations.");
return new TutorialRoles(protagonist, antagonist, buffer, neutrals, quest, all);
}
private static Point[] GeneratePoints(
int count,
ref CultMath.Random random,
Func<float, float, float> density)
{
var candidates = new Point[count * CandidateMultiplier];
var accepted = 0;
var accumulator = 0f;
var attempts = 0;
while (accepted < candidates.Length)
{
if (++attempts > candidates.Length * 1_000_000)
throw new InvalidOperationException("Tutorial density did not admit enough topology samples.");
var point = new Point(random.NextFloat(), random.NextFloat());
var dx = point.X - 0.5f;
var dy = point.Y - 0.5f;
var envelope = Saturate((0.2f - (dx * dx + dy * dy)) * 4f);
var sampleDensity = Saturate(density(point.X, point.Y));
accumulator += sampleDensity * sampleDensity * envelope;
if (accumulator <= 0.5f)
continue;
accumulator = 0;
candidates[accepted++] = point;
}
var dMax = 2f * MathF.Sqrt((1f / count) / (2f * MathF.Sqrt(3f)));
var dMaxSquared = dMax * dMax;
var weights = new float[candidates.Length];
var densities = candidates.Select(point => density(point.X, point.Y)).ToArray();
var active = Enumerable.Repeat(true, candidates.Length).ToArray();
for (var i = 0; i < candidates.Length; i++)
for (var j = 0; j < candidates.Length; j++)
{
var distanceSquared = DistanceSquared(candidates[i], candidates[j]);
if (distanceSquared > dMaxSquared)
continue;
weights[i] += Weight(distanceSquared, dMax, densities[i]);
}
for (var remaining = candidates.Length; remaining > count; remaining--)
{
var eliminated = Enumerable.Range(0, candidates.Length)
.Where(index => active[index])
.OrderByDescending(index => weights[index])
.ThenBy(index => index)
.First();
active[eliminated] = false;
for (var neighbor = 0; neighbor < candidates.Length; neighbor++)
{
if (!active[neighbor])
continue;
var distanceSquared = DistanceSquared(candidates[neighbor], candidates[eliminated]);
if (distanceSquared <= dMaxSquared)
weights[neighbor] -= Weight(distanceSquared, dMax, densities[neighbor]);
}
}
return Enumerable.Range(0, candidates.Length)
.Where(index => active[index])
.Select(index => candidates[index])
.ToArray();
}
private static float Weight(float distanceSquared, float dMax, float density)
{
var distance = MathF.Sqrt(distanceSquared);
return MathF.Pow(1f - distance / dMax, 5f + 6f * density);
}
private static HashSet<Edge> DelaunayLinks(IReadOnlyList<Point> points)
{
var vertices = points.Concat(new[]
{
new Point(-16, -8),
new Point(16, -8),
new Point(0, 16)
}).ToArray();
var superA = points.Count;
var superB = points.Count + 1;
var superC = points.Count + 2;
var triangles = new List<Triangle> { Orient(vertices, new Triangle(superA, superB, superC)) };
for (var pointIndex = 0; pointIndex < points.Count; pointIndex++)
{
var bad = triangles.Where(triangle => ContainsInCircumcircle(vertices, triangle, vertices[pointIndex])).ToArray();
var boundary = bad
.SelectMany(TriangleEdges)
.GroupBy(edge => edge)
.Where(group => group.Count() == 1)
.Select(group => group.Key)
.ToArray();
foreach (var triangle in bad)
triangles.Remove(triangle);
triangles.AddRange(boundary.Select(edge => Orient(vertices, new Triangle(edge.A, edge.B, pointIndex))));
}
return triangles
.Where(triangle => triangle.A < points.Count && triangle.B < points.Count && triangle.C < points.Count)
.SelectMany(TriangleEdges)
.ToHashSet();
}
private static Triangle Orient(IReadOnlyList<Point> points, Triangle triangle)
{
return Cross(points[triangle.A], points[triangle.B], points[triangle.C]) >= 0
? triangle
: new Triangle(triangle.B, triangle.A, triangle.C);
}
private static bool ContainsInCircumcircle(IReadOnlyList<Point> points, Triangle triangle, Point point)
{
var a = points[triangle.A];
var b = points[triangle.B];
var c = points[triangle.C];
var ax = a.X - point.X;
var ay = a.Y - point.Y;
var bx = b.X - point.X;
var by = b.Y - point.Y;
var cx = c.X - point.X;
var cy = c.Y - point.Y;
var determinant = (ax * ax + ay * ay) * (bx * cy - cx * by)
- (bx * bx + by * by) * (ax * cy - cx * ay)
+ (cx * cx + cy * cy) * (ax * by - bx * ay);
return determinant > 1e-7f;
}
private static IEnumerable<Edge> TriangleEdges(Triangle triangle)
{
yield return new Edge(triangle.A, triangle.B);
yield return new Edge(triangle.B, triangle.C);
yield return new Edge(triangle.C, triangle.A);
}
private static void PruneLinks(
IReadOnlyList<Point> points,
HashSet<Edge> links,
float linkDensity,
Func<float, float, float> density)
{
var target = (int)MathF.Ceiling(Saturate(linkDensity) * links.Count);
while (links.Count > target)
{
var degrees = Enumerable.Range(0, points.Count)
.Select(index => links.Count(edge => edge.Contains(index)))
.ToArray();
var removable = links
.Where(edge => IsConnectedWithout(points.Count, links, edge.A, edge.B, edge))
.Select(edge => new { Edge = edge, Weight = LinkWeight(points, edge, degrees, density) })
.OrderByDescending(value => value.Weight)
.ThenBy(value => value.Edge.A)
.ThenBy(value => value.Edge.B)
.FirstOrDefault();
if (removable == null)
break;
links.Remove(removable.Edge);
}
}
private static float LinkWeight(
IReadOnlyList<Point> points,
Edge edge,
IReadOnlyList<int> degrees,
Func<float, float, float> density)
{
var a = points[edge.A];
var b = points[edge.B];
var midpointDensity = Saturate(density((a.X + b.X) * 0.5f, (a.Y + b.Y) * 0.5f));
var densityFactor = midpointDensity <= 0 ? float.PositiveInfinity : 1f / midpointDensity;
return densityFactor * DistanceSquared(a, b) * (degrees[edge.A] - 1) * (degrees[edge.B] - 1);
}
private static Dictionary<string, int> PlaceFactions(
int zoneCount,
HashSet<Edge> links,
int[,] distances,
TutorialRoles roles)
{
var home = new Dictionary<string, int>(StringComparer.Ordinal);
var influence = roles.All.ToDictionary(
faction => faction.CorporationKey,
faction => Math.Max(0, (faction.InfluenceDistance + 1) / 2),
StringComparer.Ordinal);
home[roles.Protagonist.CorporationKey] = MaxZone(zoneCount, zone =>
RegionSize(zone, influence[roles.Protagonist.CorporationKey], distances));
home[roles.Antagonist.CorporationKey] = MaxZone(zoneCount, zone =>
RegionSize(zone, influence[roles.Antagonist.CorporationKey], distances) *
distances[zone, home[roles.Protagonist.CorporationKey]]);
home[roles.Protagonist.CorporationKey] = MaxZone(zoneCount, zone =>
RegionSize(zone, influence[roles.Protagonist.CorporationKey], distances) *
Math.Sqrt(distances[zone, home[roles.Antagonist.CorporationKey]]));
var minimumDifference = Enumerable.Range(0, zoneCount)
.Min(zone => Math.Abs(
distances[zone, home[roles.Antagonist.CorporationKey]] -
distances[zone, home[roles.Protagonist.CorporationKey]]));
home[roles.Buffer.CorporationKey] = MaxZone(
Enumerable.Range(0, zoneCount).Where(zone => Math.Abs(
distances[zone, home[roles.Antagonist.CorporationKey]] -
distances[zone, home[roles.Protagonist.CorporationKey]]) == minimumDifference),
zone => RegionSize(zone, influence[roles.Buffer.CorporationKey], distances));
foreach (var neutral in roles.Neutrals)
{
home[neutral.CorporationKey] = MaxZone(zoneCount, zone =>
{
var score = (double)RegionSize(zone, influence[neutral.CorporationKey], distances);
foreach (var occupied in home.Values)
score *= Math.Sqrt(distances[zone, occupied]);
return score;
});
}
var present = BuildPresence(zoneCount, roles.All.Where(value => value != roles.Quest), home, influence, distances);
var preferredQuestZones = Enumerable.Range(0, zoneCount)
.Where(zone => present[zone].Contains(roles.Antagonist.CorporationKey) && present[zone].Contains(roles.Buffer.CorporationKey))
.ToArray();
if (preferredQuestZones.Length > 0)
{
home[roles.Quest.CorporationKey] = MaxZone(preferredQuestZones, zone =>
distances[zone, home[roles.Antagonist.CorporationKey]] *
RegionSize(zone, influence[roles.Quest.CorporationKey], distances));
}
else
{
var antagonistZones = Enumerable.Range(0, zoneCount)
.Where(zone => present[zone].Contains(roles.Antagonist.CorporationKey));
home[roles.Quest.CorporationKey] = antagonistZones
.OrderBy(zone => distances[zone, home[roles.Buffer.CorporationKey]])
.ThenBy(zone => zone)
.First();
}
return home;
}
private static HashSet<string>[] BuildPresence(
int zoneCount,
IEnumerable<AetheriaDaemonTutorialFactionInput> factions,
IReadOnlyDictionary<string, int> home,
IReadOnlyDictionary<string, int> influence,
int[,] distances)
{
return Enumerable.Range(0, zoneCount)
.Select(zone => factions
.Where(faction => distances[zone, home[faction.CorporationKey]] <= influence[faction.CorporationKey])
.Select(faction => faction.CorporationKey)
.ToHashSet(StringComparer.Ordinal))
.ToArray();
}
private static int[,] BuildDistances(int zoneCount, IReadOnlySet<Edge> links)
{
var output = new int[zoneCount, zoneCount];
for (var source = 0; source < zoneCount; source++)
{
var queue = new Queue<int>();
var visited = new bool[zoneCount];
queue.Enqueue(source);
visited[source] = true;
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var neighbor in Neighbors(current, links))
{
if (visited[neighbor])
continue;
visited[neighbor] = true;
output[source, neighbor] = output[source, current] + 1;
queue.Enqueue(neighbor);
}
}
if (visited.Any(value => !value))
throw new InvalidOperationException("Tutorial topology graph is disconnected.");
}
return output;
}
private static bool IsConnectedWithout(
int zoneCount,
IReadOnlySet<Edge> links,
int source,
int target,
Edge ignored)
{
var queue = new Queue<int>();
var visited = new bool[zoneCount];
queue.Enqueue(source);
visited[source] = true;
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var edge in links)
{
if (edge == ignored || !edge.Contains(current))
continue;
var neighbor = edge.Other(current);
if (neighbor == target)
return true;
if (visited[neighbor])
continue;
visited[neighbor] = true;
queue.Enqueue(neighbor);
}
}
return false;
}
private static IEnumerable<int> Neighbors(int zone, IEnumerable<Edge> links)
{
return links.Where(edge => edge.Contains(zone)).Select(edge => edge.Other(zone));
}
private static int RegionSize(int zone, int maxDistance, int[,] distances)
{
return Enumerable.Range(0, distances.GetLength(0)).Count(other => distances[zone, other] <= maxDistance);
}
private static int MaxZone(int zoneCount, Func<int, double> score) =>
MaxZone(Enumerable.Range(0, zoneCount), score);
private static int MaxZone(IEnumerable<int> zones, Func<int, double> score) =>
zones.Select(zone => new { Zone = zone, Score = score(zone) })
.OrderByDescending(value => value.Score)
.ThenBy(value => value.Zone)
.First().Zone;
private static float DistanceSquared(Point a, Point b)
{
var x = a.X - b.X;
var y = a.Y - b.Y;
return x * x + y * y;
}
private static float Cross(Point a, Point b, Point c) =>
(b.X - a.X) * (c.Y - a.Y) - (b.Y - a.Y) * (c.X - a.X);
private static float Saturate(float value) => value < 0 ? 0 : value > 1 ? 1 : value;
private readonly record struct Point(float X, float Y);
private readonly record struct Triangle(int A, int B, int C);
private readonly record struct Edge
{
public Edge(int a, int b)
{
A = Math.Min(a, b);
B = Math.Max(a, b);
}
public int A { get; }
public int B { get; }
public bool Contains(int value) => A == value || B == value;
public int Other(int value) => A == value ? B : B == value ? A : throw new ArgumentOutOfRangeException(nameof(value));
}
private sealed record TutorialRoles(
AetheriaDaemonTutorialFactionInput Protagonist,
AetheriaDaemonTutorialFactionInput Antagonist,
AetheriaDaemonTutorialFactionInput Buffer,
IReadOnlyList<AetheriaDaemonTutorialFactionInput> Neutrals,
AetheriaDaemonTutorialFactionInput Quest,
AetheriaDaemonTutorialFactionInput[] All);
}

View file

@ -1106,6 +1106,34 @@ the Terminus proof arena cannot award completion.
requested steps, proves the causal damage commits at that step, proves later
steps do not execute, and round-trips the clock/cause fields through Eve.
### Daemon Tutorial Topology Ownership
- Owner: the daemon tutorial topology generator owns the tutorial zone graph,
faction roles and homes, zone influence, entrance, and initial discovery.
- Inputs: the authored six faction selectors, 64-zone/link-density settings,
typed corporation catalog, daemon generation seed, and the historical
ten-octave simplex cloud-density field.
- Outputs: a deterministic connected zone graph with stable positions,
adjacency, faction presence/ownership, home zones, entrance, discovery set,
and the selected cloud-noise position.
- Derived state: the sector map and eventual zone materialization consume this
topology. Names, bodies, stations, ships, and Eve map rendering do not get to
revise it.
- Forbidden writers: Unity `MainMenu`, `Galaxy`, `ZoneGenerator`, scene objects,
and Eve clients cannot generate or repair tutorial topology.
- Shared paths: generation and parity smoke use the same CultMath random stream,
Unity-compatible simplex semantics, weighted sample elimination, Delaunay
graph, connectivity-preserving link pruning, and faction placement owner.
- Cut line: a generated Terminus proof arena cannot be relabelled as the
tutorial. The daemon topology exists as a distinct pre-materialization result;
it is not selected as a playable run until daemon zone contents are migrated.
- Verification layer: smoke generates the authored 64-zone graph twice from one
seed, proves point/adjacency repeatability, a connected undirected graph, six
distinct role homes, an unowned entrance, and discovery containing exactly
that entrance and its neighbors. CultMath separately proves the C# simplex
implementation against Unity Mathematics vectors and carries the same
semantics in HLSL.
### Daemon Pickup Contact Transaction
- Owner: the daemon simulation owns pickup collection by interpreting typed

View file

@ -15,7 +15,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity.
| Organ | Original authority/evidence | Target owner | Eve contract | Runtime lowering | Current status | Required proof |
|---|---|---|---|---|---|---|
| Run/session state | `ServerShared`, `ActionGameManager` | Aetheria daemon/CultCache | provider/session advertisement plus run lifecycle bindings | all clients | active/failed lifecycle is daemon-owned and restart-stable: controlled-entity destruction commits the fossil cause, terminal frame, one `run.failed` fact, releases control, rejects later gameplay commands, and freezes the world; Eve receives terminal presentation/input levers without becoming an owner. Terminus keeps persistent requested simulation rate distinct from effective rate: compressed batches inspect authoritative facts after each fixed step, stop at the causal damage/destruction/cargo/thermal/docking/weapon/wormhole/run event, pause effectively without erasing the request, and project the exact cause and executed-step count through Eve. Docked, travelling, fighting, mining, looting, and refitting remain derived world conditions rather than counterfeit durable phases. Terminus scenario generation preserves `TutorialPassed`: entering a generated/proof arena cannot impersonate the missing tutorial-completion transaction | full-daemon death/restart witness, broader attention taxonomy (contact, targeting, readiness, arrival, discovery, and failed orders), multi-client convergence, daemon-owned tutorial generation/completion, and a source-backed game-mode completion rule; the fossil supplied no victory transition |
| Run/session state | `ServerShared`, `ActionGameManager` | Aetheria daemon/CultCache | provider/session advertisement plus run lifecycle bindings | all clients | active/failed lifecycle is daemon-owned and restart-stable: controlled-entity destruction commits the fossil cause, terminal frame, one `run.failed` fact, releases control, rejects later gameplay commands, and freezes the world; Eve receives terminal presentation/input levers without becoming an owner. Terminus keeps persistent requested simulation rate distinct from effective rate: compressed batches inspect authoritative facts after each fixed step, stop at the causal damage/destruction/cargo/thermal/docking/weapon/wormhole/run event, pause effectively without erasing the request, and project the exact cause and executed-step count through Eve. Docked, travelling, fighting, mining, looting, and refitting remain derived world conditions rather than counterfeit durable phases. The distinct tutorial topology is now daemon-owned and deterministic: authored 64-zone weighted sampling, Delaunay/link pruning, six faction roles/homes, influence, unowned entrance, and initial discovery run on Unity-compatible CultMath simplex semantics. It is deliberately not selectable yet because fossil zone bodies/entities are not materialized; Terminus generation preserves `TutorialPassed` and cannot impersonate completion | tutorial zone materialization, New Game selection, and a source-backed completion rule (none survived in the fossil); full-daemon death/restart witness, broader attention taxonomy (contact, targeting, readiness, arrival, discovery, and failed orders), and multi-client convergence |
| Entity identity/loadout | `ServerShared/Entity*` | daemon | typed entity and inventory surfaces | EveUnity/Electron | daemon-owned hull identity, exact installed placement/rotation and instance state, cargo stacks, weapon membership/held state, equipment wear, executable behavior state, weapon chronology, active-effect state, and loadout-generation provenance survive a hard CultCache close/reopen through the durable latest-frame checkpoint. An advertised generic Eve equip operation now translates into the typed daemon transaction before that checkpoint; its accepted placement survives restart and its consumed station-cargo source does not resurrect. Generic Eve rebuilds weapon and scalar thermostat controls from that restored truth; legacy per-entity documents are bootstrap inputs only when no live frame exists | live client refit witness and multi-client convergence |
| Loadout generation | map/faction availability, item cost, hull hardpoints and role constraints | daemon using canonical galaxy, faction knowledge/economy and typed catalog | generated loadout plus provenance/availability facts | Eve inspection and refit surfaces | daemon now uses the fossil candidate filter and seeded weighted selection: manufacturer must be present and allied, ally weight decays by home-zone graph distance, price uses the authored exponent, and hardpoints require matching semantic type, rotated footprint containment, and controller role; the April 2021 catalog fossil restores the distinct common `Longinus`, rare AetherDrive-compatible `LonginusX`, and `Djinni` rows with their original IDs, shapes, hardpoints, prices, drag, and prefabs, plus missing Thruster rows required to fill their authored mounts; typed `rarity:rare` tags apply a 0.05 generation multiplier to `LonginusX` and AetherDrive equipment; the generator owns one mutable CultMath stream, fixing the readonly-struct defensive-copy bug that restarted every weighted draw; placement rotation survives durable state and reconnect; generation fills the largest compatible footprint before economic weighting; cargo/capacitor plus station docking equipment must fit remaining hull space; stations pack faction-available inventory largest-first; ordinary ships begin without invented scenario cargo; every generated entity persists a source/selection receipt that reaches Eve as `loadout.item` semantics | multi-zone generated-world parity and exact root-to-zone stream sequencing against the fossil |
| Flight intent | `Ship`, `Thruster`, `AetherDrive`, `VelocityConversion`, `VelocityLimit`, input paths | daemon actuator simulation + Ymir integration | analog local helm/look/tractor plus actuator state | generic Eve input and scene lowerers | direct faction-speed velocity ownership is deleted; daemon persists local strafe/forward helm and desired look, allocates ordinary directional thruster banks by authored rotation, derives torque from hull/item placement, compensates lateral torque, gates execution through the shared energy ledger, deposits equipment-cell heat, publishes plume visibility and actuator state, applies hull drag, then runs operational velocity conversion with the fossil exponential damp equation and clamps the result through evaluated velocity limits before giving velocity/direction to Ymir. The evaluated limit remains in durable behavior state for reconnect and Eve inspection. Autonomous tasks translate world travel into the same local helm axes and issue look commands instead of mutating heading. AetherDrive rotor/coupling simulation is additive and explicitly non-default: common `Longinus` and `Djinni` hulls use ordinary thrusters; the rare `LonginusX` replaces those mounts with the single rare `Traction` AetherDrive path | hardpoint-transform direction parity, exact cross-equipment behavior execution ordering, CultMath/float32 parity harness, live production-frequency evidence, generic thrust/turn feedback lowering, and live handling screenshots |

View file

@ -130,6 +130,22 @@ latest complete leased frame, and reactive UI state is sampled at a bounded
publication cadence. Internal fixed steps remain deterministic and testable
without requiring a renderer or wall-clock delays.
## Tutorial Generation
The fossil tutorial was not a scripted objective mode. Its surviving gameplay
difference is a distinct generated galaxy selected while `TutorialPassed` is
false: six authored faction roles, 64 weighted blue-noise zones, a pruned
Delaunay link graph, faction influence and ownership, an unowned entrance, and
initial discovery of the entrance plus its neighbors. The narrative processor
was commented out and no source-backed tutorial-completion trigger survived.
That topology now has a deterministic daemon owner driven by CultMath's
Unity-compatible simplex noise. It remains a pre-materialization result until
the fossil zone-body/entity generator is migrated; the daemon must not present
an empty graph or the handcrafted Terminus proof arena as a completed tutorial.
Once materialized, New Game may select it from player settings and Eve may lower
its map and entry controls without becoming generation authority.
## Current Mechanism
The repo is mid-migration.