Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.EnemyTerritory.cs

422 lines
17 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Gang territory enemies at their authored city corners — the enemy counterpart of
/// <see cref="GameRouter.FunctionalNpc"/> (which does the same follow loop for shop clerks).
///
/// Where the positions come from is the interesting part, because the enemy tables have none:
/// <c>EnemyCatalog.json</c> is a pure template table. The only authored enemy coordinates in the
/// whole dump live in the faction influence system — <c>FactionInfluenceAreaConfig</c> (24 turfs,
/// each a FactionId plus the point ids it owns) × <c>FactionAreaPointConfig</c> (85 points, 2D).
/// Together they answer "which gang stands on which street corner", which is exactly the "put
/// bodies where they belong" placement. See <see cref="EnemyTerritorySpotCatalog4229938"/>.
///
/// What they DO once they exist is not decided here. The spawn goes through
/// <c>GameAdminBridge.SpawnAgentAsync</c>, the same path the admin panel's 生成敌人 button uses:
/// it sets <c>forbidAetherAI = true</c>, 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 (<see cref="EnsureEnemyTerritory"/>) — walk into a turf and its gang is
/// already standing there;
/// • the panel button (<see cref="SpawnEnemyTerritoryAsync"/>) — "put the nearby ones down now".
/// </summary>
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;
/// <summary>entityId -&gt; the spot it was placed for. Doubles as the "already placed" set.</summary>
internal Dictionary<ulong, EnemyTerritorySpot4229938> Active { get; } = new();
/// <summary>Point ids already stocked this scene; a change of raid wipes them.</summary>
internal HashSet<uint> 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;
}
/// <summary>World-ready hook: start the follow loop. Idempotent; no-op when disabled.</summary>
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));
}
}
/// <summary>Disconnect / scene switch: stop the loop. The client clears the entities itself.</summary>
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();
}
}
/// <summary>How many territory enemies the follow loop currently has standing. Panel display only.</summary>
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<ulong>();
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);
}
/// <summary>
/// Puts &lt;count&gt; members of one turf on the ground.
///
/// Where they go comes from <see cref="EnemyTerritorySpot4229938.Members"/>, 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// Panel button: stock every turf within &lt;radius&gt; of the player (radius &lt;= 0 = a 90 m
/// default), nearest first. Turfs already stocked are skipped.
/// </summary>
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<ulong>();
var lines = new List<string>();
string? failure = null;
try
{
foreach (var spot in spots)
{
lock (owner.SyncRoot)
{
if (owner.Stocked.Contains(spot.PointId))
continue;
}
var before = new List<ulong>();
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);
}
}