Merge origin/master (2403a5f3, gear-without-presence) into eureka/aetheria-release-retire-longinusx
Lands retire-longinusx on master after gear-without-presence. The catalog is regenerated from master's catalog with retire-longinusx apply (next commit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # GameData/Aetheria.cc
This commit is contained in:
commit
284231f4c1
49 changed files with 2770 additions and 180 deletions
|
|
@ -112,14 +112,10 @@ public abstract class Weapon : Behavior, IActivatedBehavior
|
|||
// so a weapon is never safed against one.
|
||||
public bool StanceAllowsFire => Entity.Target.Value.Entity == null || Entity.IsHostileTo(Entity.Target.Value.Entity);
|
||||
|
||||
// Cut 3 (docs/fire-control-cut.md, Q2): player fire is arc-gated like everyone else's. This is the same
|
||||
// predicate (FireControl.InArc) Combat.cs and TurretController.cs gate AI and turret fire with, read at
|
||||
// the one point every shooter's trigger passes through (InstantWeapon.Trigger) -- not a parallel check, and
|
||||
// not the AI's own AgentMinHitProbability heuristic, which is a "worth it" decision layered above this
|
||||
// gate, not the gate itself. With no target set, behaviour is unchanged (nothing to bear on).
|
||||
// Cut 5, 5.4 (Soul finding 11): the point-blank special case this used to carry moved into InArc itself --
|
||||
// one bearing test, one owner, so every caller (not just this one) gets a point-blank shot that bears.
|
||||
// FireControl owns the gate, including its one exemption: a fused weapon fires out of arc.
|
||||
// The trigger gate for player and AI shooters alike, read at the one point every shooter's trigger passes
|
||||
// through (InstantWeapon.Trigger). A gun fires on its solution (FireControl.Solution) without the aim, else
|
||||
// along the aim when the aim is inside its arc, else it holds. An AI shooter's aim is its intercept, so its
|
||||
// fire is unchanged. FireControl owns the gate, including its exemption: a fused weapon fires out of arc.
|
||||
public bool ArcAllowsFire => FireControl.ArcPermitsFire(this, Entity);
|
||||
|
||||
public Weapon(WeaponData data, EquippedItem item) : base(data, item)
|
||||
|
|
|
|||
|
|
@ -14,8 +14,13 @@ using int2 = CultMath.int2;
|
|||
|
||||
public abstract class Entity
|
||||
{
|
||||
// Every hull cell's temperature (kelvin) when the entity is built.
|
||||
public const float StartingCellTemperature = 280;
|
||||
|
||||
public Zone Zone;
|
||||
public Faction Faction;
|
||||
// The mark of a zone's boss, written only by ZoneGenerator and restored from the pack. RunGoal reads it.
|
||||
public bool IsBoss;
|
||||
public EquippableItem Hull;
|
||||
public EquippedItem EquippedHull;
|
||||
|
||||
|
|
@ -911,7 +916,7 @@ public abstract class Entity
|
|||
Armor[v.x, v.y] += Hardpoints[v.x, v.y].Armor;
|
||||
MaxArmor[v.x, v.y] += Hardpoints[v.x, v.y].Armor;
|
||||
}
|
||||
Temperature[v.x, v.y] = 280;
|
||||
Temperature[v.x, v.y] = StartingCellTemperature;
|
||||
ThermalMass[v.x, v.y] = hullData.Mass * hullData.SpecificHeat / cellCount;
|
||||
}
|
||||
GearOccupancy = new EquippedItem[hullData.Shape.Width, hullData.Shape.Height];
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ public static class EntitySerializer
|
|||
else throw new ArgumentException("Attempted to pack an instance of abstract class Entity!");
|
||||
|
||||
pack.Settings = entity.Settings;
|
||||
pack.Boss = entity.IsBoss;
|
||||
|
||||
pack.Hull = entity.Hull;
|
||||
pack.Name = entity.Name;
|
||||
|
|
@ -81,6 +82,7 @@ public static class EntitySerializer
|
|||
private static void Restore(ItemManager itemManager, Zone zone, EntityPack pack, Entity entity)
|
||||
{
|
||||
entity.Name = pack.Name;
|
||||
entity.IsBoss = pack.Boss;
|
||||
entity.Faction = itemManager.ItemData.Get(pack.Faction);
|
||||
entity.Children = pack.Children.Select(c =>
|
||||
{
|
||||
|
|
@ -187,6 +189,8 @@ public abstract class EntityPack
|
|||
[Key(14)] public EntitySettings Settings;
|
||||
[Key(15)] public CultRecordRef<Faction> Faction;
|
||||
[Key(16)] public int[][] WeaponGroups;
|
||||
// Keys 17-20 belong to the subclasses; the base's next free key is 21.
|
||||
[Key(21)] public bool Boss;
|
||||
|
||||
private int _price;
|
||||
|
||||
|
|
|
|||
|
|
@ -67,24 +67,18 @@ public static class FireControl
|
|||
return blastRadius > 0f ? data.Fuse : null;
|
||||
}
|
||||
|
||||
// The one arc gate on the trigger. A fused weapon fires whatever the arc says: a target it cannot bear on is
|
||||
// no reason to keep it silent, the round flies along the arc-clamped aim and bursts at the target's range
|
||||
// (Fire; operator ruling 2026-09-30, "the user is hoping that the arc gets the weapon close enough to still
|
||||
// splash some damage"). A weapon that only hits what it fires at still refuses. Fire reads InArc itself to
|
||||
// choose that flight, so the two share the one bearing test and differ only in this exemption.
|
||||
// Mining Cut 3: a chunk target bears through its own position, on the plane the bearing test reads.
|
||||
// The one gate on the trigger (operator rulings fire-on-solutions, controls-solution-trigger, 2026-10-06). A gun
|
||||
// fires on its solution without the aim, else free along the aim if the aim is inside its arc, else it holds.
|
||||
// A fused weapon fires whatever the arc says: a target it cannot bear on is no reason to keep it silent, the
|
||||
// round flies along the arc-clamped aim and bursts at the target's range (Fire; operator ruling 2026-09-30,
|
||||
// "the user is hoping that the arc gets the weapon close enough to still splash some damage"). A mine layer
|
||||
// lays a body, not a round, and is never arc-gated. The price of a free shot at a target that does not bear is
|
||||
// HitProbability's: zero.
|
||||
public static bool ArcPermitsFire(Weapon weapon, Entity shooter)
|
||||
{
|
||||
var target = shooter.Target.Value;
|
||||
if (target.IsNone) return true;
|
||||
float3 toTarget;
|
||||
if (target.Chunk is ChunkId chunk)
|
||||
{
|
||||
var at = shooter.Zone.ChunkPose(chunk.Field, chunk.Index).xy;
|
||||
toTarget = float3(at.x, 0, at.y) - shooter.Position;
|
||||
}
|
||||
else toTarget = target.Entity.Position - shooter.Position;
|
||||
return FuseOf(weapon.Item, out _) != null || IsMineLayer(weapon) || InArc(weapon.Item, toTarget);
|
||||
if (FuseOf(weapon.Item, out _) != null || IsMineLayer(weapon)) return true;
|
||||
var solution = Solution(weapon, shooter, shooter.Target.Value);
|
||||
return solution.Bears || solution.Free;
|
||||
}
|
||||
|
||||
// Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so
|
||||
|
|
@ -131,6 +125,33 @@ public static class FireControl
|
|||
return float2(mount.x * cos(turn) - mount.y * sin(turn), mount.x * sin(turn) + mount.y * cos(turn));
|
||||
}
|
||||
|
||||
// The one owner of a gun's solution against its subject (the designated target; ruling controls-solution-subject).
|
||||
// Bears: the gun can engage the subject, so the player fires on it without the aim. Free: the aim is inside the
|
||||
// arc, so the gun can fire along it. Direction: where a round from this gun travels and where its barrel points --
|
||||
// the subject's intercept when it bears, else the arc-clamped aim. A chunk subject bears through its pose and
|
||||
// flies the aim, as an untargeted round (Subject is null for it). Derived on every read and never stored: turrets
|
||||
// traverse instantly in the simulation. Traverse speed belongs to the link (ruling arc-and-traverse-on-the-link),
|
||||
// which will make this stateful behind the same signature. It never prices; HitProbability is the one price.
|
||||
public static GunSolution Solution(Weapon weapon, Entity shooter, TargetRef subject)
|
||||
{
|
||||
var bears = false;
|
||||
if (subject.Entity != null) bears = Bears(weapon, shooter, subject.Entity, out _);
|
||||
else if (subject.Chunk is ChunkId chunk)
|
||||
{
|
||||
var at = shooter.Zone.ChunkPose(chunk.Field, chunk.Index).xy;
|
||||
bears = InArc(weapon.Item, float3(at.x, 0, at.y) - shooter.Position);
|
||||
}
|
||||
return new GunSolution
|
||||
{
|
||||
Subject = subject.Entity,
|
||||
Bears = bears,
|
||||
Free = InArc(weapon.Item, shooter.Aim),
|
||||
Direction = bears && subject.Entity != null
|
||||
? TravelDirection(weapon, shooter, subject.Entity)
|
||||
: AimDirection(weapon.Item, shooter)
|
||||
};
|
||||
}
|
||||
|
||||
// Cut 2, R5: whether `observer` has resolved `item` (which must belong to some entity in the zone) well
|
||||
// enough to aim at it. Derived fresh from EntityInfoGathered on every call -- nothing caches a reveal
|
||||
// result, so decay drops it the moment info falls back below tier, with no clearing loop anywhere.
|
||||
|
|
@ -276,21 +297,26 @@ public static class FireControl
|
|||
!(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked);
|
||||
}
|
||||
|
||||
// `designated` is the answer above. `inArc` is the bearing test, asked only once a target is designated.
|
||||
// Whether this gun can engage this target: designated and inside the arc. The one test of it; PFire prices a shot
|
||||
// at zero unless it holds, and Solution reads it to decide whether the gun fires on the target. `designated` is
|
||||
// the answer above, kept apart for Solve's burst flight: a designated target out of arc still sets the burst range.
|
||||
public static bool Bears(Weapon weapon, Entity source, Entity target, out float range) =>
|
||||
Bears(weapon, source, target, out range, out _);
|
||||
|
||||
private static bool Bears(Weapon weapon, Entity source, Entity target, out float range, out bool designated)
|
||||
{
|
||||
designated = Designated(weapon, source, target, out range);
|
||||
return designated && InArc(weapon.Item, target.Position - source.Position);
|
||||
}
|
||||
|
||||
// `designated` is the answer above. `inArc` is Bears, asked only once a target is designated.
|
||||
// PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at
|
||||
// (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but
|
||||
// out of arc it bursts at the target's range, along the aim. The HUD reads Designated and the
|
||||
// arc from Solve and Inspect and decides nothing.
|
||||
private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc)
|
||||
{
|
||||
range = 0f;
|
||||
designated = false;
|
||||
inArc = false;
|
||||
if (target == null) return 0f;
|
||||
|
||||
designated = Designated(weapon, source, target, out range);
|
||||
if (!designated) return 0f;
|
||||
inArc = InArc(weapon.Item, target.Position - source.Position);
|
||||
inArc = Bears(weapon, source, target, out range, out designated);
|
||||
if (!inArc) return 0f;
|
||||
|
||||
var settings = source.ItemManager.GameplaySettings;
|
||||
|
|
@ -615,7 +641,7 @@ public static class FireControl
|
|||
// burst's later rounds and a power-starved Range included).
|
||||
var engaged = fuse != null && !(designated && inArc) ? null : target;
|
||||
var origin = source.Position;
|
||||
var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source);
|
||||
var travelDirection = Solution(weapon, source, target).Direction;
|
||||
|
||||
var arming = fuse != null ? blastRadius : 0f;
|
||||
var burstPosition = engaged != null ? engaged.Position : origin;
|
||||
|
|
@ -1989,6 +2015,16 @@ public enum FireOutcome
|
|||
Refused
|
||||
}
|
||||
|
||||
// A gun's solution against its subject (FireControl.Solution): whether the gun bears on the subject, whether the
|
||||
// aim is free (inside the arc), and the direction a round from it travels.
|
||||
public struct GunSolution
|
||||
{
|
||||
public Entity Subject;
|
||||
public bool Bears;
|
||||
public bool Free;
|
||||
public float2 Direction;
|
||||
}
|
||||
|
||||
// Solve's answer. Outcome and BurstReach (planar distance from the shooter to the burst point, read for Burst
|
||||
// only) are what the HUD shows; the rest is what Fire freezes into the PendingShot.
|
||||
public struct FireSolution
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ public class Galaxy
|
|||
public Faction[] Factions { get; }
|
||||
public GalaxyZone[] Zones { get; }
|
||||
public GalaxyZone Entrance { get; }
|
||||
public GalaxyZone Exit { get; }
|
||||
public GalaxyZone Exit { get; private set; }
|
||||
public Dictionary<Faction, FactionRelationship> FactionRelationships { get; } = new Dictionary<Faction, FactionRelationship>();
|
||||
private Action<string> Log { get; }
|
||||
public bool IsPrelude { get; }
|
||||
|
|
@ -33,7 +33,6 @@ public class Galaxy
|
|||
// restored from a save.
|
||||
public uint Seed { get; }
|
||||
|
||||
private HashSet<CultRecordKey> _containedFactions;
|
||||
private GalaxyZone[] _exitPath;
|
||||
private Dictionary<Faction, MarkovNameGenerator> _nameGenerators = new Dictionary<Faction, MarkovNameGenerator>();
|
||||
private readonly CultCache _cache;
|
||||
|
|
@ -143,6 +142,18 @@ public class Galaxy
|
|||
return _cache.GetAll<Faction>().FirstOrDefault(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
|
||||
// A cast member of a prelude galaxy, by name. A name the catalog does not hold refuses the galaxy, naming the field.
|
||||
private Faction ResolveCast(string field, string name) =>
|
||||
ResolveFaction(name) ?? throw new InvalidOperationException($"cast faction {name} ({field}) is not in the catalog");
|
||||
|
||||
// Gives a prelude galaxy its gate: the antagonist's home is the boss zone and the exit. The only writer of Exit
|
||||
// besides the constructors.
|
||||
public void PlaceGate(Faction antagonist)
|
||||
{
|
||||
BossZones[antagonist] = HomeZones[antagonist];
|
||||
Exit = HomeZones[antagonist];
|
||||
}
|
||||
|
||||
// How many jumps a faction's influence reaches in this galaxy: its authored distance, halved (rounding up) in a
|
||||
// prelude. Derived, never written back to the catalog's Faction, so one galaxy's generation cannot change the next.
|
||||
public int InfluenceOf(Faction faction) =>
|
||||
|
|
@ -169,20 +180,23 @@ public class Galaxy
|
|||
|
||||
var factions = new List<Faction>();
|
||||
|
||||
var protagonistFaction = ResolveFaction(settings.ProtagonistFaction);
|
||||
var protagonistFaction = ResolveCast(nameof(settings.ProtagonistFaction), settings.ProtagonistFaction);
|
||||
factions.Add(protagonistFaction);
|
||||
|
||||
var antagonistFaction = ResolveFaction(settings.AntagonistFaction);
|
||||
var antagonistFaction = ResolveCast(nameof(settings.AntagonistFaction), settings.AntagonistFaction);
|
||||
factions.Add(antagonistFaction);
|
||||
|
||||
var bufferFaction = ResolveFaction(settings.BufferFaction);
|
||||
var bufferFaction = ResolveCast(nameof(settings.BufferFaction), settings.BufferFaction);
|
||||
factions.Add(bufferFaction);
|
||||
|
||||
var questFaction = ResolveFaction(settings.QuestFaction);
|
||||
factions.Add(questFaction);
|
||||
// A cast without a quest faction places no quest zone.
|
||||
var questFaction = string.IsNullOrEmpty(settings.QuestFaction)
|
||||
? null
|
||||
: ResolveCast(nameof(settings.QuestFaction), settings.QuestFaction);
|
||||
if (questFaction != null) factions.Add(questFaction);
|
||||
|
||||
var neutralFactions = settings.NeutralFactions
|
||||
.Select(ResolveFaction)
|
||||
.Select(name => ResolveCast(nameof(settings.NeutralFactions), name))
|
||||
.ToArray();
|
||||
factions.AddRange(neutralFactions);
|
||||
|
||||
|
|
@ -223,17 +237,20 @@ public class Galaxy
|
|||
|
||||
CalculateFactionInfluence(progressCallback);
|
||||
|
||||
var potentialQuestZones = Zones
|
||||
.Where(z => z.Factions.Contains(antagonistFaction) && z.Factions.Contains(bufferFaction));
|
||||
if (potentialQuestZones.Any())
|
||||
HomeZones[questFaction] = potentialQuestZones
|
||||
.MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, InfluenceOf(questFaction)).Count);
|
||||
else
|
||||
HomeZones[questFaction] = Zones
|
||||
.Where(z => z.Factions.Contains(antagonistFaction))
|
||||
.MinBy(z=>z.Distance[HomeZones[bufferFaction]]);
|
||||
|
||||
CalculateFactionInfluence(progressCallback);
|
||||
if (questFaction != null)
|
||||
{
|
||||
var potentialQuestZones = Zones
|
||||
.Where(z => z.Factions.Contains(antagonistFaction) && z.Factions.Contains(bufferFaction));
|
||||
if (potentialQuestZones.Any())
|
||||
HomeZones[questFaction] = potentialQuestZones
|
||||
.MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, InfluenceOf(questFaction)).Count);
|
||||
else
|
||||
HomeZones[questFaction] = Zones
|
||||
.Where(z => z.Factions.Contains(antagonistFaction))
|
||||
.MinBy(z=>z.Distance[HomeZones[bufferFaction]]);
|
||||
|
||||
CalculateFactionInfluence(progressCallback);
|
||||
}
|
||||
|
||||
Entrance = Zones.Where(z => z.Owner == null).MinBy(z => z.Distance[HomeZones[protagonistFaction]]);
|
||||
|
||||
|
|
@ -432,12 +449,6 @@ public class Galaxy
|
|||
}
|
||||
}
|
||||
|
||||
public bool ContainsFaction(CultRecordRef<Faction> faction)
|
||||
{
|
||||
_containedFactions ??= new HashSet<CultRecordKey>(Factions.Select(f => _cache.RefOf(f).Key));
|
||||
return _containedFactions.Contains(faction.Key);
|
||||
}
|
||||
|
||||
class DijkstraVertex
|
||||
{
|
||||
public float Cost;
|
||||
|
|
|
|||
|
|
@ -68,7 +68,10 @@ public sealed class GalaxyStage
|
|||
}
|
||||
|
||||
// A prelude galaxy: the tutorial settings and their fixed factions, centred in dense cloud.
|
||||
public Galaxy Prelude()
|
||||
public Galaxy Prelude() => Prelude(_prelude);
|
||||
|
||||
// A prelude galaxy of the given fixed cast, centred in dense cloud.
|
||||
public Galaxy Prelude(TutorialGenerationSettings cast)
|
||||
{
|
||||
var background = Copy(_preludeBackground);
|
||||
var random = new Random(Seed);
|
||||
|
|
@ -78,7 +81,7 @@ public sealed class GalaxyStage
|
|||
background.NoisePosition = random.NextFloat() * 1000;
|
||||
_progress?.Invoke($"Finding Galaxy Position: iteration {iteration++}");
|
||||
} while (background.CloudDensity(float2(.5f)) < .5f);
|
||||
return new Galaxy(_prelude, background, _names, _cache, _playerSettings, _narrativeDirectory, _log, _progress, Seed);
|
||||
return new Galaxy(cast, background, _names, _cache, _playerSettings, _narrativeDirectory, _log, _progress, Seed);
|
||||
}
|
||||
|
||||
private static SectorBackgroundSettings Copy(SectorBackgroundSettings settings) =>
|
||||
|
|
|
|||
|
|
@ -221,6 +221,11 @@ public class ItemManager
|
|||
return (ItemData.Get(maker), product);
|
||||
}
|
||||
|
||||
// What the player reads an item as: its product's name when its lot's maker has a product of this design,
|
||||
// otherwise the design's own name (unbranded, console-given and extracted lots, and commodities).
|
||||
public string Title(ItemInstance item) =>
|
||||
(item is CraftedItemInstance crafted ? Brand(crafted).Product?.Name : null) ?? GetData(item).Name;
|
||||
|
||||
public (RarityTier tier, int upgrades) GetTier(CraftedItemInstance item)
|
||||
{
|
||||
var quality = GetLot(item).Quality;
|
||||
|
|
|
|||
|
|
@ -140,6 +140,18 @@ public class LoadoutGenerator
|
|||
// required item falls back to any manufacturer when none of those make one, which means the product table
|
||||
// lacks variety, so it is logged rather than hidden.
|
||||
public (FactionProductData product, T design)[] RandomProducts<T>(int count, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
|
||||
{
|
||||
var (available, preferManufacturers) = AvailableProducts(filter, required);
|
||||
return available.WeightedRandomElements(ref Random, entry =>
|
||||
(preferManufacturers ? ManufacturerPreference(entry.product.Manufacturer) : 1) *
|
||||
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
|
||||
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
|
||||
count);
|
||||
}
|
||||
|
||||
// The products this faction can be offered for a design kind, and whether manufacturer preference applies (it does
|
||||
// not once a required item has fallen back to any manufacturer). The one availability rule for every selection.
|
||||
private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts<T>(Predicate<T> filter, bool required) where T : EquippableItemData
|
||||
{
|
||||
var hulls = ItemManager.ItemData.GetAll<HullData>().ToArray();
|
||||
var candidates = ItemManager.ItemData.GetAll<FactionProductData>()
|
||||
|
|
@ -160,11 +172,27 @@ public class LoadoutGenerator
|
|||
preferManufacturers = false;
|
||||
}
|
||||
|
||||
return available.WeightedRandomElements(ref Random, entry =>
|
||||
(preferManufacturers ? ManufacturerPreference(entry.product.Manufacturer) : 1) *
|
||||
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
|
||||
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
|
||||
count);
|
||||
return (available, preferManufacturers);
|
||||
}
|
||||
|
||||
// A faction's boss: its BossHull when the faction sets one and a product sells it, else the most expensive ship hull
|
||||
// the faction can be offered, with the gap logged. Null only when no ship hull is on offer at all.
|
||||
public EntityPack GenerateBossLoadout()
|
||||
{
|
||||
var bossHull = Faction != null && Faction.BossHull.IsSet() ? ItemManager.ItemData.Get(Faction.BossHull) : null;
|
||||
if (bossHull != null)
|
||||
{
|
||||
var boss = GenerateShipLoadout(hull => hull == bossHull);
|
||||
if (boss != null) return boss;
|
||||
ItemManager.Log($"{Faction.Name}'s BossHull {bossHull.Name} is not sold by any product; its boss flies the most expensive ship hull instead.");
|
||||
}
|
||||
else
|
||||
ItemManager.Log($"{Faction?.Name ?? "This faction"} has no BossHull; its boss flies the most expensive ship hull instead.");
|
||||
|
||||
var (available, _) = AvailableProducts<HullData>(hull => hull.HullType == HullType.Ship, required: true);
|
||||
if (available.Length == 0) return null;
|
||||
var priciest = available.OrderByDescending(entry => entry.design.Price).ThenBy(entry => entry.design.Name, StringComparer.Ordinal).First().design;
|
||||
return GenerateShipLoadout(hull => hull == priciest);
|
||||
}
|
||||
|
||||
// Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a
|
||||
|
|
@ -174,26 +202,30 @@ public class LoadoutGenerator
|
|||
design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull ||
|
||||
hulls.Any(hull => hull.Hardpoints.Any(hardpoint => hardpoint.Takes(design)));
|
||||
|
||||
// No galaxy means no availability to filter by: every product is on offer. A fixture generates loadouts that
|
||||
// way, so a test can exercise placement and products without standing up a whole galaxy; no game path does.
|
||||
// Loadouts.Materialize takes availability as a predicate; RunStart, the game's one preset spawner, passes this, so
|
||||
// presets and generation share one availability rule. In a galaxy, a faction always reaches its own manufacturer's
|
||||
// gear, and otherwise gear made by a manufacturer in that galaxy that its allegiance names (operator, 2026-09-30:
|
||||
// allegiance lists only other factions).
|
||||
// No galaxy, or no faction, means no allegiance to filter by: every product is on offer. A fixture generates
|
||||
// loadouts that way, so a test can exercise placement and products without standing up a whole galaxy; a scenario
|
||||
// stage has no faction and reaches every maker. Loadouts.Materialize takes availability as a predicate; RunStart,
|
||||
// the game's one preset spawner, passes this, so presets and generation share one availability rule. A faction
|
||||
// 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).
|
||||
public bool IsAvailable(FactionProductData product) =>
|
||||
Galaxy == null || Galaxy.IsPrelude ||
|
||||
Faction != null && ItemManager.ItemData.Get(product.Manufacturer) == Faction ||
|
||||
Galaxy.ContainsFaction(product.Manufacturer) && (Faction == null || Faction.Allegiance.ContainsKey(product.Manufacturer));
|
||||
Galaxy == null || Faction == null ||
|
||||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer);
|
||||
|
||||
// Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters
|
||||
private float ManufacturerPreference(CultRecordRef<Faction> manufacturer)
|
||||
// Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters.
|
||||
// A manufacturer with no home zone in this galaxy weighs as if its headquarters lay one jump beyond the farthest
|
||||
// zone this zone reaches: reachable through allegiance, least preferred.
|
||||
public float ManufacturerPreference(CultRecordRef<Faction> manufacturer)
|
||||
{
|
||||
if (Faction == null || Galaxy == null) return 1;
|
||||
var maker = ItemManager.ItemData.Get(manufacturer);
|
||||
var allegiance = maker == Faction ? 1 :
|
||||
Faction.Allegiance.TryGetValue(manufacturer, out var a) ? a : 0;
|
||||
var home = maker != null && Galaxy.HomeZones.TryGetValue(maker, out var h) ? h : null;
|
||||
var distance = home != null && Zone?.Distance != null && Zone.Distance.TryGetValue(home, out var d) ? d : 0;
|
||||
var distance = Zone?.Distance == null ? 0 :
|
||||
home != null && Zone.Distance.TryGetValue(home, out var d) ? d :
|
||||
home == null && Zone.Distance.Count > 0 ? Zone.Distance.Values.Max() + 1 : 0;
|
||||
return allegiance / (1 + distance);
|
||||
}
|
||||
|
||||
|
|
|
|||
42
Assets/Scripts/ServerShared/RunGoal.cs
Normal file
42
Assets/Scripts/ServerShared/RunGoal.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
using System.Linq;
|
||||
using CultMath;
|
||||
using static CultMath.math;
|
||||
|
||||
// The run's goal: whether a zone's boss lives, and so whether the exit gate is open. Engine-free and stateless; the
|
||||
// answer is derived from Zone.Entities (the death path removes the dead, Zone.cs), so no "boss defeated" flag exists to
|
||||
// disagree with it, and no presentation decides it.
|
||||
public static class RunGoal
|
||||
{
|
||||
public static Entity Boss(Zone zone) => zone.Entities.FirstOrDefault(entity => entity.IsBoss);
|
||||
|
||||
public static bool BossAlive(Zone zone) => Boss(zone) != null;
|
||||
|
||||
// The exit gate fails closed: it is open only in the galaxy's exit zone, once a boss was spawned there and no living
|
||||
// boss remains. An exit zone that never got a boss stays sealed, so a missing boss can never read as a dead one.
|
||||
public static bool ExitOpen(Zone zone) =>
|
||||
zone.Galaxy?.Exit != null && zone.GalaxyZone == zone.Galaxy.Exit && zone.Pack.BossSpawned && !BossAlive(zone);
|
||||
|
||||
// Where the exit gate sits: at the adjacency wormholes' radius (the zone's radius times the ratio), in the middle of
|
||||
// the widest angular gap between them, so it shares a point with none. A zone with no neighbours gets the +x axis.
|
||||
public static float2 ExitGatePosition(Zone zone, float wormholeDistanceRatio)
|
||||
{
|
||||
var radius = zone.Pack.Radius * wormholeDistanceRatio;
|
||||
var angles = zone.GalaxyZone.AdjacentZones
|
||||
.Select(adjacent => normalize(adjacent.Position - zone.GalaxyZone.Position))
|
||||
.Select(direction => atan2(direction.y, direction.x))
|
||||
.OrderBy(angle => angle)
|
||||
.ToArray();
|
||||
if (angles.Length == 0) return float2(radius, 0);
|
||||
|
||||
var bestAngle = 0f;
|
||||
var bestGap = -1f;
|
||||
for (var i = 0; i < angles.Length; i++)
|
||||
{
|
||||
var next = i + 1 < angles.Length ? angles[i + 1] : angles[0] + 2 * PI;
|
||||
if (next - angles[i] <= bestGap) continue;
|
||||
bestGap = next - angles[i];
|
||||
bestAngle = angles[i] + bestGap / 2;
|
||||
}
|
||||
return float2(cos(bestAngle), sin(bestAngle)) * radius;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/ServerShared/RunGoal.cs.meta
Normal file
2
Assets/Scripts/ServerShared/RunGoal.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: de03c3545cb04ce38bf85b713dc5df74
|
||||
|
|
@ -4,8 +4,9 @@ using static ItemRotation;
|
|||
public sealed class Arcs : Scenario
|
||||
{
|
||||
public override string Name => "Arcs";
|
||||
public override string Brief => "Forward mounts, a bare hull off the bow, one off the stern, a hostile turret. Verify: the bow " +
|
||||
"hull can be hit, nothing fires at the stern one through the ship, the turret tracks you all the way round.";
|
||||
public override string Brief => "Forward mounts, a bare hull off the bow, one off the stern, 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 " +
|
||||
"aim anywhere: the forward guns fire on it and hit. The turret tracks you all the way round.";
|
||||
public override uint Seed => 103;
|
||||
public override bool Ambient => false;
|
||||
|
||||
|
|
|
|||
41
Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs
Normal file
41
Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using float2 = CultMath.float2;
|
||||
|
||||
// The demo: one gate, one region and one boss, with a fixed cast. The Pirates are the protagonist's ostensible
|
||||
// employer, Zhestokost the antagonist whose home is the boss zone and the exit, Lucent Media and Aeronautics Unlimited
|
||||
// the neutrals. A prelude galaxy of that cast; the player, who is not a Pirate, flies the base Longinus.
|
||||
public sealed class DemoTerminus : Scenario
|
||||
{
|
||||
public static readonly TutorialGenerationSettings Cast = new TutorialGenerationSettings
|
||||
{
|
||||
ProtagonistFaction = "Pirates",
|
||||
AntagonistFaction = "Zhe",
|
||||
BufferFaction = "Luc",
|
||||
NeutralFactions = new[] { "Aero" },
|
||||
QuestFaction = null,
|
||||
ZoneCount = 64,
|
||||
LinkDensity = .5f
|
||||
};
|
||||
|
||||
public override string Name => "Terminus (Demo)";
|
||||
public override string Brief => "One gate, one region, one boss. Allied: the Pirates. Opposed: Zhestokost. " +
|
||||
"Neutral: Lucent Media and Aeronautics Unlimited.";
|
||||
|
||||
public override Galaxy Generate(GalaxyStage stage)
|
||||
{
|
||||
var galaxy = stage.Prelude(Cast);
|
||||
galaxy.PlaceGate(galaxy.ResolveFaction(Cast.AntagonistFaction));
|
||||
return galaxy;
|
||||
}
|
||||
|
||||
public override void Stage(ScenarioStage stage)
|
||||
{
|
||||
var galaxy = stage.Galaxy;
|
||||
var pirates = galaxy.ResolveFaction(Cast.ProtagonistFaction);
|
||||
galaxy.FactionRelationships[pirates] = FactionRelationship.Friendly;
|
||||
galaxy.FactionRelationships[galaxy.ResolveFaction(Cast.AntagonistFaction)] = FactionRelationship.Hated;
|
||||
galaxy.FactionRelationships[galaxy.ResolveFaction(Cast.BufferFaction)] = FactionRelationship.Neutral;
|
||||
foreach (var neutral in Cast.NeutralFactions)
|
||||
galaxy.FactionRelationships[galaxy.ResolveFaction(neutral)] = FactionRelationship.Neutral;
|
||||
stage.Player(stage.Generated("Longinus"), float2.zero);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5ba7152d3c8b46a98924a4148be550db
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
// Every way to set up a new run, in the order New Game lists them. Modes are the game; Tests are arenas built to check
|
||||
// one system each, listed only in editor and development builds.
|
||||
// Every way to set up a new run, in the order New Game lists them. Modes are what a release build lists; Development
|
||||
// are the other ways the game sets up a run and Tests are arenas built to check one system each, both listed only in
|
||||
// editor and development builds.
|
||||
public static class Scenarios
|
||||
{
|
||||
public static readonly Scenario[] Modes = { new TutorialGalaxy(), new MainGalaxy() };
|
||||
public static readonly Scenario[] Modes = { new DemoTerminus() };
|
||||
|
||||
public static readonly Scenario[] Development = { new TutorialGalaxy(), new MainGalaxy() };
|
||||
|
||||
public static readonly Scenario[] Tests =
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@ public class Wormhole
|
|||
{
|
||||
public float2 Position;
|
||||
public GalaxyZone Target;
|
||||
// The run's exit gate: it leads nowhere (Target is null) and ends the run when RunGoal.ExitOpen.
|
||||
public bool Exit;
|
||||
}
|
||||
|
|
@ -190,6 +190,7 @@ public class Zone
|
|||
Orbits = Orbits.Keys.Select(key => new CultRecordRef<OrbitData>(key)).ToList(),
|
||||
Planets = Planets.Keys.Select(key => new CultRecordRef<BodyData>(key)).ToList(),
|
||||
Time = _time,
|
||||
BossSpawned = Pack.BossSpawned,
|
||||
// Cut 2 (docs/mining-cut.md): drop entries that carry no live information -- healed (no damage) and
|
||||
// not currently broken -- so a save never accumulates wear rows for chunks nobody has touched since
|
||||
// they last respawned.
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ public class ZonePack
|
|||
// default instance.
|
||||
[JsonProperty("chunkWear"), Key(6)]
|
||||
public List<ChunkWearPack> ChunkWear;
|
||||
|
||||
// True once this zone's boss has been spawned. The dead boss leaves no entity, so this is the one fact that tells a
|
||||
// slain boss from one that never existed; RunGoal.ExitOpen reads it. Written only by ZoneGenerator, carried by PackZone.
|
||||
[JsonProperty("bossSpawned"), Key(7)]
|
||||
public bool BossSpawned;
|
||||
}
|
||||
|
||||
// Cut 2 (docs/mining-cut.md): one packed wear entry for one chunk. "Field" names the body that owns the chunk
|
||||
|
|
|
|||
|
|
@ -337,6 +337,24 @@ public static class ZoneGenerator
|
|||
if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2);
|
||||
}
|
||||
|
||||
// A boss zone gets its faction's boss whether or not the zone is ambient: the boss is the run's goal, not
|
||||
// wandering traffic. RunGoal reads the mark; nothing else decides whether a boss lives.
|
||||
foreach (var bossFaction in galaxy.BossZones.Where(entry => entry.Value == galaxyZone).Select(entry => entry.Key))
|
||||
{
|
||||
var boss = GetLoadoutGenerator(bossFaction).GenerateBossLoadout();
|
||||
if (boss == null)
|
||||
{
|
||||
itemManager.Log($"No boss could be generated for {bossFaction.Name}; its boss zone has no boss to kill!");
|
||||
continue;
|
||||
}
|
||||
boss.Boss = true;
|
||||
pack.Entities.Add(boss);
|
||||
pack.BossSpawned = true;
|
||||
}
|
||||
|
||||
if (galaxyZone == galaxy.Exit && !pack.BossSpawned)
|
||||
itemManager.Log("Galaxy.Exit has no boss: Galaxy.BossZones does not map a boss faction to the exit zone, or its boss could not be generated (ZoneGenerator exit-without-boss). The exit gate stays sealed.");
|
||||
|
||||
if (!ambient) return pack;
|
||||
|
||||
var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount;
|
||||
|
|
|
|||
Loading…
Reference in a new issue