diff --git a/IdleSpectator/InterestCollector.cs b/IdleSpectator/InterestCollector.cs index b0a74eb..c140002 100644 --- a/IdleSpectator/InterestCollector.cs +++ b/IdleSpectator/InterestCollector.cs @@ -108,4 +108,20 @@ public static class InterestCollector Pending.Clear(); } } + + public static bool HasPendingAtLeast(InterestTier minTier) + { + lock (Gate) + { + for (int i = 0; i < Pending.Count; i++) + { + if (Pending[i] != null && Pending[i].Tier >= minTier) + { + return true; + } + } + + return false; + } + } } diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 4ee111a..3e8093f 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -10,6 +10,7 @@ public static class InterestDirector { public const float MinDwellSeconds = 10f; public const float CuriosityDwellSeconds = 6f; + public const float CuriosityRotateSeconds = 10f; public const float SwitchCooldownSeconds = 5f; public const float HighTierInterruptAfterSeconds = 3f; public const float QuietWorldAmbientAfterSeconds = 8f; @@ -97,6 +98,13 @@ public static class InterestDirector } } + // Queued discovery tips beat ambient, but never stall ambient under Action/Story. + if (InterestCollector.HasPendingAtLeast(InterestTier.Curiosity) + && (_current == null || _current.Tier <= InterestTier.Curiosity)) + { + return; + } + bool quiet = now - _lastInterestingAt >= QuietWorldAmbientAfterSeconds; bool dwellDone = _current == null || onCurrent >= DwellFor(_current); bool ambientDue = now - _lastAmbientAt >= AmbientRotateSeconds; @@ -110,6 +118,24 @@ public static class InterestDirector } } + /// + /// True when a drained New species tip would be allowed to take the camera. + /// + public static bool WouldAcceptCuriosity() + { + if (_current == null) + { + return true; + } + + float onCurrent = Time.unscaledTime - _currentStartedAt; + float sinceSwitch = Time.unscaledTime - _lastSwitchAt; + return CanSwitchTo( + new InterestEvent { Tier = InterestTier.Curiosity }, + onCurrent, + sinceSwitch); + } + private static bool PlayerTookManualControl() { // Rising-edge style checks only - avoid sticky flags like already_used_power @@ -165,7 +191,8 @@ public static class InterestDirector return true; } - // Curiosity is a footnote: never interrupt Action/Story/Epic, and rarely replace another curiosity. + // Curiosity is a footnote: never interrupt Action/Story/Epic. + // Curiosity→curiosity uses a shorter rotate so discovery drains can surface. if (candidate.Tier == InterestTier.Curiosity) { if (_current.Tier >= InterestTier.Action) @@ -173,7 +200,7 @@ public static class InterestDirector return false; } - if (_current.Tier == InterestTier.Curiosity && onCurrent < AmbientRotateSeconds) + if (_current.Tier == InterestTier.Curiosity && onCurrent < CuriosityRotateSeconds) { return false; } diff --git a/IdleSpectator/SpeciesDiscovery.cs b/IdleSpectator/SpeciesDiscovery.cs index 783c262..f7ff965 100644 --- a/IdleSpectator/SpeciesDiscovery.cs +++ b/IdleSpectator/SpeciesDiscovery.cs @@ -11,11 +11,14 @@ namespace IdleSpectator; public static class SpeciesDiscovery { private const float DrainIntervalSeconds = 12f; + private const float NotReadyBackoffSeconds = 5f; private const int MaxBuffered = 24; private const int MaxDrainPerSession = 8; + private const int MaxNotReadyAttempts = 8; - private static readonly Queue Pending = new Queue(); + private static readonly List Pending = new List(); private static readonly HashSet SeenAssets = new HashSet(); + private static readonly HashSet PresentedAssets = new HashSet(); private static float _lastDrainAt = -999f; private static int _drainedThisSession; @@ -24,6 +27,7 @@ public static class SpeciesDiscovery public string AssetId; public Vector3 Position; public Subspecies Subspecies; + public int NotReadyAttempts; } public static void OnSpectatorEnabled() @@ -50,20 +54,53 @@ public static class SpeciesDiscovery return; } + // Already shown via live path before buffer ran - nothing to queue. + if (PresentedAssets.Contains(asset.id)) + { + return; + } + if (Pending.Count >= MaxBuffered) { - Pending.Dequeue(); + Pending.RemoveAt(0); } Vector3 pos = tile != null ? tile.posV3 : Vector3.zero; - Pending.Enqueue(new PendingSpecies + Pending.Add(new PendingSpecies { AssetId = asset.id, Position = pos, - Subspecies = subspecies + Subspecies = subspecies, + NotReadyAttempts = 0 }); } + /// + /// Live newSpecies path already tipped this asset - drop any buffered copy. + /// + public static void MarkPresented(string assetId) + { + if (string.IsNullOrEmpty(assetId)) + { + return; + } + + PresentedAssets.Add(assetId); + SeenAssets.Add(assetId); + for (int i = Pending.Count - 1; i >= 0; i--) + { + if (Pending[i].AssetId == assetId) + { + Pending.RemoveAt(i); + } + } + } + + public static bool WasPresented(string assetId) + { + return !string.IsNullOrEmpty(assetId) && PresentedAssets.Contains(assetId); + } + public static void Update() { if (!SpectatorMode.Active || !Config.game_loaded || World.world == null) @@ -87,23 +124,43 @@ public static class SpeciesDiscovery return; } - PendingSpecies next = Pending.Dequeue(); + // Wait until the director can actually show a curiosity tip. + if (!InterestDirector.WouldAcceptCuriosity()) + { + return; + } + + int index = FindNextReadyIndex(); + if (index < 0) + { + // Front items not spawnable yet - retry later without dequeue flap. + _lastDrainAt = now - DrainIntervalSeconds + NotReadyBackoffSeconds; + return; + } + + PendingSpecies next = Pending[index]; ActorAsset asset = AssetManager.actor_library?.get(next.AssetId); if (asset == null) { + Pending.RemoveAt(index); return; } InterestEvent curiosity = WorldActivityScanner.FromNewSpecies(next.Subspecies, asset, null); if (curiosity == null) { - // Unit may not exist yet - put back once, try later. - if (Pending.Count < MaxBuffered) + next.NotReadyAttempts++; + if (next.NotReadyAttempts >= MaxNotReadyAttempts) { - Pending.Enqueue(next); + Pending.RemoveAt(index); + LogService.LogInfo($"[IdleSpectator] Discovery skip (never spawned): {next.AssetId}"); + } + else + { + Pending[index] = next; } - _lastDrainAt = now - DrainIntervalSeconds + 3f; + _lastDrainAt = now - DrainIntervalSeconds + NotReadyBackoffSeconds; return; } @@ -113,9 +170,40 @@ public static class SpeciesDiscovery curiosity.Position = next.Position; } + Pending.RemoveAt(index); + PresentedAssets.Add(next.AssetId); _lastDrainAt = now; _drainedThisSession++; InterestCollector.EnqueueDirect(curiosity); LogService.LogInfo($"[IdleSpectator] Discovery drain: {next.AssetId} ({_drainedThisSession}/{MaxDrainPerSession}, queued={Pending.Count})"); } + + private static int FindNextReadyIndex() + { + for (int i = 0; i < Pending.Count; i++) + { + PendingSpecies next = Pending[i]; + if (PresentedAssets.Contains(next.AssetId)) + { + Pending.RemoveAt(i); + i--; + continue; + } + + ActorAsset asset = AssetManager.actor_library?.get(next.AssetId); + if (asset == null) + { + Pending.RemoveAt(i); + i--; + continue; + } + + if (WorldActivityScanner.FromNewSpecies(next.Subspecies, asset, null) != null) + { + return i; + } + } + + return -1; + } } diff --git a/IdleSpectator/SubspeciesPatches.cs b/IdleSpectator/SubspeciesPatches.cs index d7dd0e8..1948bab 100644 --- a/IdleSpectator/SubspeciesPatches.cs +++ b/IdleSpectator/SubspeciesPatches.cs @@ -32,6 +32,12 @@ public static class SubspeciesNewSpeciesPatch return; } + // Drain already tipped this species (or live tip already fired). + if (SpeciesDiscovery.WasPresented(assetId)) + { + return; + } + float now = Time.unscaledTime; if (LastLiveEnqueuedAt.TryGetValue(assetId, out float last) && now - last < PerSpeciesCooldownSeconds) { @@ -45,6 +51,7 @@ public static class SubspeciesNewSpeciesPatch } LastLiveEnqueuedAt[assetId] = now; + SpeciesDiscovery.MarkPresented(assetId); InterestCollector.EnqueueDirect(curiosity); } } diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 5af44ce..5401efa 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.3.9", + "version": "0.3.10", "description": "AFK Idle Spectator (I): follows wars, disasters, battles, creatures, and new species from fresh worlds to empires.", "GUID": "com.dazed.idlespectator" }