Files

347 lines
14 KiB
C#

using System.Collections.Concurrent;
using System.Text.Json;
using Ananta.SDK.Network;
using Ananta.Server.Gameplay.Traffic;
namespace Ananta.Server.Gameplay.World;
/// <summary>Send channel for the gadget grid-AOI stream (implemented in the Handlers layer).</summary>
public interface IGadgetStreamDriver
{
/// <summary>Pushes one SyncGadgetGridAOIDecrease batch for the given 40m grid cells with one sector list.</summary>
Task SendGadgetGridAoiAsync(
TcpSession session, IReadOnlyList<(int X, int Z)> cells, IReadOnlyList<int> sectorIds, (int X, int Z) playerCell, bool firstPush);
/// <summary>Pushes one SyncDestructibleGridAOIIncrease batch (lampposts, props, breakables).</summary>
Task SendSceneItemGridAoiAsync(
TcpSession session, IReadOnlyList<(int X, int Z)> cells, (int X, int Z) playerCell, bool firstPush);
/// <summary>Latest reported player transform (same source and shape as the traffic driver).</summary>
(bool HasPlayer, float X, float Y, float Z, float Yaw, uint RaidId) GetPlayerPosition(TcpSession session);
}
/// <summary>
/// Streams the gadget grid window around every connected player.
///
/// The client build bakes every interactive gadget (vending machines, claw machines, doors,
/// benches, arcades, stalls...) into per-grid-cell sector data (40m cells, outdoor city data
/// under sector 0). Pushing SyncGadgetGridAOIDecrease with the cells around the player makes
/// the client instantiate them — the same call the community's Frida-based AnantaGadgetLoader
/// performs on GadgetMgr.SyncGadgetGridAOIAddByIndex, but done server-side with no injection.
///
/// Lessons baked in from that tool's field notes: cells are only accepted once the client's
/// grid data is ready after login (so every visited window is re-sent periodically — re-adding
/// a loaded cell never duplicates), removal is never pushed explicitly (re-adding a removed
/// cell often yields nothing — "poisoned"), and the batch is budgeted so movement stays smooth.
/// </summary>
public sealed class GadgetStreamingEngine
{
public static GadgetStreamingEngine Instance { get; } = new();
internal const float CellSize = 40f;
private const int TickIntervalMs = 700; // matches the proven injection tool cadence
private const int RescanAllSeconds = 120; // periodic full-window re-send (self-healing)
private const int MinSpawnRadiusCells = 1;
public bool Enabled { get; set; } = true;
public int RadiusCells { get; set; } = 3; // 7x7 window = 49 cells
public int LoadBudgetCells { get; set; } = 49;
private readonly ConcurrentDictionary<TcpSession, SessionState> _states = new();
private readonly object _lifecycleLock = new();
private CancellationTokenSource? _cts;
private volatile bool _running;
private IGadgetStreamDriver? _driver;
private Func<IEnumerable<TcpSession>>? _sessionProvider;
/// <summary>Cell → baked sector ids (CellSectors.json, exported from the client's own grid
/// data). Sector 0 is the outdoor city; indoor gadgets live under per-building sector ids.</summary>
private readonly Dictionary<(int X, int Z), List<int>> _cellSectors = new();
private bool _useCellSectors;
private sealed class SessionState
{
public HashSet<(int X, int Z)> LoadedCells = new();
public (int X, int Z) LastCell = (int.MinValue, int.MinValue);
public bool FirstPushDone;
public long LastRescanUtcTicks;
}
public void Initialize(string clientDataPath, IGadgetStreamDriver driver, Func<IEnumerable<TcpSession>> sessionProvider)
{
ArgumentNullException.ThrowIfNull(driver);
ArgumentNullException.ThrowIfNull(sessionProvider);
var settings = Configuration.PrivateServerConfigStore.Current.World.Content.Gadgets;
Enabled = settings.Enabled;
RadiusCells = Math.Max(MinSpawnRadiusCells, settings.RadiusCells);
LoadBudgetCells = Math.Max(1, settings.LoadBudgetCells);
_useCellSectors = settings.LoadAllSectors;
LoadCellSectors(clientDataPath);
_driver = driver;
_sessionProvider = sessionProvider;
Start();
}
/// <summary>
/// Loads CellSectors.json (grid cell → baked sector ids, [x, z, sectorId...] rows exported
/// from the client's own runtime grid data). With LoadAllSectors=true the gadget stream
/// addresses each cell's REAL sectors — that is what pulls indoor gadgets per building.
/// </summary>
private void LoadCellSectors(string clientDataPath)
{
_cellSectors.Clear();
try
{
var path = Path.Combine(clientDataPath, "World", "CellSectors.json");
if (!File.Exists(path))
{
Console.WriteLine($"[GADGET] CellSectors.json not found at {path}; gadget stream stays on outdoor sector 0.");
return;
}
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("cells", out var rows))
return;
foreach (var row in rows.EnumerateArray())
{
if (row.GetArrayLength() < 3)
continue;
var x = row[0].GetInt32();
var z = row[1].GetInt32();
var sectors = new List<int>();
for (var i = 2; i < row.GetArrayLength(); i++)
sectors.Add(row[i].GetInt32());
_cellSectors[(x, z)] = sectors;
}
Console.WriteLine($"[GADGET] Cell sector map ready: {_cellSectors.Count} cells across {_cellSectors.Values.SelectMany(v => v).Distinct().Count()} sectors.");
}
catch (Exception ex)
{
_cellSectors.Clear();
Console.WriteLine($"[GADGET-ERROR] Failed to load CellSectors.json: {ex.Message}");
}
}
public void Start()
{
lock (_lifecycleLock)
{
if (_running || !Enabled)
return;
_running = true;
_cts = new CancellationTokenSource();
var token = _cts.Token;
_ = Task.Run(async () =>
{
try
{
await LoopAsync(token);
}
finally
{
_running = false;
}
});
Console.WriteLine($"[GADGET] Grid stream started (cell=40m, radius={RadiusCells} cells, budget={LoadBudgetCells}/tick, rescan={RescanAllSeconds}s).");
}
}
public void Stop()
{
lock (_lifecycleLock)
{
try
{
_cts?.Cancel();
}
catch
{
// Cancel is best-effort; the loop exits on its next tick regardless.
}
}
}
private async Task LoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(TickIntervalMs, token);
}
catch (OperationCanceledException)
{
break;
}
try
{
await TickAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[GADGET-ERROR] tick exception: {ex.Message}");
}
}
}
private async Task TickAsync()
{
var driver = _driver;
var provider = _sessionProvider;
if (driver is null || provider is null)
return;
var sessions = provider().Where(s => s is not null).Distinct().ToList();
if (sessions.Count == 0)
{
_states.Clear();
return;
}
foreach (var session in sessions)
{
if (!_states.TryGetValue(session, out var state))
_states[session] = state = new SessionState();
var (hasPlayer, px, _, pz, _, _) = driver.GetPlayerPosition(session);
if (!hasPlayer || !float.IsFinite(px) || !float.IsFinite(pz))
continue;
var cell = ((int)MathF.Floor(px / CellSize), (int)MathF.Floor(pz / CellSize));
var radius = RadiusCells;
// Ring candidates around the current cell, closest first.
var candidates = new List<(int X, int Z, float Dist)>();
for (var dx = -radius; dx <= radius; dx++)
{
for (var dz = -radius; dz <= radius; dz++)
{
var cx = cell.Item1 + dx;
var cz = cell.Item2 + dz;
if (state.LoadedCells.Contains((cx, cz)))
continue;
candidates.Add((cx, cz, MathF.Sqrt(dx * dx + dz * dz)));
}
}
candidates.Sort((a, b) => a.Dist.CompareTo(b.Dist));
var take = Math.Min(candidates.Count, LoadBudgetCells);
if (take > 0)
{
var batch = new List<(int X, int Z)>(take);
for (var i = 0; i < take; i++)
{
batch.Add((candidates[i].X, candidates[i].Z));
state.LoadedCells.Add((candidates[i].X, candidates[i].Z));
}
try
{
// Scene items (lampposts, props, breakables) — the client resolves each
// cell's baked sectors itself, one packet covers the whole batch.
await driver.SendSceneItemGridAoiAsync(session, batch, cell, !state.FirstPushDone);
// Gadgets: group cells by their baked sector list so indoor cells address
// their building's sector instead of collapsing onto outdoor sector 0.
if (_useCellSectors && _cellSectors.Count > 0)
{
foreach (var group in GroupBySectors(batch))
{
await driver.SendGadgetGridAoiAsync(session, group.Cells, group.Sectors, cell, !state.FirstPushDone);
}
}
else
{
await driver.SendGadgetGridAoiAsync(session, batch, OutdoorSectors, cell, !state.FirstPushDone);
}
state.FirstPushDone = true;
state.LastCell = cell;
}
catch (ObjectDisposedException)
{
_states.TryRemove(session, out _);
continue;
}
catch (Exception ex)
{
Console.WriteLine($"[GADGET-ERROR] push failed: {ex.Message}");
}
}
// Periodic full-window re-send: cells pushed before the client's grid data was
// ready are silently ignored, and re-adding a loaded cell never duplicates.
if ((DateTime.UtcNow.Ticks - state.LastRescanUtcTicks) / TimeSpan.TicksPerSecond >= RescanAllSeconds
&& state.LoadedCells.Count > 0)
{
state.LastRescanUtcTicks = DateTime.UtcNow.Ticks;
var rescan = state.LoadedCells
.Where(c => Math.Abs(c.Item1 - cell.Item1) <= radius && Math.Abs(c.Item2 - cell.Item2) <= radius)
.ToList();
if (rescan.Count > 0)
{
try
{
await driver.SendSceneItemGridAoiAsync(session, rescan, cell, firstPush: false);
if (_useCellSectors && _cellSectors.Count > 0)
{
foreach (var group in GroupBySectors(rescan))
{
await driver.SendGadgetGridAoiAsync(session, group.Cells, group.Sectors, cell, firstPush: false);
}
}
else
{
await driver.SendGadgetGridAoiAsync(session, rescan, OutdoorSectors, cell, firstPush: false);
}
}
catch (ObjectDisposedException)
{
_states.TryRemove(session, out _);
}
catch (Exception ex)
{
Console.WriteLine($"[GADGET-ERROR] rescan failed: {ex.Message}");
}
}
}
}
// Drop states of sessions that are gone.
foreach (var key in _states.Keys)
{
if (!sessions.Contains(key))
_states.TryRemove(key, out _);
}
}
/// <summary>Debug-panel helper: forces an immediate rescan of the current window.</summary>
public void ResetSession(TcpSession session)
{
_states.TryRemove(session, out _);
}
public int TrackedSessionCount => _states.Count;
private static readonly List<int> OutdoorSectors = [0];
/// <summary>Groups batch cells by identical baked sector list (packet carries one sector list).</summary>
private IEnumerable<(List<(int X, int Z)> Cells, List<int> Sectors)> GroupBySectors(List<(int X, int Z)> batch)
{
var groups = new Dictionary<string, (List<(int X, int Z)> Cells, List<int> Sectors)>();
foreach (var cell in batch)
{
var sectors = _cellSectors.TryGetValue(cell, out var s) && s.Count > 0 ? s : OutdoorSectors;
var key = string.Join(',', sectors);
if (!groups.TryGetValue(key, out var group))
groups[key] = group = (new List<(int X, int Z)>(), sectors);
group.Cells.Add(cell);
}
return groups.Values;
}
}