diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 853bea91e..4bc82d89c 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -16,6 +16,8 @@ public abstract class Entity { 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; diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 6fb749b29..7f934b937 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -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; [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; diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 2fb2a69f9..42f43033e 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -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(int count, float sizeExponent, Predicate 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(Predicate filter, bool required) where T : EquippableItemData { var hulls = ItemManager.ItemData.GetAll().ToArray(); var candidates = ItemManager.ItemData.GetAll() @@ -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(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 diff --git a/Assets/Scripts/ServerShared/RunGoal.cs b/Assets/Scripts/ServerShared/RunGoal.cs new file mode 100644 index 000000000..966789a36 --- /dev/null +++ b/Assets/Scripts/ServerShared/RunGoal.cs @@ -0,0 +1,41 @@ +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 is open in the galaxy's exit zone once that zone holds no living boss. + public static bool ExitOpen(Zone zone) => + zone.Galaxy?.Exit != null && zone.GalaxyZone == zone.Galaxy.Exit && !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; + } +} diff --git a/Assets/Scripts/ServerShared/RunGoal.cs.meta b/Assets/Scripts/ServerShared/RunGoal.cs.meta new file mode 100644 index 000000000..2e3839fad --- /dev/null +++ b/Assets/Scripts/ServerShared/RunGoal.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: de03c3545cb04ce38bf85b713dc5df74 diff --git a/Assets/Scripts/ServerShared/Wormhole.cs b/Assets/Scripts/ServerShared/Wormhole.cs index bad43b9fd..fd93e51ca 100644 --- a/Assets/Scripts/ServerShared/Wormhole.cs +++ b/Assets/Scripts/ServerShared/Wormhole.cs @@ -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; } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 2ff4fe005..40078c562 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -337,6 +337,20 @@ 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); + } + if (!ambient) return pack; var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount; diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs new file mode 100644 index 000000000..897d647aa --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using CultMath; +using UniRx; +using Xunit; + +// The demo's goal (aetheria-release, cut boss-gate): the boss zone spawns its faction's boss, and the exit gate opens +// only when that boss is dead. Every input is the production one: the demo galaxy, GenerateZone, the Zone death path, +// and the run save. +public sealed partial class RunStartTests +{ + private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true) + { + items.Random = new CultMath.Random(7); + return ZoneGenerator.GenerateZone(items, _zoneSettings, galaxy, zone, galaxy.IsPrelude, ambient); + } + + private Zone ZoneOf(Galaxy galaxy, GalaxyZone zone) => + new Zone(_items, _planetSettings, BossZonePack(galaxy, _items, zone), zone, galaxy); + + private static Faction Zhestokost(Galaxy galaxy) => galaxy.Factions.Single(faction => faction.Name == "Zhestokost"); + + private ItemManager LoggingItems(List log) => + new ItemManager(_cache, new ProvenanceLedger(), AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings"), log.Add); + + private static string HullName(ItemManager items, EntityPack pack) => items.GetData(pack.Hull).Name; + + private HullData[] SoldShipHulls() => _cache.GetAll() + .Where(product => product.Manufacturer.IsSet()) + .Select(product => _cache.Get(product.Design) as HullData) + .Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0) + .Distinct().ToArray(); + + [Fact] + public void Boss_spawns_once_in_boss_zone() + { + AddPirates(); + foreach (var seed in DemoSeeds.Take(3)) + { + var galaxy = DemoGalaxy(seed); + var antagonist = Zhestokost(galaxy); + foreach (var zone in galaxy.Zones) + { + var bosses = BossZonePack(galaxy, _items, zone).Entities.Where(entity => entity.Boss).ToArray(); + if (zone != galaxy.Exit) { Assert.Empty(bosses); continue; } + + var boss = Assert.Single(bosses); + Assert.IsType(boss); + Assert.Same(antagonist, _cache.Get(boss.Faction)); + // Generation is repeatable per zone: the same zone yields the same boss hull. + var again = Assert.Single(BossZonePack(galaxy, _items, zone).Entities, entity => entity.Boss); + Assert.Equal(HullName(_items, boss), HullName(_items, again)); + } + } + } + + // A zone that is not ambient still has its boss: the boss is the goal, not traffic. + [Fact] + public void Boss_spawns_in_a_zone_that_is_not_ambient() + { + AddPirates(); + var galaxy = DemoGalaxy(1); + var pack = BossZonePack(galaxy, _items, galaxy.Exit, ambient: false); + Assert.Single(pack.Entities, entity => entity.Boss); + Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack); + } + + [Fact] + public void Boss_hull_from_BossHull_else_fallback() + { + AddPirates(); + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var hulls = SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).ToArray(); + var priciest = hulls.First(); + var cheaper = hulls.Last(); + Assert.True(cheaper.Price < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart"); + + // Set and sold: the boss flies it, and nothing is logged about a gap. + antagonist.BossHull = _cache.RefOf(cheaper); + var log = new List(); + var items = LoggingItems(log); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal(cheaper.Name, HullName(items, boss)); + Assert.DoesNotContain(log, line => line.Contains("BossHull")); + + // Unset: the most expensive ship hull on offer, and the log names the missing BossHull. + antagonist.BossHull = default; + log.Clear(); + boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal(priciest.Name, HullName(items, boss)); + Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("no BossHull")); + } + + // Set but sold by no product: the boss falls back the same way, and the log says which hull has no seller. + [Fact] + public void Boss_hull_nobody_sells_falls_back() + { + AddPirates(); + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var sold = SoldShipHulls(); + var unsold = _cache.GetAll().FirstOrDefault(hull => hull.HullType == HullType.Ship && !sold.Contains(hull)); + Assert.True(unsold != null, "the catalog needs a ship hull no product sells for this test"); + antagonist.BossHull = _cache.RefOf(unsold); + var log = new List(); + var items = LoggingItems(log); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal(sold.OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First().Name, HullName(items, boss)); + Assert.Contains(log, line => line.Contains(unsold.Name) && line.Contains("BossHull")); + } + + [Fact] + public void Exit_sealed_until_boss_dies() + { + AddPirates(); + var galaxy = DemoGalaxy(1); + var exit = ZoneOf(galaxy, galaxy.Exit); + var boss = Assert.Single(exit.Entities, entity => entity.IsBoss); + Assert.True(RunGoal.BossAlive(exit)); + Assert.False(RunGoal.ExitOpen(exit)); + + // Death removes the boss from the zone (the one death path), and that alone opens the gate. + boss.HeatstrokeDeath.OnNext(Unit.Default); + Assert.DoesNotContain(boss, exit.Entities); + Assert.False(RunGoal.BossAlive(exit)); + Assert.True(RunGoal.ExitOpen(exit)); + + // No other zone is ever an exit, boss or no boss. + foreach (var zone in galaxy.Zones.Where(zone => zone != galaxy.Exit)) + Assert.False(RunGoal.ExitOpen(ZoneOf(galaxy, zone))); + } + + // Continue: the mark is saved with the zone and restored, so a boss alive when the run was saved is alive after. + [Fact] + public void Boss_mark_survives_continue() + { + AddPirates(); + var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + galaxy.Entrance.Contents = arena; + galaxy.Exit.Contents = ZoneOf(galaxy, galaxy.Exit); + var before = Assert.Single(galaxy.Exit.Contents.Entities, entity => entity.IsBoss); + Assert.False(RunGoal.ExitOpen(galaxy.Exit.Contents)); + + var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0]); + RunSave.Commit(_cache, game, zones, _items.Lots); + var restored = new Galaxy(_cache, _cache.GetGlobal(), _ => { }); + var exit = new Zone(_items, _planetSettings, restored.Exit.PackedContents, restored.Exit, restored); + + var after = Assert.Single(exit.Entities, entity => entity.IsBoss); + Assert.Equal(before.Name, after.Name); + Assert.Equal(before.Faction.Name, after.Faction.Name); + Assert.False(RunGoal.ExitOpen(exit)); + after.HeatstrokeDeath.OnNext(Unit.Default); + Assert.True(RunGoal.ExitOpen(exit)); + } + + // The gate shares no point with an adjacency wormhole: every one is farther from it than a ship's wormhole reach. + [Fact] + public void Exit_gate_shares_no_point_with_a_wormhole() + { + AddPirates(); + var ratio = float.Parse(File.ReadLines(Path.Combine(FindRepoRoot(), "Assets", "Resources", "Settings.asset")) + .Select(line => Regex.Match(line, @"^ WormholeDistanceRatio: (.+)$")).First(match => match.Success).Groups[1].Value.Trim(), + System.Globalization.CultureInfo.InvariantCulture); + var reach = AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings").WormholeExitRadius; + foreach (var seed in DemoSeeds) + { + var galaxy = DemoGalaxy(seed); + var zone = ZoneOf(galaxy, galaxy.Exit); + var gate = RunGoal.ExitGatePosition(zone, ratio); + foreach (var adjacent in galaxy.Exit.AdjacentZones) + { + var wormhole = math.normalize(adjacent.Position - galaxy.Exit.Position) * zone.Pack.Radius * ratio; + Assert.True(math.length(gate - wormhole) > 2 * reach, $"seed {seed}: the exit gate sits {math.length(gate - wormhole)} from a wormhole"); + } + } + } +}