From 65a6297aee247c603141850eaf0be2dc7048f989 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 11:55:50 +0000 Subject: [PATCH 1/3] Save standings by faction, not by list position RunSave.Capture wrote Factions in HomeZones key order and Relationships in galaxy.Factions order; Galaxy's save constructor paired them by index, so a galaxy whose two orders differed resumed with standings on the wrong factions. SavedGame.Standings (Key 16) now carries each standing with its faction ref. A save without it still reads Relationships by index, the only record it holds. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Galaxy.cs | 12 +++-- Assets/Scripts/ServerShared/SavedGame.cs | 20 +++++++- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 53 +++++++++++++++++++-- 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 08512f8af..797e77764 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -58,10 +58,14 @@ public class Galaxy Seed = savedGame.Seed; Factions = savedGame.Factions.Select(faction => cultCache.Get(faction)).ToArray(); - for (var i = 0; i < Factions.Length; i++) - { - FactionRelationships[Factions[i]] = savedGame.Relationships[i]; - } + if (savedGame.Standings != null) + foreach (var standing in savedGame.Standings) + FactionRelationships[cultCache.Get(standing.Faction)] = standing.Relationship; + else + // A save before Standings wrote Relationships in an order it did not record; it is right only for a + // galaxy that never reordered, and is all that save holds. + for (var i = 0; i < Factions.Length; i++) + FactionRelationships[Factions[i]] = savedGame.Relationships[i]; var savedZones = savedGame.Zones.Select(zone => cultCache.Get(zone)).ToArray(); Zones = savedZones.Select(zone => diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index 90ef03566..ff4de6e9b 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -45,6 +45,8 @@ public class SavedGame [Key(11)] public bool IsTutorial; + // Read only by a save written before Standings: its entries follow galaxy.Factions order, not Factions order, so + // Galaxy trusts it only when the two orders cannot differ (see Galaxy's save constructor). [Key(12)] public FactionRelationship[] Relationships; @@ -61,6 +63,21 @@ public class SavedGame // gets Regions, Partition and Deal from its saved graph, boss zones, Exit and seed. [Key(15)] public List Provinces; + + // Each faction's standing, keyed by the faction itself so no list order can misplace it. Written by RunSave.Capture; + // null in a save written before it, which then reads Relationships. + [Key(16)] + public List Standings; +} + +[MessagePackObject] +public class SavedStanding +{ + [Key(0)] + public CultRecordRef Faction; + + [Key(1)] + public FactionRelationship Relationship; } [MessagePackObject] @@ -87,7 +104,8 @@ public static class RunSave DiscoveredZones = galaxy.DiscoveredZones.Select(dz => Array.IndexOf(galaxy.Zones, dz)).ToArray(), Background = galaxy.Background, Factions = factions.Select(f => cache.RefOf(f)).ToArray(), - Relationships = galaxy.Factions.Select(f => galaxy.FactionRelationships[f]).ToArray(), + Standings = galaxy.Factions.Select(f => new SavedStanding + {Faction = cache.RefOf(f), Relationship = galaxy.FactionRelationships[f]}).ToList(), HomeZones = galaxy.HomeZones.ToDictionary( x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)), diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 00a6baa75..05c0a86ac 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -150,6 +150,48 @@ public sealed class RunSaveTests : IDisposable } } + // Galaxy.Factions and Galaxy.HomeZones.Keys are two orders of the same factions. A saved standing belongs to its + // faction, whichever order the galaxy lists them in. + [Fact] + public void StandingsSurviveSaveAndContinueWhenFactionOrderDiffersFromHomeZoneOrder() + { + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.Upsert(new Faction { Name = "Borealis", ShortName = "BOR" }); + cache.Upsert(new Faction { Name = "Corvid", ShortName = "COR" }); + cache.FlushAsync().Wait(); + } + + using (var cache = Open()) + { + var factions = cache.GetAll().OrderBy(f => f.Name, StringComparer.Ordinal).ToArray(); + var game = Game(cache, factions); + RunSave.Commit(cache, game, Zones(2, null, -1), new ProvenanceLedger()); + } + + var expected = new Dictionary + { + { "Adrasteia", FactionRelationship.Hated }, { "Borealis", FactionRelationship.Friendly }, { "Corvid", FactionRelationship.Beloved } + }; + + using (var cache = Open()) + { + var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + Assert.NotEqual(galaxy.Factions, galaxy.HomeZones.Keys); + foreach (var faction in galaxy.Factions) galaxy.FactionRelationships[faction] = expected[faction.Name]; + var itemManager = new ItemManager(1u, cache, new ProvenanceLedger(), TestSettings(), _ => { }); + galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); + var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0], 0); + RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); + } + + using (var cache = Open()) + { + var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + Assert.Equal(expected, galaxy.Factions.ToDictionary(f => f.Name, f => galaxy.FactionRelationships[f])); + } + } + // The run's credits ride the save: what Capture is given is what a later Continue reads from the run store. [Fact] public void CreditsSurviveContinue() @@ -753,11 +795,14 @@ public sealed class RunSaveTests : IDisposable return new ZonePack { Orbits = { root, moon }, Planets = { body } }; } - private static SavedGame Game(CultCache cache) => new SavedGame + private static SavedGame Game(CultCache cache) => Game(cache, cache.GetAll().Single()); + + // HomeZones lists the factions in reverse of their saved order. + private static SavedGame Game(CultCache cache, params Faction[] factions) => new SavedGame { - Factions = new[] { cache.RefOf(cache.GetAll().Single()) }, - Relationships = new[] { FactionRelationship.Neutral }, - HomeZones = new Dictionary { { 0, 0 } }, + Factions = factions.Select(f => cache.RefOf(f)).ToArray(), + Relationships = factions.Select(_ => FactionRelationship.Neutral).ToArray(), + HomeZones = factions.Select((_, i) => factions.Length - 1 - i).ToDictionary(i => i, _ => 0), BossZones = new Dictionary(), DiscoveredZones = new[] { 0 }, ActionBarBindings = new SavedActionBarBinding[0], From b571555e2a2ac2637366e200924f769d9168dd70 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 15:00:22 +0000 Subject: [PATCH 2/3] ship-authoring stats --top-speed adds the VelocityLimit a bare hull lacks create without --like makes a hull with no behaviors, Validate refuses a ship hull without a VelocityLimit, and stats --top-speed refused any hull without exactly one: no command could give such a hull a top speed. The first --top-speed now adds the one limit; two or more are still refused. Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ShipProductTests.cs | 6 ++++-- tools/AetherDb/ShipAuthoringCommands.cs | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index bb358c4df..f2326ed82 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -353,8 +353,6 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched(path, "--mass", "5", "--mass", "6"); // A valid flag beside a bad one writes nothing. RefusedAndUntouched(path, "--mass", "5", "--durability", "-2"); - RefusedAndUntouched(bare, "--top-speed", "50"); - RefusedAndUntouched(bare, "--mass", "5", "--top-speed", "50"); // Two VelocityLimits leave no one top speed to set. RefusedAndUntouched(twin, "--top-speed", "50"); @@ -385,6 +383,10 @@ public sealed class ShipProductTests : IDisposable Assert.Equal((1f, 0f, 8000f), (ShipAuthoringStore.Load(path).Hull.Mass, ShipAuthoringStore.Load(path).Hull.Drag, ShipAuthoringStore.Load(path).Hull.Durability)); Assert.Equal(0, Run("stats", bare, "--armor", "12").Code); Assert.Equal(12f, ShipAuthoringStore.Load(bare).Hull.Armor); + // A hull with no top speed (create without --like) gets its one VelocityLimit from the first --top-speed. + Assert.Equal(0, Run("stats", bare, "--top-speed", "50").Code); + var added = ShipAuthoringStore.Load(bare).Hull.Behaviors.OfType().Single().TopSpeed; + Assert.Equal((50f, 50f), (added.Min, added.Max)); } [Fact] diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index bce7eda08..9f8f1c41c 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -110,8 +110,11 @@ public static class ShipAuthoringCommands var (hull, ship, _) = ShipAuthoringStore.Load(args[1]); if (stats.TryGetValue("--top-speed", out var topSpeed)) { - var limits = (hull.Behaviors ?? new List()).OfType().ToArray(); - if (limits.Length != 1) throw new InvalidOperationException($"{ship.Id}: --top-speed needs exactly one VelocityLimit on the hull; it has {limits.Length}."); + hull.Behaviors ??= new List(); + var limits = hull.Behaviors.OfType().ToList(); + // A hull made without --like has none, and Validate refuses a ship hull without one: the first --top-speed adds it. + if (limits.Count == 0) { limits.Add(new VelocityLimitData()); hull.Behaviors.Add(limits[0]); } + if (limits.Count != 1) throw new InvalidOperationException($"{ship.Id}: --top-speed needs at most one VelocityLimit on the hull; it has {limits.Count}."); // A fixed top speed, as every shipped hull authors one (Min equal to Max); its terms are kept. limits[0].TopSpeed.Min = limits[0].TopSpeed.Max = topSpeed; } From d97e2714cd529dd5590cbb0c9ddef9a489428d74 Mon Sep 17 00:00:00 2001 From: Nibu Date: Tue, 10 Nov 2026 05:50:28 +0000 Subject: [PATCH 3/3] Retire the Wwise catalog fields: SoundBank, AudioStats (AudioStat) and Faction music Nothing reads or writes them (audio is native Unity plus AquaSynth). Keys 21, 22 and 13 to 15 stay reserved as never-reuse comments, as with the earlier retirements. The shipped catalog still holds old values; the reader skips unknown keys. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Corporations.cs | 9 +-------- Assets/Scripts/ServerShared/ItemData.cs | 16 +--------------- docs/aetheria-release-map.md | 8 +++++--- 3 files changed, 7 insertions(+), 26 deletions(-) diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs index 3b676aef4..e527606d9 100644 --- a/Assets/Scripts/ServerShared/Corporations.cs +++ b/Assets/Scripts/ServerShared/Corporations.cs @@ -47,14 +47,7 @@ public class Faction [Inspectable, CultReference(typeof(Faction), many: true), CultInspectorRange(0, 1), JsonProperty("allegiance"), Key(12)] public Dictionary, float> Allegiance = new Dictionary, float>(); - [Inspectable, JsonProperty("overworldMusic"), Key(13)] - public uint OverworldMusic; - - [Inspectable, JsonProperty("combatMusic"), Key(14)] - public uint CombatMusic; - - [Inspectable, JsonProperty("bossMusic"), Key(15)] - public uint BossMusic; + // Keys 13 to 15 (overworldMusic, combatMusic, bossMusic) held Wwise ids; retired, never reuse them. // Key 16 is reserved for Relations (operator ruling faction-relations-field). diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index fe705b4f6..72c07fc9a 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -417,11 +417,7 @@ public abstract class EquippableItemData : CraftedItemData [Inspectable, CultInspectorAssetGuid, JsonProperty("actionIcon"), Key(20)] public string ActionBarIcon; - [Inspectable, JsonProperty("soundBank"), Key(21)] - public uint SoundBank; - - [Inspectable, JsonProperty("audioStats"), Key(22)] - public List AudioStats = new List(); + // Keys 21 (soundBank) and 22 (audioStats) held Wwise ids; audio is native Unity plus AquaSynth now. Retired; never reuse them. [IgnoreMember] public abstract HardpointType HardpointType { get; } @@ -455,16 +451,6 @@ public abstract class EquippableItemData : CraftedItemData } } -[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] -public class AudioStat -{ - [Inspectable, JsonProperty("parameter"), Key(0)] - public uint Parameter; - - [Inspectable, JsonProperty("stat"), Key(1)] - public PerformanceStat Stat = new PerformanceStat(); -} - [CultDocument("aetheria.geardata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] public class GearData : EquippableItemData { diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 3176c3163..87e5beb8c 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -224,9 +224,11 @@ at the same commit. stat); `SoundBank` is catalog key 21 (`:403-404`). - Gone since B5: the `AetherDrive` audio calls and its `RpmAudioParameter` and `TorqueRatioAudioParameter` fields (grep 0), and the `EntityInstance` block that read - `item.Data.SoundBank` (grep 0). Follow-up `wwise-catalog-fields` still names both; its live - set is `SoundBank` (key 21), `AudioStats` (key 22) and `Faction.OverworldMusic`, - `CombatMusic`, `BossMusic` (`Corporations.cs:52-59`, keys 13 to 15). + `item.Data.SoundBank` (grep 0). Follow-up `wwise-catalog-fields` retired the rest: `SoundBank` + (key 21), `AudioStats` (key 22, with `AudioStat`) and `Faction.OverworldMusic`, `CombatMusic`, + `BossMusic` (keys 13 to 15) are deleted, their keys left as never-reuse comments. The shipped + `GameData/Aetheria.cc` still carries the old values (29 equippables with a `SoundBank`, 2 factions + with music); the reader skips unknown keys and the next catalog write drops them. - `WwiseMetadata.cs` (91 lines) and `WwiseSoundBinding.cs` (21 lines) are used only by that surface; both have `.meta` files. A repo-wide grep outside `docs` finds the Wwise symbols in exactly these files: `ChargedWeapon.cs`, `InstantWeapon.cs`, `Reactor.cs`, `Thruster.cs`,