288 lines
8.4 KiB
C#
288 lines
8.4 KiB
C#
using System.Collections.Generic;
|
|
using NeoModLoader.services;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Captures subspecies creation during map gen / before spectator is on,
|
|
/// then drains a few as Curiosity once the world is playable.
|
|
/// </summary>
|
|
public static class SpeciesDiscovery
|
|
{
|
|
private const float DrainIntervalSecondsDefault = 12f;
|
|
private const float NotReadyBackoffSecondsDefault = 5f;
|
|
private const int MaxBuffered = 24;
|
|
private const int MaxDrainPerSession = 8;
|
|
private const int MaxNotReadyAttempts = 8;
|
|
|
|
private static float _drainInterval = DrainIntervalSecondsDefault;
|
|
private static float _notReadyBackoff = NotReadyBackoffSecondsDefault;
|
|
|
|
private static readonly List<PendingSpecies> Pending = new List<PendingSpecies>();
|
|
private static readonly HashSet<string> SeenAssets = new HashSet<string>();
|
|
private static readonly HashSet<string> PresentedAssets = new HashSet<string>();
|
|
private static float _lastDrainAt = -999f;
|
|
private static int _drainedThisSession;
|
|
|
|
private struct PendingSpecies
|
|
{
|
|
public string AssetId;
|
|
public Vector3 Position;
|
|
public Subspecies Subspecies;
|
|
public int NotReadyAttempts;
|
|
}
|
|
|
|
public static int PendingCount => Pending.Count;
|
|
|
|
public static bool IsPending(string assetId)
|
|
{
|
|
return !string.IsNullOrEmpty(assetId) && Pending.Exists(p => p.AssetId == assetId);
|
|
}
|
|
|
|
public static int DrainedThisSession => _drainedThisSession;
|
|
|
|
public static void OnSpectatorEnabled()
|
|
{
|
|
_drainedThisSession = 0;
|
|
_lastDrainAt = Time.unscaledTime - _drainInterval + 2f;
|
|
}
|
|
|
|
public static void OnSpectatorDisabled()
|
|
{
|
|
// Keep buffer so re-enabling can still discover; clear session drain counter only.
|
|
_drainedThisSession = 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Harness-only: shorten drain interval. Uses unscaled time (game 5x does not help).
|
|
/// </summary>
|
|
public static void SetHarnessFastTiming(bool enabled)
|
|
{
|
|
_drainInterval = enabled ? 0.75f : DrainIntervalSecondsDefault;
|
|
_notReadyBackoff = enabled ? 0.35f : NotReadyBackoffSecondsDefault;
|
|
}
|
|
|
|
/// <summary>Harness: clear presented/seen so the same asset can be re-tested.</summary>
|
|
public static void HarnessResetTracking()
|
|
{
|
|
PresentedAssets.Clear();
|
|
SeenAssets.Clear();
|
|
Pending.Clear();
|
|
_drainedThisSession = 0;
|
|
_lastDrainAt = Time.unscaledTime - _drainInterval;
|
|
}
|
|
|
|
/// <summary>Harness: buffer a living asset as if newSpecies fired during load.</summary>
|
|
public static bool HarnessNoteAsset(string assetId, Vector3 position)
|
|
{
|
|
if (string.IsNullOrEmpty(assetId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ActorAsset asset = AssetManager.actor_library?.get(assetId);
|
|
if (asset == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
SeenAssets.Remove(assetId);
|
|
PresentedAssets.Remove(assetId);
|
|
for (int i = Pending.Count - 1; i >= 0; i--)
|
|
{
|
|
if (Pending[i].AssetId == assetId)
|
|
{
|
|
Pending.RemoveAt(i);
|
|
}
|
|
}
|
|
|
|
NoteCreated(asset, null, mutation: false, subspecies: null);
|
|
if (Pending.Count > 0)
|
|
{
|
|
PendingSpecies last = Pending[Pending.Count - 1];
|
|
if (last.AssetId == assetId && position != Vector3.zero)
|
|
{
|
|
last.Position = position;
|
|
Pending[Pending.Count - 1] = last;
|
|
}
|
|
}
|
|
|
|
return Pending.Exists(p => p.AssetId == assetId);
|
|
}
|
|
|
|
/// <summary>Harness: ignore drain interval and attempt one drain immediately.</summary>
|
|
public static bool HarnessForceDrainOnce()
|
|
{
|
|
_lastDrainAt = Time.unscaledTime - _drainInterval - 0.05f;
|
|
int before = _drainedThisSession;
|
|
int pendingBefore = Pending.Count;
|
|
Update();
|
|
return _drainedThisSession > before || Pending.Count < pendingBefore;
|
|
}
|
|
|
|
public static void NoteCreated(ActorAsset asset, WorldTile tile, bool mutation, Subspecies subspecies)
|
|
{
|
|
if (mutation || asset == null || string.IsNullOrEmpty(asset.id))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!SeenAssets.Add(asset.id))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Already shown via live path before buffer ran - nothing to queue.
|
|
if (PresentedAssets.Contains(asset.id))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Pending.Count >= MaxBuffered)
|
|
{
|
|
Pending.RemoveAt(0);
|
|
}
|
|
|
|
Vector3 pos = tile != null ? tile.posV3 : Vector3.zero;
|
|
Pending.Add(new PendingSpecies
|
|
{
|
|
AssetId = asset.id,
|
|
Position = pos,
|
|
Subspecies = subspecies,
|
|
NotReadyAttempts = 0
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Live newSpecies path already tipped this asset - drop any buffered copy.
|
|
/// </summary>
|
|
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)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (SmoothLoader.isLoading())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_drainedThisSession >= MaxDrainPerSession || Pending.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
if (now - _lastDrainAt < _drainInterval)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// 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 - _drainInterval + _notReadyBackoff;
|
|
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)
|
|
{
|
|
next.NotReadyAttempts++;
|
|
if (next.NotReadyAttempts >= MaxNotReadyAttempts)
|
|
{
|
|
Pending.RemoveAt(index);
|
|
LogService.LogInfo($"[IdleSpectator] Discovery skip (never spawned): {next.AssetId}");
|
|
}
|
|
else
|
|
{
|
|
Pending[index] = next;
|
|
}
|
|
|
|
_lastDrainAt = now - _drainInterval + _notReadyBackoff;
|
|
return;
|
|
}
|
|
|
|
// Prefer the recorded spawn tile when the unit search used a weak position.
|
|
if (next.Position != Vector3.zero && curiosity.Position == Vector3.zero)
|
|
{
|
|
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;
|
|
}
|
|
}
|