using Ananta.SDK.Network; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.SDK.Serialization; using Ananta.Server.RpcTypes.Client4229938; namespace Ananta.Server.Handlers.Game; /// /// Gang territory enemies at their authored city corners — the enemy counterpart of /// (which does the same follow loop for shop clerks). /// /// Where the positions come from is the interesting part, because the enemy tables have none: /// EnemyCatalog.json is a pure template table. The only authored enemy coordinates in the /// whole dump live in the faction influence system — FactionInfluenceAreaConfig (24 turfs, /// each a FactionId plus the point ids it owns) × FactionAreaPointConfig (85 points, 2D). /// Together they answer "which gang stands on which street corner", which is exactly the "put /// bodies where they belong" placement. See . /// /// What they DO once they exist is not decided here. The spawn goes through /// GameAdminBridge.SpawnAgentAsync, the same path the admin panel's 生成敌人 button uses: /// it sets forbidAetherAI = true, so the client's own behaviour tree takes the unit over the /// moment it appears — idle, then whatever its tree does when the player gets close. That is why /// they do NOT stroll along the pavement like ambient peds (which the server drives with /// SyncLogicAgentCommand): this path has no server-side walking at all. /// /// Two ways in, one placement helper: /// • the follow loop () — walk into a turf and its gang is /// already standing there; /// • the panel button () — "put the nearby ones down now". /// internal sealed partial class GameRouter { private const string EnemyTerritoryKey = "client4229938-enemy-territory"; internal sealed class EnemyTerritorySessionState { internal object SyncRoot { get; } = new(); internal CancellationTokenSource? Cts; internal Task? Loop; /// entityId -> the spot it was placed for. Doubles as the "already placed" set. internal Dictionary Active { get; } = new(); /// Point ids already stocked this scene; a change of raid wipes them. internal HashSet Stocked { get; } = new(); internal uint Raid; } private static EnemyTerritorySessionState EnemyTerritoryStateOf(TcpSession session) { if (session.Items.TryGetValue(EnemyTerritoryKey, out var existing) && existing is EnemyTerritorySessionState typed) return typed; var created = new EnemyTerritorySessionState(); session.Items[EnemyTerritoryKey] = created; return created; } /// World-ready hook: start the follow loop. Idempotent; no-op when disabled. internal static void EnsureEnemyTerritory(TcpSession session) { if (!PrivateServerConfigStore.Current.Gameplay.Aether.EnemyTerritoryEnabled) return; var owner = EnemyTerritoryStateOf(session); lock (owner.SyncRoot) { if (owner.Loop is { IsCompleted: false }) return; owner.Cts?.Dispose(); owner.Cts = new CancellationTokenSource(); owner.Loop = Task.Run(() => RunEnemyTerritoryLoopAsync(session, owner, owner.Cts.Token)); } } /// Disconnect / scene switch: stop the loop. The client clears the entities itself. internal static void StopEnemyTerritory(TcpSession session) { if (!session.Items.TryGetValue(EnemyTerritoryKey, out var existing) || existing is not EnemyTerritorySessionState owner) return; lock (owner.SyncRoot) { owner.Cts?.Cancel(); owner.Cts?.Dispose(); owner.Cts = null; owner.Active.Clear(); owner.Stocked.Clear(); } } /// How many territory enemies the follow loop currently has standing. Panel display only. internal static int EnemyTerritoryActiveCount(TcpSession session) { var owner = EnemyTerritoryStateOf(session); lock (owner.SyncRoot) return owner.Active.Count; } private static async Task RunEnemyTerritoryLoopAsync( TcpSession session, EnemyTerritorySessionState owner, CancellationToken token) { var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; var tick = Math.Max(500, cfg.EnemyTerritoryTickMs); session.Log.Info($"[TERRITORY] follow loop started enter={cfg.EnemyTerritoryEnterMeters:0}m " + $"exit={cfg.EnemyTerritoryExitMeters:0}m tick={tick}ms " + $"per-point={cfg.EnemyTerritoryPerPoint} max={cfg.EnemyTerritoryMaxActive} " + $"engage={cfg.EnemyTerritoryEngage}"); while (!token.IsCancellationRequested) { try { // 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明(规则 37)。 if (!IsBackgroundPushPaused(session)) await TickEnemyTerritoryAsync(session, owner, cfg); } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { session.Log.Info("[TERRITORY] loop stopped: session disposed"); break; } catch (Exception ex) { session.Log.Info($"[TERRITORY] tick failed: {ex.Message}"); } try { await Task.Delay(tick, token); } catch (OperationCanceledException) { break; } } } private static async Task TickEnemyTerritoryAsync( TcpSession session, EnemyTerritorySessionState owner, AetherSettings cfg) { var state = GetStateIfExists(session); if (state is null) return; uint raid; float px, pz; lock (state.SyncRoot) { if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0) return; raid = state.ActiveRaidId; px = state.LastReportedPlayerPosition.X; pz = state.LastReportedPlayerPosition.Z; } if (!float.IsFinite(px) || !float.IsFinite(pz)) return; if (EnemyTerritorySpotCatalog4229938.LoadError is not null) return; // A scene switch wiped every entity on the client, so our bookkeeping goes with it. lock (owner.SyncRoot) { if (owner.Raid != raid) { owner.Active.Clear(); owner.Stocked.Clear(); owner.Raid = raid; } } // 1. Retire what the player walked away from. Hysteresis (exit > enter) is the whole point: // a single threshold would spawn and despawn on every tick at the boundary (规则 26). var exit2 = cfg.EnemyTerritoryExitMeters * cfg.EnemyTerritoryExitMeters; var gone = new List(); lock (owner.SyncRoot) foreach (var (id, spot) in owner.Active) if (spot.Raid != raid || EnemyTerritorySpotCatalog4229938.Distance2(spot, px, pz) > exit2) gone.Add(id); foreach (var id in gone) await RemoveTerritoryEnemyAsync(session, owner, id); // A turf is only "stocked" while somebody from it is actually standing there, so walking // away and coming back restocks it. The 90/160 m hysteresis is what keeps that from // flapping — without it a stocked-forever set would mean a turf never refills after one // visit, which reads as "the gang vanished". if (gone.Count > 0) lock (owner.SyncRoot) { owner.Stocked.Clear(); foreach (var spot in owner.Active.Values) owner.Stocked.Add(spot.PointId); } // 2. Stock the turfs the player just walked into. One turf per tick: a whole gang // materialising at once is both a packet burst and, with engage on, an instant dogpile. int activeCount; lock (owner.SyncRoot) activeCount = owner.Active.Count; var maxActive = Math.Max(1, cfg.EnemyTerritoryMaxActive); if (activeCount >= maxActive) return; var enter2 = cfg.EnemyTerritoryEnterMeters * cfg.EnemyTerritoryEnterMeters; EnemyTerritorySpot4229938? next = null; lock (owner.SyncRoot) { var best = float.MaxValue; foreach (var spot in EnemyTerritorySpotCatalog4229938.ForRaid(raid)) { if (owner.Stocked.Contains(spot.PointId)) continue; var d2 = EnemyTerritorySpotCatalog4229938.Distance2(spot, px, pz); if (d2 <= enter2 && d2 < best) { best = d2; next = spot; } } } if (next is null) return; var perPoint = Math.Clamp(cfg.EnemyTerritoryPerPoint, 1, 6); await PlaceTerritoryGroupAsync(session, owner, next, perPoint, cfg.EnemyTerritoryEngage); } /// /// Puts <count> members of one turf on the ground. /// /// Where they go comes from , and that list is /// the whole point of the 2026-09-24 rework: the authored turf point is the corner of the /// influence polygon, i.e. the middle of the intersection (measured: median 12.0 m from the /// nearest pavement spot). Standing bodies there read as "traffic cone". The generator now /// moves the whole group onto CivilianDistricts pavement spots near the turf point, preferring /// the ones with 2-4 neighbours (an alley edge) over 7+ (a plaza/crossroads), and the members /// face the group centroid so they read as a knot of people rather than a firing line. /// /// The old ring-around-the-point placement is kept as a fallback for a JSON without "members" /// — it is worse, but it is better than standing on the road. /// private static async Task PlaceTerritoryGroupAsync( TcpSession session, EnemyTerritorySessionState owner, EnemyTerritorySpot4229938 spot, int count, bool engage) { // Deterministic pick: the same corner always gets the same gang members, and a rerun of // the same walk does not reshuffle the cast mid-session. var roster = spot.Agents; if (roster.Count == 0) return; var members = spot.Members; var useMembers = members.Count > 0; var n = useMembers ? Math.Min(count, members.Count) : count; var placed = 0; float ax = spot.X, az = spot.Z; for (var i = 0; i < n; i++) { var agentId = roster[(int)(spot.PointId * 7 + i) % roster.Count]; float x, y, z, facing; if (useMembers) { var m = members[i]; x = m.X; y = m.Y; z = m.Z; facing = m.Facing; if (i == 0) { ax = x; az = z; } } else { var angle = (spot.Facing + i * (360f / Math.Max(1, n))) * (float)Math.PI / 180f; x = spot.X + (float)Math.Sin(angle) * 1.6f; y = spot.Y; z = spot.Z + (float)Math.Cos(angle) * 1.6f; facing = spot.Facing; } var (ok, message, uid) = await GameAdminBridge.SpawnAgentAsync( agentId, x, y, z, facing, hostile: true, engage: engage); if (!ok || uid == 0) { session.Log.Info($"[TERRITORY] point={spot.PointId} agent={agentId} 生成失败:{message}"); continue; } lock (owner.SyncRoot) owner.Active[uid] = spot; placed++; } if (placed > 0) { lock (owner.SyncRoot) owner.Stocked.Add(spot.PointId); session.Log.Info($"[TERRITORY] stocked point={spot.PointId} area={spot.AreaName}" + $"(faction {spot.FactionId}) n={placed} " + $"at=({ax:F1},{(members.Count > 0 ? members[0].Y : spot.Y):F1},{az:F1}) " + $"placement={(useMembers ? "pavement" : "ring-fallback")}"); } } private static async Task RemoveTerritoryEnemyAsync( TcpSession session, EnemyTerritorySessionState owner, ulong id) { // SyncLogicAgentLeave(68281244) is the pair of SyncLogicAgentEnter(68281243), which is the // first packet SpawnAgentAsync sends — so it is the removal channel for this spawn path. // SyncAetherAINpcRemove is a different registry (ambient peds / functional NPCs) and does // nothing to these units. // WorldCodec.LogicAgentLeave 返回的是契约对象,TcpSession.NotifyAsync 要的是裸字节。 await session.NotifyAsync(MethodId.SyncLogicAgentLeave, UxSerializer.Serialize(WorldCodec.LogicAgentLeave(id)), CancellationToken.None); ForgetTerritoryEnemy(session, id); lock (owner.SyncRoot) owner.Active.Remove(id); } /// /// Drops a retired unit out of the enemy ledger. SpawnAgentAsync registered it so its hits are /// settled; leaving the entry behind would keep the attack AI driving a unit that no longer /// exists, and the death-clear / groggy timers would fire into the void. /// private static void ForgetTerritoryEnemy(TcpSession session, ulong uid) { var runtime = GetLocalEnemyRuntimeState(session); lock (runtime.SyncRoot) { runtime.Enemies.Remove(uid); runtime.PendingDeathClear.Remove(uid); runtime.LastDeathReassert.Remove(uid); runtime.DeathEarliest.Remove(uid); runtime.LastGroggyReassert.Remove(uid); } } /// /// Panel button: stock every turf within <radius> of the player (radius <= 0 = a 90 m /// default), nearest first. Turfs already stocked are skipped. /// internal static async Task<(bool Ok, string Message, ulong[] Ids, string Detail)> SpawnEnemyTerritoryAsync( TcpSession session, float radius) { var state = GetStateIfExists(session); if (state is null) return (false, "没有玩家世界状态", [], ""); uint raidId; Vec3 near; lock (state.SyncRoot) { if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0) return (false, "等玩家在场景里站稳(已知道坐标)再生成", [], ""); raidId = state.ActiveRaidId; near = state.LastReportedPlayerPosition; } var loadError = EnemyTerritorySpotCatalog4229938.LoadError; if (loadError is not null) return (false, $"地盘点位没加载起来:{loadError}", [], ""); var effective = radius <= 0 ? 90f : radius; var spots = EnemyTerritorySpotCatalog4229938.WithinRadius(raidId, near.X, near.Z, effective); if (spots.Count == 0) return (false, $"当前场景(raid {raidId}){effective:0}m 内没有地盘点位。" + $"(点位文件共 {EnemyTerritorySpotCatalog4229938.Count} 个,只在主城 23300888 有数据)", [], ""); var owner = EnemyTerritoryStateOf(session); var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; var ids = new List(); var lines = new List(); string? failure = null; try { foreach (var spot in spots) { lock (owner.SyncRoot) { if (owner.Stocked.Contains(spot.PointId)) continue; } var before = new List(); lock (owner.SyncRoot) before.AddRange(owner.Active.Keys); await PlaceTerritoryGroupAsync(session, owner, spot, Math.Clamp(cfg.EnemyTerritoryPerPoint, 1, 6), cfg.EnemyTerritoryEngage); lock (owner.SyncRoot) { foreach (var id in owner.Active.Keys) if (!before.Contains(id)) ids.Add(id); } var dist = Math.Sqrt(EnemyTerritorySpotCatalog4229938.Distance2(spot, near.X, near.Z)); lines.Add($" {spot.AreaName}(faction {spot.FactionId})· {dist:0}m · " + $"({spot.X:F0},{spot.Z:F0}) · {spot.Agents.Count} 个候选模板"); } } catch (Exception ex) { failure = ex.Message; } var head = $"场景 {raidId} · 半径 {effective:0}m · 命中 {spots.Count} 个地盘,实际生成 {ids.Count} 个敌人\n"; var detail = head + string.Join("\n", lines); return failure is null ? (true, $"已铺 {ids.Count} 个地盘敌人", [.. ids], detail) : (false, $"已送出 {ids.Count} 个后失败:{failure}", [.. ids], detail); } }