347 lines
14 KiB
C#
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;
|
|
}
|
|
}
|