feat: Add metro transit, gadgets, shop, social apps, and traffic models checkpoint

This commit is contained in:
CAtlas0784 committed 2026-10-29 01:55:01 +07:00
1 parent caa42b84ed
commit 74e2963a12
79 files changed
+527962 -496

No files matched your search

@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using Ananta.SDK.Network;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Protocol.Client4229938;
namespace Ananta.Server.Gameplay.Crowd;
@@ -121,8 +122,43 @@ public sealed class UrbanCrowdEngine
40968790, // Kaneko Mei - Pedestrian
];
// Common idle / chatter / phone POI action IDs
private static readonly uint[] AmbientPoiActions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// Contextual ambient POI pools, built from the client's UrbanDiversityPOIActionConfig by
// NAME keyword. The old fixed [1..10] roll included 走路 (walk-in-place), 跑步 (run) and
// 受惊 (startled) — motion/fear states that looked broken on a standing NPC. 站姿* rows
// are the authored standing set (phone, look-around, lean, clap…); shop rows add
// browsing/vending actions for interior patrons.
private uint[]? _idlePoiPool;
private uint[]? _shopPoiPool;
private uint[] IdlePoiPool()
{
if (_idlePoiPool is null)
{
var pool = PoiActionCatalog4229938.All()
.Where(p => p.MainActions.Length > 0 && p.Name.StartsWith("站姿", StringComparison.Ordinal))
.Select(p => p.Id)
.ToList();
pool.Insert(0, 2u); // plain idle is always valid
_idlePoiPool = pool.ToArray();
}
return _idlePoiPool;
}
private uint[] ShopPoiPool()
{
if (_shopPoiPool is null)
{
var pool = PoiActionCatalog4229938.All()
.Where(p => p.MainActions.Length > 0 &&
(p.Name.StartsWith("站姿", StringComparison.Ordinal) ||
p.Name.Contains("商品") || p.Name.Contains("售货机") || p.Name.Contains("购物")))
.Select(p => p.Id)
.ToList();
pool.Insert(0, 2u);
_shopPoiPool = pool.ToArray();
}
return _shopPoiPool;
}
private readonly Random _rng = new();
@@ -400,7 +436,8 @@ public sealed class UrbanCrowdEngine
// Spawn 1 shopkeeper/patron inside
var formworkId = CitizenFormworkPool[_rng.Next(CitizenFormworkPool.Length)];
var poiAction = AmbientPoiActions[_rng.Next(AmbientPoiActions.Length)];
var shopPool = ShopPoiPool();
var poiAction = shopPool[_rng.Next(shopPool.Length)];
var facing = _rng.Next(0, 360);
var (ok, ids) = await _driver.SpawnCrowdPedestriansAsync(
@@ -475,7 +512,8 @@ public sealed class UrbanCrowdEngine
var spawnY = MathF.Abs(pt0[1] - py) > 2.5f ? py : pt0[1];
var formworkId = CitizenFormworkPool[_rng.Next(CitizenFormworkPool.Length)];
var isWalker = _rng.Next(100) < 60; // 60% walkers, 40% ambient idle/talkers
var poiAction = isWalker ? 0u : AmbientPoiActions[_rng.Next(AmbientPoiActions.Length)];
var idlePool = IdlePoiPool();
var poiAction = isWalker ? 0u : idlePool[_rng.Next(idlePool.Length)];
var facing = _rng.Next(0, 360);
var (ok, ids) = await _driver.SpawnCrowdPedestriansAsync(
File diff suppressed because it is too large. Load diff
@@ -2,6 +2,15 @@ using Ananta.SDK.Network;
namespace Ananta.Server.Gameplay.Traffic;
/// <summary>One lane-progress sample for the batched Aether lane sync.</summary>
public readonly record struct AetherLaneSample(ulong EntityId, int LaneHandle, float Distance, byte Status);
/// <summary>One Aether vehicle spawn sample for the batch form.</summary>
public readonly record struct AetherVehicleInit(
ulong EntityId, uint ConfigId, uint ColorPartId, int LaneHandle, float Distance,
float Speed, double Timestamp, float X, float Y, float Z, float Yaw,
List<(float X, float Y, float Z)>? CruisePoints = null);
public interface ITrafficVehicleDriver
{
Task<(bool Ok, ulong EntityId)> SpawnTrafficVehicleAsync(
@@ -13,6 +22,44 @@ public interface ITrafficVehicleDriver
Task SendVehicleMoveAsync(
TcpSession session, ulong entityId, float x, float y, float z, float yaw, float vx, float vz);
/// <summary>
/// Native client-driven spawn: registers an Aether vehicle on the client's own zone graph.
/// The client simulates/renders it from lane handle + distance; no scene entity is created.
/// </summary>
Task SpawnAetherVehicleAsync(
TcpSession session, ulong entityId, uint configId, uint colorPartId, int laneHandle,
float distanceAlongLane, float speed, double timestamp, float x, float y, float z, float yaw);
/// <summary>
/// Spawns Aether vehicles on the client's zone graph. The 4229938 client has NO batch
/// AddDatas method — the implementer must send one SyncAetherAIVehicleAddData (68916691)
/// notify per vehicle.
/// </summary>
Task SpawnAetherVehiclesAsync(
TcpSession session, IReadOnlyList<AetherVehicleInit> vehicles);
/// <summary>Binds a driver/passenger NPC into an Aether vehicle seat (SyncAetherAIVehicleNpcAdd).</summary>
Task SpawnAetherVehicleNpcAsync(
TcpSession session, ulong npcId, uint npcFormworkId, ulong vehicleId, byte seatIndex);
/// <summary>Removes an Aether vehicle NPC (SyncAetherAINpcRemove 68332284 — the only Aether NPC remove the client has).</summary>
Task RemoveAetherVehicleNpcAsync(TcpSession session, ulong npcId);
/// <summary>Pushes one intersection period change (SyncAetherAIIntersectionUpdateData); the id is the InstanceId previously sent in the init.</summary>
Task SendAetherIntersectionUpdateAsync(
TcpSession session, ulong intersectionInstanceId, int currentPeriod, int nextPeriod, byte state);
/// <summary>
/// Batched lane-progress sync (one packet per session per tick); the client interpolates
/// between samples using its 0.35s delay buffer.
/// </summary>
Task SendAetherLaneDatasAsync(
TcpSession session, List<AetherLaneSample> samples, double timestamp);
/// <summary>Removes an Aether vehicle from the client's traffic simulation.</summary>
Task RemoveAetherVehicleAsync(
TcpSession session, ulong entityId);
bool IsVehiclePlayerControlled(ulong entityId);
(bool HasPlayer, float X, float Y, float Z, float Yaw, uint RaidId) GetPlayerPosition(TcpSession session);
@@ -8,6 +8,100 @@ public sealed class TrafficSignalFile
{
[JsonPropertyName("lights")]
public List<TrafficLightEntry> Lights { get; set; } = [];
[JsonPropertyName("intersections")]
public List<TrafficIntersectionEntry> Intersections { get; set; } = [];
}
/// <summary>
/// City zone-graph intersection mapping (InterZones.json): for every signalized city
/// intersection (id from the world TrafficLights dump) the indexes of its zones in the
/// city ZoneGraphStorage zones array. The client's AetherIntersectionManager resolves each
/// SyncAetherAIInitDatas entry by ZoneIndex against its baked StaticIntersectionData blob,
/// so these are the values ClientTrafficIntersectionInitInfo.ZoneIndex must carry.
/// Generated from readable_world_dump 03_ZONEGRAPHS vfc_1286_e108 + 04_WORLD_JSON vfc_1286_e104.
/// </summary>
public sealed class IntersectionZoneFile
{
[JsonPropertyName("source")]
public string Source { get; set; } = string.Empty;
[JsonPropertyName("intersections")]
public List<IntersectionZoneEntry> Intersections { get; set; } = [];
}
public sealed class IntersectionZoneEntry
{
[JsonPropertyName("id")]
public uint Id { get; set; }
[JsonPropertyName("zones")]
public List<int> Zones { get; set; } = [];
}
/// <summary>
/// The client's baked StaticIntersectionData.json (recovered from the L50 VFS, artifact 111):
/// the authoritative intersection zones for the city raid — one entry per signalized
/// intersection, with its real zone-graph ZoneIndex, position, traffic-light bindings and
/// per-period control tables. This is exactly what the client's AetherIntersectionManager
/// resolves against, so the ambient init must use these ZoneIndexes.
/// </summary>
public sealed class StaticIntersectionFile
{
[JsonPropertyName("ZoneInfos")]
public List<StaticZoneInfoEntry> ZoneInfos { get; set; } = [];
}
public sealed class StaticZoneInfoEntry
{
[JsonPropertyName("ZoneIndex")]
public int ZoneIndex { get; set; }
[JsonPropertyName("Position")]
public List<float> Position { get; set; } = [];
[JsonPropertyName("TrafficLights")]
public List<StaticTrafficLightEntry> TrafficLights { get; set; } = [];
[JsonPropertyName("TrafficLightPeriodControls")]
public List<StaticPeriodRowEntry> TrafficLightPeriodControls { get; set; } = [];
}
public sealed class StaticTrafficLightEntry
{
[JsonPropertyName("Index")]
public int Index { get; set; }
[JsonPropertyName("CrosswalkControlIndex")]
public int CrosswalkControlIndex { get; set; }
[JsonPropertyName("VehicleControlIndex")]
public int VehicleControlIndex { get; set; }
}
public sealed class StaticPeriodRowEntry
{
[JsonPropertyName("TrafficLightControls")]
public List<StaticLightControlEntry> TrafficLightControls { get; set; } = [];
[JsonPropertyName("VehicleLanesOpen")]
public List<int> VehicleLanesOpen { get; set; } = [];
}
public sealed class StaticLightControlEntry
{
[JsonPropertyName("TrafficLightStateFlags")]
public int TrafficLightStateFlags { get; set; }
}
/// <summary>One signalized intersection: its id and the zebra (stop-line group) indexes it controls.</summary>
public sealed class TrafficIntersectionEntry
{
[JsonPropertyName("id")]
public uint Id { get; set; }
[JsonPropertyName("zebras")]
public List<uint> Zebras { get; set; } = [];
}
public sealed class TrafficLightEntry
@@ -0,0 +1,346 @@
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;
}
}