From 35f94f410aa28c47d9ba84c2681a1e9357b65c0f Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Wed, 16 Sep 2026 20:11:25 +0200 Subject: [PATCH 01/12] feat: separate equipment configuration skill trees and tooltips --- VGModAPI.Abstractions/Equipment.cs | 35 +++++ VGModAPI.Abstractions/ModServices.cs | 11 +- VGModAPI.Abstractions/SkillTrees.cs | 25 ++++ VGModAPI.Abstractions/Tooltips.cs | 48 +++++++ VGModAPI.Core/EquipmentService.cs | 74 +++++++++++ VGModAPI.Core/SkillTreeService.cs | 36 +++++ VGModAPI.Core/TooltipService.cs | 81 ++++++++++++ VGModAPI.Tests/InstalledTractorBeamTests.cs | 41 ++++++ VGModAPI.Tests/ServiceRootTests.cs | 8 +- VGModAPI.Tests/SkillTreeAndTooltipTests.cs | 99 ++++++++++++++ VGModAPI.Tests/TractorBeamTests.cs | 81 ++++++++++++ VGModAPI/Native/SkillTreeRuntime.cs | 47 +++++++ VGModAPI/Native/TractorBeamRuntime.cs | 139 ++++++++++++++++++++ VGModAPI/Patches/TractorBeamPatches.cs | 26 ++++ VGModAPI/Plugin.cs | 6 + VGModAPI/ServiceIntegration.cs | 8 +- VGModAPI/TractorBeamIntegration.cs | 44 +++++++ docs/reference/tractor-beams.md | 69 ++++++++++ 18 files changed, 875 insertions(+), 3 deletions(-) create mode 100644 VGModAPI.Abstractions/Equipment.cs create mode 100644 VGModAPI.Abstractions/SkillTrees.cs create mode 100644 VGModAPI.Abstractions/Tooltips.cs create mode 100644 VGModAPI.Core/EquipmentService.cs create mode 100644 VGModAPI.Core/SkillTreeService.cs create mode 100644 VGModAPI.Core/TooltipService.cs create mode 100644 VGModAPI.Tests/InstalledTractorBeamTests.cs create mode 100644 VGModAPI.Tests/SkillTreeAndTooltipTests.cs create mode 100644 VGModAPI.Tests/TractorBeamTests.cs create mode 100644 VGModAPI/Native/SkillTreeRuntime.cs create mode 100644 VGModAPI/Native/TractorBeamRuntime.cs create mode 100644 VGModAPI/Patches/TractorBeamPatches.cs create mode 100644 VGModAPI/TractorBeamIntegration.cs create mode 100644 docs/reference/tractor-beams.md diff --git a/VGModAPI.Abstractions/Equipment.cs b/VGModAPI.Abstractions/Equipment.cs new file mode 100644 index 00000000..267d6c4c --- /dev/null +++ b/VGModAPI.Abstractions/Equipment.cs @@ -0,0 +1,35 @@ +using System; + +namespace VGModAPI; + +/// Read-only tractor-module values captured for one configuration or tooltip callback. +public sealed class TractorModule +{ + /// Vanilla automatic tractor beams (amountOfBeams). + public int BeamCount { get; } + /// Manual Tractor Beams in the game's UI (amountOfBonusBeams). + public int ManualBeamCount { get; } + internal TractorModule(int beamCount, int manualBeamCount) + { BeamCount = beamCount; ManualBeamCount = manualBeamCount; } +} + +/// Changes targeting capacity, not the number of physical beams or their native eligibility rules. +public sealed class TractorTargeting +{ + public int AutomaticBeamLimit { get; } + public bool AllowManualBorrowing { get; } + public TractorTargeting(int automaticBeamLimit, bool allowManualBorrowing = false) + { + if (automaticBeamLimit < 0) throw new ArgumentOutOfRangeException(nameof(automaticBeamLimit)); + AutomaticBeamLimit = automaticBeamLimit; AllowManualBorrowing = allowManualBorrowing; + } +} + +public interface IEquipmentService : IServiceStatus +{ + /// Configure player-ship tractor targeting. Return null for vanilla behavior. The first non-null + /// configuration wins in registration order. Evaluated synchronously when the module needs it, with current + /// module values. Exceptions abstain; gameplay mutation and recursive evaluation are not allowed. + /// Disposal removes the rule. Native cargo/crew/brig checks and occupied beams remain protected. + IDisposable ConfigurePlayerTractorModules(string pluginId, Func configure); +} diff --git a/VGModAPI.Abstractions/ModServices.cs b/VGModAPI.Abstractions/ModServices.cs index b3b7cc30..595ad5d7 100644 --- a/VGModAPI.Abstractions/ModServices.cs +++ b/VGModAPI.Abstractions/ModServices.cs @@ -22,6 +22,12 @@ public sealed class ModServices private readonly ICraftingJobService _craftingJobs; private readonly ICraftingCommandService _craftingCommands; private readonly IHudService _hud; + private readonly IEquipmentService _equipment; + private readonly ISkillTreeService _skillTrees; + private readonly ITooltipService _tooltips; + public IEquipmentService Equipment { get { CheckThread(); return _equipment; } } + public ISkillTreeService SkillTrees { get { CheckThread(); return _skillTrees; } } + public ITooltipService Tooltips { get { CheckThread(); return _tooltips; } } private readonly IGameplayUiService _gameplayUi; private readonly IForgeUiService _forgeUi; private readonly IBoardingRuleService _boardingRules; @@ -65,7 +71,7 @@ public sealed class ModServices internal ModServices(ILifecycleService lifecycle, IModInformationService mods, IModSettingsService settings, ISaveDataService saveData, IMissionService missions, ITravelService travel, IStationService station, IRecipeService recipes, IRecipeQuoteService recipeQuotes, - ICraftingJobService craftingJobs, ICraftingCommandService craftingCommands, IHudService hud, IForgeUiService forgeUi, IBoardingRuleService boardingRules, IDungeonService dungeons, IStoryService story, IBarService bars, IWorldService world, IDialogueService dialogue, IGameService game, IOwnedItemService items, IOwnedRecipeService recipeRegistration, IGameplayUiService gameplayUi) + ICraftingJobService craftingJobs, ICraftingCommandService craftingCommands, IHudService hud, IForgeUiService forgeUi, IBoardingRuleService boardingRules, IDungeonService dungeons, IStoryService story, IBarService bars, IWorldService world, IDialogueService dialogue, IGameService game, IOwnedItemService items, IOwnedRecipeService recipeRegistration, IGameplayUiService gameplayUi, IEquipmentService equipment, ISkillTreeService skillTrees, ITooltipService tooltips) { _lifecycle = lifecycle ?? throw new ArgumentNullException(nameof(lifecycle)); _mods = mods ?? throw new ArgumentNullException(nameof(mods)); @@ -79,6 +85,9 @@ internal ModServices(ILifecycleService lifecycle, IModInformationService mods, I _craftingJobs = craftingJobs ?? throw new ArgumentNullException(nameof(craftingJobs)); _craftingCommands = craftingCommands ?? throw new ArgumentNullException(nameof(craftingCommands)); _hud = hud ?? throw new ArgumentNullException(nameof(hud)); + _equipment = equipment ?? throw new ArgumentNullException(nameof(equipment)); + _skillTrees = skillTrees ?? throw new ArgumentNullException(nameof(skillTrees)); + _tooltips = tooltips ?? throw new ArgumentNullException(nameof(tooltips)); _gameplayUi = gameplayUi ?? throw new ArgumentNullException(nameof(gameplayUi)); _forgeUi = forgeUi ?? throw new ArgumentNullException(nameof(forgeUi)); _boardingRules = boardingRules ?? throw new ArgumentNullException(nameof(boardingRules)); diff --git a/VGModAPI.Abstractions/SkillTrees.cs b/VGModAPI.Abstractions/SkillTrees.cs new file mode 100644 index 00000000..74a4bc5e --- /dev/null +++ b/VGModAPI.Abstractions/SkillTrees.cs @@ -0,0 +1,25 @@ +namespace VGModAPI; + +/// The game's commander specializations. +public enum CommanderSpecialization +{ + Leadership = 1, Mining, Drones, Engineering, Industrial, Salvaging, Economy, Offense, Defense +} + +/// Read-only current values for a skill tree; retained values do not follow a replacement game. +public sealed class SkillTree +{ + public string Identifier { get; } + public CommanderSpecialization? Specialization { get; } + public int MasteryLevel { get; } + public int MaximumLevel { get; } + internal SkillTree(string identifier, CommanderSpecialization? specialization, int masteryLevel, int maximumLevel) + { Identifier = identifier; Specialization = specialization; MasteryLevel = masteryLevel; MaximumLevel = maximumLevel; } +} + +public interface ISkillTreeService : IServiceStatus +{ + /// Read the current commander's specialization tree. Returns null without a commander, + /// when the tree does not exist, or when integration is unavailable. Main-thread only. + SkillTree? Get(CommanderSpecialization specialization); +} diff --git a/VGModAPI.Abstractions/Tooltips.cs b/VGModAPI.Abstractions/Tooltips.cs new file mode 100644 index 00000000..022600f6 --- /dev/null +++ b/VGModAPI.Abstractions/Tooltips.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; + +namespace VGModAPI; + +public enum TooltipTextStyle { Normal, Bonus, Details } + +/// Append-only tooltip content. Valid only during the tooltip callback; no Unity objects are exposed. +public sealed class Tooltip +{ + private readonly List _lines = new(); + private readonly Action _checkThread; + private bool _closed; + internal IReadOnlyList Lines => _lines; + internal Tooltip(Action checkThread) { _checkThread = checkThread; } + internal void Close() { _closed = true; } + internal void Check() + { _checkThread(); if (_closed) throw new InvalidOperationException("Tooltip callback has ended."); } + public TooltipLine AddLine(string text, TooltipTextStyle style = TooltipTextStyle.Normal) + { + Check(); var line = new TooltipLine(this); line.Append(text, style); _lines.Add(line); return line; + } +} + +/// A line of styled text. Append adds inline text, without introducing another row. +public sealed class TooltipLine +{ + private readonly Tooltip _owner; + private readonly List<(string Text, TooltipTextStyle Style)> _spans = new(); + internal IReadOnlyList<(string Text, TooltipTextStyle Style)> Spans => _spans; + internal TooltipLine(Tooltip owner) { _owner = owner; } + public TooltipLine Append(string text, TooltipTextStyle style = TooltipTextStyle.Normal) + { + _owner.Check(); + if (text == null) throw new ArgumentNullException(nameof(text)); + if (!Enum.IsDefined(typeof(TooltipTextStyle), style)) throw new ArgumentOutOfRangeException(nameof(style)); + _spans.Add((text, style)); return this; + } +} + +public interface ITooltipService : IServiceStatus +{ + /// Append module stat descriptions after vanilla builds them. Native caching is preserved; + /// changes do not retroactively rebuild existing stats. Style is ignored by the native stat list. + IDisposable RegisterTractorModule(string pluginId, Action describe); + /// Append after any skill tree's native mastery bonus lines, using live tree values. + IDisposable RegisterSkillTree(string pluginId, Action describe); +} diff --git a/VGModAPI.Core/EquipmentService.cs b/VGModAPI.Core/EquipmentService.cs new file mode 100644 index 00000000..fd7d01a4 --- /dev/null +++ b/VGModAPI.Core/EquipmentService.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; + +namespace VGModAPI.Core; + +internal sealed class EquipmentService : IEquipmentService, IDisposable +{ + private readonly LifecycleHub _hub; + private readonly IServiceStatus _status; + private readonly List _entries = new(); + private bool _disposed, _evaluating; + internal EquipmentService(LifecycleHub hub) { _hub = hub; _status = hub.Services.Get("equipment"); } + public ServiceAvailability Availability => _status.Availability; + public event Action? AvailabilityChanged + { add => _status.AvailabilityChanged += value; remove => _status.AvailabilityChanged -= value; } + internal void SetAvailable(bool available) + { + _hub.CheckThread(); if (_disposed) return; + if (available) _hub.SetAvailable("equipment", "Player tractor module targeting bound."); + else _hub.SetUnavailable("equipment", ServiceUnavailableReason.BindingFailed, "Equipment integration unavailable."); + } + public IDisposable ConfigurePlayerTractorModules(string pluginId, Func configure) + { + _hub.CheckThread(); if (_disposed) throw new ObjectDisposedException(nameof(EquipmentService)); + var id = new RecipeId(pluginId, "tractor").ProviderId; + if (configure == null) throw new ArgumentNullException(nameof(configure)); + if (_entries.Exists(e => e.Owner == id)) throw new InvalidOperationException("Duplicate tractor rule owner."); + var entry = new Entry(this, id, configure); _entries.Add(entry); return entry; + } + internal TractorTargeting? Resolve(TractorModule module) + { + _hub.CheckThread(); + if (_disposed || _evaluating || !Availability.IsAvailable) return null; + _evaluating = true; + try + { + using var scope = _hub.EnterServiceDispatch(); + foreach (var entry in _entries.ToArray()) + { + if (entry.Configure == null) continue; + try + { + var result = entry.Configure(module); + if (_disposed || !Availability.IsAvailable) return null; + if (entry.Configure != null && result != null) return result; + } + catch (Exception error) { _hub.ReportSubscriberFailure(entry.Owner, error); } + } + return null; + } + finally { _evaluating = false; } + } + // Native automatic targeting is not reduced: configure sharing without breaking vanilla's own pool. + internal static int AutomaticCapacity(TractorModule module, TractorTargeting targeting) + => Math.Max(module.BeamCount, Math.Min(checked(module.BeamCount + module.ManualBeamCount), targeting.AutomaticBeamLimit)); + internal static bool MayBorrow(TractorModule module, TractorTargeting targeting, int busyBeams, bool manual) + => manual ? targeting.AllowManualBorrowing : busyBeams < AutomaticCapacity(module, targeting); + public void Dispose() + { + _hub.CheckThread(); if (_disposed) return; + _disposed = true; + foreach (var entry in _entries.ToArray()) entry.Dispose(); + _hub.SetUnavailable("equipment", ServiceUnavailableReason.ApiStopped, "Equipment integration stopped."); + } + private sealed class Entry : IDisposable + { + private readonly EquipmentService _service; + internal readonly string Owner; + internal Func? Configure; + internal Entry(EquipmentService service, string owner, Func configure) + { _service = service; Owner = owner; Configure = configure; } + public void Dispose() { _service._hub.CheckThread(); Configure = null; _service._entries.Remove(this); } + } +} diff --git a/VGModAPI.Core/SkillTreeService.cs b/VGModAPI.Core/SkillTreeService.cs new file mode 100644 index 00000000..e3a88e65 --- /dev/null +++ b/VGModAPI.Core/SkillTreeService.cs @@ -0,0 +1,36 @@ +using System; + +namespace VGModAPI.Core; + +internal sealed class SkillTreeService : ISkillTreeService, IDisposable +{ + private readonly LifecycleHub _hub; + private readonly IServiceStatus _status; + private Func? _read; + private bool _disposed; + internal SkillTreeService(LifecycleHub hub) { _hub = hub; _status = hub.Services.Get("skill-trees"); } + public ServiceAvailability Availability => _status.Availability; + public event Action? AvailabilityChanged + { add => _status.AvailabilityChanged += value; remove => _status.AvailabilityChanged -= value; } + internal void Bind(Func? read) + { + _hub.CheckThread(); if (_disposed) return; + _read = read; + if (read != null) _hub.SetAvailable("skill-trees", "Commander skill trees bound."); + else _hub.SetUnavailable("skill-trees", ServiceUnavailableReason.BindingFailed, "Skill trees unavailable."); + } + public SkillTree? Get(CommanderSpecialization specialization) + { + _hub.CheckThread(); + if (!Enum.IsDefined(typeof(CommanderSpecialization), specialization)) throw new ArgumentOutOfRangeException(nameof(specialization)); + if (_disposed || !Availability.IsAvailable || _hub.Services.IsStopping) return null; + try { return _read?.Invoke(specialization); } + catch (Exception error) { _hub.ReportSubscriberFailure("skill-trees", error); return null; } + } + public void Dispose() + { + _hub.CheckThread(); if (_disposed) return; + _disposed = true; _read = null; + _hub.SetUnavailable("skill-trees", ServiceUnavailableReason.ApiStopped, "Skill trees stopped."); + } +} diff --git a/VGModAPI.Core/TooltipService.cs b/VGModAPI.Core/TooltipService.cs new file mode 100644 index 00000000..92c634bd --- /dev/null +++ b/VGModAPI.Core/TooltipService.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; + +namespace VGModAPI.Core; + +internal sealed class TooltipService : ITooltipService, IDisposable +{ + private readonly LifecycleHub _hub; + private readonly IServiceStatus _status; + private readonly List _entries = new(); + private bool _disposed, _rendering; + internal TooltipService(LifecycleHub hub) { _hub = hub; _status = hub.Services.Get("tooltips"); } + public ServiceAvailability Availability => _status.Availability; + public event Action? AvailabilityChanged + { add => _status.AvailabilityChanged += value; remove => _status.AvailabilityChanged -= value; } + internal void SetAvailable(bool available) + { + _hub.CheckThread(); if (_disposed) return; + if (available) _hub.SetAvailable("tooltips", "Tractor module and skill-tree tooltip extensions bound."); + else _hub.SetUnavailable("tooltips", ServiceUnavailableReason.BindingFailed, "Tooltip extensions unavailable."); + } + public IDisposable RegisterTractorModule(string pluginId, Action describe) + { + if (describe == null) throw new ArgumentNullException(nameof(describe)); + return Register(pluginId, typeof(TractorModule), (value, tooltip) => describe((TractorModule)value, tooltip)); + } + public IDisposable RegisterSkillTree(string pluginId, Action describe) + { + if (describe == null) throw new ArgumentNullException(nameof(describe)); + return Register(pluginId, typeof(SkillTree), (value, tooltip) => describe((SkillTree)value, tooltip)); + } + private IDisposable Register(string pluginId, Type type, Action describe) + { + _hub.CheckThread(); if (_disposed) throw new ObjectDisposedException(nameof(TooltipService)); + var id = new RecipeId(pluginId, "tooltip").ProviderId; + if (_entries.Exists(e => e.Owner == id && e.Type == type)) throw new InvalidOperationException("Duplicate tooltip owner and target."); + var entry = new Entry(this, id, type, describe); _entries.Add(entry); return entry; + } + internal IReadOnlyList Describe(object value) + { + _hub.CheckThread(); var lines = new List(); + if (_disposed || _rendering || !Availability.IsAvailable) return lines; + _rendering = true; + try + { + using var scope = _hub.EnterServiceDispatch(); + foreach (var entry in _entries.ToArray()) + { + if (entry.Callback == null || entry.Type != value.GetType()) continue; + var content = new Tooltip(_hub.CheckThread); + try + { + entry.Callback(value, content); + if (_disposed || !Availability.IsAvailable) return Array.Empty(); + if (entry.Callback != null) lines.AddRange(content.Lines); + } + catch (Exception error) { _hub.ReportSubscriberFailure(entry.Owner, error); } + finally { content.Close(); } + } + return lines; + } + finally { _rendering = false; } + } + public void Dispose() + { + _hub.CheckThread(); if (_disposed) return; + _disposed = true; + foreach (var entry in _entries.ToArray()) entry.Dispose(); + _hub.SetUnavailable("tooltips", ServiceUnavailableReason.ApiStopped, "Tooltip extensions stopped."); + } + private sealed class Entry : IDisposable + { + private readonly TooltipService _service; + internal readonly string Owner; + internal readonly Type Type; + internal Action? Callback; + internal Entry(TooltipService service, string owner, Type type, Action callback) + { _service = service; Owner = owner; Type = type; Callback = callback; } + public void Dispose() { _service._hub.CheckThread(); Callback = null; _service._entries.Remove(this); } + } +} diff --git a/VGModAPI.Tests/InstalledTractorBeamTests.cs b/VGModAPI.Tests/InstalledTractorBeamTests.cs new file mode 100644 index 00000000..ebd3bbad --- /dev/null +++ b/VGModAPI.Tests/InstalledTractorBeamTests.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; +using Mono.Cecil; +using Xunit; + +namespace VGModAPI.Tests; + +[Trait("Category", "InstalledGame")] +public sealed class InstalledTractorBeamTests +{ + [Fact] + public void TractorHooksAndNativeSafetyPredicatesExist() + { + using var assembly = AssemblyDefinition.ReadAssembly(Environment.GetEnvironmentVariable("VG_GAME_ASSEMBLY") + ?? throw new InvalidOperationException("Run make check-bindings.")); + var module = assembly.MainModule; + var tractor = module.GetType("Behaviour.Equipment.Module.TractorModule"); + var available = Assert.Single(tractor.Methods, m => m.Name == "GetAvailableTractorBeam"); + Assert.Equal("Behaviour.Tractoring.TractorBeam", available.ReturnType.FullName); + Assert.Equal("System.Boolean", Assert.Single(available.Parameters).ParameterType.FullName); + foreach (var name in new[] { "UpdateAvailableTargets", "SetMainSubStats", "IsCrewPodTargetingBlocked" }) + Assert.True(Assert.Single(tractor.Methods, m => m.Name == name).HasBody); + var targets = tractor.Methods.Single(m => m.Name == "UpdateAvailableTargets"); + Assert.Equal("System.Collections.Generic.IEnumerable`1", Assert.Single(targets.Parameters).ParameterType.FullName); + foreach (var name in new[] { "CanBeAutoTractoredBy", "IsCrewPodTargetingBlocked" }) + Assert.Contains(targets.Body.Instructions, i => i.Operand is MethodReference method && method.Name == name); + Assert.Equal("System.Int32", tractor.Fields.Single(f => f.Name == "amountOfBonusBeams").FieldType.FullName); + Assert.Contains(tractor.Fields, f => f.Name == "tractorBeams" && f.FieldType.FullName.Contains("TractorBeam")); + Assert.Contains(module.GetType("Behaviour.Equipment.Module.AbstractTargetingModule").Fields, f => f.Name == "filteredTargets"); + var equipment = module.GetType("Behaviour.Equipment.AbstractEquipment"); + Assert.Contains(equipment.Fields, f => f.Name == "mainSubStats"); + Assert.Contains(equipment.Properties, p => p.Name == "parent" && p.PropertyType.FullName == "Behaviour.Unit.AbstractUnit"); + Assert.Equal("System.Int32", module.GetType("Source.Util.GameMath").Properties.Single(p => p.Name == "maxLevel").PropertyType.FullName); + Assert.Contains(module.GetType("Source.Player.GamePlayer").Properties, p => p.Name == "commander"); + var badge = module.GetType("Behaviour.UI.MasteryBadge"); + Assert.Contains(badge.Properties, p => p.Name == "skillTree"); + Assert.Equal("Behaviour.UI.UITooltip", badge.Methods.Single(m => m.Name == "AddTooltipCustomContent").Parameters.Single().ParameterType.FullName); + var addText = module.GetType("Behaviour.UI.UITooltip").Methods.Single(m => m.Name == "AddTextLine"); + Assert.Equal(new[] { "System.String", "System.Int32", "System.Single" }, addText.Parameters.Select(p => p.ParameterType.FullName)); + } +} diff --git a/VGModAPI.Tests/ServiceRootTests.cs b/VGModAPI.Tests/ServiceRootTests.cs index 31c3b6f9..d4cce2e4 100644 --- a/VGModAPI.Tests/ServiceRootTests.cs +++ b/VGModAPI.Tests/ServiceRootTests.cs @@ -24,6 +24,12 @@ private static ModServices Compose(LifecycleHub hub, ModInformationCatalog catal var jobs = new CraftingJobService(hub, null, hub.ReportSubscriberFailure); var commands = new CraftingCommandService(hub, jobs, null, _ => { }); var gameplayUi = new GameplayUiService(hub); + var equipment = new EquipmentService(hub); + var skills = new SkillTreeService(hub); + var tooltips = new TooltipService(hub); + hub.Services.AfterStopped(equipment.Dispose); + hub.Services.AfterStopped(skills.Dispose); + hub.Services.AfterStopped(tooltips.Dispose); var dungeonCombat = new BoardingCombatService(hub, hub.ReportSubscriberFailure); var dungeonRewards = new DungeonRewardService(hub, hub.ReportSubscriberFailure); var dungeonOperations = new BoardingService(hub, hub.ReportSubscriberFailure); @@ -42,7 +48,7 @@ private static ModServices Compose(LifecycleHub hub, ModInformationCatalog catal new GameService(hub, new NavigationService(hub, _ => null, (_, _, _) => NavigationStatus.Unavailable, (_, _) => null), new InventoryService(hub, () => null), new StoryMissionService(hub.Services, null, hub, (_, _) => null), new BarService(null, hub, (_, _) => null, _ => false, hub.CheckThread)), new OwnedItemService(hub, (_, _) => null), new OwnedRecipeService(hub, (_, _) => null, _ => null, _ => { }, _ => { }), - gameplayUi }); + gameplayUi, equipment, skills, tooltips }); } [Fact] diff --git a/VGModAPI.Tests/SkillTreeAndTooltipTests.cs b/VGModAPI.Tests/SkillTreeAndTooltipTests.cs new file mode 100644 index 00000000..d80e20e2 --- /dev/null +++ b/VGModAPI.Tests/SkillTreeAndTooltipTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading; +using VGModAPI.Core; +using Xunit; + +namespace VGModAPI.Tests; + +public sealed class SkillTreeAndTooltipTests +{ + [Fact] + public void SkillTreesAreIndependentOfEquipmentAndDescribeAnySpecialization() + { + using var service = new SkillTreeService(new LifecycleHub((_, _) => { })); + Assert.Null(service.Get(CommanderSpecialization.Mining)); + int level = 4; + service.Bind(s => new SkillTree(s.ToString(), s, level, 100)); + var original = service.Get(CommanderSpecialization.Mining)!; + level = 10; + Assert.Equal(4, original.MasteryLevel); + Assert.Equal(10, service.Get(CommanderSpecialization.Mining)!.MasteryLevel); + service.Bind(_ => null); // No commander/tree. + Assert.Null(service.Get(CommanderSpecialization.Engineering)); + service.Dispose(); + Assert.Null(service.Get(CommanderSpecialization.Mining)); + } + + [Fact] + public void TooltipCallbacksCanAddStyledTextWithoutEquipmentRules() + { + using var service = new TooltipService(new LifecycleHub((_, _) => { })); + service.SetAvailable(true); + service.RegisterSkillTree("mod", (tree, tooltip) => + tooltip.AddLine("Mining " + tree.MasteryLevel, TooltipTextStyle.Bonus).Append(" (mod)", TooltipTextStyle.Details)); + service.RegisterTractorModule("mod", (module, tooltip) => tooltip.AddLine("Beams " + module.BeamCount)); + var skillLines = service.Describe(new SkillTree("Mining", CommanderSpecialization.Mining, 12, 100)); + Assert.Single(skillLines); + Assert.Equal("Mining 12", skillLines[0].Spans[0].Text); + Assert.Equal(TooltipTextStyle.Details, skillLines[0].Spans[1].Style); + Assert.Equal("Beams 2", Assert.Single(service.Describe(new TractorModule(2, 3))).Spans[0].Text); + } + + [Fact] + public void FaultyTooltipContributorDoesNotLeakPartialLinesOrSuppressOthers() + { + int errors = 0; + using var service = new TooltipService(new LifecycleHub((_, _) => errors++)); + service.SetAvailable(true); + Tooltip? retained = null; TooltipLine? retainedLine = null; + service.RegisterTractorModule("broken", (_, tooltip) => + { + retained = tooltip; retainedLine = tooltip.AddLine("discard this"); + throw new InvalidOperationException(); + }); + service.RegisterTractorModule("working", (_, tooltip) => tooltip.AddLine("keep this")); + Assert.Equal("keep this", Assert.Single(service.Describe(new TractorModule(1, 2))).Spans[0].Text); + Assert.Equal(1, errors); + Assert.Throws(() => retained!.AddLine("late")); + Assert.Throws(() => retainedLine!.Append("late")); + } + + [Fact] + public void TooltipReentrancyAndDisposalAreSafe() + { + using var service = new TooltipService(new LifecycleHub((_, _) => { })); + var module = new TractorModule(1, 1); + service.SetAvailable(true); + var lease = service.RegisterTractorModule("mod", (_, tooltip) => + { + Assert.Empty(service.Describe(module)); + tooltip.AddLine("ok"); + }); + Assert.Single(service.Describe(module)); + lease.Dispose(); + Assert.Empty(service.Describe(module)); + service.Dispose(); + Assert.Throws(() => service.RegisterTractorModule("mod", (_, _) => { })); + } + + [Fact] + public void IndependentServicesRejectForeignThreads() + { + var hub = new LifecycleHub((_, _) => { }); + using var skills = new SkillTreeService(hub); + using var equipment = new EquipmentService(hub); + using var tooltips = new TooltipService(hub); + Exception? failed = null; + var worker = new Thread(() => + { + try + { + Assert.Throws(() => skills.Get(CommanderSpecialization.Engineering)); + Assert.Throws(() => equipment.ConfigurePlayerTractorModules("mod", _ => null)); + Assert.Throws(() => tooltips.RegisterSkillTree("mod", (_, _) => { })); + } + catch (Exception error) { failed = error; } + }); + worker.Start(); Assert.True(worker.Join(TimeSpan.FromSeconds(5))); Assert.Null(failed); + } +} diff --git a/VGModAPI.Tests/TractorBeamTests.cs b/VGModAPI.Tests/TractorBeamTests.cs new file mode 100644 index 00000000..60706ed7 --- /dev/null +++ b/VGModAPI.Tests/TractorBeamTests.cs @@ -0,0 +1,81 @@ +using System; +using VGModAPI.Core; +using Xunit; + +namespace VGModAPI.Tests; + +public sealed class TractorBeamTests +{ + private static TractorModule Module => new(2, 3); + [Theory] + [InlineData(0, 2)] + [InlineData(3, 3)] + [InlineData(10, 5)] + public void CapacityStaysBetweenVanillaAndPhysicalBeamCount(int limit, int expected) + => Assert.Equal(expected, EquipmentService.AutomaticCapacity(Module, new(limit))); + [Theory] + [InlineData(2, false, true)] + [InlineData(3, false, false)] + [InlineData(5, false, false)] + [InlineData(5, true, true)] + public void BusyCountIncludesBothPoolsAndManualBorrowIsIndependent(int busy, bool manual, bool expected) + => Assert.Equal(expected, EquipmentService.MayBorrow(Module, new(3, true), busy, manual)); + [Fact] + public void ManualBorrowRequiresOptIn() + => Assert.False(EquipmentService.MayBorrow(Module, new(5), 0, true)); + [Fact] + public void NegativeLimitIsRejected() => Assert.Throws(() => new TractorTargeting(-1)); + [Fact] + public void UnavailableAndDisabledRulesLeaveVanillaUntouched() + { + using var service = new EquipmentService(new LifecycleHub((_, _) => { })); + var enabled = false; + service.ConfigurePlayerTractorModules("mod", _ => enabled ? new(3, true) : null); + Assert.Null(service.Resolve(Module)); + service.SetAvailable(true); + Assert.Null(service.Resolve(Module)); + enabled = true; + Assert.Equal(3, service.Resolve(Module)!.AutomaticBeamLimit); + } + [Fact] + public void CallbackFailuresAreIsolatedAndReentrancyAbstains() + { + int failures = 0; + var hub = new LifecycleHub((_, _) => failures++); + using var service = new EquipmentService(hub); + service.SetAvailable(true); + service.ConfigurePlayerTractorModules("broken", _ => throw new InvalidOperationException()); + service.ConfigurePlayerTractorModules("valid", module => + { + Assert.True(hub.IsDispatchingCallbacks); + Assert.Null(service.Resolve(module)); + return new(4); + }); + Assert.Equal(4, service.Resolve(Module)!.AutomaticBeamLimit); + Assert.Equal(1, failures); + Assert.False(hub.IsDispatchingCallbacks); + } + [Fact] + public void RemovalDuringEvaluationSuppressesLaterRulesAndDisposeClosesService() + { + using var service = new EquipmentService(new LifecycleHub((_, _) => { })); + service.SetAvailable(true); + IDisposable? removed = null; + service.ConfigurePlayerTractorModules("first", _ => { removed!.Dispose(); return null; }); + removed = service.ConfigurePlayerTractorModules("second", _ => new(5)); + Assert.Null(service.Resolve(Module)); + service.Dispose(); + Assert.Null(service.Resolve(Module)); + Assert.Equal(ServiceUnavailableReason.ApiStopped, service.Availability.Reason); + Assert.Throws(() => service.ConfigurePlayerTractorModules("later", _ => null)); + } + [Fact] + public void OwnerIdentityCannotBeDuplicatedButCanBeReusedAfterDisposal() + { + using var service = new EquipmentService(new LifecycleHub((_, _) => { })); + var lease = service.ConfigurePlayerTractorModules("mod", _ => null); + Assert.Throws(() => service.ConfigurePlayerTractorModules("mod", _ => null)); + lease.Dispose(); + using var replacement = service.ConfigurePlayerTractorModules("mod", _ => null); + } +} diff --git a/VGModAPI/Native/SkillTreeRuntime.cs b/VGModAPI/Native/SkillTreeRuntime.cs new file mode 100644 index 00000000..5e24b991 --- /dev/null +++ b/VGModAPI/Native/SkillTreeRuntime.cs @@ -0,0 +1,47 @@ +using System; +using System.Reflection; +using System.Threading; + +namespace VGModAPI.Runtime; + +internal sealed class SkillTreeRuntime +{ + private const BindingFlags Any = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + private readonly int _thread = Thread.CurrentThread.ManagedThreadId; + private readonly FieldInfo _player; + private readonly PropertyInfo _commander, _maximumLevel, _identifier; + private readonly MethodInfo _get, _name, _mastery; + private readonly Type _specialization; + internal SkillTreeRuntime(Assembly assembly) + { + var player = assembly.GetType("Source.Player.GamePlayer", true)!; + var tree = assembly.GetType("Behaviour.Crew.Skilltree", true)!; + _specialization = assembly.GetType("Source.Personnel.CommanderSpecialization", true)!; + _player = player.GetField("current", Any) ?? throw new MissingFieldException(player.FullName, "current"); + _commander = player.GetProperty("commander", Any) ?? throw new MissingMemberException(player.FullName, "commander"); + _maximumLevel = assembly.GetType("Source.Util.GameMath", true)!.GetProperty("maxLevel", Any) ?? throw new MissingMemberException("GameMath.maxLevel"); + _identifier = tree.GetProperty("identifier", Any) ?? throw new MissingMemberException(tree.FullName, "identifier"); + _get = tree.GetMethod("Get", Any, null, new[] { typeof(string) }, null) ?? throw new MissingMethodException(tree.FullName, "Get"); + _name = assembly.GetType("Source.Personnel.SkillTreeData", true)!.GetMethod("GetSpecializationTreeName", Any, null, new[] { _specialization }, null) ?? throw new MissingMethodException("SkillTreeData.GetSpecializationTreeName"); + _mastery = tree.GetMethod("GetMasteryLevel", Any, null, Type.EmptyTypes, null) ?? throw new MissingMethodException(tree.FullName, "GetMasteryLevel"); + if (_mastery.ReturnType != typeof(int) || _maximumLevel.PropertyType != typeof(int) || _identifier.PropertyType != typeof(string)) + throw new InvalidOperationException("Skill tree binding shape changed."); + } + private string Name(CommanderSpecialization specialization) => (string)_name.Invoke(null, new[] { Enum.Parse(_specialization, specialization.ToString()) })!; + private bool HasCommander => _player.GetValue(null) is object player && _commander.GetValue(player) != null; + internal SkillTree? Get(CommanderSpecialization specialization) + { + if (Thread.CurrentThread.ManagedThreadId != _thread || !HasCommander) return null; + var tree = _get.Invoke(null, new object[] { Name(specialization) }); + return tree == null ? null : Read(tree); + } + internal SkillTree? Read(object tree) + { + if (Thread.CurrentThread.ManagedThreadId != _thread || !HasCommander) return null; + var identifier = (string)_identifier.GetValue(tree)!; + CommanderSpecialization? specialization = null; + foreach (CommanderSpecialization value in Enum.GetValues(typeof(CommanderSpecialization))) + if (Name(value) == identifier) { specialization = value; break; } + return new SkillTree(identifier, specialization, (int)_mastery.Invoke(tree, null)!, (int)_maximumLevel.GetValue(null)!); + } +} diff --git a/VGModAPI/Native/TractorBeamRuntime.cs b/VGModAPI/Native/TractorBeamRuntime.cs new file mode 100644 index 00000000..2b690aa7 --- /dev/null +++ b/VGModAPI/Native/TractorBeamRuntime.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using System.Threading; +using UnityEngine; +using VGModAPI.Core; + +namespace VGModAPI.Runtime; + +internal sealed class TractorBeamRuntime +{ + private const BindingFlags Any = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + private readonly int _thread = Thread.CurrentThread.ManagedThreadId; + private readonly EquipmentService _equipment; + private readonly TooltipService _tooltips; + private readonly SkillTreeRuntime _skills; + private readonly Action _report; + private readonly Type _item; + private readonly FieldInfo _beams, _automatic, _manual, _stats, _filtered; + private readonly PropertyInfo _parent, _tractored, _badgeTree; + private readonly MethodInfo _isPlayer, _busy, _eligible, _blocked, _addStat, _addText; + private readonly FieldInfo _green, _details; + internal MethodInfo AvailableBeam { get; } + internal MethodInfo UpdateTargets { get; } + internal MethodInfo ModuleStats { get; } + internal MethodInfo MasteryTooltip { get; } + + internal TractorBeamRuntime(Assembly a, EquipmentService equipmentService, TooltipService tooltips, SkillTreeRuntime skills, Action report) + { + _equipment = equipmentService; _tooltips = tooltips; _skills = skills; _report = report; + var module = a.GetType("Behaviour.Equipment.Module.TractorModule", true)!; + _item = a.GetType("Behaviour.Tractoring.TractorableItem", true)!; + var equipment = a.GetType("Behaviour.Equipment.AbstractEquipment", true)!; + var beam = a.GetType("Behaviour.Tractoring.TractorBeam", true)!; + var badge = a.GetType("Behaviour.UI.MasteryBadge", true)!; + var tooltip = a.GetType("Behaviour.UI.UITooltip", true)!; + _beams = Field(module, "tractorBeams"); _automatic = Field(module, "amountOfBeams"); _manual = Field(module, "amountOfBonusBeams"); + _stats = Field(equipment, "mainSubStats"); _parent = Property(equipment, "parent"); _filtered = Field(module, "filteredTargets"); + _tractored = Property(_item, "isTractored"); _badgeTree = Property(badge, "skillTree"); + _isPlayer = Method(equipment, "IsPlayer", typeof(bool)); _busy = Method(beam, "HasTarget"); + _eligible = Method(_item, "CanBeAutoTractoredBy", _parent.PropertyType); + _blocked = Method(module, "IsCrewPodTargetingBlocked", _item); + _addStat = Method(_stats.FieldType, "AddMainSubStat", typeof(string), typeof(string)); + _addText = Method(tooltip, "AddTextLine", typeof(string), typeof(int), typeof(float)); + var colors = a.GetType("Source.Util.ColorHelper", true)!; + _green = Field(colors, "greenish"); _details = Field(colors, "detailsColor"); + AvailableBeam = Method(module, "GetAvailableTractorBeam", typeof(bool)); + var targets = a.GetType("Behaviour.Weapons.TargetableUnit", true)!; + UpdateTargets = Method(module, "UpdateAvailableTargets", typeof(System.Collections.Generic.IEnumerable<>).MakeGenericType(targets)); + ModuleStats = Method(module, "SetMainSubStats"); MasteryTooltip = Method(badge, "AddTooltipCustomContent", tooltip); + if (AvailableBeam.ReturnType != beam || UpdateTargets.ReturnType != typeof(void) || ModuleStats.ReturnType != typeof(void) || MasteryTooltip.ReturnType != typeof(void) + || _isPlayer.ReturnType != typeof(bool) || _busy.ReturnType != typeof(bool) || _eligible.ReturnType != typeof(bool) || _blocked.ReturnType != typeof(bool) + || _automatic.FieldType != typeof(int) || _manual.FieldType != typeof(int) || _tractored.PropertyType != typeof(bool)) + throw new InvalidOperationException("Tractor binding shape changed."); + } + private static MethodInfo Method(Type type, string name, params Type[] parameters) + => type.GetMethod(name, Any, null, parameters, null) ?? throw new MissingMethodException(type.FullName, name); + private static FieldInfo Field(Type type, string name) + { + for (Type? current = type; current != null; current = current.BaseType) + if (current.GetField(name, Any | BindingFlags.DeclaredOnly) is FieldInfo field) return field; + throw new MissingFieldException(type.FullName, name); + } + private static PropertyInfo Property(Type type, string name) + => type.GetProperty(name, Any) ?? throw new MissingMemberException(type.FullName, name); + private TractorModule ReadModule(object module) => new((int)_automatic.GetValue(module)!, (int)_manual.GetValue(module)!); + private int BusyBeams(object module) + { + int busy = 0; + foreach (var beam in (IEnumerable)_beams.GetValue(module)!) if ((bool)_busy.Invoke(beam, null)!) busy++; + return busy; + } + private bool MainThread => Thread.CurrentThread.ManagedThreadId == _thread; + internal object? Borrow(object module, bool manual, object? original) + { + if (original != null || !MainThread || !_equipment.Availability.IsAvailable) return original; + try + { + if (_isPlayer.Invoke(module, new object[] { true }) is not true) return original; + var values = ReadModule(module); var targeting = _equipment.Resolve(values); + if (targeting == null || !EquipmentService.MayBorrow(values, targeting, BusyBeams(module), manual)) return original; + foreach (var beam in (IEnumerable)_beams.GetValue(module)!) if (_busy.Invoke(beam, null) is false) return beam; + } + catch (Exception error) { Report(error); } + return original; + } + internal void TopUp(object module, IEnumerable targets) + { + if (!MainThread || !_equipment.Availability.IsAvailable) return; + try + { + if (_isPlayer.Invoke(module, new object[] { true }) is not true) return; + var values = ReadModule(module); var targeting = _equipment.Resolve(values); + if (targeting == null) return; + int cap = EquipmentService.AutomaticCapacity(values, targeting); + var filtered = (IList)_filtered.GetValue(module)!; + var parent = _parent.GetValue(module); + foreach (var target in targets) + { + if (filtered.Count >= cap) break; + if (target is not UnityEngine.Object native || native == null || !_item.IsInstanceOfType(target) || filtered.Contains(target)) continue; + if (_tractored.GetValue(target) is false && _eligible.Invoke(target, new[] { parent }) is true && _blocked.Invoke(module, new[] { target }) is false) + filtered.Add(target); + } + } + catch (Exception error) { Report(error); } + } + internal void AddModuleDescription(object module) + { + if (!MainThread || !_tooltips.Availability.IsAvailable) return; + try + { + foreach (var line in _tooltips.Describe(ReadModule(module))) + _addStat.Invoke(_stats.GetValue(module), new object[] { string.Concat(line.Spans.Select(span => span.Text)), "" }); + } + catch (Exception error) { Report(error); } + } + internal void AddMasteryDescription(object badge, object tooltip) + { + if (!MainThread || !_tooltips.Availability.IsAvailable) return; + try + { + var nativeTree = _badgeTree.GetValue(badge); + var tree = nativeTree == null ? null : _skills.Read(nativeTree); + if (tree == null) return; + foreach (var line in _tooltips.Describe(tree)) + _addText.Invoke(tooltip, new object[] { string.Concat(line.Spans.Select(span => Format(span.Text, span.Style))), 12, 8f }); + } + catch (Exception error) { Report(error); } + } + private string Format(string text, TooltipTextStyle style) + { + if (style == TooltipTextStyle.Normal) return text; + var color = style == TooltipTextStyle.Bonus ? _green : _details; + return "" + text + ""; + } + private void Report(Exception error) { try { _report(error); } catch { } } +} diff --git a/VGModAPI/Patches/TractorBeamPatches.cs b/VGModAPI/Patches/TractorBeamPatches.cs new file mode 100644 index 00000000..92743512 --- /dev/null +++ b/VGModAPI/Patches/TractorBeamPatches.cs @@ -0,0 +1,26 @@ +using System.Collections; +using VGModAPI.Runtime; + +namespace VGModAPI.Patches; + +internal static class TractorBeamPatches +{ + internal static TractorBeamRuntime? Runtime; + internal static class Available + { + internal static void Postfix(object __instance, bool bonus, ref object? __result) + => __result = Runtime?.Borrow(__instance, bonus, __result) ?? __result; + } + internal static class Targets + { + internal static void Postfix(object __instance, IEnumerable targets) => Runtime?.TopUp(__instance, targets); + } + internal static class ModuleStats + { + internal static void Postfix(object __instance) => Runtime?.AddModuleDescription(__instance); + } + internal static class MasteryTooltip + { + internal static void Postfix(object __instance, object tooltip) => Runtime?.AddMasteryDescription(__instance, tooltip); + } +} diff --git a/VGModAPI/Plugin.cs b/VGModAPI/Plugin.cs index b6ea9138..9b315e40 100644 --- a/VGModAPI/Plugin.cs +++ b/VGModAPI/Plugin.cs @@ -132,6 +132,7 @@ private void Awake() ["writeMetadata"] = typeof(SavePatches.WriteMetadata), ["storeFailure"] = typeof(SavePatches.StoreFailure) }); InstallHud(assembly); + InstallTractorBeams(assembly); InstallGameplayUi(assembly); InstallAmbientTraffic(assembly); InstallUnitProtection(assembly); @@ -155,6 +156,7 @@ private void Awake() // Stop observation even if a failed rollback leaves a detour installed. _adapter?.Guard(() => throw new InvalidOperationException("Adapter installation failed.", ex)); TeardownHud(); + TeardownTractorBeams(); TeardownGameplayUi(); TeardownAmbientTraffic(); TeardownUnitProtection(); @@ -168,6 +170,9 @@ private void Awake() var reason = ex is NotSupportedException && _inspectedGameAssembly == null ? ServiceUnavailableReason.UnsupportedGame : ServiceUnavailableReason.BindingFailed; _hub.SetUnavailable("gameplay-ui", reason, ex.Message); + _hub.SetUnavailable("equipment", reason, ex.Message); + _hub.SetUnavailable("skill-trees", reason, ex.Message); + _hub.SetUnavailable("tooltips", reason, ex.Message); _hub.SetUnavailable("ambient-traffic", reason, ex.Message); _hub.SetUnavailable("unit-protection", reason, ex.Message); _hub.SetUnavailable("story-characters", reason, ex.Message); @@ -1056,6 +1061,7 @@ private void OnDestroy() ModApi.ClearServices(_serviceRoot); _hub?.Dispose(); // Close gates and preserve queued terminal delivery before releasing service views. StopDungeonPanel(); + TeardownTractorBeams(); TeardownHud(); TeardownGameplayUi(); TeardownAmbientTraffic(); diff --git a/VGModAPI/ServiceIntegration.cs b/VGModAPI/ServiceIntegration.cs index 284b9316..2dcc5f14 100644 --- a/VGModAPI/ServiceIntegration.cs +++ b/VGModAPI/ServiceIntegration.cs @@ -25,6 +25,9 @@ private void PublishServiceRoot() _craftingCommands ??= new CraftingCommandService(hub, _craftingJobs, null, error => Logger.LogError(error)); _hudService ??= new HudService(hub, hub.ReportSubscriberFailure); _gameplayUi ??= new GameplayUiService(hub); + _equipment ??= new EquipmentService(hub); + _skillTrees ??= new SkillTreeService(hub); + _tooltips ??= new TooltipService(hub); _forgeUi ??= new ForgeUiService(hub, null, hub.ReportSubscriberFailure); _boardingRuleService ??= new BoardingRuleService(hub, hub.ReportSubscriberFailure); _boardingCombat ??= new BoardingCombatService(hub, hub.ReportSubscriberFailure); @@ -51,12 +54,15 @@ private void PublishServiceRoot() var storyCharacters = _storyCharacters ??= new StoryCharacterService(hub); _dialogueService ??= new DialogueService(hub.Services.Get("dialogue"), hub.CheckThread, error => hub.ReportSubscriberFailure("dialogue", error), storyCharacters); var root = new ModServices(lifecycle, mods, settings, (_persistence ??= new PersistenceService(hub)), missions, travel, station, - _recipes, _recipeQuotes, _craftingJobs, _craftingCommands, _hudService, _forgeUi, _boardingRuleService, _dungeonFacade, _story, _bars, _worldContent, _dialogueService, new GameService(hub, _navigationService, _inventoryService, _story, _bars), _ownedItems, _ownedRecipes, _gameplayUi); + _recipes, _recipeQuotes, _craftingJobs, _craftingCommands, _hudService, _forgeUi, _boardingRuleService, _dungeonFacade, _story, _bars, _worldContent, _dialogueService, new GameService(hub, _navigationService, _inventoryService, _story, _bars), _ownedItems, _ownedRecipes, _gameplayUi, _equipment, _skillTrees, _tooltips); // Deferred cleanup preserves terminal lifecycle delivery when shutdown starts inside a callback. // Content owners release their registrations before the save-data coordinator stops. foreach (var service in new IDisposable[] { mods, settings, missions, travel, station, _recipes, _recipeQuotes, _craftingJobs, _craftingCommands, _hudService, _forgeUi, _boardingRuleService, _dungeonFacade, _story, _bars }) hub.Services.AfterStopped(service.Dispose); hub.Services.AfterStopped(_gameplayUi.Dispose); + hub.Services.AfterStopped(_equipment.Dispose); + hub.Services.AfterStopped(_skillTrees.Dispose); + hub.Services.AfterStopped(_tooltips.Dispose); hub.Services.AfterStopped(ambient.Dispose); hub.Services.AfterStopped(protection.Dispose); hub.Services.AfterStopped(droneBays.Dispose); diff --git a/VGModAPI/TractorBeamIntegration.cs b/VGModAPI/TractorBeamIntegration.cs new file mode 100644 index 00000000..bd4edddb --- /dev/null +++ b/VGModAPI/TractorBeamIntegration.cs @@ -0,0 +1,44 @@ +using System; +using System.Reflection; +using HarmonyLib; +using VGModAPI.Core; +using VGModAPI.Patches; +using VGModAPI.Runtime; + +namespace VGModAPI; + +public sealed partial class Plugin +{ + private EquipmentService? _equipment; + private SkillTreeService? _skillTrees; + private TooltipService? _tooltips; + private Harmony? _tractorHarmony; + private void InstallTractorBeams(Assembly assembly) + { + _equipment ??= new EquipmentService(_hub!); + _skillTrees ??= new SkillTreeService(_hub!); + _tooltips ??= new TooltipService(_hub!); + try + { + var skills = new SkillTreeRuntime(assembly); + var runtime = new TractorBeamRuntime(assembly, _equipment, _tooltips, skills, error => Logger.LogError(error)); + _tractorHarmony = new Harmony(ModApi.PluginId + ".tractor-beams"); + TractorBeamPatches.Runtime = runtime; + _tractorHarmony.Patch(runtime.AvailableBeam, postfix: new HarmonyMethod(typeof(TractorBeamPatches.Available), "Postfix")); + _tractorHarmony.Patch(runtime.UpdateTargets, postfix: new HarmonyMethod(typeof(TractorBeamPatches.Targets), "Postfix")); + _tractorHarmony.Patch(runtime.ModuleStats, postfix: new HarmonyMethod(typeof(TractorBeamPatches.ModuleStats), "Postfix")); + _tractorHarmony.Patch(runtime.MasteryTooltip, postfix: new HarmonyMethod(typeof(TractorBeamPatches.MasteryTooltip), "Postfix")); + _skillTrees.Bind(skills.Get); + _equipment.SetAvailable(true); _tooltips.SetAvailable(true); + } + catch (Exception error) { TeardownTractorBeams(); Logger.LogError(error); } + } + private void TeardownTractorBeams() + { + TractorBeamPatches.Runtime = null; + try { _tractorHarmony?.UnpatchSelf(); } + catch (Exception error) { Logger.LogError(error); } + _tractorHarmony = null; + _equipment?.SetAvailable(false); _skillTrees?.Bind(null); _tooltips?.SetAvailable(false); + } +} diff --git a/docs/reference/tractor-beams.md b/docs/reference/tractor-beams.md new file mode 100644 index 00000000..b84e054a --- /dev/null +++ b/docs/reference/tractor-beams.md @@ -0,0 +1,69 @@ +# Equipment, skill trees, and tooltips + +These independent surfaces are unreleased; public API 0.2.8 does not contain them. + +## Configure player tractor modules + +```csharp +var api = ModApi.Services; +var targeting = api.Equipment.ConfigurePlayerTractorModules("my.mod", module => + new TractorTargeting(module.BeamCount + 1, allowManualBorrowing: true)); +``` + +`TractorModule` carries read-only values for this callback, not a native object: +`BeamCount` is vanilla `amountOfBeams`; `ManualBeamCount` is the UI's **Manual Tractor +Beams** (`amountOfBonusBeams`). No mastery or presentation policy lives on the module. + +Vanilla scans its requested pool first. Only after it finds no beam can the API +borrow a free one. `AutomaticBeamLimit` is clamped between the base automatic count +and total physical count. Busy beams in either pool count toward the automatic +limit. Manual borrowing is separately opt-in. Occupied beams are never stolen. +Candidate expansion preserves native ordering, ownership and crew/brig eligibility. +Actual tracking and cargo-space checks remain vanilla. Rules apply to the player +ship, not AI ships or carrier fighters. + +Return null to leave behavior to the next registration or vanilla; the first +non-null result wins in registration order. Callbacks are synchronous, main-thread, +read-only evaluations; errors abstain and recursive evaluation falls back to vanilla. +Dispose the registration to remove the rule. There is no polling or session handling +for ordinary consumers. Unavailable integration leaves vanilla unchanged. + +## Read commander skill trees + +```csharp +var tree = api.SkillTrees.Get(CommanderSpecialization.Engineering); +int level = tree?.MasteryLevel ?? 0; +``` + +Specialization names match the game's enum, including Engineering, Mining, Economy, +Industrial and Leadership. `Identifier` is the native skill-tree identifier (for +example Engineering resolves through vanilla to its tree); `MasteryLevel` and +`MaximumLevel` are current native values. Get returns null without a commander/tree +or when integration is unavailable. Retained values do not follow a replacement game. +Mastery scaling formulas remain in mods, not in the targeting API. + +## Extend tooltips independently + +```csharp +var moduleTip = api.Tooltips.RegisterTractorModule("my.mod", (module, tooltip) => +{ + if (module.ManualBeamCount > 0) + tooltip.AddLine("Manual beams can also target automatically"); +}); +var masteryTip = api.Tooltips.RegisterSkillTree("my.mod", (tree, tooltip) => +{ + if (tree.Specialization == CommanderSpecialization.Engineering) + tooltip.AddLine($"Mastery: {tree.MasteryLevel}", TooltipTextStyle.Bonus) + .Append(" (my mod)", TooltipTextStyle.Details); +}); +``` + +No equipment rule is required to add tooltip text. All skill-tree mastery tooltips +are supported, not only Engineering. Lines append after native content; Bonus and +Details use the game palette. Module stats use plain text with an empty numeric value. +Native module-stat caching is preserved: a config change does not rebuild existing +stats. Use ASCII strings for the game's pixel font. + +Tooltip builders are valid only during their callback. Failing contributions are +discarded without suppressing other mods. Disposal removes a registration; API +shutdown disables all surfaces. None of these APIs expose game or Unity types. From a7719b5aa9fff23eb28c513ab538e82284eef3ad Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Wed, 16 Sep 2026 20:23:59 +0200 Subject: [PATCH 02/12] perf: cache native specialization identifiers --- VGModAPI/Native/SkillTreeRuntime.cs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/VGModAPI/Native/SkillTreeRuntime.cs b/VGModAPI/Native/SkillTreeRuntime.cs index 5e24b991..03a4933e 100644 --- a/VGModAPI/Native/SkillTreeRuntime.cs +++ b/VGModAPI/Native/SkillTreeRuntime.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Reflection; using System.Threading; @@ -10,38 +11,42 @@ internal sealed class SkillTreeRuntime private readonly int _thread = Thread.CurrentThread.ManagedThreadId; private readonly FieldInfo _player; private readonly PropertyInfo _commander, _maximumLevel, _identifier; - private readonly MethodInfo _get, _name, _mastery; - private readonly Type _specialization; + private readonly MethodInfo _get, _mastery; + private readonly Dictionary _names = new(); + private readonly Dictionary _specializations = new(StringComparer.Ordinal); internal SkillTreeRuntime(Assembly assembly) { var player = assembly.GetType("Source.Player.GamePlayer", true)!; var tree = assembly.GetType("Behaviour.Crew.Skilltree", true)!; - _specialization = assembly.GetType("Source.Personnel.CommanderSpecialization", true)!; + var specializationType = assembly.GetType("Source.Personnel.CommanderSpecialization", true)!; _player = player.GetField("current", Any) ?? throw new MissingFieldException(player.FullName, "current"); _commander = player.GetProperty("commander", Any) ?? throw new MissingMemberException(player.FullName, "commander"); _maximumLevel = assembly.GetType("Source.Util.GameMath", true)!.GetProperty("maxLevel", Any) ?? throw new MissingMemberException("GameMath.maxLevel"); _identifier = tree.GetProperty("identifier", Any) ?? throw new MissingMemberException(tree.FullName, "identifier"); _get = tree.GetMethod("Get", Any, null, new[] { typeof(string) }, null) ?? throw new MissingMethodException(tree.FullName, "Get"); - _name = assembly.GetType("Source.Personnel.SkillTreeData", true)!.GetMethod("GetSpecializationTreeName", Any, null, new[] { _specialization }, null) ?? throw new MissingMethodException("SkillTreeData.GetSpecializationTreeName"); + var nameMethod = assembly.GetType("Source.Personnel.SkillTreeData", true)!.GetMethod("GetSpecializationTreeName", Any, null, new[] { specializationType }, null) ?? throw new MissingMethodException("SkillTreeData.GetSpecializationTreeName"); + foreach (CommanderSpecialization value in Enum.GetValues(typeof(CommanderSpecialization))) + { + var identifier = (string)nameMethod.Invoke(null, new[] { Enum.Parse(specializationType, value.ToString()) })!; + _names.Add(value, identifier); + _specializations.Add(identifier, value); + } _mastery = tree.GetMethod("GetMasteryLevel", Any, null, Type.EmptyTypes, null) ?? throw new MissingMethodException(tree.FullName, "GetMasteryLevel"); if (_mastery.ReturnType != typeof(int) || _maximumLevel.PropertyType != typeof(int) || _identifier.PropertyType != typeof(string)) throw new InvalidOperationException("Skill tree binding shape changed."); } - private string Name(CommanderSpecialization specialization) => (string)_name.Invoke(null, new[] { Enum.Parse(_specialization, specialization.ToString()) })!; private bool HasCommander => _player.GetValue(null) is object player && _commander.GetValue(player) != null; internal SkillTree? Get(CommanderSpecialization specialization) { if (Thread.CurrentThread.ManagedThreadId != _thread || !HasCommander) return null; - var tree = _get.Invoke(null, new object[] { Name(specialization) }); + var tree = _get.Invoke(null, new object[] { _names[specialization] }); return tree == null ? null : Read(tree); } internal SkillTree? Read(object tree) { if (Thread.CurrentThread.ManagedThreadId != _thread || !HasCommander) return null; var identifier = (string)_identifier.GetValue(tree)!; - CommanderSpecialization? specialization = null; - foreach (CommanderSpecialization value in Enum.GetValues(typeof(CommanderSpecialization))) - if (Name(value) == identifier) { specialization = value; break; } + CommanderSpecialization? specialization = _specializations.TryGetValue(identifier, out var value) ? value : null; return new SkillTree(identifier, specialization, (int)_mastery.Invoke(tree, null)!, (int)_maximumLevel.GetValue(null)!); } } From 324b7b168f123f597ab1bf638944973cf9bf7850 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 17 Sep 2026 16:05:40 +0200 Subject: [PATCH 03/12] feat: extend tooltips to every equipment module family and item Replace RegisterTractorModule with game-aligned family surfaces: RegisterShipModule dispatches one centralized stat-builder postfix per native AbstractEquipment override with a typed ShipModule snapshot (ShipModuleKind, StatLines, tractor sub-snapshot), and RegisterItem hooks the shared tooltip content fill for item sources. All three families dispatch from a single TooltipRuntime behind one Harmony instance with fail-closed binding. E2E gains committed tractor-module case with family and item checks. --- VGModAPI.Abstractions/Tooltips.cs | 55 +++++- VGModAPI.Core/TooltipService.cs | 11 +- VGModAPI.E2E/Plugin.cs | 2 + VGModAPI.E2E/TractorCase.cs | 207 ++++++++++++++++++++ VGModAPI.Tests/InstalledTractorBeamTests.cs | 39 ++++ VGModAPI.Tests/SkillTreeAndTooltipTests.cs | 46 ++++- VGModAPI/Native/TooltipRuntime.cs | 192 ++++++++++++++++++ VGModAPI/Native/TractorBeamRuntime.cs | 57 +----- VGModAPI/Patches/TractorBeamPatches.cs | 9 + VGModAPI/Plugin.cs | 3 + VGModAPI/TractorBeamIntegration.cs | 35 +++- docs/reference/tractor-beams.md | 19 +- tools/e2e.py | 2 +- 13 files changed, 605 insertions(+), 72 deletions(-) create mode 100644 VGModAPI.E2E/TractorCase.cs create mode 100644 VGModAPI/Native/TooltipRuntime.cs diff --git a/VGModAPI.Abstractions/Tooltips.cs b/VGModAPI.Abstractions/Tooltips.cs index 022600f6..1b5dd259 100644 --- a/VGModAPI.Abstractions/Tooltips.cs +++ b/VGModAPI.Abstractions/Tooltips.cs @@ -5,6 +5,51 @@ namespace VGModAPI; public enum TooltipTextStyle { Normal, Bonus, Details } +/// The game's ship equipment module families, named after the native +/// Behaviour.Equipment.Module types. Other covers native modules without a dedicated kind. +public enum ShipModuleKind +{ + Tractor, Mining, Salvage, ShieldGenerator, Reactor, ReactorLight, DroneBay, TorpedoBay, + Armor, CargoScoop, EngineThrusters, HangarBay, Hull, Painter, Repair, Scanner, Other +} + +/// A stat line the native module just showed, captured before extensions append. +public sealed class ModuleStatLine +{ + public string Label { get; } + public string Value { get; } + internal ModuleStatLine(string label, string value) + { Label = label; Value = value; } +} + +/// Read-only ship equipment module values captured for one tooltip render. +public sealed class ShipModule +{ + public ShipModuleKind Kind { get; } + public string DisplayName { get; } + public int QualityLevel { get; } + /// The vanilla stat lines currently shown, in native order. + public IReadOnlyList StatLines { get; } + /// Beam counts, non-null only when Kind is Tractor. + public TractorModule? Tractor { get; } + internal ShipModule(ShipModuleKind kind, string displayName, int qualityLevel, + IReadOnlyList statLines, TractorModule? tractor) + { Kind = kind; DisplayName = displayName; QualityLevel = qualityLevel; StatLines = statLines; Tractor = tractor; } +} + +/// Read-only inventory item values captured for one item tooltip render. +public sealed class ItemInfo +{ + public string Identifier { get; } + public string DisplayName { get; } + public string Description { get; } + /// Stack size shown by the source, when a concrete stack was provided. + public int Count { get; } + public int ItemLevel { get; } + internal ItemInfo(string identifier, string displayName, string description, int count, int itemLevel) + { Identifier = identifier; DisplayName = displayName; Description = description; Count = count; ItemLevel = itemLevel; } +} + /// Append-only tooltip content. Valid only during the tooltip callback; no Unity objects are exposed. public sealed class Tooltip { @@ -40,9 +85,13 @@ public TooltipLine Append(string text, TooltipTextStyle style = TooltipTextStyle public interface ITooltipService : IServiceStatus { - /// Append module stat descriptions after vanilla builds them. Native caching is preserved; - /// changes do not retroactively rebuild existing stats. Style is ignored by the native stat list. - IDisposable RegisterTractorModule(string pluginId, Action describe); + /// Append lines to any ship equipment module's stat list, for every native module family. + /// Called while the module builds its stats; filter by Kind for your family. Changes do not + /// retroactively rebuild existing stats; native caching is preserved. + IDisposable RegisterShipModule(string pluginId, Action describe); + /// Append lines after any inventory item's native tooltip body, using the item the + /// source is showing (inventory, shop, loot and compare sources all route through it). + IDisposable RegisterItem(string pluginId, Action describe); /// Append after any skill tree's native mastery bonus lines, using live tree values. IDisposable RegisterSkillTree(string pluginId, Action describe); } diff --git a/VGModAPI.Core/TooltipService.cs b/VGModAPI.Core/TooltipService.cs index 92c634bd..76511f99 100644 --- a/VGModAPI.Core/TooltipService.cs +++ b/VGModAPI.Core/TooltipService.cs @@ -16,13 +16,18 @@ public event Action? AvailabilityChanged internal void SetAvailable(bool available) { _hub.CheckThread(); if (_disposed) return; - if (available) _hub.SetAvailable("tooltips", "Tractor module and skill-tree tooltip extensions bound."); + if (available) _hub.SetAvailable("tooltips", "Ship module, item and skill-tree tooltip extensions bound."); else _hub.SetUnavailable("tooltips", ServiceUnavailableReason.BindingFailed, "Tooltip extensions unavailable."); } - public IDisposable RegisterTractorModule(string pluginId, Action describe) + public IDisposable RegisterShipModule(string pluginId, Action describe) { if (describe == null) throw new ArgumentNullException(nameof(describe)); - return Register(pluginId, typeof(TractorModule), (value, tooltip) => describe((TractorModule)value, tooltip)); + return Register(pluginId, typeof(ShipModule), (value, tooltip) => describe((ShipModule)value, tooltip)); + } + public IDisposable RegisterItem(string pluginId, Action describe) + { + if (describe == null) throw new ArgumentNullException(nameof(describe)); + return Register(pluginId, typeof(ItemInfo), (value, tooltip) => describe((ItemInfo)value, tooltip)); } public IDisposable RegisterSkillTree(string pluginId, Action describe) { diff --git a/VGModAPI.E2E/Plugin.cs b/VGModAPI.E2E/Plugin.cs index 009f589f..d10ceca0 100644 --- a/VGModAPI.E2E/Plugin.cs +++ b/VGModAPI.E2E/Plugin.cs @@ -48,6 +48,7 @@ private void Awake() throw new InvalidOperationException("Missing controller deadline."); _caseId = requested switch { + TractorCase.Id => TractorCase.Id, ModSettingsMenuCase.Id => ModSettingsMenuCase.Id, FreshSessionCase.Id => FreshSessionCase.Id, PocketWorldsCase.Id => PocketWorldsCase.Id, @@ -73,6 +74,7 @@ private void Awake() var remaining = (deadline - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) / 1000.0; _test = _caseId switch { + TractorCase.Id => new GameTest(TractorCase.Steps(_lifecycle, _events), remaining), ModSettingsMenuCase.Id => new GameTest(ModSettingsMenuCase.Steps(), remaining), FreshSessionCase.Id => new GameTest(FreshSessionCase.Steps(_lifecycle, _events), remaining), PocketWorldsCase.Id => new GameTest(PocketWorldsCase.Steps(_lifecycle, _events, _travelEvents), remaining), diff --git a/VGModAPI.E2E/TractorCase.cs b/VGModAPI.E2E/TractorCase.cs new file mode 100644 index 00000000..d1d7e3c0 --- /dev/null +++ b/VGModAPI.E2E/TractorCase.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEngine; + +namespace VGModAPI.E2E; + +internal static class TractorCase +{ + internal const string Id = "tractor-module"; + private const BindingFlags Any = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + private static object? Get(object obj, string name) => obj.GetType().GetProperty(name, Any)?.GetValue(obj) ?? Field(obj.GetType(), name).GetValue(obj); + private static FieldInfo Field(Type type, string name) + { + for (Type? t = type; t != null; t = t.BaseType) + if (t.GetField(name, Any | BindingFlags.DeclaredOnly) is FieldInfo f) return f; + throw new MissingFieldException(type.FullName, name); + } + private static void Require(bool value, string detail) { if (!value) throw new InvalidOperationException(detail); } + internal static IReadOnlyList Steps(ILifecycleService lifecycle, List events) + { + var steps = new List(LiveBoot.Steps("vgtractorauto", lifecycle, events)); + steps.Add(new TestStep("tractor targeting, tooltips and disabled behavior", "TractorModule / Skilltree / MasteryBadge", 30, Run)); + return steps; + } + private static StepResult Run() + { + Require(ModApi.Services.Equipment.Availability.IsAvailable && ModApi.Services.SkillTrees.Availability.IsAvailable && ModApi.Services.Tooltips.Availability.IsAvailable, "API services not bound"); + var a = AppDomain.CurrentDomain.GetAssemblies().Single(x => x.GetName().Name == "Assembly-CSharp"); + var moduleType = a.GetType("Behaviour.Equipment.Module.TractorModule", true)!; + var module = Resources.FindObjectsOfTypeAll(moduleType).OfType().FirstOrDefault(m => moduleType.GetMethod("IsPlayer", Any)!.Invoke(m, new object[] { true }) is true); + if (module == null) return StepResult.Wait("player tractor module not ready"); + var plugin = NativeGameplay.PluginInstance("vgtractorauto")!; + var enabled = Get(plugin, "_enabled")!; + var enabledValue = enabled.GetType().GetProperty("Value")!; + var oldEnabled = enabledValue.GetValue(enabled); + var tree = a.GetType("Behaviour.Crew.Skilltree", true)!.GetMethod("Get", Any)!.Invoke(null, new object[] { ModApi.Services.SkillTrees.Get(CommanderSpecialization.Engineering)!.Identifier })!; + var player = a.GetType("Source.Player.GamePlayer", true)!.GetField("current", Any)!.GetValue(null)!; + var commander = Get(player, "commander")!; + var data = commander.GetType().GetMethod("GetSkillTreeData", Any)!.Invoke(commander, new object[] { tree, (object)true })!; + var level = Field(data.GetType(), "masteryLevel"); var oldLevel = level.GetValue(data); + var manualField = Field(moduleType, "amountOfBonusBeams"); var oldManual = manualField.GetValue(module); + var beams = (IList)Get(module, "tractorBeams")!; int originalBeamCount = beams.Count; + var statsField = Field(moduleType, "mainSubStats"); var oldStats = statsField.GetValue(module); + var filtered = (IList)Get(module, "filteredTargets")!; var oldFiltered = filtered.Cast().ToArray(); + var originals = beams.Cast().ToDictionary(b => b, b => Get(b, "target")); + var temporary = new List(); + try + { + // Ensure enough physical manual beams to exercise partial mastery deterministically. + moduleType.GetMethod("CreateTractorBeams", Any)!.Invoke(module, new object[] { 3, true }); + manualField.SetValue(module, (int)oldManual! + 3); + var itemType = a.GetType("Behaviour.Tractoring.TractorableItem", true)!; + var markerGo = new GameObject("tractor-e2e-busy"); markerGo.SetActive(false); temporary.Add(markerGo); + var marker = markerGo.AddComponent(itemType); + var all = beams.Cast().ToArray(); + var auto = all.Where(b => Get(b, "bonusBeam") is false).ToArray(); + var manual = all.Where(b => Get(b, "bonusBeam") is true).ToArray(); + Require(auto.Length > 0, "fixture needs automatic beam"); + var request = moduleType.GetMethod("GetAvailableTractorBeam", Any)!; + void Busy(object b, bool busy) => Field(b.GetType(), "target").SetValue(b, busy ? marker : null); + object? Request(bool bonus) => request.Invoke(module, new object[] { bonus }); + foreach (var b in all) Busy(b, false); + foreach (var b in auto) Busy(b, true); + enabledValue.SetValue(enabled, true); level.SetValue(data, 0); + Require(Request(false) == null, "zero mastery promoted manual beam"); + Require(manual.Contains(Request(true)), "vanilla manual pool changed"); + int cap = ModApi.Services.SkillTrees.Get(CommanderSpecialization.Engineering)!.MaximumLevel; + level.SetValue(data, cap); + Require(manual.Contains(Request(false)), "max mastery did not borrow free manual beam"); + enabledValue.SetValue(enabled, false); + Require(Request(false) == null, "disabled mod promoted manual beam"); + foreach (var b in all) Busy(b, true); + Busy(auto[0], false); + Require(Request(true) == null, "disabled mod borrowed automatic beam"); + enabledValue.SetValue(enabled, true); level.SetValue(data, 0); + Require(ReferenceEquals(auto[0], Request(true)), "manual fallback failed at zero mastery"); + foreach (var b in all) Busy(b, true); + Require(Request(true) == null && Request(false) == null, "occupied beam stolen"); + foreach (var b in manual) Busy(b, false); + level.SetValue(data, cap / 2); + int promoted = (int)Math.Floor((float)(cap / 2) / cap * manual.Length); + for (int i = 0; i < promoted; i++) Busy(manual[i], true); + Require(Request(false) == null, "auto exceeded conservative busy-beam cap"); + Busy(manual[promoted - 1], false); + Require(Request(false) != null, "auto did not fill remaining allowance"); + + // Actual native target filtering on controlled inactive loot components. + level.SetValue(data, cap); + var inventoryType = a.GetType("Behaviour.Item.InventoryItemType", true)!; + var crewPodType = a.GetType("Behaviour.Item.Usable.CrewPodItem", true)!; + var ordinary = ((IEnumerable)inventoryType.GetProperty("all", Any)!.GetValue(null)!).Cast().First(i => i.GetComponent(crewPodType) == null); + var lootDataType = a.GetType("Source.Data.Persistable.TractorableItemData", true)!; + var targetType = a.GetType("Behaviour.Weapons.TargetableUnit", true)!; + var candidates = Array.CreateInstance(targetType, all.Length + 3); + for (int i = 0; i < candidates.Length; i++) + { + var go = new GameObject("tractor-e2e-candidate"); go.SetActive(false); temporary.Add(go); + var candidate = go.AddComponent(itemType); + var loot = Activator.CreateInstance(lootDataType)!; + lootDataType.GetProperty("itemType")!.SetValue(loot, ordinary); + if (i == 0) lootDataType.GetProperty("jettisoned")!.SetValue(loot, true); + itemType.GetProperty("data", Any)!.SetValue(candidate, loot); + if (i == 1) itemType.GetProperty("isTractored", Any)!.SetValue(candidate, true); + candidates.SetValue(candidate, i); + } + var updateTargets = moduleType.GetMethod("UpdateAvailableTargets", Any)!; + updateTargets.Invoke(module, new object[] { candidates }); + Require(filtered.Count == all.Length, "candidate cap did not include promoted beams"); + Require(!filtered.Contains(candidates.GetValue(0)) && !filtered.Contains(candidates.GetValue(1)), "ineligible candidate added"); + for (int i = 0; i < filtered.Count; i++) Require(ReferenceEquals(filtered[i], candidates.GetValue(i + 2)), "candidate ordering changed"); + enabledValue.SetValue(enabled, false); + updateTargets.Invoke(module, new object[] { candidates }); + Require(filtered.Count == auto.Length, "disabled candidate cap differs from vanilla"); + + // Bake real module stats, preserving the native caching boundary. + enabledValue.SetValue(enabled, true); + statsField.SetValue(module, Activator.CreateInstance(statsField.FieldType)); + moduleType.GetMethod("SetMainSubStats", Any)!.Invoke(module, null); + var stats = ((IEnumerable)Get(statsField.GetValue(module)!, "subStatsList")!).Cast().ToArray(); + Require(stats.Any(s => ((string)Get(s, "mainSubStatName")!).Contains("VGTractorAuto") && (string)Get(s, "mainSubStatAmount")! == ""), "module tooltip missing or numeric artifact"); + enabledValue.SetValue(enabled, false); + statsField.SetValue(module, Activator.CreateInstance(statsField.FieldType)); + moduleType.GetMethod("SetMainSubStats", Any)!.Invoke(module, null); + Require(!((IEnumerable)Get(statsField.GetValue(module)!, "subStatsList")!).Cast().Any(s => ((string)Get(s, "mainSubStatName")!).Contains("VGTractorAuto")), "disabled module tooltip contributed text"); + + // Generalized module family: an API-owned registration must reach a non-tractor + // equipment module's own stat builder with vanilla StatLines as context. + var equipmentType = a.GetType("Behaviour.Equipment.AbstractEquipment", true)!; + var otherModule = Resources.FindObjectsOfTypeAll(equipmentType).OfType() + .FirstOrDefault(c => c.GetType() != moduleType && equipmentType.GetMethod("IsPlayer", Any)!.Invoke(c, new object[] { true }) is true + && c.GetType().GetMethod("SetMainSubStats", Any, null, Type.EmptyTypes, null) != null); + Require(otherModule != null, "no non-tractor player equipment module for the family check"); + var otherStats = Field(otherModule!.GetType(), "mainSubStats"); + var oldOtherStats = otherStats.GetValue(otherModule); + string otherName = (string)Get(otherModule, "name")!; + using (var lease = ModApi.Services.Tooltips.RegisterShipModule("vgmodapi.e2e", (m, tooltip) => + { + Require(m.Kind != ShipModuleKind.Tractor && m.Tractor == null, "non-tractor module received a tractor snapshot"); + Require(m.StatLines.Count > 0, "module registration saw no vanilla stat lines"); + Require(m.DisplayName.Length > 0 && m.Kind.ToString().Length > 0, "module snapshot missing identity"); + tooltip.AddLine("e2e-family " + m.Kind); + })) + { + otherStats.SetValue(otherModule, Activator.CreateInstance(otherStats.FieldType)); + otherModule.GetType().GetMethod("SetMainSubStats", Any)!.Invoke(otherModule, null); + Require(((IEnumerable)Get(otherStats.GetValue(otherModule)!, "subStatsList")!).Cast() + .Any(s => ((string)Get(s, "mainSubStatName")!).Contains("e2e-family")), "family registration missed a non-tractor module"); + } + otherStats.SetValue(otherModule, oldOtherStats); + + // Item family: fill a real tooltip from an item source without desktop interaction. + var tooltipType = a.GetType("Behaviour.UI.UITooltip", true)!; + var prefab = Resources.FindObjectsOfTypeAll(tooltipType).OfType().First(t => Get(t, "_textPrefab") != null); + var tooltipObject = UnityEngine.Object.Instantiate(prefab.gameObject); temporary.Add(tooltipObject); + var tooltip = tooltipObject.GetComponent(tooltipType)!; + var sourceType = a.GetType("Behaviour.UI.Tooltip.ItemTooltipSource", true)!; + var sourceGo = new GameObject("tractor-e2e-item"); sourceGo.SetActive(false); temporary.Add(sourceGo); + var source = sourceGo.AddComponent(sourceType); + var expectedId = (string)Get(ordinary, "identifier")!; + using (var lease = ModApi.Services.Tooltips.RegisterItem("vgmodapi.e2e", (item, tip) => + { + Require(item.Identifier == expectedId && item.DisplayName.Length > 0, "item snapshot identity mismatch"); + Require(item.Count == 7, "item snapshot lost the shown stack count"); + tip.AddLine("e2e-item " + item.DisplayName, TooltipTextStyle.Bonus); + })) + { + var context = Enum.Parse(a.GetType("Behaviour.UI.Tooltip.ItemTooltipContext", true)!, "InInventory"); + sourceType.GetMethod("SetItem", Any)!.Invoke(source, new object?[] { ordinary, 7, false, context, false, null }); + tooltipType.GetProperty("Source", Any)!.SetValue(tooltip, source, null); // Show() normally wires this before filling. + tooltipType.GetMethod("SetContent", Any)!.Invoke(tooltip, new object[] { source }); + string[] Lines() => ((IEnumerable)Get(tooltip, "_contentList")!).Cast().Select(c => c.GetType().GetProperty("Text", Any)?.GetValue(c)).Where(t => t != null).Select(t => (string)t!.GetType().GetProperty("text", Any)!.GetValue(t)!).ToArray(); + Require(Lines().Any(t => t.Contains("e2e-item") && t.Contains("k__BackingField").SetValue(badge, tree); + enabledValue.SetValue(enabled, true); level.SetValue(data, 0); + badgeType.GetMethod("AddTooltipCustomContent", Any)!.Invoke(badge, new object[] { tooltip }); + string[] MasteryLines() => ((IEnumerable)Get(tooltip, "_contentList")!).Cast().Select(c => c.GetType().GetProperty("Text", Any)?.GetValue(c)).Where(t => t != null).Select(t => (string)t!.GetType().GetProperty("text", Any)!.GetValue(t)!).ToArray(); + Require(MasteryLines().Any(t => t.Contains("automatic: 0%") && t.Contains("VGTractorAuto") && t.Contains(" t.Contains("VGTractorAuto")), "disabled mastery tooltip contributed text"); + Debug.Log("Tractor E2E passed: zero/half/max mastery, manual borrowing, occupied beams, candidate ordering/eligibility/caps, module family + item + mastery tooltips, disabled behavior."); + return StepResult.Pass("Tractor behavior and tooltip checks passed"); + } + finally + { + enabledValue.SetValue(enabled, oldEnabled); level.SetValue(data, oldLevel); + manualField.SetValue(module, oldManual); statsField.SetValue(module, oldStats); + filtered.Clear(); foreach (var value in oldFiltered) filtered.Add(value); + foreach (var entry in originals) Field(entry.Key.GetType(), "target").SetValue(entry.Key, entry.Value); + while (beams.Count > originalBeamCount) + { + var extra = (Component)beams[beams.Count - 1]!; beams.RemoveAt(beams.Count - 1); UnityEngine.Object.Destroy(extra.gameObject); + } + foreach (var go in temporary) UnityEngine.Object.Destroy(go); + } + } +} diff --git a/VGModAPI.Tests/InstalledTractorBeamTests.cs b/VGModAPI.Tests/InstalledTractorBeamTests.cs index ebd3bbad..e3a01b1f 100644 --- a/VGModAPI.Tests/InstalledTractorBeamTests.cs +++ b/VGModAPI.Tests/InstalledTractorBeamTests.cs @@ -38,4 +38,43 @@ public void TractorHooksAndNativeSafetyPredicatesExist() var addText = module.GetType("Behaviour.UI.UITooltip").Methods.Single(m => m.Name == "AddTextLine"); Assert.Equal(new[] { "System.String", "System.Int32", "System.Single" }, addText.Parameters.Select(p => p.ParameterType.FullName)); } + + [Fact] + public void TooltipExtensionNativeShapesExist() + { + using var assembly = AssemblyDefinition.ReadAssembly(Environment.GetEnvironmentVariable("VG_GAME_ASSEMBLY") + ?? throw new InvalidOperationException("Run make check-bindings.")); + var module = assembly.MainModule; + // Ship module family: every required module declares its own stat builder on AbstractEquipment. + var equipment = module.GetType("Behaviour.Equipment.AbstractEquipment"); + Assert.NotNull(Assert.Single(equipment.Methods, m => m.Name == "SetMainSubStats" && !m.HasBody)); + Assert.Equal("System.String", equipment.Methods.Single(m => m.Name == "GetName").ReturnType.FullName); + Assert.Equal("System.Int32", equipment.Properties.Single(p => p.Name == "qualityLevel").PropertyType.FullName); + foreach (var name in new[] { "TractorModule", "MiningModule", "SalvageModule", "ShieldGeneratorModule", "DroneBayModule" }) + { + var type = module.GetType("Behaviour.Equipment.Module." + name); + Assert.True(Assert.Single(type.Methods, m => m.Name == "SetMainSubStats").HasBody); + } + var mainSubStats = module.GetType("Behaviour.Equipment.MainSubStats"); + Assert.NotNull(Assert.Single(mainSubStats.Methods, m => m.Name == "AddMainSubStat" + && m.Parameters.Select(p => p.ParameterType.FullName).SequenceEqual(new[] { "System.String", "System.String" }))); + Assert.Contains(mainSubStats.Properties, p => p.Name == "subStatsList"); + var subStat = module.GetType("Behaviour.Equipment.SubStat"); + foreach (var name in new[] { "mainSubStatName", "mainSubStatAmount" }) + Assert.Equal("System.String", subStat.Properties.Single(p => p.Name == name).PropertyType.FullName); + // Item tooltip family: the shared fill plus the item source surface. + var tooltip = module.GetType("Behaviour.UI.UITooltip"); + var fill = Assert.Single(tooltip.Methods, m => m.Name == "SetContent"); + Assert.Equal("Behaviour.UI.Tooltip.TooltipSource", fill.Parameters.Single().ParameterType.FullName); + Assert.Contains(tooltip.Properties, p => p.Name == "Source" && p.PropertyType.FullName == "Behaviour.UI.Tooltip.TooltipSource"); + var source = module.GetType("Behaviour.UI.Tooltip.ItemTooltipSource"); + Assert.Equal("Behaviour.Item.InventoryItemType", source.Properties.Single(p => p.Name == "item").PropertyType.FullName); + Assert.Equal("System.Int32", source.Properties.Single(p => p.Name == "count").PropertyType.FullName); + var item = module.GetType("Behaviour.Item.InventoryItemType"); + foreach (var name in new[] { "identifier", "displayName", "description" }) + Assert.Equal("System.String", item.Properties.Single(p => p.Name == name).PropertyType.FullName); + Assert.Equal("System.Int32", item.Properties.Single(p => p.Name == "itemLevel").PropertyType.FullName); + Assert.Contains(module.GetType("Source.Util.ColorHelper").Fields, f => f.Name == "greenish"); + Assert.Contains(module.GetType("Source.Util.ColorHelper").Fields, f => f.Name == "detailsColor"); + } } diff --git a/VGModAPI.Tests/SkillTreeAndTooltipTests.cs b/VGModAPI.Tests/SkillTreeAndTooltipTests.cs index d80e20e2..b9926b45 100644 --- a/VGModAPI.Tests/SkillTreeAndTooltipTests.cs +++ b/VGModAPI.Tests/SkillTreeAndTooltipTests.cs @@ -7,6 +7,10 @@ namespace VGModAPI.Tests; public sealed class SkillTreeAndTooltipTests { + private static ShipModule Tractor(int automatic = 2, int manual = 3) => + new(ShipModuleKind.Tractor, "Tractor Unit", 1, new[] { new ModuleStatLine("Beams", "2") }, new TractorModule(automatic, manual)); + private static ItemInfo Item(string id = "scrap") => new(id, "Scrap", "A pile of scrap.", 12, 3); + [Fact] public void SkillTreesAreIndependentOfEquipmentAndDescribeAnySpecialization() { @@ -25,18 +29,40 @@ public void SkillTreesAreIndependentOfEquipmentAndDescribeAnySpecialization() } [Fact] - public void TooltipCallbacksCanAddStyledTextWithoutEquipmentRules() + public void TooltipCallbacksCanAddStyledTextAndDispatchByPayloadType() { using var service = new TooltipService(new LifecycleHub((_, _) => { })); service.SetAvailable(true); service.RegisterSkillTree("mod", (tree, tooltip) => tooltip.AddLine("Mining " + tree.MasteryLevel, TooltipTextStyle.Bonus).Append(" (mod)", TooltipTextStyle.Details)); - service.RegisterTractorModule("mod", (module, tooltip) => tooltip.AddLine("Beams " + module.BeamCount)); + service.RegisterShipModule("mod", (module, tooltip) => + { + Assert.Equal("Beams", Assert.Single(module.StatLines).Label); + tooltip.AddLine("Beams " + module.Tractor!.BeamCount); + }); + service.RegisterItem("mod", (item, tooltip) => tooltip.AddLine("Item " + item.Identifier + " x" + item.Count)); var skillLines = service.Describe(new SkillTree("Mining", CommanderSpecialization.Mining, 12, 100)); Assert.Single(skillLines); Assert.Equal("Mining 12", skillLines[0].Spans[0].Text); Assert.Equal(TooltipTextStyle.Details, skillLines[0].Spans[1].Style); - Assert.Equal("Beams 2", Assert.Single(service.Describe(new TractorModule(2, 3))).Spans[0].Text); + Assert.Equal("Beams 2", Assert.Single(service.Describe(Tractor())).Spans[0].Text); + Assert.Equal("Item scrap x12", Assert.Single(service.Describe(Item())).Spans[0].Text); + // Non-tractor modules receive their kind without a tractor snapshot. + service.RegisterShipModule("other", (module, tooltip) => + { Assert.Equal(ShipModuleKind.ShieldGenerator, module.Kind); Assert.Null(module.Tractor); tooltip.AddLine("shielded"); }); + Assert.Equal("shielded", Assert.Single(service.Describe(new ShipModule(ShipModuleKind.ShieldGenerator, "Shield", 0, Array.Empty(), null))).Spans[0].Text); + } + + [Fact] + public void OneOwnerMayRegisterEveryTooltipFamilyOnce() + { + using var service = new TooltipService(new LifecycleHub((_, _) => { })); + service.SetAvailable(true); + service.RegisterShipModule("mod", (_, _) => { }); + service.RegisterItem("mod", (_, _) => { }); + service.RegisterSkillTree("mod", (_, _) => { }); + Assert.Throws(() => service.RegisterShipModule("mod", (_, _) => { })); + Assert.Throws(() => service.RegisterItem("mod", (_, _) => { })); } [Fact] @@ -46,13 +72,13 @@ public void FaultyTooltipContributorDoesNotLeakPartialLinesOrSuppressOthers() using var service = new TooltipService(new LifecycleHub((_, _) => errors++)); service.SetAvailable(true); Tooltip? retained = null; TooltipLine? retainedLine = null; - service.RegisterTractorModule("broken", (_, tooltip) => + service.RegisterItem("broken", (_, tooltip) => { retained = tooltip; retainedLine = tooltip.AddLine("discard this"); throw new InvalidOperationException(); }); - service.RegisterTractorModule("working", (_, tooltip) => tooltip.AddLine("keep this")); - Assert.Equal("keep this", Assert.Single(service.Describe(new TractorModule(1, 2))).Spans[0].Text); + service.RegisterItem("working", (_, tooltip) => tooltip.AddLine("keep this")); + Assert.Equal("keep this", Assert.Single(service.Describe(Item())).Spans[0].Text); Assert.Equal(1, errors); Assert.Throws(() => retained!.AddLine("late")); Assert.Throws(() => retainedLine!.Append("late")); @@ -62,9 +88,9 @@ public void FaultyTooltipContributorDoesNotLeakPartialLinesOrSuppressOthers() public void TooltipReentrancyAndDisposalAreSafe() { using var service = new TooltipService(new LifecycleHub((_, _) => { })); - var module = new TractorModule(1, 1); + var module = Tractor(); service.SetAvailable(true); - var lease = service.RegisterTractorModule("mod", (_, tooltip) => + var lease = service.RegisterShipModule("mod", (_, tooltip) => { Assert.Empty(service.Describe(module)); tooltip.AddLine("ok"); @@ -73,7 +99,7 @@ public void TooltipReentrancyAndDisposalAreSafe() lease.Dispose(); Assert.Empty(service.Describe(module)); service.Dispose(); - Assert.Throws(() => service.RegisterTractorModule("mod", (_, _) => { })); + Assert.Throws(() => service.RegisterShipModule("mod", (_, _) => { })); } [Fact] @@ -91,6 +117,8 @@ public void IndependentServicesRejectForeignThreads() Assert.Throws(() => skills.Get(CommanderSpecialization.Engineering)); Assert.Throws(() => equipment.ConfigurePlayerTractorModules("mod", _ => null)); Assert.Throws(() => tooltips.RegisterSkillTree("mod", (_, _) => { })); + Assert.Throws(() => tooltips.RegisterShipModule("mod", (_, _) => { })); + Assert.Throws(() => tooltips.RegisterItem("mod", (_, _) => { })); } catch (Exception error) { failed = error; } }); diff --git a/VGModAPI/Native/TooltipRuntime.cs b/VGModAPI/Native/TooltipRuntime.cs new file mode 100644 index 00000000..4e4a0769 --- /dev/null +++ b/VGModAPI/Native/TooltipRuntime.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using UnityEngine; +using VGModAPI.Core; + +namespace VGModAPI.Runtime; + +/// Binds the native tooltip populate paths (module stat builders, the mastery badge and +/// the shared tooltip content fill) and dispatches them into the consumer-facing TooltipService. +internal sealed class TooltipRuntime +{ + private const BindingFlags Any = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + private static readonly string[] RequiredModuleTypes = + { "TractorModule", "MiningModule", "SalvageModule", "ShieldGeneratorModule", "DroneBayModule" }; + private readonly int _thread = Thread.CurrentThread.ManagedThreadId; + private readonly TooltipService _tooltips; + private readonly SkillTreeRuntime _skills; + private readonly Action _report; + private readonly FieldInfo _stats; + private readonly PropertyInfo _quality; + private readonly PropertyInfo _badgeTree, _subStats, _subName, _subAmount, _source, _sourceItem, _sourceCount; + private readonly PropertyInfo _itemId, _itemName, _itemDescription, _itemLevel; + private readonly MethodInfo _getName, _addStat, _addText; + private readonly Type _itemSourceType; + private readonly Dictionary _kinds = new(); + private readonly FieldInfo? _tractorAutomatic, _tractorManual; + private readonly Type _tractorModuleType; + private readonly Color _green, _details; + internal MethodInfo MasteryTooltip { get; } + internal IReadOnlyList ModuleStatBuilders { get; } + internal IReadOnlyList ContentFills { get; } + + internal TooltipRuntime(Assembly a, TooltipService tooltips, SkillTreeRuntime skills, Action report) + { + _tooltips = tooltips; _skills = skills; _report = report; + var equipment = a.GetType("Behaviour.Equipment.AbstractEquipment", true)!; + var mainSubStats = a.GetType("Behaviour.Equipment.MainSubStats", true)!; + var subStat = a.GetType("Behaviour.Equipment.SubStat", true)!; + var badge = a.GetType("Behaviour.UI.MasteryBadge", true)!; + var tooltip = a.GetType("Behaviour.UI.UITooltip", true)!; + var tooltipSource = a.GetType("Behaviour.UI.Tooltip.TooltipSource", true)!; + _itemSourceType = a.GetType("Behaviour.UI.Tooltip.ItemTooltipSource", true)!; + var itemType = a.GetType("Behaviour.Item.InventoryItemType", true)!; + if (!_itemSourceType.IsSubclassOf(tooltipSource)) throw new InvalidOperationException("Item tooltip source is no longer a tooltip source."); + _stats = Field(equipment, "mainSubStats", mainSubStats); _quality = Property(equipment, "qualityLevel", typeof(int)); + _subStats = Property(mainSubStats, "subStatsList", typeof(IList), assignable: true); + _subName = Property(subStat, "mainSubStatName", typeof(string)); _subAmount = Property(subStat, "mainSubStatAmount", typeof(string)); + _badgeTree = Property(badge, "skillTree"); + _source = Property(tooltip, "Source", tooltipSource); + _sourceItem = Property(_itemSourceType, "item", itemType); _sourceCount = Property(_itemSourceType, "count", typeof(int)); + _itemId = Property(itemType, "identifier", typeof(string)); _itemName = Property(itemType, "displayName", typeof(string)); + _itemDescription = Property(itemType, "description", typeof(string)); _itemLevel = Property(itemType, "itemLevel", typeof(int)); + _getName = Method(equipment, "GetName", Type.EmptyTypes); + _addStat = Method(mainSubStats, "AddMainSubStat", typeof(string), typeof(string)); + _addText = Method(tooltip, "AddTextLine", typeof(string), typeof(int), typeof(float)); + var colors = a.GetType("Source.Util.ColorHelper", true)!; + _green = (Color)Field(colors, "greenish", typeof(Color)).GetValue(null)!; + _details = (Color)Field(colors, "detailsColor", typeof(Color)).GetValue(null)!; + MasteryTooltip = Method(badge, "AddTooltipCustomContent", tooltip); + if (MasteryTooltip.ReturnType != typeof(void) || _addStat.ReturnType != typeof(void) || _getName.ReturnType != typeof(string)) + throw new InvalidOperationException("Tooltip binding shape changed."); + + // One postfix per concrete stat builder; the native dispatch resolves to the nearest + // declaring type, so patching every declaring override covers the whole equipment family. + var builders = new List(); + var seen = new HashSet(); + foreach (var type in Subclasses(a, equipment)) + { + if (type.IsAbstract) continue; + var overrideMethod = type.GetMethod("SetMainSubStats", Any | BindingFlags.DeclaredOnly, null, Type.EmptyTypes, null); + if (overrideMethod == null || overrideMethod.DeclaringType != type || overrideMethod.ReturnType != typeof(void) || !seen.Add(type)) continue; + builders.Add(overrideMethod); + _kinds[type] = MapKind(type.Name); + } + foreach (var required in RequiredModuleTypes) + if (!_kinds.Keys.Any(t => t.Name == required)) throw new MissingMethodException(equipment.FullName, required + ".SetMainSubStats"); + _tractorModuleType = a.GetType("Behaviour.Equipment.Module.TractorModule", true)!; + if (_kinds.TryGetValue(_tractorModuleType, out var tractorKind) && tractorKind != ShipModuleKind.Tractor) + throw new InvalidOperationException("Tractor module kind mapping changed."); + _tractorAutomatic = Field(_tractorModuleType, "amountOfBeams", typeof(int)); + _tractorManual = Field(_tractorModuleType, "amountOfBonusBeams", typeof(int)); + ModuleStatBuilders = builders; + + // The fill is virtual; subclass prefabs bypass a base-method patch, so bind each override. + var fills = new List(); + var baseFill = Method(tooltip, "SetContent", tooltipSource); + if (baseFill.ReturnType != typeof(void)) throw new InvalidOperationException("Tooltip content fill shape changed."); + fills.Add(baseFill); + foreach (var type in Subclasses(a, tooltip)) + if (!type.IsAbstract) + { + var fill = type.GetMethod("SetContent", Any | BindingFlags.DeclaredOnly, null, new[] { tooltipSource }, null); + if (fill != null && fill.DeclaringType == type && fill.ReturnType == typeof(void)) fills.Add(fill); + } + ContentFills = fills; + } + + private static ShipModuleKind MapKind(string typeName) + { + var stem = typeName.EndsWith("Module", StringComparison.Ordinal) ? typeName[..^"Module".Length] : typeName; + return Enum.TryParse(stem, out var kind) ? kind : ShipModuleKind.Other; + } + + private static IEnumerable Subclasses(Assembly a, Type baseType) + { + foreach (var type in a.GetTypes()) + if (type != baseType && baseType.IsAssignableFrom(type)) yield return type; + } + + private static MethodInfo Method(Type type, string name, params Type[] parameters) + => type.GetMethod(name, Any, null, parameters, null) ?? throw new MissingMethodException(type.FullName, name); + private static FieldInfo Field(Type type, string name, Type type_) + { + for (Type? current = type; current != null; current = current.BaseType) + if (current.GetField(name, Any | BindingFlags.DeclaredOnly) is FieldInfo field && field.FieldType == type_) return field; + throw new MissingFieldException(type.FullName, name); + } + private static PropertyInfo Property(Type type, string name, Type? type_ = null, bool assignable = false) + { + var property = type.GetProperty(name, Any) ?? throw new MissingMemberException(type.FullName, name); + if (type_ != null && !(assignable ? type_.IsAssignableFrom(property.PropertyType) : type_ == property.PropertyType)) + throw new InvalidOperationException(type.FullName + "." + name + " shape changed."); + return property; + } + + private bool MainThread => Thread.CurrentThread.ManagedThreadId == _thread; + + internal void AddModuleDescription(object module) + { + if (!MainThread || !_tooltips.Availability.IsAvailable) return; + try + { + var type = module.GetType(); + if (!_kinds.TryGetValue(type, out var kind)) kind = MapKind(type.Name); + var stats = _stats.GetValue(module); if (stats == null) return; + var lines = new List(); + if (_subStats.GetValue(stats) is IList current) + foreach (var stat in current) + { if (stat != null) lines.Add(new ModuleStatLine((string?)_subName.GetValue(stat) ?? "", (string?)_subAmount.GetValue(stat) ?? "")); } + var displayName = _getName.Invoke(module, null) as string ?? ""; + var tractor = kind == ShipModuleKind.Tractor && _tractorModuleType.IsInstanceOfType(module) && _tractorAutomatic != null && _tractorManual != null + ? new TractorModule((int)_tractorAutomatic.GetValue(module)!, (int)_tractorManual.GetValue(module)!) : null; + var values = new ShipModule(kind, displayName, (int)_quality.GetValue(module)!, lines, tractor); + foreach (var line in _tooltips.Describe(values)) + _addStat.Invoke(stats, new object[] { string.Concat(line.Spans.Select(span => span.Text)), "" }); + } + catch (Exception error) { Report(error); } + } + + internal void AddItemDescription(object tooltipObject) + { + if (!MainThread || !_tooltips.Availability.IsAvailable) return; + try + { + if (_source.GetValue(tooltipObject) is not Component source || !_itemSourceType.IsInstanceOfType(source)) return; + if (_sourceItem.GetValue(source) is not object item) return; + var values = new ItemInfo( + (string?)_itemId.GetValue(item) ?? "", (string?)_itemName.GetValue(item) ?? "", + (string?)_itemDescription.GetValue(item) ?? "", (int)_sourceCount.GetValue(source)!, + (int)_itemLevel.GetValue(item)!); + foreach (var line in _tooltips.Describe(values)) + _addText.Invoke(tooltipObject, new object[] { string.Concat(line.Spans.Select(span => Format(span.Text, span.Style))), 12, 8f }); + } + catch (Exception error) { Report(error); } + } + + internal void AddMasteryDescription(object badge, object tooltipObject) + { + if (!MainThread || !_tooltips.Availability.IsAvailable) return; + try + { + var nativeTree = _badgeTree.GetValue(badge); + var tree = nativeTree == null ? null : _skills.Read(nativeTree); + if (tree == null) return; + foreach (var line in _tooltips.Describe(tree)) + _addText.Invoke(tooltipObject, new object[] { string.Concat(line.Spans.Select(span => Format(span.Text, span.Style))), 12, 8f }); + } + catch (Exception error) { Report(error); } + } + + private string Format(string text, TooltipTextStyle style) + { + if (style == TooltipTextStyle.Normal) return text; + var color = style == TooltipTextStyle.Bonus ? _green : _details; + return "" + text + ""; + } + private void Report(Exception error) { try { _report(error); } catch { } } +} diff --git a/VGModAPI/Native/TractorBeamRuntime.cs b/VGModAPI/Native/TractorBeamRuntime.cs index 2b690aa7..13fe38b9 100644 --- a/VGModAPI/Native/TractorBeamRuntime.cs +++ b/VGModAPI/Native/TractorBeamRuntime.cs @@ -13,43 +13,31 @@ internal sealed class TractorBeamRuntime private const BindingFlags Any = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; private readonly int _thread = Thread.CurrentThread.ManagedThreadId; private readonly EquipmentService _equipment; - private readonly TooltipService _tooltips; - private readonly SkillTreeRuntime _skills; private readonly Action _report; private readonly Type _item; - private readonly FieldInfo _beams, _automatic, _manual, _stats, _filtered; - private readonly PropertyInfo _parent, _tractored, _badgeTree; - private readonly MethodInfo _isPlayer, _busy, _eligible, _blocked, _addStat, _addText; - private readonly FieldInfo _green, _details; + private readonly FieldInfo _beams, _automatic, _manual, _filtered; + private readonly PropertyInfo _parent, _tractored; + private readonly MethodInfo _isPlayer, _busy, _eligible, _blocked; internal MethodInfo AvailableBeam { get; } internal MethodInfo UpdateTargets { get; } - internal MethodInfo ModuleStats { get; } - internal MethodInfo MasteryTooltip { get; } - internal TractorBeamRuntime(Assembly a, EquipmentService equipmentService, TooltipService tooltips, SkillTreeRuntime skills, Action report) + internal TractorBeamRuntime(Assembly a, EquipmentService equipmentService, Action report) { - _equipment = equipmentService; _tooltips = tooltips; _skills = skills; _report = report; + _equipment = equipmentService; _report = report; var module = a.GetType("Behaviour.Equipment.Module.TractorModule", true)!; _item = a.GetType("Behaviour.Tractoring.TractorableItem", true)!; var equipment = a.GetType("Behaviour.Equipment.AbstractEquipment", true)!; var beam = a.GetType("Behaviour.Tractoring.TractorBeam", true)!; - var badge = a.GetType("Behaviour.UI.MasteryBadge", true)!; - var tooltip = a.GetType("Behaviour.UI.UITooltip", true)!; _beams = Field(module, "tractorBeams"); _automatic = Field(module, "amountOfBeams"); _manual = Field(module, "amountOfBonusBeams"); - _stats = Field(equipment, "mainSubStats"); _parent = Property(equipment, "parent"); _filtered = Field(module, "filteredTargets"); - _tractored = Property(_item, "isTractored"); _badgeTree = Property(badge, "skillTree"); + _parent = Property(equipment, "parent"); _filtered = Field(module, "filteredTargets"); + _tractored = Property(_item, "isTractored"); _isPlayer = Method(equipment, "IsPlayer", typeof(bool)); _busy = Method(beam, "HasTarget"); _eligible = Method(_item, "CanBeAutoTractoredBy", _parent.PropertyType); _blocked = Method(module, "IsCrewPodTargetingBlocked", _item); - _addStat = Method(_stats.FieldType, "AddMainSubStat", typeof(string), typeof(string)); - _addText = Method(tooltip, "AddTextLine", typeof(string), typeof(int), typeof(float)); - var colors = a.GetType("Source.Util.ColorHelper", true)!; - _green = Field(colors, "greenish"); _details = Field(colors, "detailsColor"); AvailableBeam = Method(module, "GetAvailableTractorBeam", typeof(bool)); var targets = a.GetType("Behaviour.Weapons.TargetableUnit", true)!; UpdateTargets = Method(module, "UpdateAvailableTargets", typeof(System.Collections.Generic.IEnumerable<>).MakeGenericType(targets)); - ModuleStats = Method(module, "SetMainSubStats"); MasteryTooltip = Method(badge, "AddTooltipCustomContent", tooltip); - if (AvailableBeam.ReturnType != beam || UpdateTargets.ReturnType != typeof(void) || ModuleStats.ReturnType != typeof(void) || MasteryTooltip.ReturnType != typeof(void) + if (AvailableBeam.ReturnType != beam || UpdateTargets.ReturnType != typeof(void) || _isPlayer.ReturnType != typeof(bool) || _busy.ReturnType != typeof(bool) || _eligible.ReturnType != typeof(bool) || _blocked.ReturnType != typeof(bool) || _automatic.FieldType != typeof(int) || _manual.FieldType != typeof(int) || _tractored.PropertyType != typeof(bool)) throw new InvalidOperationException("Tractor binding shape changed."); @@ -106,34 +94,5 @@ internal void TopUp(object module, IEnumerable targets) } catch (Exception error) { Report(error); } } - internal void AddModuleDescription(object module) - { - if (!MainThread || !_tooltips.Availability.IsAvailable) return; - try - { - foreach (var line in _tooltips.Describe(ReadModule(module))) - _addStat.Invoke(_stats.GetValue(module), new object[] { string.Concat(line.Spans.Select(span => span.Text)), "" }); - } - catch (Exception error) { Report(error); } - } - internal void AddMasteryDescription(object badge, object tooltip) - { - if (!MainThread || !_tooltips.Availability.IsAvailable) return; - try - { - var nativeTree = _badgeTree.GetValue(badge); - var tree = nativeTree == null ? null : _skills.Read(nativeTree); - if (tree == null) return; - foreach (var line in _tooltips.Describe(tree)) - _addText.Invoke(tooltip, new object[] { string.Concat(line.Spans.Select(span => Format(span.Text, span.Style))), 12, 8f }); - } - catch (Exception error) { Report(error); } - } - private string Format(string text, TooltipTextStyle style) - { - if (style == TooltipTextStyle.Normal) return text; - var color = style == TooltipTextStyle.Bonus ? _green : _details; - return "" + text + ""; - } private void Report(Exception error) { try { _report(error); } catch { } } } diff --git a/VGModAPI/Patches/TractorBeamPatches.cs b/VGModAPI/Patches/TractorBeamPatches.cs index 92743512..ebeef486 100644 --- a/VGModAPI/Patches/TractorBeamPatches.cs +++ b/VGModAPI/Patches/TractorBeamPatches.cs @@ -15,6 +15,11 @@ internal static class Targets { internal static void Postfix(object __instance, IEnumerable targets) => Runtime?.TopUp(__instance, targets); } +} + +internal static class TooltipPatches +{ + internal static TooltipRuntime? Runtime; internal static class ModuleStats { internal static void Postfix(object __instance) => Runtime?.AddModuleDescription(__instance); @@ -23,4 +28,8 @@ internal static class MasteryTooltip { internal static void Postfix(object __instance, object tooltip) => Runtime?.AddMasteryDescription(__instance, tooltip); } + internal static class ItemContent + { + internal static void Postfix(object __instance) => Runtime?.AddItemDescription(__instance); + } } diff --git a/VGModAPI/Plugin.cs b/VGModAPI/Plugin.cs index 9b315e40..cfe17b33 100644 --- a/VGModAPI/Plugin.cs +++ b/VGModAPI/Plugin.cs @@ -133,6 +133,7 @@ private void Awake() }); InstallHud(assembly); InstallTractorBeams(assembly); + InstallTooltips(assembly); InstallGameplayUi(assembly); InstallAmbientTraffic(assembly); InstallUnitProtection(assembly); @@ -157,6 +158,7 @@ private void Awake() _adapter?.Guard(() => throw new InvalidOperationException("Adapter installation failed.", ex)); TeardownHud(); TeardownTractorBeams(); + TeardownTooltips(); TeardownGameplayUi(); TeardownAmbientTraffic(); TeardownUnitProtection(); @@ -1062,6 +1064,7 @@ private void OnDestroy() _hub?.Dispose(); // Close gates and preserve queued terminal delivery before releasing service views. StopDungeonPanel(); TeardownTractorBeams(); + TeardownTooltips(); TeardownHud(); TeardownGameplayUi(); TeardownAmbientTraffic(); diff --git a/VGModAPI/TractorBeamIntegration.cs b/VGModAPI/TractorBeamIntegration.cs index bd4edddb..ef34bba2 100644 --- a/VGModAPI/TractorBeamIntegration.cs +++ b/VGModAPI/TractorBeamIntegration.cs @@ -13,6 +13,7 @@ public sealed partial class Plugin private SkillTreeService? _skillTrees; private TooltipService? _tooltips; private Harmony? _tractorHarmony; + private Harmony? _tooltipsHarmony; private void InstallTractorBeams(Assembly assembly) { _equipment ??= new EquipmentService(_hub!); @@ -21,24 +22,48 @@ private void InstallTractorBeams(Assembly assembly) try { var skills = new SkillTreeRuntime(assembly); - var runtime = new TractorBeamRuntime(assembly, _equipment, _tooltips, skills, error => Logger.LogError(error)); + var runtime = new TractorBeamRuntime(assembly, _equipment, error => Logger.LogError(error)); _tractorHarmony = new Harmony(ModApi.PluginId + ".tractor-beams"); TractorBeamPatches.Runtime = runtime; _tractorHarmony.Patch(runtime.AvailableBeam, postfix: new HarmonyMethod(typeof(TractorBeamPatches.Available), "Postfix")); _tractorHarmony.Patch(runtime.UpdateTargets, postfix: new HarmonyMethod(typeof(TractorBeamPatches.Targets), "Postfix")); - _tractorHarmony.Patch(runtime.ModuleStats, postfix: new HarmonyMethod(typeof(TractorBeamPatches.ModuleStats), "Postfix")); - _tractorHarmony.Patch(runtime.MasteryTooltip, postfix: new HarmonyMethod(typeof(TractorBeamPatches.MasteryTooltip), "Postfix")); _skillTrees.Bind(skills.Get); - _equipment.SetAvailable(true); _tooltips.SetAvailable(true); + _equipment.SetAvailable(true); } catch (Exception error) { TeardownTractorBeams(); Logger.LogError(error); } } + private void InstallTooltips(Assembly assembly) + { + _tooltips ??= new TooltipService(_hub!); + try + { + var skills = new SkillTreeRuntime(assembly); + var runtime = new TooltipRuntime(assembly, _tooltips, skills, error => Logger.LogError(error)); + _tooltipsHarmony = new Harmony(ModApi.PluginId + ".tooltips"); + TooltipPatches.Runtime = runtime; + foreach (var builder in runtime.ModuleStatBuilders) + _tooltipsHarmony.Patch(builder, postfix: new HarmonyMethod(typeof(TooltipPatches.ModuleStats), "Postfix")); + _tooltipsHarmony.Patch(runtime.MasteryTooltip, postfix: new HarmonyMethod(typeof(TooltipPatches.MasteryTooltip), "Postfix")); + foreach (var fill in runtime.ContentFills) + _tooltipsHarmony.Patch(fill, postfix: new HarmonyMethod(typeof(TooltipPatches.ItemContent), "Postfix")); + _tooltips.SetAvailable(true); + } + catch (Exception error) { TeardownTooltips(); Logger.LogError(error); } + } private void TeardownTractorBeams() { TractorBeamPatches.Runtime = null; try { _tractorHarmony?.UnpatchSelf(); } catch (Exception error) { Logger.LogError(error); } _tractorHarmony = null; - _equipment?.SetAvailable(false); _skillTrees?.Bind(null); _tooltips?.SetAvailable(false); + _equipment?.SetAvailable(false); _skillTrees?.Bind(null); + } + private void TeardownTooltips() + { + TooltipPatches.Runtime = null; + try { _tooltipsHarmony?.UnpatchSelf(); } + catch (Exception error) { Logger.LogError(error); } + _tooltipsHarmony = null; + _tooltips?.SetAvailable(false); } } diff --git a/docs/reference/tractor-beams.md b/docs/reference/tractor-beams.md index b84e054a..114ffff1 100644 --- a/docs/reference/tractor-beams.md +++ b/docs/reference/tractor-beams.md @@ -45,11 +45,17 @@ Mastery scaling formulas remain in mods, not in the targeting API. ## Extend tooltips independently ```csharp -var moduleTip = api.Tooltips.RegisterTractorModule("my.mod", (module, tooltip) => +// Any ship equipment module — filter by Kind for your family. +var moduleTip = api.Tooltips.RegisterShipModule("my.mod", (module, tooltip) => { - if (module.ManualBeamCount > 0) + if (module.Kind == ShipModuleKind.Tractor && module.Tractor?.ManualBeamCount > 0) tooltip.AddLine("Manual beams can also target automatically"); }); +// Any inventory item: inventory, shop, loot and compare sources all route through it. +var itemTip = api.Tooltips.RegisterItem("my.mod", (item, tooltip) => +{ + if (item.Identifier == "scrap") tooltip.AddLine(item.DisplayName + " is worth more now", TooltipTextStyle.Bonus); +}); var masteryTip = api.Tooltips.RegisterSkillTree("my.mod", (tree, tooltip) => { if (tree.Specialization == CommanderSpecialization.Engineering) @@ -58,6 +64,15 @@ var masteryTip = api.Tooltips.RegisterSkillTree("my.mod", (tree, tooltip) => }); ``` +`ShipModule` covers every native `Behaviour.Equipment.Module` family through the +game-vocabulary `ShipModuleKind` (`Tractor`, `Mining`, `Salvage`, `ShieldGenerator`, +`Reactor`, `DroneBay`, `TorpedoBay`, `Armor`, `CargoScoop`, `EngineThrusters`, +`HangarBay`, `Hull`, `Painter`, `Repair`, `Scanner`, `Other`). `StatLines` shows the +vanilla stat lines currently displayed and `DisplayName`/`QualityLevel` identify the +module; the typed `Tractor` snapshot is non-null only for tractor modules. Item +tooltips receive `ItemInfo` (`Identifier`, `DisplayName`, `Description`, `Count`, +`ItemLevel`) for the item the source is showing. + No equipment rule is required to add tooltip text. All skill-tree mastery tooltips are supported, not only Engineering. Lines append after native content; Bonus and Details use the game palette. Module stats use plain text with an empty numeric value. diff --git a/tools/e2e.py b/tools/e2e.py index 1e7b838b..d1e3bced 100644 --- a/tools/e2e.py +++ b/tools/e2e.py @@ -16,7 +16,7 @@ CASE = "fresh-session" KNOWN_CASES = ("fresh-session", "pocket-worlds", "story-missions", "observation", - "cargo-recovery", "station-commerce", "ui-surfaces", "mod-settings-menu") + "cargo-recovery", "station-commerce", "ui-surfaces", "mod-settings-menu", "tractor-module") HANDSHAKE = "--vgmodapi-e2e" ASSEMBLIES = ("VGModAPI.dll", "VGModAPI.Core.dll", "VGModAPI.Abstractions.dll", "VGModAPI.Unity.dll", "VGModAPI.E2E.dll", "UiSurfaces.dll", "Newtonsoft.Json.dll") From b23e74e2a95341185e09976226db2821d662ed9e Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 17 Sep 2026 17:07:30 +0200 Subject: [PATCH 04/12] fix: bind abstract stat-builder intermediates and add turret module kind Concrete equipment leaves (turrets) declare SetMainSubStats on abstract intermediates such as AbstractTurret; skipping abstract types left those overrides unpatched. Bind every declaring override with a body, map *Turret types to the new ShipModuleKind.Turret, and extend installed bindings plus the committed E2E family/item checks. Live E2E tractor-module passes (realSavesUnchanged, installationRestored). --- VGModAPI.Abstractions/Tooltips.cs | 2 +- VGModAPI.E2E/TractorCase.cs | 53 ++++++++++++++------- VGModAPI.Tests/InstalledTractorBeamTests.cs | 1 + VGModAPI/Native/TooltipRuntime.cs | 19 ++++---- docs/reference/tractor-beams.md | 2 +- tools/e2e.py | 2 +- 6 files changed, 51 insertions(+), 28 deletions(-) diff --git a/VGModAPI.Abstractions/Tooltips.cs b/VGModAPI.Abstractions/Tooltips.cs index 1b5dd259..bd614a83 100644 --- a/VGModAPI.Abstractions/Tooltips.cs +++ b/VGModAPI.Abstractions/Tooltips.cs @@ -10,7 +10,7 @@ public enum TooltipTextStyle { Normal, Bonus, Details } public enum ShipModuleKind { Tractor, Mining, Salvage, ShieldGenerator, Reactor, ReactorLight, DroneBay, TorpedoBay, - Armor, CargoScoop, EngineThrusters, HangarBay, Hull, Painter, Repair, Scanner, Other + Armor, CargoScoop, EngineThrusters, HangarBay, Hull, Painter, Repair, Scanner, Turret, Other } /// A stat line the native module just showed, captured before extensions append. diff --git a/VGModAPI.E2E/TractorCase.cs b/VGModAPI.E2E/TractorCase.cs index d1d7e3c0..8ee7b900 100644 --- a/VGModAPI.E2E/TractorCase.cs +++ b/VGModAPI.E2E/TractorCase.cs @@ -129,27 +129,44 @@ private static StepResult Run() // Generalized module family: an API-owned registration must reach a non-tractor // equipment module's own stat builder with vanilla StatLines as context. var equipmentType = a.GetType("Behaviour.Equipment.AbstractEquipment", true)!; - var otherModule = Resources.FindObjectsOfTypeAll(equipmentType).OfType() - .FirstOrDefault(c => c.GetType() != moduleType && equipmentType.GetMethod("IsPlayer", Any)!.Invoke(c, new object[] { true }) is true - && c.GetType().GetMethod("SetMainSubStats", Any, null, Type.EmptyTypes, null) != null); - Require(otherModule != null, "no non-tractor player equipment module for the family check"); - var otherStats = Field(otherModule!.GetType(), "mainSubStats"); - var oldOtherStats = otherStats.GetValue(otherModule); - string otherName = (string)Get(otherModule, "name")!; + var candidatesForFamily = Resources.FindObjectsOfTypeAll(equipmentType).OfType() + .Where(c => c.GetType() != moduleType && equipmentType.GetMethod("IsPlayer", Any)!.Invoke(c, new object[] { true }) is true + && c.GetType().GetMethod("SetMainSubStats", Any, null, Type.EmptyTypes, null) != null).ToArray(); + // Probe first: pick a module whose native builder really renders vanilla lines, so the + // registration under test always has content to append to. + Component? otherModule = null; FieldInfo? otherStats = null; int vanillaLineCount = 0; + foreach (var candidate in candidatesForFamily) + { + var probeStats = Field(candidate.GetType(), "mainSubStats"); + var keep = probeStats.GetValue(candidate); + try + { + probeStats.SetValue(candidate, Activator.CreateInstance(probeStats.FieldType)); + candidate.GetType().GetMethod("SetMainSubStats", Any)!.Invoke(candidate, null); + var count = ((IEnumerable)Get(probeStats.GetValue(candidate)!, "subStatsList")!).Cast().Count(); + if (count > 0) { otherModule = candidate; otherStats = probeStats; vanillaLineCount = count; break; } + } + finally { probeStats.SetValue(candidate, keep); } + } + Require(otherModule != null && otherStats != null, "no player equipment module renders vanilla stat lines for the family check"); + ShipModule? familySeen = null; using (var lease = ModApi.Services.Tooltips.RegisterShipModule("vgmodapi.e2e", (m, tooltip) => { - Require(m.Kind != ShipModuleKind.Tractor && m.Tractor == null, "non-tractor module received a tractor snapshot"); - Require(m.StatLines.Count > 0, "module registration saw no vanilla stat lines"); - Require(m.DisplayName.Length > 0 && m.Kind.ToString().Length > 0, "module snapshot missing identity"); + familySeen = m; tooltip.AddLine("e2e-family " + m.Kind); })) { - otherStats.SetValue(otherModule, Activator.CreateInstance(otherStats.FieldType)); - otherModule.GetType().GetMethod("SetMainSubStats", Any)!.Invoke(otherModule, null); + otherStats!.SetValue(otherModule!, Activator.CreateInstance(otherStats.FieldType)); + otherModule!.GetType().GetMethod("SetMainSubStats", Any)!.Invoke(otherModule, null); Require(((IEnumerable)Get(otherStats.GetValue(otherModule)!, "subStatsList")!).Cast() - .Any(s => ((string)Get(s, "mainSubStatName")!).Contains("e2e-family")), "family registration missed a non-tractor module"); + .Any(s => ((string)Get(s, "mainSubStatName")!).Contains("e2e-family")), + "family registration missed a non-tractor module (candidate=" + otherModule!.GetType().Name + " saw=" + (familySeen?.Kind.ToString() ?? "nothing") + " vanilla=" + vanillaLineCount + ")"); } - otherStats.SetValue(otherModule, oldOtherStats); + Require(familySeen != null && familySeen.Kind != ShipModuleKind.Tractor && familySeen.Tractor == null, + "non-tractor module received a tractor snapshot or no payload"); + Require(familySeen!.StatLines.Count > 0 && familySeen.DisplayName.Length > 0 && familySeen.QualityLevel >= 0, + "module snapshot missing vanilla stat lines or identity (vanilla built " + vanillaLineCount + " lines)"); + otherStats.SetValue(otherModule, Activator.CreateInstance(otherStats.FieldType)); // Item family: fill a real tooltip from an item source without desktop interaction. var tooltipType = a.GetType("Behaviour.UI.UITooltip", true)!; @@ -160,11 +177,11 @@ private static StepResult Run() var sourceGo = new GameObject("tractor-e2e-item"); sourceGo.SetActive(false); temporary.Add(sourceGo); var source = sourceGo.AddComponent(sourceType); var expectedId = (string)Get(ordinary, "identifier")!; + ItemInfo? itemSeen = null; using (var lease = ModApi.Services.Tooltips.RegisterItem("vgmodapi.e2e", (item, tip) => { - Require(item.Identifier == expectedId && item.DisplayName.Length > 0, "item snapshot identity mismatch"); - Require(item.Count == 7, "item snapshot lost the shown stack count"); - tip.AddLine("e2e-item " + item.DisplayName, TooltipTextStyle.Bonus); + itemSeen = item; + if (item.Identifier == expectedId) tip.AddLine("e2e-item " + item.DisplayName, TooltipTextStyle.Bonus); })) { var context = Enum.Parse(a.GetType("Behaviour.UI.Tooltip.ItemTooltipContext", true)!, "InInventory"); @@ -173,6 +190,8 @@ private static StepResult Run() tooltipType.GetMethod("SetContent", Any)!.Invoke(tooltip, new object[] { source }); string[] Lines() => ((IEnumerable)Get(tooltip, "_contentList")!).Cast().Select(c => c.GetType().GetProperty("Text", Any)?.GetValue(c)).Where(t => t != null).Select(t => (string)t!.GetType().GetProperty("text", Any)!.GetValue(t)!).ToArray(); Require(Lines().Any(t => t.Contains("e2e-item") && t.Contains(" 0 && itemSeen.Count == 7, + "item snapshot identity/stack mismatch (saw " + (itemSeen == null ? "nothing" : itemSeen.Identifier + " x" + itemSeen.Count) + ")"); } // Build a native mastery tooltip from a real UI prefab, without desktop interaction. diff --git a/VGModAPI.Tests/InstalledTractorBeamTests.cs b/VGModAPI.Tests/InstalledTractorBeamTests.cs index e3a01b1f..26a4dd08 100644 --- a/VGModAPI.Tests/InstalledTractorBeamTests.cs +++ b/VGModAPI.Tests/InstalledTractorBeamTests.cs @@ -50,6 +50,7 @@ public void TooltipExtensionNativeShapesExist() Assert.NotNull(Assert.Single(equipment.Methods, m => m.Name == "SetMainSubStats" && !m.HasBody)); Assert.Equal("System.String", equipment.Methods.Single(m => m.Name == "GetName").ReturnType.FullName); Assert.Equal("System.Int32", equipment.Properties.Single(p => p.Name == "qualityLevel").PropertyType.FullName); + Assert.True(Assert.Single(module.GetType("Behaviour.Equipment.Turret.AbstractTurret").Methods, m => m.Name == "SetMainSubStats").HasBody); // abstract intermediates hold real builders foreach (var name in new[] { "TractorModule", "MiningModule", "SalvageModule", "ShieldGeneratorModule", "DroneBayModule" }) { var type = module.GetType("Behaviour.Equipment.Module." + name); diff --git a/VGModAPI/Native/TooltipRuntime.cs b/VGModAPI/Native/TooltipRuntime.cs index 4e4a0769..4a41853f 100644 --- a/VGModAPI/Native/TooltipRuntime.cs +++ b/VGModAPI/Native/TooltipRuntime.cs @@ -68,11 +68,13 @@ internal TooltipRuntime(Assembly a, TooltipService tooltips, SkillTreeRuntime sk // declaring type, so patching every declaring override covers the whole equipment family. var builders = new List(); var seen = new HashSet(); + // Abstract intermediate classes may hold the real implementation (AbstractTurret), so + // patch every declaring override with a body; virtual dispatch reaches it from the leaves. foreach (var type in Subclasses(a, equipment)) { - if (type.IsAbstract) continue; var overrideMethod = type.GetMethod("SetMainSubStats", Any | BindingFlags.DeclaredOnly, null, Type.EmptyTypes, null); - if (overrideMethod == null || overrideMethod.DeclaringType != type || overrideMethod.ReturnType != typeof(void) || !seen.Add(type)) continue; + if (overrideMethod == null || overrideMethod.DeclaringType != type || overrideMethod.IsAbstract + || overrideMethod.ReturnType != typeof(void) || !seen.Add(type)) continue; builders.Add(overrideMethod); _kinds[type] = MapKind(type.Name); } @@ -91,18 +93,19 @@ internal TooltipRuntime(Assembly a, TooltipService tooltips, SkillTreeRuntime sk if (baseFill.ReturnType != typeof(void)) throw new InvalidOperationException("Tooltip content fill shape changed."); fills.Add(baseFill); foreach (var type in Subclasses(a, tooltip)) - if (!type.IsAbstract) - { - var fill = type.GetMethod("SetContent", Any | BindingFlags.DeclaredOnly, null, new[] { tooltipSource }, null); - if (fill != null && fill.DeclaringType == type && fill.ReturnType == typeof(void)) fills.Add(fill); - } + { + var fill = type.GetMethod("SetContent", Any | BindingFlags.DeclaredOnly, null, new[] { tooltipSource }, null); + if (fill != null && fill.DeclaringType == type && !fill.IsAbstract && fill.ReturnType == typeof(void)) fills.Add(fill); + } ContentFills = fills; } private static ShipModuleKind MapKind(string typeName) { var stem = typeName.EndsWith("Module", StringComparison.Ordinal) ? typeName[..^"Module".Length] : typeName; - return Enum.TryParse(stem, out var kind) ? kind : ShipModuleKind.Other; + if (Enum.TryParse(stem, out var kind)) return kind; + if (typeName.EndsWith("Turret", StringComparison.Ordinal)) return ShipModuleKind.Turret; + return ShipModuleKind.Other; } private static IEnumerable Subclasses(Assembly a, Type baseType) diff --git a/docs/reference/tractor-beams.md b/docs/reference/tractor-beams.md index 114ffff1..d24aed9e 100644 --- a/docs/reference/tractor-beams.md +++ b/docs/reference/tractor-beams.md @@ -67,7 +67,7 @@ var masteryTip = api.Tooltips.RegisterSkillTree("my.mod", (tree, tooltip) => `ShipModule` covers every native `Behaviour.Equipment.Module` family through the game-vocabulary `ShipModuleKind` (`Tractor`, `Mining`, `Salvage`, `ShieldGenerator`, `Reactor`, `DroneBay`, `TorpedoBay`, `Armor`, `CargoScoop`, `EngineThrusters`, -`HangarBay`, `Hull`, `Painter`, `Repair`, `Scanner`, `Other`). `StatLines` shows the +`HangarBay`, `Hull`, `Painter`, `Repair`, `Scanner`, `Turret`, `Other`). `StatLines` shows the vanilla stat lines currently displayed and `DisplayName`/`QualityLevel` identify the module; the typed `Tractor` snapshot is non-null only for tractor modules. Item tooltips receive `ItemInfo` (`Identifier`, `DisplayName`, `Description`, `Count`, diff --git a/tools/e2e.py b/tools/e2e.py index d1e3bced..b3047846 100644 --- a/tools/e2e.py +++ b/tools/e2e.py @@ -19,7 +19,7 @@ "cargo-recovery", "station-commerce", "ui-surfaces", "mod-settings-menu", "tractor-module") HANDSHAKE = "--vgmodapi-e2e" ASSEMBLIES = ("VGModAPI.dll", "VGModAPI.Core.dll", "VGModAPI.Abstractions.dll", - "VGModAPI.Unity.dll", "VGModAPI.E2E.dll", "UiSurfaces.dll", "Newtonsoft.Json.dll") + "VGModAPI.Unity.dll", "VGModAPI.E2E.dll", "UiSurfaces.dll", "VGTractorAuto.dll", "Newtonsoft.Json.dll") class E2EError(Exception): From c5033b968efedb8c315cac19cb1482b20d3be358 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 17 Sep 2026 17:38:19 +0200 Subject: [PATCH 05/12] fix: address tooltip review findings Claim stats/tooltip objects per frame so a future base-chaining native override cannot double-append a contribution; document that the native module stat list renders plain text only (TooltipTextStyle ignored for RegisterShipModule); drop the dead dedupe set; the E2E now asserts exactly-once contributions and rebuilds the live module stats after the family check. Live E2E tractor-module passes (run-6). --- VGModAPI.Abstractions/Tooltips.cs | 3 ++- VGModAPI.E2E/TractorCase.cs | 9 ++++++--- VGModAPI/Native/TooltipRuntime.cs | 15 ++++++++++++--- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/VGModAPI.Abstractions/Tooltips.cs b/VGModAPI.Abstractions/Tooltips.cs index bd614a83..0ec91308 100644 --- a/VGModAPI.Abstractions/Tooltips.cs +++ b/VGModAPI.Abstractions/Tooltips.cs @@ -86,7 +86,8 @@ public TooltipLine Append(string text, TooltipTextStyle style = TooltipTextStyle public interface ITooltipService : IServiceStatus { /// Append lines to any ship equipment module's stat list, for every native module family. - /// Called while the module builds its stats; filter by Kind for your family. Changes do not + /// Called while the module builds its stats; filter by Kind for your family. The native stat + /// list renders plain label text only, so TooltipTextStyle is ignored here. Changes do not /// retroactively rebuild existing stats; native caching is preserved. IDisposable RegisterShipModule(string pluginId, Action describe); /// Append lines after any inventory item's native tooltip body, using the item the diff --git a/VGModAPI.E2E/TractorCase.cs b/VGModAPI.E2E/TractorCase.cs index 8ee7b900..81113304 100644 --- a/VGModAPI.E2E/TractorCase.cs +++ b/VGModAPI.E2E/TractorCase.cs @@ -159,8 +159,8 @@ private static StepResult Run() otherStats!.SetValue(otherModule!, Activator.CreateInstance(otherStats.FieldType)); otherModule!.GetType().GetMethod("SetMainSubStats", Any)!.Invoke(otherModule, null); Require(((IEnumerable)Get(otherStats.GetValue(otherModule)!, "subStatsList")!).Cast() - .Any(s => ((string)Get(s, "mainSubStatName")!).Contains("e2e-family")), - "family registration missed a non-tractor module (candidate=" + otherModule!.GetType().Name + " saw=" + (familySeen?.Kind.ToString() ?? "nothing") + " vanilla=" + vanillaLineCount + ")"); + .Count(s => ((string)Get(s, "mainSubStatName")!).Contains("e2e-family")) == 1, + "family registration did not contribute exactly once (candidate=" + otherModule!.GetType().Name + " saw=" + (familySeen?.Kind.ToString() ?? "nothing") + " vanilla=" + vanillaLineCount + ")"); } Require(familySeen != null && familySeen.Kind != ShipModuleKind.Tractor && familySeen.Tractor == null, "non-tractor module received a tractor snapshot or no payload"); @@ -189,10 +189,13 @@ private static StepResult Run() tooltipType.GetProperty("Source", Any)!.SetValue(tooltip, source, null); // Show() normally wires this before filling. tooltipType.GetMethod("SetContent", Any)!.Invoke(tooltip, new object[] { source }); string[] Lines() => ((IEnumerable)Get(tooltip, "_contentList")!).Cast().Select(c => c.GetType().GetProperty("Text", Any)?.GetValue(c)).Where(t => t != null).Select(t => (string)t!.GetType().GetProperty("text", Any)!.GetValue(t)!).ToArray(); - Require(Lines().Any(t => t.Contains("e2e-item") && t.Contains(" t.Contains("e2e-item") && t.Contains(" 0 && itemSeen.Count == 7, "item snapshot identity/stack mismatch (saw " + (itemSeen == null ? "nothing" : itemSeen.Identifier + " x" + itemSeen.Count) + ")"); } + // Leave the live panel showing current vanilla content, not the test fixture's lines. + otherStats.SetValue(otherModule!, Activator.CreateInstance(otherStats.FieldType)); + otherModule!.GetType().GetMethod("SetMainSubStats", Any)!.Invoke(otherModule, null); // Build a native mastery tooltip from a real UI prefab, without desktop interaction. var badgeGo = new GameObject("tractor-e2e-mastery"); badgeGo.SetActive(false); temporary.Add(badgeGo); diff --git a/VGModAPI/Native/TooltipRuntime.cs b/VGModAPI/Native/TooltipRuntime.cs index 4a41853f..efa079de 100644 --- a/VGModAPI/Native/TooltipRuntime.cs +++ b/VGModAPI/Native/TooltipRuntime.cs @@ -67,14 +67,13 @@ internal TooltipRuntime(Assembly a, TooltipService tooltips, SkillTreeRuntime sk // One postfix per concrete stat builder; the native dispatch resolves to the nearest // declaring type, so patching every declaring override covers the whole equipment family. var builders = new List(); - var seen = new HashSet(); // Abstract intermediate classes may hold the real implementation (AbstractTurret), so // patch every declaring override with a body; virtual dispatch reaches it from the leaves. foreach (var type in Subclasses(a, equipment)) { var overrideMethod = type.GetMethod("SetMainSubStats", Any | BindingFlags.DeclaredOnly, null, Type.EmptyTypes, null); if (overrideMethod == null || overrideMethod.DeclaringType != type || overrideMethod.IsAbstract - || overrideMethod.ReturnType != typeof(void) || !seen.Add(type)) continue; + || overrideMethod.ReturnType != typeof(void)) continue; builders.Add(overrideMethod); _kinds[type] = MapKind(type.Name); } @@ -131,6 +130,15 @@ private static PropertyInfo Property(Type type, string name, Type? type_ = null, } private bool MainThread => Thread.CurrentThread.ManagedThreadId == _thread; + private readonly Dictionary _dispatched = new(); + // A native override that chains to its base would dispatch the same render twice: once per + // key. Stats objects and tooltip objects are claimed per frame; fresh instances re-dispatch. + private bool Claim(object instance) + { + var frame = Time.frameCount; + if (_dispatched.TryGetValue(instance, out var seen) && seen == frame) return false; + _dispatched[instance] = frame; return true; + } internal void AddModuleDescription(object module) { @@ -139,7 +147,7 @@ internal void AddModuleDescription(object module) { var type = module.GetType(); if (!_kinds.TryGetValue(type, out var kind)) kind = MapKind(type.Name); - var stats = _stats.GetValue(module); if (stats == null) return; + var stats = _stats.GetValue(module); if (stats == null || !Claim(stats)) return; var lines = new List(); if (_subStats.GetValue(stats) is IList current) foreach (var stat in current) @@ -159,6 +167,7 @@ internal void AddItemDescription(object tooltipObject) if (!MainThread || !_tooltips.Availability.IsAvailable) return; try { + if (!Claim(tooltipObject)) return; if (_source.GetValue(tooltipObject) is not Component source || !_itemSourceType.IsInstanceOfType(source)) return; if (_sourceItem.GetValue(source) is not object item) return; var values = new ItemInfo( From 8873806ce0064402d079d387296146dac6dab798 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 17 Sep 2026 20:01:06 +0200 Subject: [PATCH 06/12] =?UTF-8?q?feat(examples):=20TractorGuide=20?= =?UTF-8?q?=E2=80=94=20settings-gated=20tractor=20policy=20and=20all=20thr?= =?UTF-8?q?ee=20tooltip=20families?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One small patch-free mod shows the #313 surfaces as a modder uses them: ExtraAutoBeam borrows one manual beam (abstain-by-null when off), the ship-module registration renders live Engineering mastery on the tractor stat list, the skill-tree registration annotates the Engineering badge, and HighlightItem opt-in adds an item tip. Styles, caching and disposal contracts documented in the example README. Committed E2E case tractor-guide drives exactly that sequence (fresh tooltip per fill, consumer isolated, all fixture state restored); both tractor cases pass live on the qualified build. External consumer DLLs (VGTractorAuto) became optional staging assemblies; in-repo examples are required. --- Makefile | 2 +- VGModAPI.E2E/Plugin.cs | 2 + VGModAPI.E2E/TractorGuideCase.cs | 142 ++++++++++++++++++++++ docs/development/e2e-tests.md | 22 ++++ examples/README.md | 1 + examples/TractorGuide/Plugin.cs | 81 ++++++++++++ examples/TractorGuide/README.md | 47 +++++++ examples/TractorGuide/TractorGuide.csproj | 11 ++ tools/e2e.py | 9 +- 9 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 VGModAPI.E2E/TractorGuideCase.cs create mode 100644 examples/TractorGuide/Plugin.cs create mode 100644 examples/TractorGuide/README.md create mode 100644 examples/TractorGuide/TractorGuide.csproj diff --git a/Makefile b/Makefile index 78be143c..2c43f7e8 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,7 @@ e2e-build: link-libs @set -eu; for dll in VGModAPI VGModAPI.Core VGModAPI.Abstractions VGModAPI.Unity; do cp "VGModAPI/bin/$(CONFIGURATION)/netstandard2.1/$$dll.dll" "$(E2E_BUILD)/"; done cp VGModAPI.E2E/bin/$(CONFIGURATION)/netstandard2.1/VGModAPI.E2E.dll "$(E2E_BUILD)/" cp VGModAPI.E2E/bin/$(CONFIGURATION)/netstandard2.1/Newtonsoft.Json.dll "$(E2E_BUILD)/" - @set -eu; for dll in PocketWorlds CargoRecovery StoryMissions StationCommerce StationCommerceB UiSurfaces Observation; do \ + @set -eu; for dll in PocketWorlds CargoRecovery StoryMissions StationCommerce StationCommerceB UiSurfaces Observation TractorGuide; do \ f=$$(find examples -path '*/bin/$(CONFIGURATION)/netstandard2.1/'"$$dll"'.dll' | head -n 1); \ if [ -n "$$f" ]; then cp "$$f" "$(E2E_BUILD)/"; else echo "e2e staging missing example dll: $$dll" >&2; exit 1; fi; \ done diff --git a/VGModAPI.E2E/Plugin.cs b/VGModAPI.E2E/Plugin.cs index d10ceca0..277f8968 100644 --- a/VGModAPI.E2E/Plugin.cs +++ b/VGModAPI.E2E/Plugin.cs @@ -49,6 +49,7 @@ private void Awake() _caseId = requested switch { TractorCase.Id => TractorCase.Id, + TractorGuideCase.Id => TractorGuideCase.Id, ModSettingsMenuCase.Id => ModSettingsMenuCase.Id, FreshSessionCase.Id => FreshSessionCase.Id, PocketWorldsCase.Id => PocketWorldsCase.Id, @@ -75,6 +76,7 @@ private void Awake() _test = _caseId switch { TractorCase.Id => new GameTest(TractorCase.Steps(_lifecycle, _events), remaining), + TractorGuideCase.Id => new GameTest(TractorGuideCase.Steps(_lifecycle, _events), remaining), ModSettingsMenuCase.Id => new GameTest(ModSettingsMenuCase.Steps(), remaining), FreshSessionCase.Id => new GameTest(FreshSessionCase.Steps(_lifecycle, _events), remaining), PocketWorldsCase.Id => new GameTest(PocketWorldsCase.Steps(_lifecycle, _events, _travelEvents), remaining), diff --git a/VGModAPI.E2E/TractorGuideCase.cs b/VGModAPI.E2E/TractorGuideCase.cs new file mode 100644 index 00000000..ffac7877 --- /dev/null +++ b/VGModAPI.E2E/TractorGuideCase.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEngine; + +namespace VGModAPI.E2E; + +// Exercises examples/TractorGuide end to end: the settings-driven equipment policy, and the +// example's own registrations on the ship-module, skill-tree and item tooltip families. +internal static class TractorGuideCase +{ + internal const string Id = "tractor-guide"; + private const BindingFlags Any = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + private static object? Get(object obj, string name) => obj.GetType().GetProperty(name, Any)?.GetValue(obj) ?? obj.GetType().GetField(name, Any)?.GetValue(obj); + private static FieldInfo Field(Type type, string name) + { + for (Type? t = type; t != null; t = t.BaseType) + if (t.GetField(name, Any | BindingFlags.DeclaredOnly) is FieldInfo f) return f; + throw new MissingFieldException(type.FullName, name); + } + private static void Require(bool value, string detail) { if (!value) throw new InvalidOperationException(detail); } + + internal static IReadOnlyList Steps(ILifecycleService lifecycle, List events) + { + var steps = new List(LiveBoot.Steps("vgmodapi.example.tractor-guide", lifecycle, events)); + steps.Add(new TestStep("guide policy, settings and tooltip contributions", "TractorGuide example", 30, Run)); + return steps; + } + + private static StepResult Run() + { + Require(ModApi.Services.Equipment.Availability.IsAvailable && ModApi.Services.SkillTrees.Availability.IsAvailable && ModApi.Services.Tooltips.Availability.IsAvailable, "API services not bound"); + var a = AppDomain.CurrentDomain.GetAssemblies().Single(x => x.GetName().Name == "Assembly-CSharp"); + var moduleType = a.GetType("Behaviour.Equipment.Module.TractorModule", true)!; + var module = Resources.FindObjectsOfTypeAll(moduleType).OfType().FirstOrDefault(m => moduleType.GetMethod("IsPlayer", Any)!.Invoke(m, new object[] { true }) is true); + if (module == null) return StepResult.Wait("player tractor module not ready"); + var guide = NativeGameplay.PluginInstance("vgmodapi.example.tractor-guide")!; + var extraEntry = Get(guide, "_extraAutoBeam")!; var extraValue = extraEntry.GetType().GetProperty("Value")!; var oldExtra = extraValue.GetValue(extraEntry); + var highlightEntry = Get(guide, "_highlightItem")!; var highlightValue = highlightEntry.GetType().GetProperty("Value")!; var oldHighlight = highlightValue.GetValue(highlightEntry); + var otherConsumer = NativeGameplay.PluginInstance("vgtractorauto"); + object? otherEnabled = otherConsumer == null ? null : Get(otherConsumer, "_enabled"); + PropertyInfo? otherEnabledValue = otherEnabled?.GetType().GetProperty("Value"); + object? oldOtherEnabled = otherEnabledValue == null ? null : otherEnabledValue.GetValue(otherEnabled); + + var manualField = Field(moduleType, "amountOfBonusBeams"); var oldManual = manualField.GetValue(module); + var beams = (IList)Get(module, "tractorBeams")!; int originalBeamCount = beams.Count; + var originals = beams.Cast().ToDictionary(b => b, b => Get(b, "target")); + var statsField = Field(moduleType, "mainSubStats"); var oldStats = statsField.GetValue(module); + var temporary = new List(); + try + { + otherEnabledValue?.SetValue(otherEnabled, false); // isolate the example's policy from the sibling consumer + moduleType.GetMethod("CreateTractorBeams", Any)!.Invoke(module, new object[] { 3, true }); + var all = beams.Cast().ToArray(); + var auto = all.Where(b => Get(b, "bonusBeam") is false).ToArray(); + var manual = all.Where(b => Get(b, "bonusBeam") is true).ToArray(); + Require(auto.Length > 0 && manual.Length > 0, "fixture needs automatic and manual beams"); + var markerGo = new GameObject("guide-e2e-busy"); markerGo.SetActive(false); temporary.Add(markerGo); + var marker = markerGo.AddComponent(a.GetType("Behaviour.Tractoring.TractorableItem", true)!); + void Busy(object b, bool busy) => Field(b.GetType(), "target").SetValue(b, busy ? marker : null); + object? Request(bool bonus) => moduleType.GetMethod("GetAvailableTractorBeam", Any)!.Invoke(module, new object[] { bonus }); + foreach (var b in all) Busy(b, false); + foreach (var b in auto) Busy(b, true); + + // Settings-driven equipment policy: off abstains (vanilla cap), on borrows one free manual beam. + extraValue.SetValue(extraEntry, false); + Require(Request(false) == null, "example promoted beams with ExtraAutoBeam off"); + extraValue.SetValue(extraEntry, true); + Require(manual.Contains(Request(false)!), "ExtraAutoBeam did not borrow a free manual beam"); + extraValue.SetValue(extraEntry, false); + Require(Request(false) == null, "ExtraAutoBeam stayed active after being turned off"); + extraValue.SetValue(extraEntry, true); + + // Ship-module family through the example's own registration. + var tree = ModApi.Services.SkillTrees.Get(CommanderSpecialization.Engineering); + Require(tree != null && tree.Specialization == CommanderSpecialization.Engineering, "engineering tree not readable through SkillTrees"); + statsField.SetValue(module, Activator.CreateInstance(statsField.FieldType)); + moduleType.GetMethod("SetMainSubStats", Any)!.Invoke(module, null); + var guideLines = ((IEnumerable)Get(statsField.GetValue(module)!, "subStatsList")!).Cast() + .Select(s => (string)Get(s, "mainSubStatName")!).Where(t => t.Contains("(TractorGuide)")).ToArray(); + Require(guideLines.Length == 1 && guideLines[0] == $"Autopilot mastery {tree!.MasteryLevel}/{tree.MaximumLevel} (TractorGuide)", + "example module tooltip missing, duplicated or stale (" + string.Join("|", guideLines) + ")"); + + // Skill-tree family through the example's own registration. + var nativeTree = a.GetType("Behaviour.Crew.Skilltree", true)!.GetMethod("Get", Any)!.Invoke(null, new object[] { tree!.Identifier })!; + var tooltipType = a.GetType("Behaviour.UI.UITooltip", true)!; + var prefab = Resources.FindObjectsOfTypeAll(tooltipType).OfType().First(t => Get(t, "_textPrefab") != null); + Component MakeTooltip(out GameObject go) + { + // A fresh tooltip object per fill: the fill dedupe claims each tooltip per frame, + // and the native UI always rebuilds content on a new show. + go = UnityEngine.Object.Instantiate(prefab.gameObject); go.SetActive(false); temporary.Add(go); + return go.GetComponent(tooltipType)!; + } + string[] LinesOf(Component tooltip) => ((IEnumerable)Get(tooltip, "_contentList")!).Cast().Select(c => c.GetType().GetProperty("Text", Any)?.GetValue(c)).Where(t => t != null).Select(t => (string)t!.GetType().GetProperty("text", Any)!.GetValue(t)!).ToArray(); + var badgeGo = new GameObject("guide-e2e-badge"); badgeGo.SetActive(false); temporary.Add(badgeGo); + var badgeType = a.GetType("Behaviour.UI.MasteryBadge", true)!; + var badge = badgeGo.AddComponent(badgeType); + Field(badgeType, "k__BackingField").SetValue(badge, nativeTree); + var badgeTooltip = MakeTooltip(out _); + badgeType.GetMethod("AddTooltipCustomContent", Any)!.Invoke(badge, new object[] { badgeTooltip }); + Require(LinesOf(badgeTooltip).Count(t => t.Contains("Engineering also powers the tractor autopilot (TractorGuide)")) == 1, "example skill-tree tooltip missing or duplicated"); + + // Item family: the opt-in identifier setting both gates and selects the contribution. + var ordinary = ((IEnumerable)a.GetType("Behaviour.Item.InventoryItemType", true)!.GetProperty("all", Any)!.GetValue(null)!).Cast().First(); + var expectedId = (string)Get(ordinary, "identifier")!; + var sourceGo = new GameObject("guide-e2e-item"); sourceGo.SetActive(false); temporary.Add(sourceGo); + var sourceType = a.GetType("Behaviour.UI.Tooltip.ItemTooltipSource", true)!; + var source = sourceGo.AddComponent(sourceType); + var context = Enum.Parse(a.GetType("Behaviour.UI.Tooltip.ItemTooltipContext", true)!, "InInventory"); + string[] ShowItem() + { + var tooltip = MakeTooltip(out _); + sourceType.GetMethod("SetItem", Any)!.Invoke(source, new object?[] { ordinary, 2, false, context, false, null }); + tooltipType.GetProperty("Source", Any)!.SetValue(tooltip, source, null); + tooltipType.GetMethod("SetContent", Any)!.Invoke(tooltip, new object[] { source }); + return LinesOf(tooltip); + } + highlightValue.SetValue(highlightEntry, ""); + Require(!ShowItem().Any(t => t.Contains("Easy to ferry")), "item tip shown while HighlightItem is empty"); + highlightValue.SetValue(highlightEntry, expectedId); + Require(ShowItem().Count(t => t.Contains("Easy to ferry with a tractor beam (TractorGuide)") && t.Contains("().Except(originals.Keys).ToArray()) Field(b.GetType(), "target").SetValue(b, null); + while (beams.Count > originalBeamCount) + { + var extra = (Component)beams[beams.Count - 1]!; beams.RemoveAt(beams.Count - 1); UnityEngine.Object.Destroy(extra.gameObject); + } + foreach (var go in temporary) UnityEngine.Object.Destroy(go); + } + } +} diff --git a/docs/development/e2e-tests.md b/docs/development/e2e-tests.md index bac993ad..4ac3c50e 100644 --- a/docs/development/e2e-tests.md +++ b/docs/development/e2e-tests.md @@ -82,6 +82,10 @@ set (all examples + the E2E harness are deployed), only the driven workflow diff make e2e E2E_CASE=pocket-worlds ``` +Consumer-mod DLLs outside this repository (e.g. `VGTractorAuto.dll`) are staged when +present in the build directory; copy the built DLL there before `make e2e`, or the +case that asserts on it fails with a missing-registration detail. + ## `pocket-worlds` Tests the PocketWorlds example in a real game session: it clicks the rendered HUD @@ -126,6 +130,24 @@ Waits for the consumer-owned gameplay window container, then clicks the shared HUD launcher to create and toggle the owned window, asserting its live visibility. The Forge inspector is availability-dependent, so it is not gated here. +## `tractor-module` + +Runs against the sibling VGTractorAuto consumer DLL when it is staged (see optional +assemblies below). Drives the real player tractor module through the public +Equipment/SkillTrees/Tooltips surfaces: zero/half/max mastery promotion caps, manual +borrowing, occupied-beam protection, native target eligibility and ordering, plus +exactly-once contributions on a non-tractor equipment module's stat builder, an +instantiated item tooltip and a native mastery badge, with the disabled mod adding +nothing. All fixture state (beams, targets, stats, config) is restored afterwards. + +## `tractor-guide` + +Runs the in-repo TractorGuide example as the mod under test: the `ExtraAutoBeam` +setting alone gates one borrowed manual beam (off → vanilla cap), and the example's +own registrations contribute exactly once on the ship-module stat list, the +Engineering mastery badge and — gated by the `HighlightItem` setting — an item +tooltip. + ## Coverage status `fresh-session` (normal new-game lifecycle) and `pocket-worlds` (actual example, diff --git a/examples/README.md b/examples/README.md index 951abba6..d363f1f4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,6 +15,7 @@ None of these are part of the shipped API package. | **[StoryMissions](StoryMissions/)** | **Your own missions** — hand-authored campaign beats with real decisions, jobs generated from runtime text, and follow-ups that offer themselves when an arc completes. | `Story`, `Game` | | **[StationCommerce](StationCommerce/)** | **Your own trade goods and the people who deal in them** — a manufactured item, the recipe that makes it, and a bar contact with a mission, placed at a real station. | `Items`, `RecipeRegistration`, `Bars`, `Story`, `Game` | | **[UiSurfaces](UiSurfaces/)** | **UI, settings and saved progress** — a hosted window with four typed global preferences, a per-save counter, and a Forge inspector. Requires the matching development API, not public 0.2.8. | `GameplayUi`, `Hud`, `Settings`, `SaveData`, `ForgeUi`, `Recipes`, `RecipeQuotes` | +| **[TractorGuide](TractorGuide/)** | **Tractor policy and tooltip text without patches** — a settings-gated extra autopilot beam, a mastery hint on tractor module stats, an Engineering badge line and an opt-in item tip. Requires the matching development API, not public 0.2.8. | `Equipment`, `SkillTrees`, `Tooltips` | | **[Observation](Observation/)** | **Mods that react to what the player is doing** — sessions, missions and travel observed without touching them, plus optional-dependency entry points and custom save data. | `Lifecycle`, `Missions`, `Travel`, `SaveData` | | **[UpdateParticipant](UpdateParticipant/)** | **Publishing your mod** — update metadata and the packaging dry run. | *(loader metadata only)* | diff --git a/examples/TractorGuide/Plugin.cs b/examples/TractorGuide/Plugin.cs new file mode 100644 index 00000000..09c62bc8 --- /dev/null +++ b/examples/TractorGuide/Plugin.cs @@ -0,0 +1,81 @@ +using System; +using BepInEx; +using BepInEx.Configuration; +using VGModAPI; + +// Example author package: the three #313 surfaces in one small, honest mod. +// - Equipment: borrow one manual beam for the autopilot when ExtraAutoBeam is on. +// - Tooltips.RegisterShipModule: a mastery hint on tractor module stat lists. +// - Tooltips.RegisterSkillTree: what Engineering actually does for the tractor. +// - Tooltips.RegisterItem: opt-in highlight for one item by identifier. +// Policy and text stay mod-owned; all native beam, crew and cargo guards stay native. +namespace TractorGuide +{ + [BepInPlugin(Id, Name, Version)] + [BepInDependency(ModApi.PluginId)] + public sealed class Plugin : BaseUnityPlugin + { + public const string Id = "vgmodapi.example.tractor-guide"; + public const string Name = "Tractor Guide"; + public const string Version = "1.0.0"; + + private readonly ConfigEntry _extraAutoBeam = null!; + private readonly ConfigEntry _highlightItem = null!; + private IDisposable? _equipment; + private IDisposable? _moduleTooltip; + private IDisposable? _itemTooltip; + private IDisposable? _treeTooltip; + + public Plugin() + { + _extraAutoBeam = Config.Bind("Tractor", "ExtraAutoBeam", false, + "Let the tractor autopilot borrow one manual beam slot when all automatic beams are busy."); + _highlightItem = Config.Bind("Tractor", "HighlightItem", "", + "Item identifier (leave empty to disable) whose tooltip gets a tractor tip."); + } + + private void Start() + { + ModServices api; + try { api = ModApi.Services; } + catch (InvalidOperationException) { Logger.LogWarning("VGModAPI services are not available; TractorGuide does nothing."); return; } + + _equipment = api.Equipment.ConfigurePlayerTractorModules(Id, module => + { + // Abstain (null) to keep vanilla behavior; only tractors with a spare manual beam qualify. + if (!_extraAutoBeam.Value || module.BeamCount <= 0 || module.ManualBeamCount <= 0) return null; + return new TractorTargeting(module.BeamCount + 1); + }); + + _moduleTooltip = api.Tooltips.RegisterShipModule(Id, (module, tip) => + { + if (module.Kind != ShipModuleKind.Tractor) return; + var tree = api.SkillTrees.Get(CommanderSpecialization.Engineering); + tip.AddLine(tree == null + ? "Tractor autopilot (TractorGuide)" + : $"Autopilot mastery {tree.MasteryLevel}/{tree.MaximumLevel} (TractorGuide)", TooltipTextStyle.Bonus); + }); + + _treeTooltip = api.Tooltips.RegisterSkillTree(Id, (tree, tip) => + { + if (tree.Specialization == CommanderSpecialization.Engineering) + tip.AddLine("Engineering also powers the tractor autopilot (TractorGuide)", TooltipTextStyle.Details); + }); + + _itemTooltip = api.Tooltips.RegisterItem(Id, (item, tip) => + { + var wanted = _highlightItem.Value; + if (wanted.Length > 0 && item.Identifier == wanted) + tip.AddLine("Easy to ferry with a tractor beam (TractorGuide)", TooltipTextStyle.Details); + }); + + Logger.LogInfo($"TractorGuide registered (availability: equipment={api.Equipment.Availability.IsAvailable}, " + + $"skillTrees={api.SkillTrees.Availability.IsAvailable}, tooltips={api.Tooltips.Availability.IsAvailable})."); + } + + private void OnDestroy() + { + _equipment?.Dispose(); _moduleTooltip?.Dispose(); _itemTooltip?.Dispose(); _treeTooltip?.Dispose(); + } + } +} diff --git a/examples/TractorGuide/README.md b/examples/TractorGuide/README.md new file mode 100644 index 00000000..08a50c4f --- /dev/null +++ b/examples/TractorGuide/README.md @@ -0,0 +1,47 @@ +# TractorGuide + +**What you can build with this: gameplay policy and tooltip text for the tractor, without a single +Harmony patch.** A mod decides *what* the autopilot may do and *what readers should know*; the API +owns the only hook into the game. + +A sample/test BepInEx mod for the **VG Mod API** (development build; requires the matching +unreleased API, not public 0.2.8). + +## What it demonstrates (the abilities) + +| Ability | How the example uses it | +|---|---| +| **Equipment policy** (`IEquipmentService.ConfigurePlayerTractorModules`) | When `ExtraAutoBeam` is on, the autopilot may borrow one free manual beam; when off the callback returns `null` and vanilla behavior is untouched. | +| **Abstain by returning null** | A registration that abstains is invisible — no zero-limits, no half-applied state. The first non-null answer wins; yours is not silently overridden by later registrations. | +| **Mod-owned policy, native safety** | The `+1` capacity is this mod's formula. Target eligibility, crew, cargo and occupied-beam protections stay the game's — the API never exposes them and you never reimplement them. | +| **Ship-module tooltips** (`ITooltipService.RegisterShipModule`) | Every equipment module family reaches one callback with a typed `ShipModule` snapshot (`Kind`, `DisplayName`, `QualityLevel`, vanilla `StatLines`, tractor sub-snapshot); the example filters on `Kind == Tractor`. | +| **Skill-tree tooltips** (`ITooltipService.RegisterSkillTree`) | Adds a styled line to the Engineering badge's tooltip; filter by `Specialization`. | +| **Item tooltips** (`ITooltipService.RegisterItem`) | Adds a tip to one item's tooltip, selected by the `HighlightItem` setting against `ItemInfo.Identifier`; empty setting ⇒ abstain everywhere. | +| **Reading mastery** (`ISkillTreeService.Get`) | The module line embeds the live Engineering mastery level; reads are plain main-thread calls, usable from inside a tooltip callback. | +| **Typed global preferences** | Both knobs are plain `ConfigEntry` fields — BepInEx config and the Mods settings screen own the UI. No API store, no reflection. | +| **Disposal semantics** | All four registrations are `IDisposable` and disposed in `OnDestroy`; after disposal the game reverts to vanilla rendering/policy. | + +## Two contracts worth knowing + +**Module stat lists are plain text.** The native stat list renders label text only, so +`TooltipTextStyle` is ignored by `RegisterShipModule` contributions. Item and skill-tree +tooltips do apply the style. Contributions never rebuild existing stats — the game's caching +boundary is preserved; a fresh build includes your lines from then on. + +**Acquire services in `Start()`, dispose in `OnDestroy()`.** Registration is plugin-id keyed and +main-thread only. If `ModApi.Services` throws `InvalidOperationException`, the API is not up yet — +retry or stand down, don't crash. + +## Try it + +1. Deploy `TractorGuide.dll` next to the API and start a game. +2. Main menu → **Mods** → Tractor Guide → enable `ExtraAutoBeam`. Dock somewhere with loot and + watch the autopilot use one extra beam while every automatic beam is busy. +3. Hover a tractor module: the stat list ends with `Autopilot mastery n/max (TractorGuide)`. + Hover the Engineering mastery badge: it carries the example's detail line. +4. Set `HighlightItem` to any item identifier (see `BepInEx/plugins` logs or your item-mod's dump); + hovering that item in inventory gains the tractor tip. Clear it and the tip disappears. +5. Disable the mod: every trace of it is gone from tooltips and beam behavior. + +The `tractor-guide` E2E case (`make e2e E2E_CASE=tractor-guide`) verifies exactly this sequence +deterministically in a real session. diff --git a/examples/TractorGuide/TractorGuide.csproj b/examples/TractorGuide/TractorGuide.csproj new file mode 100644 index 00000000..422c39c1 --- /dev/null +++ b/examples/TractorGuide/TractorGuide.csproj @@ -0,0 +1,11 @@ + + + netstandard2.1false + + + ../../VGModAPI/lib/BepInEx.dllfalse + ../../VGModAPI/lib/UnityEngine.dllfalse + ../../VGModAPI/lib/UnityEngine.CoreModule.dllfalse + + diff --git a/tools/e2e.py b/tools/e2e.py index b3047846..2bb90a54 100644 --- a/tools/e2e.py +++ b/tools/e2e.py @@ -16,10 +16,13 @@ CASE = "fresh-session" KNOWN_CASES = ("fresh-session", "pocket-worlds", "story-missions", "observation", - "cargo-recovery", "station-commerce", "ui-surfaces", "mod-settings-menu", "tractor-module") + "cargo-recovery", "station-commerce", "ui-surfaces", "mod-settings-menu", + "tractor-module", "tractor-guide") HANDSHAKE = "--vgmodapi-e2e" ASSEMBLIES = ("VGModAPI.dll", "VGModAPI.Core.dll", "VGModAPI.Abstractions.dll", - "VGModAPI.Unity.dll", "VGModAPI.E2E.dll", "UiSurfaces.dll", "VGTractorAuto.dll", "Newtonsoft.Json.dll") + "VGModAPI.Unity.dll", "VGModAPI.E2E.dll", "UiSurfaces.dll", "TractorGuide.dll", "Newtonsoft.Json.dll") +# Consumer-mod DLLs the consumer-migration cases assert on; staged when present in the build dir. +OPTIONAL_ASSEMBLIES = ("VGTractorAuto.dll",) class E2EError(Exception): @@ -95,7 +98,7 @@ def __enter__(self): self.created.append(name) target = self.root / "plugins" / "VGModAPI.E2E" target.mkdir() - for name in ASSEMBLIES: + for name in ASSEMBLIES + tuple(n for n in OPTIONAL_ASSEMBLIES if (self.build / n).is_file()): shutil.copy2(self.build / name, target / name) # The staging vacuums the real config/ for isolation, which drops the API back to its # disabled-by-default providers. Enable the ones the example cases exercise (Story drives From 325d05a9d6d79037b2aa90927dfc311eb01f7c7e Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 17 Sep 2026 20:09:14 +0200 Subject: [PATCH 07/12] fix(examples): address TractorGuide review notes Record staged optional consumer DLLs (name/sha256/mtime) in the E2E report so a stale out-of-repo DLL is attributable; fail closed when a staged VGTractorAuto lacks the setting the case-isolation relies on; drop redundant null-forgiving initializers in the example. Tooling tests and live tractor-guide pass (guide-run-3). --- VGModAPI.E2E/TractorGuideCase.cs | 1 + examples/TractorGuide/Plugin.cs | 4 ++-- tools/e2e.py | 10 +++++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/VGModAPI.E2E/TractorGuideCase.cs b/VGModAPI.E2E/TractorGuideCase.cs index ffac7877..87c5473d 100644 --- a/VGModAPI.E2E/TractorGuideCase.cs +++ b/VGModAPI.E2E/TractorGuideCase.cs @@ -43,6 +43,7 @@ private static StepResult Run() object? otherEnabled = otherConsumer == null ? null : Get(otherConsumer, "_enabled"); PropertyInfo? otherEnabledValue = otherEnabled?.GetType().GetProperty("Value"); object? oldOtherEnabled = otherEnabledValue == null ? null : otherEnabledValue.GetValue(otherEnabled); + Require(otherConsumer == null || otherEnabled != null, "staged VGTractorAuto lacks the _enabled setting the isolation relies on"); var manualField = Field(moduleType, "amountOfBonusBeams"); var oldManual = manualField.GetValue(module); var beams = (IList)Get(module, "tractorBeams")!; int originalBeamCount = beams.Count; diff --git a/examples/TractorGuide/Plugin.cs b/examples/TractorGuide/Plugin.cs index 09c62bc8..9b5f9f4c 100644 --- a/examples/TractorGuide/Plugin.cs +++ b/examples/TractorGuide/Plugin.cs @@ -19,8 +19,8 @@ public sealed class Plugin : BaseUnityPlugin public const string Name = "Tractor Guide"; public const string Version = "1.0.0"; - private readonly ConfigEntry _extraAutoBeam = null!; - private readonly ConfigEntry _highlightItem = null!; + private readonly ConfigEntry _extraAutoBeam; + private readonly ConfigEntry _highlightItem; private IDisposable? _equipment; private IDisposable? _moduleTooltip; private IDisposable? _itemTooltip; diff --git a/tools/e2e.py b/tools/e2e.py index 2bb90a54..9f5d6b7c 100644 --- a/tools/e2e.py +++ b/tools/e2e.py @@ -73,6 +73,7 @@ def __init__(self, game, build): self.build = Path(build) self.backup = self.root / ".vgmodapi-e2e-backup" self.moved = [] + self.staged_optional = [] self.created = [] def __enter__(self): @@ -100,6 +101,12 @@ def __enter__(self): target.mkdir() for name in ASSEMBLIES + tuple(n for n in OPTIONAL_ASSEMBLIES if (self.build / n).is_file()): shutil.copy2(self.build / name, target / name) + if name in OPTIONAL_ASSEMBLIES: + # Out-of-repo consumer DLLs are not rebuilt by e2e-build; record exactly which + # bytes were staged so a stale leftover is attributable in the report. + data = (self.build / name).read_bytes() + self.staged_optional.append({"name": name, "sha256": hashlib.sha256(data).hexdigest(), + "mtimeMs": int((self.build / name).stat().st_mtime * 1000)}) # The staging vacuums the real config/ for isolation, which drops the API back to its # disabled-by-default providers. Enable the ones the example cases exercise (Story drives # station-commerce's errand and the story-missions case; Bars backs the bar-contact examples). @@ -347,8 +354,9 @@ def main(argv=None): shutil.rmtree(runtime / "screenshots", ignore_errors=True) report = new_report() try: - with SaveGuard(saves, allow_missing=args.save_dir is None), GameInstallation(game, build): + with SaveGuard(saves, allow_missing=args.save_dir is None), GameInstallation(game, build) as installation: run_game(game, runtime, args.timeout, report) + report["meta"]["stagedOptionalAssemblies"] = list(installation.staged_optional) report["meta"]["realSavesUnchanged"] = True report["meta"]["installationRestored"] = True except (E2EError, OSError, KeyboardInterrupt) as error: From 8f7414b51fd7f076f43e86bd77bbe26f7187bb0f Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 17 Sep 2026 22:04:52 +0200 Subject: [PATCH 08/12] refactor(examples): fold #313 demos into their natural packages; unblock all examples - RegisterShipModule/RegisterSkillTree join UiSurfaces behind a new typed "AnnotateModules" preference; RegisterItem joins StationCommerce as the maker tip on its own good. TractorGuide keeps exactly the one decision it owns: the settings-gated ExtraAutoBeam borrowing. - Live cases extended: ui-surfaces asserts six settings plus exactly-once annotated module/badge lines; station-commerce forces the shared item fill and asserts both authors tips once, styled. - Fix latent example breakage found while proving this: [BepInDependency("vgmodapi","0.2.8")] blocked every pinned example under the deliberate 0.0.0 development version (earlier "passes" were tag-derived 0.2.8 builds); drop the in-repo pins with a comment. StationCommerce used a non-existent vanilla id "Carbon"; the owned good never actually materialized. Use SalvageCarbon and trigger the game-side lazy lookup in the case. - tools/e2e.py staging regressed to two example DLLs on this branch; restore the full set. observation and story-missions now pass live again; cargo-recovery and pocket-worlds load and advance to their movement steps, which time out (pre-existing flow drift, separate). --- VGModAPI.E2E/StationCommerceCase.cs | 48 ++++++++++++++++++ VGModAPI.E2E/TractorGuideCase.cs | 67 +++----------------------- VGModAPI.E2E/UiSurfacesCase.cs | 60 ++++++++++++++++++++++- docs/development/e2e-tests.md | 16 +++--- examples/CargoRecovery/Plugin.cs | 4 +- examples/Observation/Plugin.cs | 4 +- examples/PocketWorlds/Plugin.cs | 4 +- examples/README.md | 6 +-- examples/StationCommerce/Plugin.cs | 17 +++++-- examples/StationCommerce/README.md | 3 +- examples/StoryMissions/Plugin.cs | 4 +- examples/TractorGuide/Plugin.cs | 47 ++---------------- examples/TractorGuide/README.md | 43 ++++++----------- examples/UiSurfaces/Plugin.Settings.cs | 17 +++++++ examples/UiSurfaces/Plugin.cs | 3 ++ tools/e2e.py | 4 +- 16 files changed, 197 insertions(+), 150 deletions(-) diff --git a/VGModAPI.E2E/StationCommerceCase.cs b/VGModAPI.E2E/StationCommerceCase.cs index 6381dfd0..f5a9aafe 100644 --- a/VGModAPI.E2E/StationCommerceCase.cs +++ b/VGModAPI.E2E/StationCommerceCase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; namespace VGModAPI.E2E; @@ -31,6 +32,7 @@ internal static IReadOnlyList Steps(ILifecycleService lifecycle, List< RegisteredGood(PluginIdB) && RegisteredErrand(PluginIdB)), new TestStep("recipe-before-item declaration resolves, never a hard failure", "OwnedRecipeProvider.Register / owned dependency", () => RecipeResolves(PluginIdA) && RecipeResolves(PluginIdB)), + new TestStep("the maker's tip renders once per author on the owned good's tooltip", "Tooltips.RegisterItem / UITooltip.SetContent", 30, MakerTipRenders), }); return steps; } @@ -50,6 +52,52 @@ private static bool RegisteredErrand(string pluginId) return NativeGameplay.GetField(p, "_errand") != null; } + private static StepResult MakerTipRenders() + { + const System.Reflection.BindingFlags All = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Static + | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic; + object? Get(object o, string name) => o.GetType().GetProperty(name, All)?.GetValue(o) ?? o.GetType().GetField(name, All)?.GetValue(o); + var a = AppDomain.CurrentDomain.GetAssemblies().Single(x => x.GetName().Name == "Assembly-CSharp"); + var itemType = a.GetType("Behaviour.Item.InventoryItemType", true)!; + UnityEngine.Component? good = UnityEngine.Resources.FindObjectsOfTypeAll(itemType).OfType() + .FirstOrDefault(i => (string?)Get(i, "displayName") == "Silo Container"); + if (good == null) + { + // Same lazy path the game itself uses: a lookup of the owned identifier runs the API's + // resolve prefix, which materializes the catalog entry. + var definitions = ModApi.Services.Items.GetType().GetProperty("Definitions", All)!.GetValue(ModApi.Services.Items) as System.Collections.IEnumerable; + var identity = definitions?.Cast().FirstOrDefault(d => (string)d.GetType().GetProperty("Owner", All)!.GetValue(d)! == PluginIdA); + if (identity == null) return StepResult.Wait("variant A definition not in the service yet"); + itemType.GetMethod("Get", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public)!.Invoke(null, new object[] { (string)identity.GetType().GetProperty("NativeId", All)!.GetValue(identity)! }); + good = UnityEngine.Resources.FindObjectsOfTypeAll(itemType).OfType() + .FirstOrDefault(i => (string?)Get(i, "displayName") == "Silo Container"); + if (good == null) return StepResult.Wait("owned good lookup did not materialize it"); + } + var tooltipType = a.GetType("Behaviour.UI.UITooltip", true)!; + var prefab = UnityEngine.Resources.FindObjectsOfTypeAll(tooltipType).OfType().First(t => Get(t, "_textPrefab") != null); + var tipGo = UnityEngine.Object.Instantiate(prefab.gameObject); tipGo.SetActive(false); + var sourceGo = new UnityEngine.GameObject("commerce-e2e-item"); sourceGo.SetActive(false); + try + { + var sourceType = a.GetType("Behaviour.UI.Tooltip.ItemTooltipSource", true)!; + var source = sourceGo.AddComponent(sourceType); + var context = Enum.Parse(a.GetType("Behaviour.UI.Tooltip.ItemTooltipContext", true)!, "InInventory"); + sourceType.GetMethod("SetItem", All)!.Invoke(source, new object?[] { good, 1, false, context, false, null }); + var tooltip = tipGo.GetComponent(tooltipType)!; + tooltipType.GetProperty("Source", All)!.SetValue(tooltip, source, null); // Show() normally wires this before filling. + tooltipType.GetMethod("SetContent", All)!.Invoke(tooltip, new object[] { source }); + var lines = ((System.Collections.IEnumerable)Get(tooltip, "_contentList")!).Cast() + .Select(c => c.GetType().GetProperty("Text", All)?.GetValue(c)).Where(t => t != null) + .Select(t => (string)t!.GetType().GetProperty("text", All)!.GetValue(t)!).ToArray(); + foreach (var variant in new[] { "A", "B" }) + Require(lines.Count(t => t.Contains($"Handmade by Station Commerce {variant}") && t.Contains(" t.Contains("Handmade")))); + UnityEngine.Debug.Log("StationCommerce E2E: both independent authors tipped their own good's tooltip exactly once."); + return StepResult.Pass("Maker's tip verified"); + } + finally { UnityEngine.Object.Destroy(tipGo); UnityEngine.Object.Destroy(sourceGo); } + } + private static void Require(bool condition, string detail) { if (!condition) throw new InvalidOperationException(detail); } private static bool RecipeResolves(string pluginId) { var p = NativeGameplay.PluginInstance(pluginId)!; diff --git a/VGModAPI.E2E/TractorGuideCase.cs b/VGModAPI.E2E/TractorGuideCase.cs index 87c5473d..75ea498b 100644 --- a/VGModAPI.E2E/TractorGuideCase.cs +++ b/VGModAPI.E2E/TractorGuideCase.cs @@ -7,8 +7,8 @@ namespace VGModAPI.E2E; -// Exercises examples/TractorGuide end to end: the settings-driven equipment policy, and the -// example's own registrations on the ship-module, skill-tree and item tooltip families. +// Exercises examples/TractorGuide: the one gameplay decision the example owns. Its settings +// toggle alone decides whether the autopilot may borrow one free manual beam. internal static class TractorGuideCase { internal const string Id = "tractor-guide"; @@ -25,30 +25,27 @@ private static FieldInfo Field(Type type, string name) internal static IReadOnlyList Steps(ILifecycleService lifecycle, List events) { var steps = new List(LiveBoot.Steps("vgmodapi.example.tractor-guide", lifecycle, events)); - steps.Add(new TestStep("guide policy, settings and tooltip contributions", "TractorGuide example", 30, Run)); + steps.Add(new TestStep("settings-gated beam borrowing", "TractorGuide example", 30, Run)); return steps; } private static StepResult Run() { - Require(ModApi.Services.Equipment.Availability.IsAvailable && ModApi.Services.SkillTrees.Availability.IsAvailable && ModApi.Services.Tooltips.Availability.IsAvailable, "API services not bound"); + Require(ModApi.Services.Equipment.Availability.IsAvailable, "equipment service not bound"); var a = AppDomain.CurrentDomain.GetAssemblies().Single(x => x.GetName().Name == "Assembly-CSharp"); var moduleType = a.GetType("Behaviour.Equipment.Module.TractorModule", true)!; var module = Resources.FindObjectsOfTypeAll(moduleType).OfType().FirstOrDefault(m => moduleType.GetMethod("IsPlayer", Any)!.Invoke(m, new object[] { true }) is true); if (module == null) return StepResult.Wait("player tractor module not ready"); var guide = NativeGameplay.PluginInstance("vgmodapi.example.tractor-guide")!; var extraEntry = Get(guide, "_extraAutoBeam")!; var extraValue = extraEntry.GetType().GetProperty("Value")!; var oldExtra = extraValue.GetValue(extraEntry); - var highlightEntry = Get(guide, "_highlightItem")!; var highlightValue = highlightEntry.GetType().GetProperty("Value")!; var oldHighlight = highlightValue.GetValue(highlightEntry); var otherConsumer = NativeGameplay.PluginInstance("vgtractorauto"); object? otherEnabled = otherConsumer == null ? null : Get(otherConsumer, "_enabled"); PropertyInfo? otherEnabledValue = otherEnabled?.GetType().GetProperty("Value"); object? oldOtherEnabled = otherEnabledValue == null ? null : otherEnabledValue.GetValue(otherEnabled); Require(otherConsumer == null || otherEnabled != null, "staged VGTractorAuto lacks the _enabled setting the isolation relies on"); - var manualField = Field(moduleType, "amountOfBonusBeams"); var oldManual = manualField.GetValue(module); var beams = (IList)Get(module, "tractorBeams")!; int originalBeamCount = beams.Count; var originals = beams.Cast().ToDictionary(b => b, b => Get(b, "target")); - var statsField = Field(moduleType, "mainSubStats"); var oldStats = statsField.GetValue(module); var temporary = new List(); try { @@ -72,65 +69,13 @@ private static StepResult Run() Require(manual.Contains(Request(false)!), "ExtraAutoBeam did not borrow a free manual beam"); extraValue.SetValue(extraEntry, false); Require(Request(false) == null, "ExtraAutoBeam stayed active after being turned off"); - extraValue.SetValue(extraEntry, true); - - // Ship-module family through the example's own registration. - var tree = ModApi.Services.SkillTrees.Get(CommanderSpecialization.Engineering); - Require(tree != null && tree.Specialization == CommanderSpecialization.Engineering, "engineering tree not readable through SkillTrees"); - statsField.SetValue(module, Activator.CreateInstance(statsField.FieldType)); - moduleType.GetMethod("SetMainSubStats", Any)!.Invoke(module, null); - var guideLines = ((IEnumerable)Get(statsField.GetValue(module)!, "subStatsList")!).Cast() - .Select(s => (string)Get(s, "mainSubStatName")!).Where(t => t.Contains("(TractorGuide)")).ToArray(); - Require(guideLines.Length == 1 && guideLines[0] == $"Autopilot mastery {tree!.MasteryLevel}/{tree.MaximumLevel} (TractorGuide)", - "example module tooltip missing, duplicated or stale (" + string.Join("|", guideLines) + ")"); - - // Skill-tree family through the example's own registration. - var nativeTree = a.GetType("Behaviour.Crew.Skilltree", true)!.GetMethod("Get", Any)!.Invoke(null, new object[] { tree!.Identifier })!; - var tooltipType = a.GetType("Behaviour.UI.UITooltip", true)!; - var prefab = Resources.FindObjectsOfTypeAll(tooltipType).OfType().First(t => Get(t, "_textPrefab") != null); - Component MakeTooltip(out GameObject go) - { - // A fresh tooltip object per fill: the fill dedupe claims each tooltip per frame, - // and the native UI always rebuilds content on a new show. - go = UnityEngine.Object.Instantiate(prefab.gameObject); go.SetActive(false); temporary.Add(go); - return go.GetComponent(tooltipType)!; - } - string[] LinesOf(Component tooltip) => ((IEnumerable)Get(tooltip, "_contentList")!).Cast().Select(c => c.GetType().GetProperty("Text", Any)?.GetValue(c)).Where(t => t != null).Select(t => (string)t!.GetType().GetProperty("text", Any)!.GetValue(t)!).ToArray(); - var badgeGo = new GameObject("guide-e2e-badge"); badgeGo.SetActive(false); temporary.Add(badgeGo); - var badgeType = a.GetType("Behaviour.UI.MasteryBadge", true)!; - var badge = badgeGo.AddComponent(badgeType); - Field(badgeType, "k__BackingField").SetValue(badge, nativeTree); - var badgeTooltip = MakeTooltip(out _); - badgeType.GetMethod("AddTooltipCustomContent", Any)!.Invoke(badge, new object[] { badgeTooltip }); - Require(LinesOf(badgeTooltip).Count(t => t.Contains("Engineering also powers the tractor autopilot (TractorGuide)")) == 1, "example skill-tree tooltip missing or duplicated"); - - // Item family: the opt-in identifier setting both gates and selects the contribution. - var ordinary = ((IEnumerable)a.GetType("Behaviour.Item.InventoryItemType", true)!.GetProperty("all", Any)!.GetValue(null)!).Cast().First(); - var expectedId = (string)Get(ordinary, "identifier")!; - var sourceGo = new GameObject("guide-e2e-item"); sourceGo.SetActive(false); temporary.Add(sourceGo); - var sourceType = a.GetType("Behaviour.UI.Tooltip.ItemTooltipSource", true)!; - var source = sourceGo.AddComponent(sourceType); - var context = Enum.Parse(a.GetType("Behaviour.UI.Tooltip.ItemTooltipContext", true)!, "InInventory"); - string[] ShowItem() - { - var tooltip = MakeTooltip(out _); - sourceType.GetMethod("SetItem", Any)!.Invoke(source, new object?[] { ordinary, 2, false, context, false, null }); - tooltipType.GetProperty("Source", Any)!.SetValue(tooltip, source, null); - tooltipType.GetMethod("SetContent", Any)!.Invoke(tooltip, new object[] { source }); - return LinesOf(tooltip); - } - highlightValue.SetValue(highlightEntry, ""); - Require(!ShowItem().Any(t => t.Contains("Easy to ferry")), "item tip shown while HighlightItem is empty"); - highlightValue.SetValue(highlightEntry, expectedId); - Require(ShowItem().Count(t => t.Contains("Easy to ferry with a tractor beam (TractorGuide)") && t.Contains("().Except(originals.Keys).ToArray()) Field(b.GetType(), "target").SetValue(b, null); while (beams.Count > originalBeamCount) diff --git a/VGModAPI.E2E/UiSurfacesCase.cs b/VGModAPI.E2E/UiSurfacesCase.cs index 7b95e81c..4311769a 100644 --- a/VGModAPI.E2E/UiSurfacesCase.cs +++ b/VGModAPI.E2E/UiSurfacesCase.cs @@ -27,6 +27,7 @@ internal static IReadOnlyList Steps(ILifecycleService lifecycle, List< () => NativeGameplay.ClickHudRow("Example window") ? StepResult.Pass("Launcher invoked") : StepResult.Wait("Launcher not ready"), () => Window()?.activeSelf == true ? StepResult.Pass("Window visible") : StepResult.Wait("Window not visible")), new TestStep("typed settings change the live window and persist global config", "ModSettingsService / ConfigEntry / UiSurfaces", 20, VerifySettings), + new TestStep("annotated module and mastery tooltips follow the typed setting", "Tooltips.RegisterShipModule / RegisterSkillTree / ConfigEntry", 30, VerifyTooltipAnnotations), TestStep.ActionThenWait("click launcher toggles the window closed", "UiSurfaces.ToggleWindow / GameObject.SetActive", 20, () => NativeGameplay.ClickHudRow("Example window") ? StepResult.Pass("Launcher invoked") : StepResult.Wait("Launcher not ready"), () => Window()?.activeSelf == false ? StepResult.Pass("Window hidden") : StepResult.Wait("Window still visible")), @@ -34,6 +35,63 @@ internal static IReadOnlyList Steps(ILifecycleService lifecycle, List< return steps; } private static GameObject? Window() => NativeGameplay.GetField(NativeGameplay.PluginInstance(PluginId)!, "_window") as GameObject; + private const BindingFlags All = Any | BindingFlags.Static; + private static object? Prop(object o, string name) => o.GetType().GetProperty(name, Any)?.GetValue(o) ?? o.GetType().GetField(name, Any)?.GetValue(o); + private static FieldInfo DeclaredField(Type type, string name) + { + for (Type? t = type; t != null; t = t.BaseType) + if (t.GetField(name, All | BindingFlags.DeclaredOnly) is FieldInfo f) return f; + throw new MissingFieldException(type.FullName, name); + } + private static StepResult VerifyTooltipAnnotations() + { + var plugin = NativeGameplay.PluginInstance(PluginId)!; + var annotate = NativeGameplay.GetField(plugin, "_annotate") ?? throw new InvalidOperationException("tooltip annotation setting unavailable"); + var valueProp = annotate.GetType().GetProperty("Value")!; var old = valueProp.GetValue(annotate); + var a = AppDomain.CurrentDomain.GetAssemblies().Single(x => x.GetName().Name == "Assembly-CSharp"); + var moduleType = a.GetType("Behaviour.Equipment.Module.TractorModule", true)!; + var module = Resources.FindObjectsOfTypeAll(moduleType).OfType().FirstOrDefault(m => moduleType.GetMethod("IsPlayer", All)!.Invoke(m, new object[] { true }) is true); + if (module == null) return StepResult.Wait("player tractor module not ready"); + valueProp.SetValue(annotate, true); + try + { + var statsField = DeclaredField(moduleType, "mainSubStats"); var oldStats = statsField.GetValue(module); + try + { + string[] Build() + { + statsField.SetValue(module, Activator.CreateInstance(statsField.FieldType)); + moduleType.GetMethod("SetMainSubStats", All)!.Invoke(module, null); + return ((IEnumerable)Prop(statsField.GetValue(module)!, "subStatsList")!).Cast().Select(x => (string)Prop(x, "mainSubStatName")!).ToArray(); + } + Require(Build().Count(t => t.StartsWith("UiSurfaces: Tractor module")) == 1, "module annotation missing or duplicated"); + valueProp.SetValue(annotate, false); + Require(!Build().Any(t => t.StartsWith("UiSurfaces:")), "module annotation shown while the setting is off"); + } + finally { statsField.SetValue(module, oldStats); } + var tree = ModApi.Services.SkillTrees.Get(CommanderSpecialization.Engineering) ?? throw new InvalidOperationException("engineering tree unavailable"); + var nativeTree = a.GetType("Behaviour.Crew.Skilltree", true)!.GetMethod("Get", All)!.Invoke(null, new object[] { tree.Identifier })!; + var tooltipType = a.GetType("Behaviour.UI.UITooltip", true)!; + var prefab = Resources.FindObjectsOfTypeAll(tooltipType).OfType().First(t => Prop(t, "_textPrefab") != null); + var tipGo = UnityEngine.Object.Instantiate(prefab.gameObject); tipGo.SetActive(false); + var badgeGo = new GameObject("ui-e2e-badge"); badgeGo.SetActive(false); + try + { + var badgeType = a.GetType("Behaviour.UI.MasteryBadge", true)!; + var badge = badgeGo.AddComponent(badgeType); + DeclaredField(badgeType, "k__BackingField").SetValue(badge, nativeTree); + valueProp.SetValue(annotate, true); + var tooltip = tipGo.GetComponent(tooltipType)!; + badgeType.GetMethod("AddTooltipCustomContent", All)!.Invoke(badge, new object[] { tooltip }); + var lines = ((IEnumerable)Prop(tooltip, "_contentList")!).Cast().Select(c => c.GetType().GetProperty("Text", Any)?.GetValue(c)).Where(t => t != null).Select(t => (string)t!.GetType().GetProperty("text", Any)!.GetValue(t)!).ToArray(); + Require(lines.Count(t => t.Contains($"UiSurfaces: Engineering mastery {tree.MasteryLevel}/{tree.MaximumLevel}") && t.Contains("() .ToDictionary(row => ((ModSettingDefinition)row.GetType().GetProperty("Definition", Any)!.GetValue(row)!).LocalId); - Require(rows.Count == 4, "Expected bool, integer, float and choice settings"); + Require(rows.Count == 6, "Expected bool, integer, float, choice, behavior and tooltip settings"); var write = settings.GetType().GetMethod("TryWrite", Any)!; void Set(string id, object value) => Require(write.Invoke(settings, new[] { rows[id], value }) is true, "Setting write refused: " + id); var counter = NativeGameplay.GetField(plugin, "_visits") ?? throw new InvalidOperationException("Save-data model unavailable"); diff --git a/docs/development/e2e-tests.md b/docs/development/e2e-tests.md index 4ac3c50e..6847ae06 100644 --- a/docs/development/e2e-tests.md +++ b/docs/development/e2e-tests.md @@ -122,13 +122,19 @@ not miss the session by loading late. Loads both independent author variants (A and B) and asserts each registers the SAME author-local good, recipe and story-errand IDs through its own authenticated provider without collision, and that the recipe-before-item declaration resolves -rather than hard-failing. +rather than hard-failing. Finally forces the shared item tooltip fill for the +owned good and asserts each variant's maker tip renders exactly once, styled. ## `ui-surfaces` Waits for the consumer-owned gameplay window container, then clicks the shared HUD launcher to create and toggle the owned window, asserting its live visibility. -The Forge inspector is availability-dependent, so it is not gated here. +Toggles all six typed settings through the settings service, asserting immediate +window effects and BepInEx config persistence. The tooltip annotations are also +driven through the example's own registrations: with the setting on, the tractor +stat list and the Engineering mastery badge each carry the example's line exactly +once; with it off, neither does. The Forge inspector is availability-dependent, so +it is not gated here. ## `tractor-module` @@ -143,10 +149,8 @@ nothing. All fixture state (beams, targets, stats, config) is restored afterward ## `tractor-guide` Runs the in-repo TractorGuide example as the mod under test: the `ExtraAutoBeam` -setting alone gates one borrowed manual beam (off → vanilla cap), and the example's -own registrations contribute exactly once on the ship-module stat list, the -Engineering mastery badge and — gated by the `HighlightItem` setting — an item -tooltip. +setting alone gates one borrowed manual beam (off ⇒ vanilla cap, on ⇒ borrows a +free manual beam), isolated from the optional sibling consumer. ## Coverage status diff --git a/examples/CargoRecovery/Plugin.cs b/examples/CargoRecovery/Plugin.cs index 97986225..eb19b47e 100644 --- a/examples/CargoRecovery/Plugin.cs +++ b/examples/CargoRecovery/Plugin.cs @@ -13,7 +13,9 @@ namespace CargoRecovery; /// "Attach cargo encounter" action appears on any observed target you select. /// [BepInPlugin(Id, "Cargo recovery example", "1.0.0")] -[BepInDependency(ModApi.PluginId, "0.2.8")] +// Version floor omitted on purpose: in-repo examples track the development API, which + // is deliberately unversioned (0.0.0); real consumers name the release they require. + [BepInDependency(ModApi.PluginId)] public sealed class Plugin : BaseUnityPlugin { private const string Id = "vgmodapi.example.cargo"; diff --git a/examples/Observation/Plugin.cs b/examples/Observation/Plugin.cs index 1f6f2449..94e2ad56 100644 --- a/examples/Observation/Plugin.cs +++ b/examples/Observation/Plugin.cs @@ -17,7 +17,9 @@ namespace Observation; /// The Unity-free injected consumers in `Consumers/` show the same discipline as plain .NET classes. /// [BepInPlugin(Id, "Observation example", "1.0.0")] -[BepInDependency(ModApi.PluginId, "0.2.8")] +// Version floor omitted on purpose: in-repo examples track the development API, which + // is deliberately unversioned (0.0.0); real consumers name the release they require. + [BepInDependency(ModApi.PluginId)] [BepInProcess("VanguardGalaxy.exe")] public sealed class Plugin : BaseUnityPlugin { diff --git a/examples/PocketWorlds/Plugin.cs b/examples/PocketWorlds/Plugin.cs index c36ce08f..d8d4840a 100644 --- a/examples/PocketWorlds/Plugin.cs +++ b/examples/PocketWorlds/Plugin.cs @@ -25,7 +25,9 @@ namespace PocketWorlds; /// and site POIs go with it) — so a delete leaves no authored system, gate, wormhole or site behind. /// [BepInPlugin(Id, "Pocket Worlds example", "1.0.0")] -[BepInDependency(ModApi.PluginId, "0.2.8")] +// Version floor omitted on purpose: in-repo examples track the development API, which + // is deliberately unversioned (0.0.0); real consumers name the release they require. + [BepInDependency(ModApi.PluginId)] public sealed class Plugin : BaseUnityPlugin { private const string Id = "vgmodapi.example.pocket-worlds"; diff --git a/examples/README.md b/examples/README.md index d363f1f4..35f81b22 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,9 +13,9 @@ None of these are part of the shipped API package. | **[PocketWorlds](PocketWorlds/)** | **Your own star systems** — pocket systems chained together, hidden off the drawn map, reachable only through wormholes you placed, holding your own mining fields, salvage wrecks and hostile encounters, and removable without a trace. | `World` | | **[CargoRecovery](CargoRecovery/)** | **Your own boarding missions** — a derelict you spawn in your own system, laid out room by room, with an authored decision in the cargo hold, extraction through a contextual panel action, crew settlement observed honestly, and clean removal afterwards. | `Dungeons`, `World` | | **[StoryMissions](StoryMissions/)** | **Your own missions** — hand-authored campaign beats with real decisions, jobs generated from runtime text, and follow-ups that offer themselves when an arc completes. | `Story`, `Game` | -| **[StationCommerce](StationCommerce/)** | **Your own trade goods and the people who deal in them** — a manufactured item, the recipe that makes it, and a bar contact with a mission, placed at a real station. | `Items`, `RecipeRegistration`, `Bars`, `Story`, `Game` | -| **[UiSurfaces](UiSurfaces/)** | **UI, settings and saved progress** — a hosted window with four typed global preferences, a per-save counter, and a Forge inspector. Requires the matching development API, not public 0.2.8. | `GameplayUi`, `Hud`, `Settings`, `SaveData`, `ForgeUi`, `Recipes`, `RecipeQuotes` | -| **[TractorGuide](TractorGuide/)** | **Tractor policy and tooltip text without patches** — a settings-gated extra autopilot beam, a mastery hint on tractor module stats, an Engineering badge line and an opt-in item tip. Requires the matching development API, not public 0.2.8. | `Equipment`, `SkillTrees`, `Tooltips` | +| **[StationCommerce](StationCommerce/)** | **Your own trade goods and the people who deal in them** — a manufactured item with the maker's own tooltip tip, the recipe that makes it, and a bar contact with a mission, placed at a real station. | `Items`, `RecipeRegistration`, `Bars`, `Story`, `Game`, `Tooltips` | +| **[UiSurfaces](UiSurfaces/)** | **UI, settings and saved progress** — a hosted window with typed global preferences, a per-save counter, a Forge inspector and opt-in annotations on ship-module stat lists and the Engineering mastery badge. Requires the matching development API, not public 0.2.8. | `GameplayUi`, `Hud`, `Settings`, `SaveData`, `ForgeUi`, `Recipes`, `RecipeQuotes`, `Tooltips` | +| **[TractorGuide](TractorGuide/)** | **One autopilot decision without patches** — a settings-gated extra tractor beam for the autopilot; abstain-by-null, native safety retained. Requires the matching development API, not public 0.2.8. | `Equipment` | | **[Observation](Observation/)** | **Mods that react to what the player is doing** — sessions, missions and travel observed without touching them, plus optional-dependency entry points and custom save data. | `Lifecycle`, `Missions`, `Travel`, `SaveData` | | **[UpdateParticipant](UpdateParticipant/)** | **Publishing your mod** — update metadata and the packaging dry run. | *(loader metadata only)* | diff --git a/examples/StationCommerce/Plugin.cs b/examples/StationCommerce/Plugin.cs index 1405a218..a65186cb 100644 --- a/examples/StationCommerce/Plugin.cs +++ b/examples/StationCommerce/Plugin.cs @@ -19,7 +19,9 @@ namespace StationCommerce; /// no serializer. /// [BepInPlugin(Id, "Station Commerce example (" + Variant + ")", "1.0.0")] -[BepInDependency(ModApi.PluginId, "0.2.8")] +// Version floor omitted on purpose: in-repo examples track the development API, which + // is deliberately unversioned (0.0.0); real consumers name the release they require. + [BepInDependency(ModApi.PluginId)] public sealed class Plugin : BaseUnityPlugin { #if AUTHOR_B @@ -46,6 +48,7 @@ public sealed class Plugin : BaseUnityPlugin private IStoryProvider? _story; private IGameService? _games; private IHudRegistration? _hud; + private IDisposable? _tip; private IStoryDefinition? _errand; private string _itemStatus = "not registered"; @@ -70,7 +73,7 @@ private void Start() else { var status = _recipes.Register(new OwnedRecipeDefinition(RecipeDef, 1, "Silo Container", 100, 30, - new[] { new OwnedRecipeIngredient(RecipeItemReference.Vanilla("Carbon"), 2) }, + new[] { new OwnedRecipeIngredient(RecipeItemReference.Vanilla("SalvageCarbon"), 2) }, new OwnedRecipeIngredient(RecipeItemReference.FromOwned(new OwnedItemReference(Id, ItemDef)), 1))); _recipeStatus = status.ToString(); } @@ -80,10 +83,17 @@ private void Start() else { var status = _items.Register(new OwnedItemDefinition(ItemDef, 1, "Silo Container", - "A manufactured container. Plain trade goods, not an installable silo.", "Carbon", 15, 100, + "A manufactured container. Plain trade goods, not an installable silo.", "SalvageCarbon", 15, 100, OwnedItemStorage.Armory)); _itemStatus = status.ToString(); } + // A maker's mark on your own trade good: match the display name this plugin authored and + // add one styled line. Every item tooltip goes through the shared native fill. + _tip = ModApi.Services.Tooltips.RegisterItem(Id, (item, tip) => + { + if (item.DisplayName == "Silo Container") + tip.AddLine("Handmade by Station Commerce " + Variant, TooltipTextStyle.Details); + }); // A contact is more interesting with something to say: register a small errand and link it to // the patron by content id. Composition across two owned providers of the same plugin. @@ -196,6 +206,7 @@ private void OnHud(HudInteraction interaction) private void OnDestroy() { var hud = _hud; _hud = null; hud?.Dispose(); + _tip?.Dispose(); _tip = null; // Releasing a provider removes runtime behavior but preserves persistent rows for a later // registration; per-save removal is a separate, reversible action on the live patron object. _bars?.Dispose(); _bars = null; diff --git a/examples/StationCommerce/README.md b/examples/StationCommerce/README.md index 641952ec..092b01c7 100644 --- a/examples/StationCommerce/README.md +++ b/examples/StationCommerce/README.md @@ -4,7 +4,8 @@ manufactured item, the recipe that produces it, and a bar contact standing in a real station's bar with a mission attached — all persisted by the API, with no save hook of your own. -A sample/test BepInEx mod for the **VG Mod API** (v0.2.8+), built **twice** as two separately loaded +A sample/test BepInEx mod for the **VG Mod API** (development build; the tooltip tip needs the +matching unreleased API). Built **twice** as two separately loaded assemblies to demonstrate authenticated provider composition. ## What it demonstrates (the abilities) diff --git a/examples/StoryMissions/Plugin.cs b/examples/StoryMissions/Plugin.cs index a0a817b1..a84c655f 100644 --- a/examples/StoryMissions/Plugin.cs +++ b/examples/StoryMissions/Plugin.cs @@ -17,7 +17,9 @@ namespace StoryMissions; /// are registered once, and the API restores missions, progress and declared choices itself. /// [BepInPlugin(Id, "Story Missions example", "1.0.0")] -[BepInDependency(ModApi.PluginId, "0.2.8")] +// Version floor omitted on purpose: in-repo examples track the development API, which + // is deliberately unversioned (0.0.0); real consumers name the release they require. + [BepInDependency(ModApi.PluginId)] public sealed class Plugin : BaseUnityPlugin { private const string Id = "vgmodapi.example.story-missions"; diff --git a/examples/TractorGuide/Plugin.cs b/examples/TractorGuide/Plugin.cs index 9b5f9f4c..2ee71300 100644 --- a/examples/TractorGuide/Plugin.cs +++ b/examples/TractorGuide/Plugin.cs @@ -3,12 +3,9 @@ using BepInEx.Configuration; using VGModAPI; -// Example author package: the three #313 surfaces in one small, honest mod. -// - Equipment: borrow one manual beam for the autopilot when ExtraAutoBeam is on. -// - Tooltips.RegisterShipModule: a mastery hint on tractor module stat lists. -// - Tooltips.RegisterSkillTree: what Engineering actually does for the tractor. -// - Tooltips.RegisterItem: opt-in highlight for one item by identifier. -// Policy and text stay mod-owned; all native beam, crew and cargo guards stay native. +// Example author package: your mod owns one tractor autopilot decision. The API owns the only +// hook into the game; target eligibility, crew, cargo and occupied-beam protections stay native. +// Tooltip and settings showcases live in the UiSurfaces and StationCommerce examples. namespace TractorGuide { [BepInPlugin(Id, Name, Version)] @@ -20,18 +17,12 @@ public sealed class Plugin : BaseUnityPlugin public const string Version = "1.0.0"; private readonly ConfigEntry _extraAutoBeam; - private readonly ConfigEntry _highlightItem; private IDisposable? _equipment; - private IDisposable? _moduleTooltip; - private IDisposable? _itemTooltip; - private IDisposable? _treeTooltip; public Plugin() { _extraAutoBeam = Config.Bind("Tractor", "ExtraAutoBeam", false, "Let the tractor autopilot borrow one manual beam slot when all automatic beams are busy."); - _highlightItem = Config.Bind("Tractor", "HighlightItem", "", - "Item identifier (leave empty to disable) whose tooltip gets a tractor tip."); } private void Start() @@ -39,43 +30,15 @@ private void Start() ModServices api; try { api = ModApi.Services; } catch (InvalidOperationException) { Logger.LogWarning("VGModAPI services are not available; TractorGuide does nothing."); return; } - _equipment = api.Equipment.ConfigurePlayerTractorModules(Id, module => { // Abstain (null) to keep vanilla behavior; only tractors with a spare manual beam qualify. if (!_extraAutoBeam.Value || module.BeamCount <= 0 || module.ManualBeamCount <= 0) return null; return new TractorTargeting(module.BeamCount + 1); }); - - _moduleTooltip = api.Tooltips.RegisterShipModule(Id, (module, tip) => - { - if (module.Kind != ShipModuleKind.Tractor) return; - var tree = api.SkillTrees.Get(CommanderSpecialization.Engineering); - tip.AddLine(tree == null - ? "Tractor autopilot (TractorGuide)" - : $"Autopilot mastery {tree.MasteryLevel}/{tree.MaximumLevel} (TractorGuide)", TooltipTextStyle.Bonus); - }); - - _treeTooltip = api.Tooltips.RegisterSkillTree(Id, (tree, tip) => - { - if (tree.Specialization == CommanderSpecialization.Engineering) - tip.AddLine("Engineering also powers the tractor autopilot (TractorGuide)", TooltipTextStyle.Details); - }); - - _itemTooltip = api.Tooltips.RegisterItem(Id, (item, tip) => - { - var wanted = _highlightItem.Value; - if (wanted.Length > 0 && item.Identifier == wanted) - tip.AddLine("Easy to ferry with a tractor beam (TractorGuide)", TooltipTextStyle.Details); - }); - - Logger.LogInfo($"TractorGuide registered (availability: equipment={api.Equipment.Availability.IsAvailable}, " + - $"skillTrees={api.SkillTrees.Availability.IsAvailable}, tooltips={api.Tooltips.Availability.IsAvailable})."); + Logger.LogInfo($"TractorGuide registered (equipment available: {api.Equipment.Availability.IsAvailable})."); } - private void OnDestroy() - { - _equipment?.Dispose(); _moduleTooltip?.Dispose(); _itemTooltip?.Dispose(); _treeTooltip?.Dispose(); - } + private void OnDestroy() => _equipment?.Dispose(); } } diff --git a/examples/TractorGuide/README.md b/examples/TractorGuide/README.md index 08a50c4f..82dba520 100644 --- a/examples/TractorGuide/README.md +++ b/examples/TractorGuide/README.md @@ -1,8 +1,8 @@ # TractorGuide -**What you can build with this: gameplay policy and tooltip text for the tractor, without a single -Harmony patch.** A mod decides *what* the autopilot may do and *what readers should know*; the API -owns the only hook into the game. +**What you can build with this: one gameplay decision for the tractor autopilot, without a single +Harmony patch.** Your mod answers "what may the autopilot use?"; the API owns the only hook into +the game, and the game keeps every native protection. A sample/test BepInEx mod for the **VG Mod API** (development build; requires the matching unreleased API, not public 0.2.8). @@ -14,34 +14,21 @@ unreleased API, not public 0.2.8). | **Equipment policy** (`IEquipmentService.ConfigurePlayerTractorModules`) | When `ExtraAutoBeam` is on, the autopilot may borrow one free manual beam; when off the callback returns `null` and vanilla behavior is untouched. | | **Abstain by returning null** | A registration that abstains is invisible — no zero-limits, no half-applied state. The first non-null answer wins; yours is not silently overridden by later registrations. | | **Mod-owned policy, native safety** | The `+1` capacity is this mod's formula. Target eligibility, crew, cargo and occupied-beam protections stay the game's — the API never exposes them and you never reimplement them. | -| **Ship-module tooltips** (`ITooltipService.RegisterShipModule`) | Every equipment module family reaches one callback with a typed `ShipModule` snapshot (`Kind`, `DisplayName`, `QualityLevel`, vanilla `StatLines`, tractor sub-snapshot); the example filters on `Kind == Tractor`. | -| **Skill-tree tooltips** (`ITooltipService.RegisterSkillTree`) | Adds a styled line to the Engineering badge's tooltip; filter by `Specialization`. | -| **Item tooltips** (`ITooltipService.RegisterItem`) | Adds a tip to one item's tooltip, selected by the `HighlightItem` setting against `ItemInfo.Identifier`; empty setting ⇒ abstain everywhere. | -| **Reading mastery** (`ISkillTreeService.Get`) | The module line embeds the live Engineering mastery level; reads are plain main-thread calls, usable from inside a tooltip callback. | -| **Typed global preferences** | Both knobs are plain `ConfigEntry` fields — BepInEx config and the Mods settings screen own the UI. No API store, no reflection. | -| **Disposal semantics** | All four registrations are `IDisposable` and disposed in `OnDestroy`; after disposal the game reverts to vanilla rendering/policy. | +| **Typed global preference** | The knob is a plain `ConfigEntry` — BepInEx config owns the file; no API store, no reflection. | +| **Service acquisition + disposal** | `ModApi.Services` in `Start()` behind an `InvalidOperationException` guard; the registration is disposed in `OnDestroy`, after which the game reverts to vanilla policy. | -## Two contracts worth knowing - -**Module stat lists are plain text.** The native stat list renders label text only, so -`TooltipTextStyle` is ignored by `RegisterShipModule` contributions. Item and skill-tree -tooltips do apply the style. Contributions never rebuild existing stats — the game's caching -boundary is preserved; a fresh build includes your lines from then on. - -**Acquire services in `Start()`, dispose in `OnDestroy()`.** Registration is plugin-id keyed and -main-thread only. If `ModApi.Services` throws `InvalidOperationException`, the API is not up yet — -retry or stand down, don't crash. +The tooltip and multi-setting showcases live where they belong: **UiSurfaces** (typed settings, +tooltip annotations on module families and mastery badges) and **StationCommerce** (a maker's tip +on the example's own trade good). ## Try it -1. Deploy `TractorGuide.dll` next to the API and start a game. -2. Main menu → **Mods** → Tractor Guide → enable `ExtraAutoBeam`. Dock somewhere with loot and - watch the autopilot use one extra beam while every automatic beam is busy. -3. Hover a tractor module: the stat list ends with `Autopilot mastery n/max (TractorGuide)`. - Hover the Engineering mastery badge: it carries the example's detail line. -4. Set `HighlightItem` to any item identifier (see `BepInEx/plugins` logs or your item-mod's dump); - hovering that item in inventory gains the tractor tip. Clear it and the tip disappears. -5. Disable the mod: every trace of it is gone from tooltips and beam behavior. +1. Deploy `TractorGuide.dll` next to the API and set `ExtraAutoBeam = true` in + `BepInEx/config/vgmodapi.example.tractor-guide.cfg`. +2. Dock somewhere with loot. While every automatic beam is busy, the autopilot now also uses one + free manual beam. With the setting off, it never does. +3. Disable the mod: the extra beam is gone — the game's own beam rules were never replaced. The `tractor-guide` E2E case (`make e2e E2E_CASE=tractor-guide`) verifies exactly this sequence -deterministically in a real session. +deterministically in a real session, including the off ⇒ vanilla-cap negative and full fixture +restore. diff --git a/examples/UiSurfaces/Plugin.Settings.cs b/examples/UiSurfaces/Plugin.Settings.cs index c06905ca..64c35cc8 100644 --- a/examples/UiSurfaces/Plugin.Settings.cs +++ b/examples/UiSurfaces/Plugin.Settings.cs @@ -10,6 +10,7 @@ public sealed partial class Plugin private ConfigEntry _opacity = null!; private ConfigEntry _theme = null!; private ConfigEntry _showGoal = null!; + private ConfigEntry _annotate = null!; private IModSettingsProvider? _settings; private void ConfigurePreferences() @@ -20,6 +21,7 @@ private void ConfigurePreferences() _opacity = Config.Bind("Window", "Opacity", .9f, new ConfigDescription("Example window opacity.", new AcceptableValueRange(.3f, 1f))); _theme = Config.Bind("Window", "Theme", "blue", new ConfigDescription("Example window theme.", new AcceptableValueList("blue", "amber"))); _showGoal = Config.Bind("Behavior", "ShowVisitGoal", true, "Show the global visit goal beside this save's count."); + _annotate = Config.Bind("Tooltips", "AnnotateModules", false, "Add this mod's note to ship module stat lists and the Engineering mastery badge."); Config.SettingChanged += PreferenceChanged; } @@ -39,6 +41,21 @@ private void Start() () => _theme.Value, value => _theme.Value = value, order: 3)); Publish(new BoolModSetting("show-goal", "Behavior", "Show visit goal", "Show or hide the global goal beside this save's progress.", (bool)_showGoal.DefaultValue, () => _showGoal.Value, value => _showGoal.Value = value, order: 0)); + Publish(new BoolModSetting("annotate-tooltips", "Tooltips", "Annotate modules and mastery", "Opt in to the tooltip contributions demonstrated below.", + (bool)_annotate.DefaultValue, () => _annotate.Value, value => _annotate.Value = value, order: 0)); + // Tooltip contributions belong to the UI surfaces this example owns. They are registered + // unconditionally and gated by a typed preference: the callback abstains when it is off. + _moduleTip = ModApi.Services.Tooltips.RegisterShipModule(Id, (module, tip) => + { + if (!_annotate.Value) return; + tip.AddLine($"UiSurfaces: {module.Kind} module, quality {module.QualityLevel}" + + (module.Tractor is { } tractor ? $" with {tractor.ManualBeamCount} manual beam(s)" : "")); + }); + _treeTip = ModApi.Services.Tooltips.RegisterSkillTree(Id, (tree, tip) => + { + if (!_annotate.Value || tree.Specialization != CommanderSpecialization.Engineering) return; + tip.AddLine($"UiSurfaces: Engineering mastery {tree.MasteryLevel}/{tree.MaximumLevel}", TooltipTextStyle.Bonus); + }); } private void Publish(ModSettingDefinition definition) diff --git a/examples/UiSurfaces/Plugin.cs b/examples/UiSurfaces/Plugin.cs index 24977cbb..1032f445 100644 --- a/examples/UiSurfaces/Plugin.cs +++ b/examples/UiSurfaces/Plugin.cs @@ -26,6 +26,8 @@ public sealed partial class Plugin : BaseUnityPlugin private WindowVisits? _visits; private string _lastOpen = ""; private Inspector? _inspector; + private IDisposable? _moduleTip; + private IDisposable? _treeTip; private float _nextInspectorAttempt; private bool _warnedInspector; @@ -157,6 +159,7 @@ private void Detach() private void OnDestroy() { Config.SettingChanged -= PreferenceChanged; + _moduleTip?.Dispose(); _moduleTip = null; _treeTip?.Dispose(); _treeTip = null; _settings?.Dispose(); _settings = null; if (_visits != null) { _visits.Changed -= RefreshWindow; _visits.Dispose(); _visits = null; } if (_ui != null) _ui.Changed -= UiChanged; diff --git a/tools/e2e.py b/tools/e2e.py index 9f5d6b7c..5c3d230c 100644 --- a/tools/e2e.py +++ b/tools/e2e.py @@ -20,7 +20,9 @@ "tractor-module", "tractor-guide") HANDSHAKE = "--vgmodapi-e2e" ASSEMBLIES = ("VGModAPI.dll", "VGModAPI.Core.dll", "VGModAPI.Abstractions.dll", - "VGModAPI.Unity.dll", "VGModAPI.E2E.dll", "UiSurfaces.dll", "TractorGuide.dll", "Newtonsoft.Json.dll") + "VGModAPI.Unity.dll", "VGModAPI.E2E.dll", "PocketWorlds.dll", "CargoRecovery.dll", + "StoryMissions.dll", "StationCommerce.dll", "StationCommerceB.dll", "UiSurfaces.dll", + "Observation.dll", "TractorGuide.dll", "Newtonsoft.Json.dll") # Consumer-mod DLLs the consumer-migration cases assert on; staged when present in the build dir. OPTIONAL_ASSEMBLIES = ("VGTractorAuto.dll",) From 7f97351015b308180d329014d18312b6e4bb33eb Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 17 Sep 2026 22:11:06 +0200 Subject: [PATCH 09/12] fix(examples): polish tooltip fold per review UiSurfaces registers its tooltip contributions before the settings- provider early return so they depend only on their own ConfigEntry; six-settings log wording; StationCommerce comment acknowledges the cross-author name match it demonstrates; dependency-comment indentation. --- VGModAPI.E2E/UiSurfacesCase.cs | 2 +- examples/CargoRecovery/Plugin.cs | 2 +- examples/Observation/Plugin.cs | 2 +- examples/PocketWorlds/Plugin.cs | 2 +- examples/StationCommerce/Plugin.cs | 6 ++++-- examples/StoryMissions/Plugin.cs | 2 +- examples/UiSurfaces/Plugin.Settings.cs | 29 +++++++++++++------------- 7 files changed, 24 insertions(+), 21 deletions(-) diff --git a/VGModAPI.E2E/UiSurfacesCase.cs b/VGModAPI.E2E/UiSurfacesCase.cs index 4311769a..3d29b918 100644 --- a/VGModAPI.E2E/UiSurfacesCase.cs +++ b/VGModAPI.E2E/UiSurfacesCase.cs @@ -124,7 +124,7 @@ private static StepResult VerifySettings() foreach (var row in rows.Values) Require(settings.GetType().GetMethod("TryReset", Any)!.Invoke(settings, new[] { row }) is true, "Setting reset refused"); Require(Count() == before + 1, "Resetting global preferences erased per-save progress"); - Debug.Log("UI Surfaces E2E: four typed settings, immediate window updates, config persistence, pause/resume count, and reset without losing progress passed."); + Debug.Log("UI Surfaces E2E: six typed settings, immediate window updates, config persistence, pause/resume count, and reset without losing progress passed."); return StepResult.Pass("Settings/config and window progress verified; no native disk saves attempted"); } } diff --git a/examples/CargoRecovery/Plugin.cs b/examples/CargoRecovery/Plugin.cs index eb19b47e..130ed6ee 100644 --- a/examples/CargoRecovery/Plugin.cs +++ b/examples/CargoRecovery/Plugin.cs @@ -13,7 +13,7 @@ namespace CargoRecovery; /// "Attach cargo encounter" action appears on any observed target you select. /// [BepInPlugin(Id, "Cargo recovery example", "1.0.0")] -// Version floor omitted on purpose: in-repo examples track the development API, which + // Version floor omitted on purpose: in-repo examples track the development API, which // is deliberately unversioned (0.0.0); real consumers name the release they require. [BepInDependency(ModApi.PluginId)] public sealed class Plugin : BaseUnityPlugin diff --git a/examples/Observation/Plugin.cs b/examples/Observation/Plugin.cs index 94e2ad56..6cbbced7 100644 --- a/examples/Observation/Plugin.cs +++ b/examples/Observation/Plugin.cs @@ -17,7 +17,7 @@ namespace Observation; /// The Unity-free injected consumers in `Consumers/` show the same discipline as plain .NET classes. /// [BepInPlugin(Id, "Observation example", "1.0.0")] -// Version floor omitted on purpose: in-repo examples track the development API, which + // Version floor omitted on purpose: in-repo examples track the development API, which // is deliberately unversioned (0.0.0); real consumers name the release they require. [BepInDependency(ModApi.PluginId)] [BepInProcess("VanguardGalaxy.exe")] diff --git a/examples/PocketWorlds/Plugin.cs b/examples/PocketWorlds/Plugin.cs index d8d4840a..9c3b2a4e 100644 --- a/examples/PocketWorlds/Plugin.cs +++ b/examples/PocketWorlds/Plugin.cs @@ -25,7 +25,7 @@ namespace PocketWorlds; /// and site POIs go with it) — so a delete leaves no authored system, gate, wormhole or site behind. /// [BepInPlugin(Id, "Pocket Worlds example", "1.0.0")] -// Version floor omitted on purpose: in-repo examples track the development API, which + // Version floor omitted on purpose: in-repo examples track the development API, which // is deliberately unversioned (0.0.0); real consumers name the release they require. [BepInDependency(ModApi.PluginId)] public sealed class Plugin : BaseUnityPlugin diff --git a/examples/StationCommerce/Plugin.cs b/examples/StationCommerce/Plugin.cs index a65186cb..a788b726 100644 --- a/examples/StationCommerce/Plugin.cs +++ b/examples/StationCommerce/Plugin.cs @@ -19,7 +19,7 @@ namespace StationCommerce; /// no serializer. /// [BepInPlugin(Id, "Station Commerce example (" + Variant + ")", "1.0.0")] -// Version floor omitted on purpose: in-repo examples track the development API, which + // Version floor omitted on purpose: in-repo examples track the development API, which // is deliberately unversioned (0.0.0); real consumers name the release they require. [BepInDependency(ModApi.PluginId)] public sealed class Plugin : BaseUnityPlugin @@ -87,7 +87,9 @@ private void Start() OwnedItemStorage.Armory)); _itemStatus = status.ToString(); } - // A maker's mark on your own trade good: match the display name this plugin authored and + // A maker's mark on your own trade good: match the display name this plugin authored — which + // also matches the sibling variant's identically named good, the cross-author composition + // this example exists to demonstrate — and // add one styled line. Every item tooltip goes through the shared native fill. _tip = ModApi.Services.Tooltips.RegisterItem(Id, (item, tip) => { diff --git a/examples/StoryMissions/Plugin.cs b/examples/StoryMissions/Plugin.cs index a84c655f..ecfa37b7 100644 --- a/examples/StoryMissions/Plugin.cs +++ b/examples/StoryMissions/Plugin.cs @@ -17,7 +17,7 @@ namespace StoryMissions; /// are registered once, and the API restores missions, progress and declared choices itself. /// [BepInPlugin(Id, "Story Missions example", "1.0.0")] -// Version floor omitted on purpose: in-repo examples track the development API, which + // Version floor omitted on purpose: in-repo examples track the development API, which // is deliberately unversioned (0.0.0); real consumers name the release they require. [BepInDependency(ModApi.PluginId)] public sealed class Plugin : BaseUnityPlugin diff --git a/examples/UiSurfaces/Plugin.Settings.cs b/examples/UiSurfaces/Plugin.Settings.cs index 64c35cc8..98bf505f 100644 --- a/examples/UiSurfaces/Plugin.Settings.cs +++ b/examples/UiSurfaces/Plugin.Settings.cs @@ -27,7 +27,21 @@ private void ConfigurePreferences() private void Start() { - // Instance authentication is available after BepInEx has finished Awake. + // Tooltip contributions belong to the UI surfaces this example owns. They are registered + // unconditionally and gated by a typed preference: the callback abstains when it is off. + _moduleTip = ModApi.Services.Tooltips.RegisterShipModule(Id, (module, tip) => + { + if (!_annotate.Value) return; + tip.AddLine($"UiSurfaces: {module.Kind} module, quality {module.QualityLevel}" + + (module.Tractor is { } tractor ? $" with {tractor.ManualBeamCount} manual beam(s)" : "")); + }); + _treeTip = ModApi.Services.Tooltips.RegisterSkillTree(Id, (tree, tip) => + { + if (!_annotate.Value || tree.Specialization != CommanderSpecialization.Engineering) return; + tip.AddLine($"UiSurfaces: Engineering mastery {tree.MasteryLevel}/{tree.MaximumLevel}", TooltipTextStyle.Bonus); + }); + // Instance authentication is available after BepInEx has finished Awake. They stay live even + // when the settings provider is unavailable, because their gate is the ConfigEntry itself. _settings = ModApi.Services.Settings.AcquireProvider(this); if (_settings == null) { Logger.LogWarning("Example settings unavailable."); return; } Publish(new BoolModSetting("count-opens", "Window", "Count window opens", "Pause counting without erasing this save's progress.", @@ -43,19 +57,6 @@ private void Start() (bool)_showGoal.DefaultValue, () => _showGoal.Value, value => _showGoal.Value = value, order: 0)); Publish(new BoolModSetting("annotate-tooltips", "Tooltips", "Annotate modules and mastery", "Opt in to the tooltip contributions demonstrated below.", (bool)_annotate.DefaultValue, () => _annotate.Value, value => _annotate.Value = value, order: 0)); - // Tooltip contributions belong to the UI surfaces this example owns. They are registered - // unconditionally and gated by a typed preference: the callback abstains when it is off. - _moduleTip = ModApi.Services.Tooltips.RegisterShipModule(Id, (module, tip) => - { - if (!_annotate.Value) return; - tip.AddLine($"UiSurfaces: {module.Kind} module, quality {module.QualityLevel}" - + (module.Tractor is { } tractor ? $" with {tractor.ManualBeamCount} manual beam(s)" : "")); - }); - _treeTip = ModApi.Services.Tooltips.RegisterSkillTree(Id, (tree, tip) => - { - if (!_annotate.Value || tree.Specialization != CommanderSpecialization.Engineering) return; - tip.AddLine($"UiSurfaces: Engineering mastery {tree.MasteryLevel}/{tree.MaximumLevel}", TooltipTextStyle.Bonus); - }); } private void Publish(ModSettingDefinition definition) From 9540b8ff16fe03f2ad7e35b6de9b29f8c33a58f3 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 17 Sep 2026 22:50:16 +0200 Subject: [PATCH 10/12] refactor(examples): rename TractorGuide to EquipmentTargeting; remove all consumer-mod awareness The example is named after the surface it demonstrates (equipment targeting), not a game feature theme. The API repository no longer knows any consumer mod: - TractorCase (a consumer-migration qualification case referencing VGTractorAuto settings and its tooltip strings) is replaced by EquipmentPolicyCase, which verifies the same native guarantees with API-owned registrations only: vanilla cap without rules, capacity of a registered rule, first-non-null precedence between two rules, disposal handing over to the remaining rule, occupied-beam guard and teardown to vanilla. - OPTIONAL_ASSEMBLIES ("VGTractorAuto.dll") is replaced by a generic repeatable --stage argument; staged extras are recorded with sha256 and mtime. E2E_STAGE= passes it through from the Makefile. - EquipmentTargetingCase dropped its consumer-isolation block; nothing references another mod. Committed consumer verification stays in the consumer repositories. Live: equipment-policy and equipment-targeting pass (realSavesUnchanged, installationRestored); host 1835, bindings 76, tooling tests OK. --- Makefile | 4 +- VGModAPI.E2E/EquipmentPolicyCase.cs | 109 +++++++++ ...GuideCase.cs => EquipmentTargetingCase.cs} | 30 +-- VGModAPI.E2E/Plugin.cs | 8 +- VGModAPI.E2E/TractorCase.cs | 229 ------------------ docs/development/e2e-tests.md | 27 +-- .../EquipmentTargeting.csproj} | 3 +- .../Plugin.cs | 17 +- .../README.md | 12 +- examples/README.md | 2 +- tools/e2e.py | 32 +-- 11 files changed, 173 insertions(+), 300 deletions(-) create mode 100644 VGModAPI.E2E/EquipmentPolicyCase.cs rename VGModAPI.E2E/{TractorGuideCase.cs => EquipmentTargetingCase.cs} (71%) delete mode 100644 VGModAPI.E2E/TractorCase.cs rename examples/{TractorGuide/TractorGuide.csproj => EquipmentTargeting/EquipmentTargeting.csproj} (79%) rename examples/{TractorGuide => EquipmentTargeting}/Plugin.cs (63%) rename examples/{TractorGuide => EquipmentTargeting}/README.md (80%) diff --git a/Makefile b/Makefile index 2c43f7e8..5727b0eb 100644 --- a/Makefile +++ b/Makefile @@ -75,11 +75,11 @@ e2e-build: link-libs @set -eu; for dll in VGModAPI VGModAPI.Core VGModAPI.Abstractions VGModAPI.Unity; do cp "VGModAPI/bin/$(CONFIGURATION)/netstandard2.1/$$dll.dll" "$(E2E_BUILD)/"; done cp VGModAPI.E2E/bin/$(CONFIGURATION)/netstandard2.1/VGModAPI.E2E.dll "$(E2E_BUILD)/" cp VGModAPI.E2E/bin/$(CONFIGURATION)/netstandard2.1/Newtonsoft.Json.dll "$(E2E_BUILD)/" - @set -eu; for dll in PocketWorlds CargoRecovery StoryMissions StationCommerce StationCommerceB UiSurfaces Observation TractorGuide; do \ + @set -eu; for dll in PocketWorlds CargoRecovery StoryMissions StationCommerce StationCommerceB UiSurfaces Observation EquipmentTargeting; do \ f=$$(find examples -path '*/bin/$(CONFIGURATION)/netstandard2.1/'"$$dll"'.dll' | head -n 1); \ if [ -n "$$f" ]; then cp "$$f" "$(E2E_BUILD)/"; else echo "e2e staging missing example dll: $$dll" >&2; exit 1; fi; \ done e2e: e2e-build - $(E2E_PYTHON) tools/e2e.py --game-dir '$(call E2E_PATH,$(GAME_DIR))' --build-dir '$(call E2E_PATH,$(E2E_BUILD))' --runtime-dir '$(call E2E_PATH,$(E2E_RUNTIME))' $(if $(E2E_SAVE_DIR),--save-dir '$(call E2E_PATH,$(E2E_SAVE_DIR))') --case $(E2E_CASE) --launch --timeout $(E2E_TIMEOUT) + $(E2E_PYTHON) tools/e2e.py --game-dir '$(call E2E_PATH,$(GAME_DIR))' --build-dir '$(call E2E_PATH,$(E2E_BUILD))' --runtime-dir '$(call E2E_PATH,$(E2E_RUNTIME))' $(if $(E2E_SAVE_DIR),--save-dir '$(call E2E_PATH,$(E2E_SAVE_DIR))') --case $(E2E_CASE) --launch --timeout $(E2E_TIMEOUT) $(addprefix --stage ,$(E2E_STAGE)) clean: $(DOTNET) clean VGModAPI.sln diff --git a/VGModAPI.E2E/EquipmentPolicyCase.cs b/VGModAPI.E2E/EquipmentPolicyCase.cs new file mode 100644 index 00000000..19e98006 --- /dev/null +++ b/VGModAPI.E2E/EquipmentPolicyCase.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEngine; + +namespace VGModAPI.E2E; + +/// +/// Exercises the equipment-targeting surface with API-owned registrations only: capacity +/// semantics, first-non-null-wins between two rules, the occupied-beam guard and teardown +/// back to vanilla. All registrations are made and disposed by this case; policy formulas here +/// exist to make the resolution observable, mirroring how any consumer mod uses the contract. +/// +internal static class EquipmentPolicyCase +{ + internal const string Id = "equipment-policy"; + private const BindingFlags Any = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + private static object? Get(object obj, string name) => obj.GetType().GetProperty(name, Any)?.GetValue(obj) ?? obj.GetType().GetField(name, Any)?.GetValue(obj); + private static FieldInfo Field(Type type, string name) + { + for (Type? t = type; t != null; t = t.BaseType) + if (t.GetField(name, Any | BindingFlags.DeclaredOnly) is FieldInfo f) return f; + throw new MissingFieldException(type.FullName, name); + } + private static void Require(bool value, string detail) { if (!value) throw new InvalidOperationException(detail); } + + internal static IReadOnlyList Steps(ILifecycleService lifecycle, List events) + { + var steps = new List(LiveBoot.Steps(ModApi.PluginId, lifecycle, events)); + steps.Add(new TestStep("capacity, precedence and occupied-beam guards through registered rules", "EquipmentService.Resolve / TractorBeamRuntime", 30, Run)); + return steps; + } + + private static StepResult Run() + { + Require(ModApi.Services.Equipment.Availability.IsAvailable, "equipment service not bound"); + var a = AppDomain.CurrentDomain.GetAssemblies().Single(x => x.GetName().Name == "Assembly-CSharp"); + var moduleType = a.GetType("Behaviour.Equipment.Module.TractorModule", true)!; + var module = Resources.FindObjectsOfTypeAll(moduleType).OfType().FirstOrDefault(m => moduleType.GetMethod("IsPlayer", Any)!.Invoke(m, new object[] { true }) is true); + if (module == null) return StepResult.Wait("player tractor module not ready"); + var manualField = Field(moduleType, "amountOfBonusBeams"); var oldManual = manualField.GetValue(module); + var beams = (IList)Get(module, "tractorBeams")!; int originalBeamCount = beams.Count; + var originals = beams.Cast().ToDictionary(b => b, b => Get(b, "target")); + var temporary = new List(); + List rules = new(); + try + { + moduleType.GetMethod("CreateTractorBeams", Any)!.Invoke(module, new object[] { 3, true }); + manualField.SetValue(module, (int)oldManual! + 3); // the field, not the list, bounds native capacity + var all = beams.Cast().ToArray(); + var auto = all.Where(b => Get(b, "bonusBeam") is false).ToArray(); + var manual = all.Where(b => Get(b, "bonusBeam") is true).ToArray(); + Require(auto.Length > 0 && manual.Length >= 2, "fixture needs automatic beams and two manual beams"); + var markerGo = new GameObject("policy-e2e-busy"); markerGo.SetActive(false); temporary.Add(markerGo); + var marker = markerGo.AddComponent(a.GetType("Behaviour.Tractoring.TractorableItem", true)!); + void Busy(object b, bool busy) => Field(b.GetType(), "target").SetValue(b, busy ? marker : null); + object? Request(bool bonus) => moduleType.GetMethod("GetAvailableTractorBeam", Any)!.Invoke(module, new object[] { bonus }); + void AddRule(string local, int extra) => rules.Add(ModApi.Services.Equipment.ConfigurePlayerTractorModules("vgmodapi.e2e." + local, + _ => new TractorTargeting(auto.Length + extra))); + foreach (var b in all) Busy(b, false); + foreach (var b in auto) Busy(b, true); + + // No rules: every registered owner absent means vanilla capacity, no borrowing. + Require(Request(false) == null, "unconfigured module borrowed a manual beam"); + + // One rule at +2: borrows the first and second free manual beam, never a third. + AddRule("wide", 2); + Require(Request(false) != null, "rule at +2 did not borrow the first manual beam"); + Busy(manual[0], true); + Require(Request(false) != null, "rule at +2 did not borrow the second manual beam"); + Busy(manual[1], true); + Require(Request(false) == null, "rule at +2 exceeded its capacity"); + + Busy(manual[1], false); // back to exactly one manual beam busy for the precedence check + // A later rule stays invisible while an earlier rule answers (first non-null wins). + AddRule("narrow", 1); + Require(Request(false) != null, "a later rule preempted the earlier one"); + + // Removing the earlier rule hands the decision to the remaining one. + rules[0].Dispose(); rules.RemoveAt(0); + Require(Request(false) == null, "the narrow rule did not take effect after the wide rule was disposed"); + + // Occupied-beam protection: nothing is handed out while every beam is busy. + foreach (var b in manual) Busy(b, true); + Require(Request(false) == null && Request(true) == null, "occupied beams were handed out"); + + // Teardown to vanilla: no rule, free manual beams, automatic still busy => no borrowing. + rules[0].Dispose(); rules.Clear(); + foreach (var b in manual) Busy(b, false); + Require(Request(false) == null, "vanilla cap not restored after disposal"); + Debug.Log("EquipmentPolicy E2E passed: abstain-by-absence, capacity, first-non-null precedence, occupied beams, disposal to vanilla."); + return StepResult.Pass("Equipment targeting policy verified through registered rules"); + } + finally + { + foreach (var rule in rules) rule.Dispose(); + manualField.SetValue(module, oldManual); + foreach (var entry in originals) Field(entry.Key.GetType(), "target").SetValue(entry.Key, entry.Value); + foreach (var b in beams.Cast().Except(originals.Keys).ToArray()) Field(b.GetType(), "target").SetValue(b, null); + while (beams.Count > originalBeamCount) + { + var extra = (Component)beams[beams.Count - 1]!; beams.RemoveAt(beams.Count - 1); UnityEngine.Object.Destroy(extra.gameObject); + } + foreach (var go in temporary) UnityEngine.Object.Destroy(go); + } + } +} diff --git a/VGModAPI.E2E/TractorGuideCase.cs b/VGModAPI.E2E/EquipmentTargetingCase.cs similarity index 71% rename from VGModAPI.E2E/TractorGuideCase.cs rename to VGModAPI.E2E/EquipmentTargetingCase.cs index 75ea498b..400d91f1 100644 --- a/VGModAPI.E2E/TractorGuideCase.cs +++ b/VGModAPI.E2E/EquipmentTargetingCase.cs @@ -7,11 +7,11 @@ namespace VGModAPI.E2E; -// Exercises examples/TractorGuide: the one gameplay decision the example owns. Its settings -// toggle alone decides whether the autopilot may borrow one free manual beam. -internal static class TractorGuideCase +// Exercises examples/EquipmentTargeting: the one targeting decision the example owns. Its +// settings toggle alone decides whether the tractor autopilot may borrow one free manual beam. +internal static class EquipmentTargetingCase { - internal const string Id = "tractor-guide"; + internal const string Id = "equipment-targeting"; private const BindingFlags Any = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; private static object? Get(object obj, string name) => obj.GetType().GetProperty(name, Any)?.GetValue(obj) ?? obj.GetType().GetField(name, Any)?.GetValue(obj); private static FieldInfo Field(Type type, string name) @@ -24,8 +24,8 @@ private static FieldInfo Field(Type type, string name) internal static IReadOnlyList Steps(ILifecycleService lifecycle, List events) { - var steps = new List(LiveBoot.Steps("vgmodapi.example.tractor-guide", lifecycle, events)); - steps.Add(new TestStep("settings-gated beam borrowing", "TractorGuide example", 30, Run)); + var steps = new List(LiveBoot.Steps("vgmodapi.example.equipment-targeting", lifecycle, events)); + steps.Add(new TestStep("settings-gated beam borrowing", "EquipmentTargeting example", 30, Run)); return steps; } @@ -36,26 +36,19 @@ private static StepResult Run() var moduleType = a.GetType("Behaviour.Equipment.Module.TractorModule", true)!; var module = Resources.FindObjectsOfTypeAll(moduleType).OfType().FirstOrDefault(m => moduleType.GetMethod("IsPlayer", Any)!.Invoke(m, new object[] { true }) is true); if (module == null) return StepResult.Wait("player tractor module not ready"); - var guide = NativeGameplay.PluginInstance("vgmodapi.example.tractor-guide")!; - var extraEntry = Get(guide, "_extraAutoBeam")!; var extraValue = extraEntry.GetType().GetProperty("Value")!; var oldExtra = extraValue.GetValue(extraEntry); - var otherConsumer = NativeGameplay.PluginInstance("vgtractorauto"); - object? otherEnabled = otherConsumer == null ? null : Get(otherConsumer, "_enabled"); - PropertyInfo? otherEnabledValue = otherEnabled?.GetType().GetProperty("Value"); - object? oldOtherEnabled = otherEnabledValue == null ? null : otherEnabledValue.GetValue(otherEnabled); - Require(otherConsumer == null || otherEnabled != null, "staged VGTractorAuto lacks the _enabled setting the isolation relies on"); - + var example = NativeGameplay.PluginInstance("vgmodapi.example.equipment-targeting")!; + var extraEntry = Get(example, "_extraAutoBeam")!; var extraValue = extraEntry.GetType().GetProperty("Value")!; var oldExtra = extraValue.GetValue(extraEntry); var beams = (IList)Get(module, "tractorBeams")!; int originalBeamCount = beams.Count; var originals = beams.Cast().ToDictionary(b => b, b => Get(b, "target")); var temporary = new List(); try { - otherEnabledValue?.SetValue(otherEnabled, false); // isolate the example's policy from the sibling consumer moduleType.GetMethod("CreateTractorBeams", Any)!.Invoke(module, new object[] { 3, true }); var all = beams.Cast().ToArray(); var auto = all.Where(b => Get(b, "bonusBeam") is false).ToArray(); var manual = all.Where(b => Get(b, "bonusBeam") is true).ToArray(); Require(auto.Length > 0 && manual.Length > 0, "fixture needs automatic and manual beams"); - var markerGo = new GameObject("guide-e2e-busy"); markerGo.SetActive(false); temporary.Add(markerGo); + var markerGo = new GameObject("equipment-e2e-busy"); markerGo.SetActive(false); temporary.Add(markerGo); var marker = markerGo.AddComponent(a.GetType("Behaviour.Tractoring.TractorableItem", true)!); void Busy(object b, bool busy) => Field(b.GetType(), "target").SetValue(b, busy ? marker : null); object? Request(bool bonus) => moduleType.GetMethod("GetAvailableTractorBeam", Any)!.Invoke(module, new object[] { bonus }); @@ -69,13 +62,12 @@ private static StepResult Run() Require(manual.Contains(Request(false)!), "ExtraAutoBeam did not borrow a free manual beam"); extraValue.SetValue(extraEntry, false); Require(Request(false) == null, "ExtraAutoBeam stayed active after being turned off"); - Debug.Log("TractorGuide E2E passed: the ExtraAutoBeam setting alone gates borrowing one free manual beam."); - return StepResult.Pass("TractorGuide example behavior verified"); + Debug.Log("EquipmentTargeting E2E passed: the ExtraAutoBeam setting alone gates borrowing one free manual beam."); + return StepResult.Pass("EquipmentTargeting example behavior verified"); } finally { extraValue.SetValue(extraEntry, oldExtra); - if (otherEnabledValue != null) otherEnabledValue.SetValue(otherEnabled, oldOtherEnabled); foreach (var entry in originals) Field(entry.Key.GetType(), "target").SetValue(entry.Key, entry.Value); foreach (var b in beams.Cast().Except(originals.Keys).ToArray()) Field(b.GetType(), "target").SetValue(b, null); while (beams.Count > originalBeamCount) diff --git a/VGModAPI.E2E/Plugin.cs b/VGModAPI.E2E/Plugin.cs index 277f8968..76cf07e6 100644 --- a/VGModAPI.E2E/Plugin.cs +++ b/VGModAPI.E2E/Plugin.cs @@ -48,8 +48,8 @@ private void Awake() throw new InvalidOperationException("Missing controller deadline."); _caseId = requested switch { - TractorCase.Id => TractorCase.Id, - TractorGuideCase.Id => TractorGuideCase.Id, + EquipmentPolicyCase.Id => EquipmentPolicyCase.Id, + EquipmentTargetingCase.Id => EquipmentTargetingCase.Id, ModSettingsMenuCase.Id => ModSettingsMenuCase.Id, FreshSessionCase.Id => FreshSessionCase.Id, PocketWorldsCase.Id => PocketWorldsCase.Id, @@ -75,8 +75,8 @@ private void Awake() var remaining = (deadline - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) / 1000.0; _test = _caseId switch { - TractorCase.Id => new GameTest(TractorCase.Steps(_lifecycle, _events), remaining), - TractorGuideCase.Id => new GameTest(TractorGuideCase.Steps(_lifecycle, _events), remaining), + EquipmentPolicyCase.Id => new GameTest(EquipmentPolicyCase.Steps(_lifecycle, _events), remaining), + EquipmentTargetingCase.Id => new GameTest(EquipmentTargetingCase.Steps(_lifecycle, _events), remaining), ModSettingsMenuCase.Id => new GameTest(ModSettingsMenuCase.Steps(), remaining), FreshSessionCase.Id => new GameTest(FreshSessionCase.Steps(_lifecycle, _events), remaining), PocketWorldsCase.Id => new GameTest(PocketWorldsCase.Steps(_lifecycle, _events, _travelEvents), remaining), diff --git a/VGModAPI.E2E/TractorCase.cs b/VGModAPI.E2E/TractorCase.cs deleted file mode 100644 index 81113304..00000000 --- a/VGModAPI.E2E/TractorCase.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using UnityEngine; - -namespace VGModAPI.E2E; - -internal static class TractorCase -{ - internal const string Id = "tractor-module"; - private const BindingFlags Any = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; - private static object? Get(object obj, string name) => obj.GetType().GetProperty(name, Any)?.GetValue(obj) ?? Field(obj.GetType(), name).GetValue(obj); - private static FieldInfo Field(Type type, string name) - { - for (Type? t = type; t != null; t = t.BaseType) - if (t.GetField(name, Any | BindingFlags.DeclaredOnly) is FieldInfo f) return f; - throw new MissingFieldException(type.FullName, name); - } - private static void Require(bool value, string detail) { if (!value) throw new InvalidOperationException(detail); } - internal static IReadOnlyList Steps(ILifecycleService lifecycle, List events) - { - var steps = new List(LiveBoot.Steps("vgtractorauto", lifecycle, events)); - steps.Add(new TestStep("tractor targeting, tooltips and disabled behavior", "TractorModule / Skilltree / MasteryBadge", 30, Run)); - return steps; - } - private static StepResult Run() - { - Require(ModApi.Services.Equipment.Availability.IsAvailable && ModApi.Services.SkillTrees.Availability.IsAvailable && ModApi.Services.Tooltips.Availability.IsAvailable, "API services not bound"); - var a = AppDomain.CurrentDomain.GetAssemblies().Single(x => x.GetName().Name == "Assembly-CSharp"); - var moduleType = a.GetType("Behaviour.Equipment.Module.TractorModule", true)!; - var module = Resources.FindObjectsOfTypeAll(moduleType).OfType().FirstOrDefault(m => moduleType.GetMethod("IsPlayer", Any)!.Invoke(m, new object[] { true }) is true); - if (module == null) return StepResult.Wait("player tractor module not ready"); - var plugin = NativeGameplay.PluginInstance("vgtractorauto")!; - var enabled = Get(plugin, "_enabled")!; - var enabledValue = enabled.GetType().GetProperty("Value")!; - var oldEnabled = enabledValue.GetValue(enabled); - var tree = a.GetType("Behaviour.Crew.Skilltree", true)!.GetMethod("Get", Any)!.Invoke(null, new object[] { ModApi.Services.SkillTrees.Get(CommanderSpecialization.Engineering)!.Identifier })!; - var player = a.GetType("Source.Player.GamePlayer", true)!.GetField("current", Any)!.GetValue(null)!; - var commander = Get(player, "commander")!; - var data = commander.GetType().GetMethod("GetSkillTreeData", Any)!.Invoke(commander, new object[] { tree, (object)true })!; - var level = Field(data.GetType(), "masteryLevel"); var oldLevel = level.GetValue(data); - var manualField = Field(moduleType, "amountOfBonusBeams"); var oldManual = manualField.GetValue(module); - var beams = (IList)Get(module, "tractorBeams")!; int originalBeamCount = beams.Count; - var statsField = Field(moduleType, "mainSubStats"); var oldStats = statsField.GetValue(module); - var filtered = (IList)Get(module, "filteredTargets")!; var oldFiltered = filtered.Cast().ToArray(); - var originals = beams.Cast().ToDictionary(b => b, b => Get(b, "target")); - var temporary = new List(); - try - { - // Ensure enough physical manual beams to exercise partial mastery deterministically. - moduleType.GetMethod("CreateTractorBeams", Any)!.Invoke(module, new object[] { 3, true }); - manualField.SetValue(module, (int)oldManual! + 3); - var itemType = a.GetType("Behaviour.Tractoring.TractorableItem", true)!; - var markerGo = new GameObject("tractor-e2e-busy"); markerGo.SetActive(false); temporary.Add(markerGo); - var marker = markerGo.AddComponent(itemType); - var all = beams.Cast().ToArray(); - var auto = all.Where(b => Get(b, "bonusBeam") is false).ToArray(); - var manual = all.Where(b => Get(b, "bonusBeam") is true).ToArray(); - Require(auto.Length > 0, "fixture needs automatic beam"); - var request = moduleType.GetMethod("GetAvailableTractorBeam", Any)!; - void Busy(object b, bool busy) => Field(b.GetType(), "target").SetValue(b, busy ? marker : null); - object? Request(bool bonus) => request.Invoke(module, new object[] { bonus }); - foreach (var b in all) Busy(b, false); - foreach (var b in auto) Busy(b, true); - enabledValue.SetValue(enabled, true); level.SetValue(data, 0); - Require(Request(false) == null, "zero mastery promoted manual beam"); - Require(manual.Contains(Request(true)), "vanilla manual pool changed"); - int cap = ModApi.Services.SkillTrees.Get(CommanderSpecialization.Engineering)!.MaximumLevel; - level.SetValue(data, cap); - Require(manual.Contains(Request(false)), "max mastery did not borrow free manual beam"); - enabledValue.SetValue(enabled, false); - Require(Request(false) == null, "disabled mod promoted manual beam"); - foreach (var b in all) Busy(b, true); - Busy(auto[0], false); - Require(Request(true) == null, "disabled mod borrowed automatic beam"); - enabledValue.SetValue(enabled, true); level.SetValue(data, 0); - Require(ReferenceEquals(auto[0], Request(true)), "manual fallback failed at zero mastery"); - foreach (var b in all) Busy(b, true); - Require(Request(true) == null && Request(false) == null, "occupied beam stolen"); - foreach (var b in manual) Busy(b, false); - level.SetValue(data, cap / 2); - int promoted = (int)Math.Floor((float)(cap / 2) / cap * manual.Length); - for (int i = 0; i < promoted; i++) Busy(manual[i], true); - Require(Request(false) == null, "auto exceeded conservative busy-beam cap"); - Busy(manual[promoted - 1], false); - Require(Request(false) != null, "auto did not fill remaining allowance"); - - // Actual native target filtering on controlled inactive loot components. - level.SetValue(data, cap); - var inventoryType = a.GetType("Behaviour.Item.InventoryItemType", true)!; - var crewPodType = a.GetType("Behaviour.Item.Usable.CrewPodItem", true)!; - var ordinary = ((IEnumerable)inventoryType.GetProperty("all", Any)!.GetValue(null)!).Cast().First(i => i.GetComponent(crewPodType) == null); - var lootDataType = a.GetType("Source.Data.Persistable.TractorableItemData", true)!; - var targetType = a.GetType("Behaviour.Weapons.TargetableUnit", true)!; - var candidates = Array.CreateInstance(targetType, all.Length + 3); - for (int i = 0; i < candidates.Length; i++) - { - var go = new GameObject("tractor-e2e-candidate"); go.SetActive(false); temporary.Add(go); - var candidate = go.AddComponent(itemType); - var loot = Activator.CreateInstance(lootDataType)!; - lootDataType.GetProperty("itemType")!.SetValue(loot, ordinary); - if (i == 0) lootDataType.GetProperty("jettisoned")!.SetValue(loot, true); - itemType.GetProperty("data", Any)!.SetValue(candidate, loot); - if (i == 1) itemType.GetProperty("isTractored", Any)!.SetValue(candidate, true); - candidates.SetValue(candidate, i); - } - var updateTargets = moduleType.GetMethod("UpdateAvailableTargets", Any)!; - updateTargets.Invoke(module, new object[] { candidates }); - Require(filtered.Count == all.Length, "candidate cap did not include promoted beams"); - Require(!filtered.Contains(candidates.GetValue(0)) && !filtered.Contains(candidates.GetValue(1)), "ineligible candidate added"); - for (int i = 0; i < filtered.Count; i++) Require(ReferenceEquals(filtered[i], candidates.GetValue(i + 2)), "candidate ordering changed"); - enabledValue.SetValue(enabled, false); - updateTargets.Invoke(module, new object[] { candidates }); - Require(filtered.Count == auto.Length, "disabled candidate cap differs from vanilla"); - - // Bake real module stats, preserving the native caching boundary. - enabledValue.SetValue(enabled, true); - statsField.SetValue(module, Activator.CreateInstance(statsField.FieldType)); - moduleType.GetMethod("SetMainSubStats", Any)!.Invoke(module, null); - var stats = ((IEnumerable)Get(statsField.GetValue(module)!, "subStatsList")!).Cast().ToArray(); - Require(stats.Any(s => ((string)Get(s, "mainSubStatName")!).Contains("VGTractorAuto") && (string)Get(s, "mainSubStatAmount")! == ""), "module tooltip missing or numeric artifact"); - enabledValue.SetValue(enabled, false); - statsField.SetValue(module, Activator.CreateInstance(statsField.FieldType)); - moduleType.GetMethod("SetMainSubStats", Any)!.Invoke(module, null); - Require(!((IEnumerable)Get(statsField.GetValue(module)!, "subStatsList")!).Cast().Any(s => ((string)Get(s, "mainSubStatName")!).Contains("VGTractorAuto")), "disabled module tooltip contributed text"); - - // Generalized module family: an API-owned registration must reach a non-tractor - // equipment module's own stat builder with vanilla StatLines as context. - var equipmentType = a.GetType("Behaviour.Equipment.AbstractEquipment", true)!; - var candidatesForFamily = Resources.FindObjectsOfTypeAll(equipmentType).OfType() - .Where(c => c.GetType() != moduleType && equipmentType.GetMethod("IsPlayer", Any)!.Invoke(c, new object[] { true }) is true - && c.GetType().GetMethod("SetMainSubStats", Any, null, Type.EmptyTypes, null) != null).ToArray(); - // Probe first: pick a module whose native builder really renders vanilla lines, so the - // registration under test always has content to append to. - Component? otherModule = null; FieldInfo? otherStats = null; int vanillaLineCount = 0; - foreach (var candidate in candidatesForFamily) - { - var probeStats = Field(candidate.GetType(), "mainSubStats"); - var keep = probeStats.GetValue(candidate); - try - { - probeStats.SetValue(candidate, Activator.CreateInstance(probeStats.FieldType)); - candidate.GetType().GetMethod("SetMainSubStats", Any)!.Invoke(candidate, null); - var count = ((IEnumerable)Get(probeStats.GetValue(candidate)!, "subStatsList")!).Cast().Count(); - if (count > 0) { otherModule = candidate; otherStats = probeStats; vanillaLineCount = count; break; } - } - finally { probeStats.SetValue(candidate, keep); } - } - Require(otherModule != null && otherStats != null, "no player equipment module renders vanilla stat lines for the family check"); - ShipModule? familySeen = null; - using (var lease = ModApi.Services.Tooltips.RegisterShipModule("vgmodapi.e2e", (m, tooltip) => - { - familySeen = m; - tooltip.AddLine("e2e-family " + m.Kind); - })) - { - otherStats!.SetValue(otherModule!, Activator.CreateInstance(otherStats.FieldType)); - otherModule!.GetType().GetMethod("SetMainSubStats", Any)!.Invoke(otherModule, null); - Require(((IEnumerable)Get(otherStats.GetValue(otherModule)!, "subStatsList")!).Cast() - .Count(s => ((string)Get(s, "mainSubStatName")!).Contains("e2e-family")) == 1, - "family registration did not contribute exactly once (candidate=" + otherModule!.GetType().Name + " saw=" + (familySeen?.Kind.ToString() ?? "nothing") + " vanilla=" + vanillaLineCount + ")"); - } - Require(familySeen != null && familySeen.Kind != ShipModuleKind.Tractor && familySeen.Tractor == null, - "non-tractor module received a tractor snapshot or no payload"); - Require(familySeen!.StatLines.Count > 0 && familySeen.DisplayName.Length > 0 && familySeen.QualityLevel >= 0, - "module snapshot missing vanilla stat lines or identity (vanilla built " + vanillaLineCount + " lines)"); - otherStats.SetValue(otherModule, Activator.CreateInstance(otherStats.FieldType)); - - // Item family: fill a real tooltip from an item source without desktop interaction. - var tooltipType = a.GetType("Behaviour.UI.UITooltip", true)!; - var prefab = Resources.FindObjectsOfTypeAll(tooltipType).OfType().First(t => Get(t, "_textPrefab") != null); - var tooltipObject = UnityEngine.Object.Instantiate(prefab.gameObject); temporary.Add(tooltipObject); - var tooltip = tooltipObject.GetComponent(tooltipType)!; - var sourceType = a.GetType("Behaviour.UI.Tooltip.ItemTooltipSource", true)!; - var sourceGo = new GameObject("tractor-e2e-item"); sourceGo.SetActive(false); temporary.Add(sourceGo); - var source = sourceGo.AddComponent(sourceType); - var expectedId = (string)Get(ordinary, "identifier")!; - ItemInfo? itemSeen = null; - using (var lease = ModApi.Services.Tooltips.RegisterItem("vgmodapi.e2e", (item, tip) => - { - itemSeen = item; - if (item.Identifier == expectedId) tip.AddLine("e2e-item " + item.DisplayName, TooltipTextStyle.Bonus); - })) - { - var context = Enum.Parse(a.GetType("Behaviour.UI.Tooltip.ItemTooltipContext", true)!, "InInventory"); - sourceType.GetMethod("SetItem", Any)!.Invoke(source, new object?[] { ordinary, 7, false, context, false, null }); - tooltipType.GetProperty("Source", Any)!.SetValue(tooltip, source, null); // Show() normally wires this before filling. - tooltipType.GetMethod("SetContent", Any)!.Invoke(tooltip, new object[] { source }); - string[] Lines() => ((IEnumerable)Get(tooltip, "_contentList")!).Cast().Select(c => c.GetType().GetProperty("Text", Any)?.GetValue(c)).Where(t => t != null).Select(t => (string)t!.GetType().GetProperty("text", Any)!.GetValue(t)!).ToArray(); - Require(Lines().Count(t => t.Contains("e2e-item") && t.Contains(" 0 && itemSeen.Count == 7, - "item snapshot identity/stack mismatch (saw " + (itemSeen == null ? "nothing" : itemSeen.Identifier + " x" + itemSeen.Count) + ")"); - } - // Leave the live panel showing current vanilla content, not the test fixture's lines. - otherStats.SetValue(otherModule!, Activator.CreateInstance(otherStats.FieldType)); - otherModule!.GetType().GetMethod("SetMainSubStats", Any)!.Invoke(otherModule, null); - - // Build a native mastery tooltip from a real UI prefab, without desktop interaction. - var badgeGo = new GameObject("tractor-e2e-mastery"); badgeGo.SetActive(false); temporary.Add(badgeGo); - var badgeType = a.GetType("Behaviour.UI.MasteryBadge", true)!; - var badge = badgeGo.AddComponent(badgeType); - Field(badgeType, "k__BackingField").SetValue(badge, tree); - enabledValue.SetValue(enabled, true); level.SetValue(data, 0); - badgeType.GetMethod("AddTooltipCustomContent", Any)!.Invoke(badge, new object[] { tooltip }); - string[] MasteryLines() => ((IEnumerable)Get(tooltip, "_contentList")!).Cast().Select(c => c.GetType().GetProperty("Text", Any)?.GetValue(c)).Where(t => t != null).Select(t => (string)t!.GetType().GetProperty("text", Any)!.GetValue(t)!).ToArray(); - Require(MasteryLines().Any(t => t.Contains("automatic: 0%") && t.Contains("VGTractorAuto") && t.Contains(" t.Contains("VGTractorAuto")), "disabled mastery tooltip contributed text"); - Debug.Log("Tractor E2E passed: zero/half/max mastery, manual borrowing, occupied beams, candidate ordering/eligibility/caps, module family + item + mastery tooltips, disabled behavior."); - return StepResult.Pass("Tractor behavior and tooltip checks passed"); - } - finally - { - enabledValue.SetValue(enabled, oldEnabled); level.SetValue(data, oldLevel); - manualField.SetValue(module, oldManual); statsField.SetValue(module, oldStats); - filtered.Clear(); foreach (var value in oldFiltered) filtered.Add(value); - foreach (var entry in originals) Field(entry.Key.GetType(), "target").SetValue(entry.Key, entry.Value); - while (beams.Count > originalBeamCount) - { - var extra = (Component)beams[beams.Count - 1]!; beams.RemoveAt(beams.Count - 1); UnityEngine.Object.Destroy(extra.gameObject); - } - foreach (var go in temporary) UnityEngine.Object.Destroy(go); - } - } -} diff --git a/docs/development/e2e-tests.md b/docs/development/e2e-tests.md index 6847ae06..7cc092ed 100644 --- a/docs/development/e2e-tests.md +++ b/docs/development/e2e-tests.md @@ -82,9 +82,9 @@ set (all examples + the E2E harness are deployed), only the driven workflow diff make e2e E2E_CASE=pocket-worlds ``` -Consumer-mod DLLs outside this repository (e.g. `VGTractorAuto.dll`) are staged when -present in the build directory; copy the built DLL there before `make e2e`, or the -case that asserts on it fails with a missing-registration detail. +Extra plugin DLLs an operator wants staged (anything built into the build directory) are +passed explicitly: `make e2e E2E_CASE=… E2E_STAGE=Foo.dll`. The harness never knows any +consumer mod by name; each staged extra is recorded in the report with sha256 and mtime. ## `pocket-worlds` @@ -136,21 +136,18 @@ stat list and the Engineering mastery badge each carry the example's line exactl once; with it off, neither does. The Forge inspector is availability-dependent, so it is not gated here. -## `tractor-module` +## `equipment-policy` -Runs against the sibling VGTractorAuto consumer DLL when it is staged (see optional -assemblies below). Drives the real player tractor module through the public -Equipment/SkillTrees/Tooltips surfaces: zero/half/max mastery promotion caps, manual -borrowing, occupied-beam protection, native target eligibility and ordering, plus -exactly-once contributions on a non-tractor equipment module's stat builder, an -instantiated item tooltip and a native mastery badge, with the disabled mod adding -nothing. All fixture state (beams, targets, stats, config) is restored afterwards. +Verifies the equipment-targeting surface with API-owned registrations only: abstain-by-absence +(vanilla cap), a registered rule at +2 borrowing exactly two free manual beams, a later rule +staying invisible while an earlier one answers (first non-null wins), disposal handing the decision +to the remaining rule, occupied beams never handed out, and teardown restoring vanilla behavior. +All fixture state (beams, targets, temporary objects) is restored afterwards. -## `tractor-guide` +## `equipment-targeting` -Runs the in-repo TractorGuide example as the mod under test: the `ExtraAutoBeam` -setting alone gates one borrowed manual beam (off ⇒ vanilla cap, on ⇒ borrows a -free manual beam), isolated from the optional sibling consumer. +Runs the in-repo EquipmentTargeting example as the mod under test: the `ExtraAutoBeam` setting +alone gates one borrowed manual beam (off ⇒ vanilla cap, on ⇒ borrows a free manual beam). ## Coverage status diff --git a/examples/TractorGuide/TractorGuide.csproj b/examples/EquipmentTargeting/EquipmentTargeting.csproj similarity index 79% rename from examples/TractorGuide/TractorGuide.csproj rename to examples/EquipmentTargeting/EquipmentTargeting.csproj index 422c39c1..84d6d3db 100644 --- a/examples/TractorGuide/TractorGuide.csproj +++ b/examples/EquipmentTargeting/EquipmentTargeting.csproj @@ -1,6 +1,5 @@ - + netstandard2.1false diff --git a/examples/TractorGuide/Plugin.cs b/examples/EquipmentTargeting/Plugin.cs similarity index 63% rename from examples/TractorGuide/Plugin.cs rename to examples/EquipmentTargeting/Plugin.cs index 2ee71300..fdf8a163 100644 --- a/examples/TractorGuide/Plugin.cs +++ b/examples/EquipmentTargeting/Plugin.cs @@ -3,17 +3,18 @@ using BepInEx.Configuration; using VGModAPI; -// Example author package: your mod owns one tractor autopilot decision. The API owns the only -// hook into the game; target eligibility, crew, cargo and occupied-beam protections stay native. -// Tooltip and settings showcases live in the UiSurfaces and StationCommerce examples. -namespace TractorGuide +// Example author package for "equipment targeting" (see PR #313): your mod answers one +// targeting question for the game's tractor autopilot. The API owns the only hook into the game; +// target eligibility, crew, cargo and occupied-beam protections stay native. Tooltip and settings +// showcases live in the UiSurfaces and StationCommerce examples. +namespace EquipmentTargeting { [BepInPlugin(Id, Name, Version)] [BepInDependency(ModApi.PluginId)] public sealed class Plugin : BaseUnityPlugin { - public const string Id = "vgmodapi.example.tractor-guide"; - public const string Name = "Tractor Guide"; + public const string Id = "vgmodapi.example.equipment-targeting"; + public const string Name = "Equipment Targeting example"; public const string Version = "1.0.0"; private readonly ConfigEntry _extraAutoBeam; @@ -29,14 +30,14 @@ private void Start() { ModServices api; try { api = ModApi.Services; } - catch (InvalidOperationException) { Logger.LogWarning("VGModAPI services are not available; TractorGuide does nothing."); return; } + catch (InvalidOperationException) { Logger.LogWarning("VGModAPI services are not available; the example does nothing."); return; } _equipment = api.Equipment.ConfigurePlayerTractorModules(Id, module => { // Abstain (null) to keep vanilla behavior; only tractors with a spare manual beam qualify. if (!_extraAutoBeam.Value || module.BeamCount <= 0 || module.ManualBeamCount <= 0) return null; return new TractorTargeting(module.BeamCount + 1); }); - Logger.LogInfo($"TractorGuide registered (equipment available: {api.Equipment.Availability.IsAvailable})."); + Logger.LogInfo($"Equipment Targeting registered (equipment available: {api.Equipment.Availability.IsAvailable})."); } private void OnDestroy() => _equipment?.Dispose(); diff --git a/examples/TractorGuide/README.md b/examples/EquipmentTargeting/README.md similarity index 80% rename from examples/TractorGuide/README.md rename to examples/EquipmentTargeting/README.md index 82dba520..ee300219 100644 --- a/examples/TractorGuide/README.md +++ b/examples/EquipmentTargeting/README.md @@ -1,7 +1,7 @@ -# TractorGuide +# EquipmentTargeting -**What you can build with this: one gameplay decision for the tractor autopilot, without a single -Harmony patch.** Your mod answers "what may the autopilot use?"; the API owns the only hook into +**What you can build with this: your own equipment-targeting decision, without a single Harmony +patch.** Your mod answers "what may the autopilot use?"; the API owns the only hook into the game, and the game keeps every native protection. A sample/test BepInEx mod for the **VG Mod API** (development build; requires the matching @@ -23,12 +23,12 @@ on the example's own trade good). ## Try it -1. Deploy `TractorGuide.dll` next to the API and set `ExtraAutoBeam = true` in - `BepInEx/config/vgmodapi.example.tractor-guide.cfg`. +1. Deploy `EquipmentTargeting.dll` next to the API and set `ExtraAutoBeam = true` in + `BepInEx/config/vgmodapi.example.equipment-targeting.cfg`. 2. Dock somewhere with loot. While every automatic beam is busy, the autopilot now also uses one free manual beam. With the setting off, it never does. 3. Disable the mod: the extra beam is gone — the game's own beam rules were never replaced. -The `tractor-guide` E2E case (`make e2e E2E_CASE=tractor-guide`) verifies exactly this sequence +The `equipment-targeting` E2E case (`make e2e E2E_CASE=equipment-targeting`) verifies exactly this sequence deterministically in a real session, including the off ⇒ vanilla-cap negative and full fixture restore. diff --git a/examples/README.md b/examples/README.md index 35f81b22..df0a6e43 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,7 +15,7 @@ None of these are part of the shipped API package. | **[StoryMissions](StoryMissions/)** | **Your own missions** — hand-authored campaign beats with real decisions, jobs generated from runtime text, and follow-ups that offer themselves when an arc completes. | `Story`, `Game` | | **[StationCommerce](StationCommerce/)** | **Your own trade goods and the people who deal in them** — a manufactured item with the maker's own tooltip tip, the recipe that makes it, and a bar contact with a mission, placed at a real station. | `Items`, `RecipeRegistration`, `Bars`, `Story`, `Game`, `Tooltips` | | **[UiSurfaces](UiSurfaces/)** | **UI, settings and saved progress** — a hosted window with typed global preferences, a per-save counter, a Forge inspector and opt-in annotations on ship-module stat lists and the Engineering mastery badge. Requires the matching development API, not public 0.2.8. | `GameplayUi`, `Hud`, `Settings`, `SaveData`, `ForgeUi`, `Recipes`, `RecipeQuotes`, `Tooltips` | -| **[TractorGuide](TractorGuide/)** | **One autopilot decision without patches** — a settings-gated extra tractor beam for the autopilot; abstain-by-null, native safety retained. Requires the matching development API, not public 0.2.8. | `Equipment` | +| **[EquipmentTargeting](EquipmentTargeting/)** | **Your own targeting decision without patches** — a settings-gated extra beam for the game's tractor autopilot; abstain-by-null, native safety retained. Requires the matching development API, not public 0.2.8. | `Equipment` | | **[Observation](Observation/)** | **Mods that react to what the player is doing** — sessions, missions and travel observed without touching them, plus optional-dependency entry points and custom save data. | `Lifecycle`, `Missions`, `Travel`, `SaveData` | | **[UpdateParticipant](UpdateParticipant/)** | **Publishing your mod** — update metadata and the packaging dry run. | *(loader metadata only)* | diff --git a/tools/e2e.py b/tools/e2e.py index 5c3d230c..75fb4bf3 100644 --- a/tools/e2e.py +++ b/tools/e2e.py @@ -17,14 +17,12 @@ CASE = "fresh-session" KNOWN_CASES = ("fresh-session", "pocket-worlds", "story-missions", "observation", "cargo-recovery", "station-commerce", "ui-surfaces", "mod-settings-menu", - "tractor-module", "tractor-guide") + "equipment-policy", "equipment-targeting") HANDSHAKE = "--vgmodapi-e2e" ASSEMBLIES = ("VGModAPI.dll", "VGModAPI.Core.dll", "VGModAPI.Abstractions.dll", "VGModAPI.Unity.dll", "VGModAPI.E2E.dll", "PocketWorlds.dll", "CargoRecovery.dll", "StoryMissions.dll", "StationCommerce.dll", "StationCommerceB.dll", "UiSurfaces.dll", - "Observation.dll", "TractorGuide.dll", "Newtonsoft.Json.dll") -# Consumer-mod DLLs the consumer-migration cases assert on; staged when present in the build dir. -OPTIONAL_ASSEMBLIES = ("VGTractorAuto.dll",) + "Observation.dll", "EquipmentTargeting.dll", "Newtonsoft.Json.dll") class E2EError(Exception): @@ -70,12 +68,16 @@ def __exit__(self, *exc): class GameInstallation: """Reversible plugins/config staging. An interrupted run leaves an explicit backup.""" - def __init__(self, game, build): + def __init__(self, game, build, extra=()): self.root = Path(game) / "BepInEx" self.build = Path(build) + self.extra = tuple(extra) + for name in self.extra: + if not (self.build / name).is_file(): + raise E2EError(f"Missing --stage file {name} in the build directory.") self.backup = self.root / ".vgmodapi-e2e-backup" self.moved = [] - self.staged_optional = [] + self.staged_extra = [] self.created = [] def __enter__(self): @@ -101,14 +103,14 @@ def __enter__(self): self.created.append(name) target = self.root / "plugins" / "VGModAPI.E2E" target.mkdir() - for name in ASSEMBLIES + tuple(n for n in OPTIONAL_ASSEMBLIES if (self.build / n).is_file()): + for name in ASSEMBLIES + self.extra: shutil.copy2(self.build / name, target / name) - if name in OPTIONAL_ASSEMBLIES: - # Out-of-repo consumer DLLs are not rebuilt by e2e-build; record exactly which - # bytes were staged so a stale leftover is attributable in the report. + if name not in ASSEMBLIES: + # Operator-supplied extra plugins are not built here; record exactly which bytes + # were staged so any run is reproducible from the report alone. data = (self.build / name).read_bytes() - self.staged_optional.append({"name": name, "sha256": hashlib.sha256(data).hexdigest(), - "mtimeMs": int((self.build / name).stat().st_mtime * 1000)}) + self.staged_extra.append({"name": name, "sha256": hashlib.sha256(data).hexdigest(), + "mtimeMs": int((self.build / name).stat().st_mtime * 1000)}) # The staging vacuums the real config/ for isolation, which drops the API back to its # disabled-by-default providers. Enable the ones the example cases exercise (Story drives # station-commerce's errand and the story-missions case; Bars backs the bar-contact examples). @@ -323,6 +325,8 @@ def main(argv=None): parser.add_argument("--save-dir", type=nonempty_path) parser.add_argument("--timeout", type=int, default=90) parser.add_argument("--case", choices=KNOWN_CASES, default=CASE) + parser.add_argument("--stage", action="append", default=[], metavar="DLL", + help="Extra assembly file (relative to --build-dir) to stage; repeatable.") parser.add_argument("--launch", action="store_true", help="explicit permission to stage plugins and launch the game") parser.add_argument("--report", type=nonempty_path, help="read/gate a previous report without a game") args = parser.parse_args(argv) @@ -356,9 +360,9 @@ def main(argv=None): shutil.rmtree(runtime / "screenshots", ignore_errors=True) report = new_report() try: - with SaveGuard(saves, allow_missing=args.save_dir is None), GameInstallation(game, build) as installation: + with SaveGuard(saves, allow_missing=args.save_dir is None), GameInstallation(game, build, args.stage) as installation: run_game(game, runtime, args.timeout, report) - report["meta"]["stagedOptionalAssemblies"] = list(installation.staged_optional) + report["meta"]["stagedExtraAssemblies"] = list(installation.staged_extra) report["meta"]["realSavesUnchanged"] = True report["meta"]["installationRestored"] = True except (E2EError, OSError, KeyboardInterrupt) as error: From d75c27fe77a98d1976143737adcea4404fb8ee7d Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 17 Sep 2026 22:55:57 +0200 Subject: [PATCH 11/12] fix(examples): save-independent beam fixture; validate --stage names The example case now bumps and restores amountOfBonusBeams like the policy case, so the borrow assertion no longer depends on the test save happening to have a manual beam. --stage rejects anything but a bare file name, keeping staged files inside the plugins directory. --- VGModAPI.E2E/EquipmentTargetingCase.cs | 3 +++ tools/e2e.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/VGModAPI.E2E/EquipmentTargetingCase.cs b/VGModAPI.E2E/EquipmentTargetingCase.cs index 400d91f1..712ce216 100644 --- a/VGModAPI.E2E/EquipmentTargetingCase.cs +++ b/VGModAPI.E2E/EquipmentTargetingCase.cs @@ -38,12 +38,14 @@ private static StepResult Run() if (module == null) return StepResult.Wait("player tractor module not ready"); var example = NativeGameplay.PluginInstance("vgmodapi.example.equipment-targeting")!; var extraEntry = Get(example, "_extraAutoBeam")!; var extraValue = extraEntry.GetType().GetProperty("Value")!; var oldExtra = extraValue.GetValue(extraEntry); + var manualField = Field(moduleType, "amountOfBonusBeams"); var oldManual = manualField.GetValue(module); var beams = (IList)Get(module, "tractorBeams")!; int originalBeamCount = beams.Count; var originals = beams.Cast().ToDictionary(b => b, b => Get(b, "target")); var temporary = new List(); try { moduleType.GetMethod("CreateTractorBeams", Any)!.Invoke(module, new object[] { 3, true }); + manualField.SetValue(module, (int)oldManual! + 3); // native capacity is bounded by the field, not the beam list var all = beams.Cast().ToArray(); var auto = all.Where(b => Get(b, "bonusBeam") is false).ToArray(); var manual = all.Where(b => Get(b, "bonusBeam") is true).ToArray(); @@ -68,6 +70,7 @@ private static StepResult Run() finally { extraValue.SetValue(extraEntry, oldExtra); + manualField.SetValue(module, oldManual); foreach (var entry in originals) Field(entry.Key.GetType(), "target").SetValue(entry.Key, entry.Value); foreach (var b in beams.Cast().Except(originals.Keys).ToArray()) Field(b.GetType(), "target").SetValue(b, null); while (beams.Count > originalBeamCount) diff --git a/tools/e2e.py b/tools/e2e.py index 75fb4bf3..dfeac814 100644 --- a/tools/e2e.py +++ b/tools/e2e.py @@ -73,6 +73,8 @@ def __init__(self, game, build, extra=()): self.build = Path(build) self.extra = tuple(extra) for name in self.extra: + if Path(name).name != name: + raise E2EError(f"--stage accepts bare file names only, got: {name}") if not (self.build / name).is_file(): raise E2EError(f"Missing --stage file {name} in the build directory.") self.backup = self.root / ".vgmodapi-e2e-backup" From 267f6f713b516229bfd5eabf25c49529d83cc4e3 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 17 Sep 2026 23:11:49 +0200 Subject: [PATCH 12/12] refactor: name our artifacts after the surfaces, not the game feature Files this repo owns now speak the PR surface; names that mirror game types keep the game vocabulary (TractorBeamRuntime binds the game\s --- ...eamTests.cs => EquipmentTargetingTests.cs} | 2 +- VGModAPI.Tests/InstalledTooltipTests.cs | 50 +++++++++++++++++++ VGModAPI.Tests/InstalledTractorBeamTests.cs | 39 --------------- VGModAPI/EquipmentTargetingIntegration.cs | 38 ++++++++++++++ VGModAPI/Patches/TooltipPatches.cs | 20 ++++++++ VGModAPI/Patches/TractorBeamPatches.cs | 17 ------- VGModAPI/Plugin.cs | 6 +-- ...amIntegration.cs => TooltipIntegration.cs} | 34 ++----------- ...r-beams.md => equipment-skill-tooltips.md} | 0 9 files changed, 117 insertions(+), 89 deletions(-) rename VGModAPI.Tests/{TractorBeamTests.cs => EquipmentTargetingTests.cs} (98%) create mode 100644 VGModAPI.Tests/InstalledTooltipTests.cs create mode 100644 VGModAPI/EquipmentTargetingIntegration.cs create mode 100644 VGModAPI/Patches/TooltipPatches.cs rename VGModAPI/{TractorBeamIntegration.cs => TooltipIntegration.cs} (56%) rename docs/reference/{tractor-beams.md => equipment-skill-tooltips.md} (100%) diff --git a/VGModAPI.Tests/TractorBeamTests.cs b/VGModAPI.Tests/EquipmentTargetingTests.cs similarity index 98% rename from VGModAPI.Tests/TractorBeamTests.cs rename to VGModAPI.Tests/EquipmentTargetingTests.cs index 60706ed7..0e46aa22 100644 --- a/VGModAPI.Tests/TractorBeamTests.cs +++ b/VGModAPI.Tests/EquipmentTargetingTests.cs @@ -4,7 +4,7 @@ namespace VGModAPI.Tests; -public sealed class TractorBeamTests +public sealed class EquipmentTargetingTests { private static TractorModule Module => new(2, 3); [Theory] diff --git a/VGModAPI.Tests/InstalledTooltipTests.cs b/VGModAPI.Tests/InstalledTooltipTests.cs new file mode 100644 index 00000000..51c65cd2 --- /dev/null +++ b/VGModAPI.Tests/InstalledTooltipTests.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using Mono.Cecil; +using Xunit; + +namespace VGModAPI.Tests; + +[Trait("Category", "InstalledGame")] +public sealed class InstalledTooltipTests +{ + [Fact] + public void TooltipExtensionNativeShapesExist() + { + using var assembly = AssemblyDefinition.ReadAssembly(Environment.GetEnvironmentVariable("VG_GAME_ASSEMBLY") + ?? throw new InvalidOperationException("Run make check-bindings.")); + var module = assembly.MainModule; + // Ship module family: every required module declares its own stat builder on AbstractEquipment. + var equipment = module.GetType("Behaviour.Equipment.AbstractEquipment"); + Assert.NotNull(Assert.Single(equipment.Methods, m => m.Name == "SetMainSubStats" && !m.HasBody)); + Assert.Equal("System.String", equipment.Methods.Single(m => m.Name == "GetName").ReturnType.FullName); + Assert.Equal("System.Int32", equipment.Properties.Single(p => p.Name == "qualityLevel").PropertyType.FullName); + Assert.True(Assert.Single(module.GetType("Behaviour.Equipment.Turret.AbstractTurret").Methods, m => m.Name == "SetMainSubStats").HasBody); // abstract intermediates hold real builders + foreach (var name in new[] { "TractorModule", "MiningModule", "SalvageModule", "ShieldGeneratorModule", "DroneBayModule" }) + { + var type = module.GetType("Behaviour.Equipment.Module." + name); + Assert.True(Assert.Single(type.Methods, m => m.Name == "SetMainSubStats").HasBody); + } + var mainSubStats = module.GetType("Behaviour.Equipment.MainSubStats"); + Assert.NotNull(Assert.Single(mainSubStats.Methods, m => m.Name == "AddMainSubStat" + && m.Parameters.Select(p => p.ParameterType.FullName).SequenceEqual(new[] { "System.String", "System.String" }))); + Assert.Contains(mainSubStats.Properties, p => p.Name == "subStatsList"); + var subStat = module.GetType("Behaviour.Equipment.SubStat"); + foreach (var name in new[] { "mainSubStatName", "mainSubStatAmount" }) + Assert.Equal("System.String", subStat.Properties.Single(p => p.Name == name).PropertyType.FullName); + // Item tooltip family: the shared fill plus the item source surface. + var tooltip = module.GetType("Behaviour.UI.UITooltip"); + var fill = Assert.Single(tooltip.Methods, m => m.Name == "SetContent"); + Assert.Equal("Behaviour.UI.Tooltip.TooltipSource", fill.Parameters.Single().ParameterType.FullName); + Assert.Contains(tooltip.Properties, p => p.Name == "Source" && p.PropertyType.FullName == "Behaviour.UI.Tooltip.TooltipSource"); + var source = module.GetType("Behaviour.UI.Tooltip.ItemTooltipSource"); + Assert.Equal("Behaviour.Item.InventoryItemType", source.Properties.Single(p => p.Name == "item").PropertyType.FullName); + Assert.Equal("System.Int32", source.Properties.Single(p => p.Name == "count").PropertyType.FullName); + var item = module.GetType("Behaviour.Item.InventoryItemType"); + foreach (var name in new[] { "identifier", "displayName", "description" }) + Assert.Equal("System.String", item.Properties.Single(p => p.Name == name).PropertyType.FullName); + Assert.Equal("System.Int32", item.Properties.Single(p => p.Name == "itemLevel").PropertyType.FullName); + Assert.Contains(module.GetType("Source.Util.ColorHelper").Fields, f => f.Name == "greenish"); + Assert.Contains(module.GetType("Source.Util.ColorHelper").Fields, f => f.Name == "detailsColor"); + } +} diff --git a/VGModAPI.Tests/InstalledTractorBeamTests.cs b/VGModAPI.Tests/InstalledTractorBeamTests.cs index 26a4dd08..f3f7be68 100644 --- a/VGModAPI.Tests/InstalledTractorBeamTests.cs +++ b/VGModAPI.Tests/InstalledTractorBeamTests.cs @@ -39,43 +39,4 @@ public void TractorHooksAndNativeSafetyPredicatesExist() Assert.Equal(new[] { "System.String", "System.Int32", "System.Single" }, addText.Parameters.Select(p => p.ParameterType.FullName)); } - [Fact] - public void TooltipExtensionNativeShapesExist() - { - using var assembly = AssemblyDefinition.ReadAssembly(Environment.GetEnvironmentVariable("VG_GAME_ASSEMBLY") - ?? throw new InvalidOperationException("Run make check-bindings.")); - var module = assembly.MainModule; - // Ship module family: every required module declares its own stat builder on AbstractEquipment. - var equipment = module.GetType("Behaviour.Equipment.AbstractEquipment"); - Assert.NotNull(Assert.Single(equipment.Methods, m => m.Name == "SetMainSubStats" && !m.HasBody)); - Assert.Equal("System.String", equipment.Methods.Single(m => m.Name == "GetName").ReturnType.FullName); - Assert.Equal("System.Int32", equipment.Properties.Single(p => p.Name == "qualityLevel").PropertyType.FullName); - Assert.True(Assert.Single(module.GetType("Behaviour.Equipment.Turret.AbstractTurret").Methods, m => m.Name == "SetMainSubStats").HasBody); // abstract intermediates hold real builders - foreach (var name in new[] { "TractorModule", "MiningModule", "SalvageModule", "ShieldGeneratorModule", "DroneBayModule" }) - { - var type = module.GetType("Behaviour.Equipment.Module." + name); - Assert.True(Assert.Single(type.Methods, m => m.Name == "SetMainSubStats").HasBody); - } - var mainSubStats = module.GetType("Behaviour.Equipment.MainSubStats"); - Assert.NotNull(Assert.Single(mainSubStats.Methods, m => m.Name == "AddMainSubStat" - && m.Parameters.Select(p => p.ParameterType.FullName).SequenceEqual(new[] { "System.String", "System.String" }))); - Assert.Contains(mainSubStats.Properties, p => p.Name == "subStatsList"); - var subStat = module.GetType("Behaviour.Equipment.SubStat"); - foreach (var name in new[] { "mainSubStatName", "mainSubStatAmount" }) - Assert.Equal("System.String", subStat.Properties.Single(p => p.Name == name).PropertyType.FullName); - // Item tooltip family: the shared fill plus the item source surface. - var tooltip = module.GetType("Behaviour.UI.UITooltip"); - var fill = Assert.Single(tooltip.Methods, m => m.Name == "SetContent"); - Assert.Equal("Behaviour.UI.Tooltip.TooltipSource", fill.Parameters.Single().ParameterType.FullName); - Assert.Contains(tooltip.Properties, p => p.Name == "Source" && p.PropertyType.FullName == "Behaviour.UI.Tooltip.TooltipSource"); - var source = module.GetType("Behaviour.UI.Tooltip.ItemTooltipSource"); - Assert.Equal("Behaviour.Item.InventoryItemType", source.Properties.Single(p => p.Name == "item").PropertyType.FullName); - Assert.Equal("System.Int32", source.Properties.Single(p => p.Name == "count").PropertyType.FullName); - var item = module.GetType("Behaviour.Item.InventoryItemType"); - foreach (var name in new[] { "identifier", "displayName", "description" }) - Assert.Equal("System.String", item.Properties.Single(p => p.Name == name).PropertyType.FullName); - Assert.Equal("System.Int32", item.Properties.Single(p => p.Name == "itemLevel").PropertyType.FullName); - Assert.Contains(module.GetType("Source.Util.ColorHelper").Fields, f => f.Name == "greenish"); - Assert.Contains(module.GetType("Source.Util.ColorHelper").Fields, f => f.Name == "detailsColor"); - } } diff --git a/VGModAPI/EquipmentTargetingIntegration.cs b/VGModAPI/EquipmentTargetingIntegration.cs new file mode 100644 index 00000000..dc7c414a --- /dev/null +++ b/VGModAPI/EquipmentTargetingIntegration.cs @@ -0,0 +1,38 @@ +using System; +using System.Reflection; +using HarmonyLib; +using VGModAPI.Core; +using VGModAPI.Patches; +using VGModAPI.Runtime; + +namespace VGModAPI; + +/// Equipment targeting policy: the single native hook pair behind IEquipmentService. +/// TractorBeamRuntime keeps its name because it binds the game's own TractorBeam types. +public sealed partial class Plugin +{ + private EquipmentService? _equipment; + private Harmony? _equipmentHarmony; + private void InstallEquipmentTargeting(Assembly assembly) + { + _equipment ??= new EquipmentService(_hub!); + try + { + var runtime = new TractorBeamRuntime(assembly, _equipment, error => Logger.LogError(error)); + _equipmentHarmony = new Harmony(ModApi.PluginId + ".equipment-targeting"); + TractorBeamPatches.Runtime = runtime; + _equipmentHarmony.Patch(runtime.AvailableBeam, postfix: new HarmonyMethod(typeof(TractorBeamPatches.Available), "Postfix")); + _equipmentHarmony.Patch(runtime.UpdateTargets, postfix: new HarmonyMethod(typeof(TractorBeamPatches.Targets), "Postfix")); + _equipment.SetAvailable(true); + } + catch (Exception error) { TeardownEquipmentTargeting(); Logger.LogError(error); } + } + private void TeardownEquipmentTargeting() + { + TractorBeamPatches.Runtime = null; + try { _equipmentHarmony?.UnpatchSelf(); } + catch (Exception error) { Logger.LogError(error); } + _equipmentHarmony = null; + _equipment?.SetAvailable(false); + } +} diff --git a/VGModAPI/Patches/TooltipPatches.cs b/VGModAPI/Patches/TooltipPatches.cs new file mode 100644 index 00000000..56c8a8e3 --- /dev/null +++ b/VGModAPI/Patches/TooltipPatches.cs @@ -0,0 +1,20 @@ +using VGModAPI.Runtime; + +namespace VGModAPI.Patches; + +internal static class TooltipPatches +{ + internal static TooltipRuntime? Runtime; + internal static class ModuleStats + { + internal static void Postfix(object __instance) => Runtime?.AddModuleDescription(__instance); + } + internal static class MasteryTooltip + { + internal static void Postfix(object __instance, object tooltip) => Runtime?.AddMasteryDescription(__instance, tooltip); + } + internal static class ItemContent + { + internal static void Postfix(object __instance) => Runtime?.AddItemDescription(__instance); + } +} diff --git a/VGModAPI/Patches/TractorBeamPatches.cs b/VGModAPI/Patches/TractorBeamPatches.cs index ebeef486..624a33b0 100644 --- a/VGModAPI/Patches/TractorBeamPatches.cs +++ b/VGModAPI/Patches/TractorBeamPatches.cs @@ -16,20 +16,3 @@ internal static class Targets internal static void Postfix(object __instance, IEnumerable targets) => Runtime?.TopUp(__instance, targets); } } - -internal static class TooltipPatches -{ - internal static TooltipRuntime? Runtime; - internal static class ModuleStats - { - internal static void Postfix(object __instance) => Runtime?.AddModuleDescription(__instance); - } - internal static class MasteryTooltip - { - internal static void Postfix(object __instance, object tooltip) => Runtime?.AddMasteryDescription(__instance, tooltip); - } - internal static class ItemContent - { - internal static void Postfix(object __instance) => Runtime?.AddItemDescription(__instance); - } -} diff --git a/VGModAPI/Plugin.cs b/VGModAPI/Plugin.cs index cfe17b33..d5636bb5 100644 --- a/VGModAPI/Plugin.cs +++ b/VGModAPI/Plugin.cs @@ -132,7 +132,7 @@ private void Awake() ["writeMetadata"] = typeof(SavePatches.WriteMetadata), ["storeFailure"] = typeof(SavePatches.StoreFailure) }); InstallHud(assembly); - InstallTractorBeams(assembly); + InstallEquipmentTargeting(assembly); InstallTooltips(assembly); InstallGameplayUi(assembly); InstallAmbientTraffic(assembly); @@ -157,7 +157,7 @@ private void Awake() // Stop observation even if a failed rollback leaves a detour installed. _adapter?.Guard(() => throw new InvalidOperationException("Adapter installation failed.", ex)); TeardownHud(); - TeardownTractorBeams(); + TeardownEquipmentTargeting(); TeardownTooltips(); TeardownGameplayUi(); TeardownAmbientTraffic(); @@ -1063,7 +1063,7 @@ private void OnDestroy() ModApi.ClearServices(_serviceRoot); _hub?.Dispose(); // Close gates and preserve queued terminal delivery before releasing service views. StopDungeonPanel(); - TeardownTractorBeams(); + TeardownEquipmentTargeting(); TeardownTooltips(); TeardownHud(); TeardownGameplayUi(); diff --git a/VGModAPI/TractorBeamIntegration.cs b/VGModAPI/TooltipIntegration.cs similarity index 56% rename from VGModAPI/TractorBeamIntegration.cs rename to VGModAPI/TooltipIntegration.cs index ef34bba2..200d0273 100644 --- a/VGModAPI/TractorBeamIntegration.cs +++ b/VGModAPI/TooltipIntegration.cs @@ -7,34 +7,17 @@ namespace VGModAPI; +/// Tooltip extensions and the skill-tree reader behind ISkillTreeService: one Harmony +/// instance per seam, bound fail-closed. public sealed partial class Plugin { - private EquipmentService? _equipment; private SkillTreeService? _skillTrees; private TooltipService? _tooltips; - private Harmony? _tractorHarmony; private Harmony? _tooltipsHarmony; - private void InstallTractorBeams(Assembly assembly) - { - _equipment ??= new EquipmentService(_hub!); - _skillTrees ??= new SkillTreeService(_hub!); - _tooltips ??= new TooltipService(_hub!); - try - { - var skills = new SkillTreeRuntime(assembly); - var runtime = new TractorBeamRuntime(assembly, _equipment, error => Logger.LogError(error)); - _tractorHarmony = new Harmony(ModApi.PluginId + ".tractor-beams"); - TractorBeamPatches.Runtime = runtime; - _tractorHarmony.Patch(runtime.AvailableBeam, postfix: new HarmonyMethod(typeof(TractorBeamPatches.Available), "Postfix")); - _tractorHarmony.Patch(runtime.UpdateTargets, postfix: new HarmonyMethod(typeof(TractorBeamPatches.Targets), "Postfix")); - _skillTrees.Bind(skills.Get); - _equipment.SetAvailable(true); - } - catch (Exception error) { TeardownTractorBeams(); Logger.LogError(error); } - } private void InstallTooltips(Assembly assembly) { _tooltips ??= new TooltipService(_hub!); + _skillTrees ??= new SkillTreeService(_hub!); try { var skills = new SkillTreeRuntime(assembly); @@ -46,24 +29,17 @@ private void InstallTooltips(Assembly assembly) _tooltipsHarmony.Patch(runtime.MasteryTooltip, postfix: new HarmonyMethod(typeof(TooltipPatches.MasteryTooltip), "Postfix")); foreach (var fill in runtime.ContentFills) _tooltipsHarmony.Patch(fill, postfix: new HarmonyMethod(typeof(TooltipPatches.ItemContent), "Postfix")); + _skillTrees.Bind(skills.Get); _tooltips.SetAvailable(true); } catch (Exception error) { TeardownTooltips(); Logger.LogError(error); } } - private void TeardownTractorBeams() - { - TractorBeamPatches.Runtime = null; - try { _tractorHarmony?.UnpatchSelf(); } - catch (Exception error) { Logger.LogError(error); } - _tractorHarmony = null; - _equipment?.SetAvailable(false); _skillTrees?.Bind(null); - } private void TeardownTooltips() { TooltipPatches.Runtime = null; try { _tooltipsHarmony?.UnpatchSelf(); } catch (Exception error) { Logger.LogError(error); } _tooltipsHarmony = null; - _tooltips?.SetAvailable(false); + _tooltips?.SetAvailable(false); _skillTrees?.Bind(null); } } diff --git a/docs/reference/tractor-beams.md b/docs/reference/equipment-skill-tooltips.md similarity index 100% rename from docs/reference/tractor-beams.md rename to docs/reference/equipment-skill-tooltips.md