422 lines
17 KiB
C#
422 lines
17 KiB
C#
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 -> 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 <count> 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 <radius> of the player (radius <= 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);
|
||
}
|
||
}
|