Files

454 lines
20 KiB
C#
Raw Permalink Normal View History

using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Fifth entity channel: functional NPCs at fixed city spots (shop clerks, ticket sellers, ...).
///
/// Same four-step announce sequence as the citizen channels — this file does not invent a spawn
/// path, it only supplies different coordinates. Instead of "a plot's pavement spots, how many
/// decided by the hour" the positions are固定的: one per venue, already snapped onto pavement by
/// the generator, so a shop clerk ends up where the shop actually is.
///
/// What the NPC *does* is not our problem here: F-key behaviour (open a shop panel, buy a ticket,
/// start a mahjong match) is driven entirely by the client's own lua through
/// AgentConfig.InteractSetting. The server just has to put a body there. That is deliberate — it
/// is the whole reason this card is cheap.
///
/// Two ways in, sharing one placement helper so they can never disagree:
/// • the follow loop (<see cref="EnsureFunctionalNpcs"/>) — how they normally appear: walk up to
/// a shop and its clerk is standing there;
/// • the panel button (<see cref="SpawnFunctionalNpcsAsync"/>) — "put them all down now".
/// </summary>
internal sealed partial class GameRouter
{
private const string FunctionalNpcKey = "client4229938-functional-npcs";
internal sealed class FunctionalNpcSessionState
{
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, FunctionalNpcSpot4229938> Active { get; } = new();
/// <summary>
/// Formwork ids whose placement has been started but not finished yet.
/// ⛑ Placement awaits several network writes and happens OUTSIDE the lock, so a tick that
/// starts while the previous batch is still in flight cannot see it in <see cref="Active"/>
/// and would place the same clerk again — two identical NPCs stacked on one spot.
/// </summary>
internal HashSet<uint> Pending { get; } = new();
/// <summary>Scene the <see cref="Active"/> entries belong to; a change wipes them.</summary>
internal uint Raid;
}
private static FunctionalNpcSessionState FunctionalNpcStateOf(TcpSession session)
{
if (session.Items.TryGetValue(FunctionalNpcKey, out var existing)
&& existing is FunctionalNpcSessionState typed)
return typed;
var created = new FunctionalNpcSessionState();
session.Items[FunctionalNpcKey] = created;
return created;
}
/// <summary>World-ready hook: start the follow loop. Idempotent; no-op when disabled.</summary>
internal static void EnsureFunctionalNpcs(TcpSession session)
{
if (!PrivateServerConfigStore.Current.Gameplay.Aether.FunctionalNpcsEnabled)
return;
var owner = FunctionalNpcStateOf(session);
lock (owner.SyncRoot)
{
if (owner.Loop is { IsCompleted: false })
return;
owner.Cts?.Dispose();
owner.Cts = new CancellationTokenSource();
owner.Loop = Task.Run(() => RunFunctionalNpcLoopAsync(session, owner, owner.Cts.Token));
}
}
/// <summary>Disconnect / scene switch: stop the loop. The client clears the entities itself.</summary>
internal static void StopFunctionalNpcs(TcpSession session)
{
if (!session.Items.TryGetValue(FunctionalNpcKey, out var existing)
|| existing is not FunctionalNpcSessionState owner)
return;
lock (owner.SyncRoot)
{
owner.Cts?.Cancel();
owner.Cts?.Dispose();
owner.Cts = null;
owner.Active.Clear();
owner.Pending.Clear();
}
}
/// <summary>How many the follow loop currently has standing. Panel display only.</summary>
internal static int FunctionalNpcActiveCount(TcpSession session)
{
var owner = FunctionalNpcStateOf(session);
lock (owner.SyncRoot)
return owner.Active.Count;
}
private static async Task RunFunctionalNpcLoopAsync(
TcpSession session, FunctionalNpcSessionState owner, CancellationToken token)
{
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
var tick = Math.Max(500, cfg.FunctionalNpcTickMs);
session.Log.Info($"[FUNCNPC] follow loop started enter={cfg.FunctionalNpcEnterMeters:0}m " +
$"exit={cfg.FunctionalNpcExitMeters:0}m tick={tick}ms per-tick={cfg.FunctionalNpcPerTick}");
while (!token.IsCancellationRequested)
{
try
{
// 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。
if (!IsBackgroundPushPaused(session))
await TickFunctionalNpcsAsync(session, owner, cfg);
}
catch (OperationCanceledException)
{
break;
}
catch (ObjectDisposedException)
{
session.Log.Info("[FUNCNPC] loop stopped: session disposed");
break;
}
catch (Exception ex)
{
session.Log.Info($"[FUNCNPC] tick failed: {ex.Message}");
}
try
{
await Task.Delay(tick, token);
}
catch (OperationCanceledException)
{
break;
}
}
}
private static async Task TickFunctionalNpcsAsync(
TcpSession session, FunctionalNpcSessionState 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 (FunctionalNpcSpotCatalog4229938.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.Pending.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.
//
// ⛑⛑ 默认不撤(Aether.FunctionalNpcRetireOnExit = false,见配置里的说明):
// SyncAetherAINpcRemove 删不掉 8300 段那具身体(2026-09-25 实机),撤不掉却在玩家折回时
// 重新发一具 ⇒ 同一坐标叠 2~3 具(2026-10-01「健身房里 3 个卢卡斯·高桥」)。
// 只铺不撤时 Active 就是「本局已铺过」的账本,第 2 步的 busy 判断自然不会重铺。
if (cfg.FunctionalNpcRetireOnExit)
{
var exit2 = cfg.FunctionalNpcExitMeters * cfg.FunctionalNpcExitMeters;
var gone = new List<(ulong Id, FunctionalNpcSpot4229938 Spot)>();
lock (owner.SyncRoot)
foreach (var (id, spot) in owner.Active)
if (spot.Raid != raid || Distance2(spot, px, pz) > exit2)
gone.Add((id, spot));
foreach (var (id, spot) in gone)
{
await RemoveFunctionalNpcAsync(session, state, owner, id, spot);
session.Log.Info($"[FUNCNPC] retired entity={id}");
}
}
// 2. Place what the player just walked into. Two per tick at most: the client drops the tail
// of a large batch, and these are meant to appear as you stroll past, not all at once.
var enter2 = cfg.FunctionalNpcEnterMeters * cfg.FunctionalNpcEnterMeters;
var wanted = new List<FunctionalNpcSpot4229938>();
lock (owner.SyncRoot)
{
var busy = new HashSet<uint>(owner.Active.Values.Select(s => s.NpcFormworkId));
busy.UnionWith(owner.Pending); // ⛑ in-flight placements count as busy too
wanted = FunctionalNpcSpotCatalog4229938.ForRaid(raid, true)
.Where(s => !busy.Contains(s.NpcFormworkId) && Distance2(s, px, pz) <= enter2)
.OrderBy(s => Distance2(s, px, pz))
.Take(Math.Max(1, cfg.FunctionalNpcPerTick))
.ToList();
foreach (var s in wanted)
owner.Pending.Add(s.NpcFormworkId);
}
if (wanted.Count == 0)
return;
await SendDonorAetherInitAsync(session);
foreach (var spot in wanted)
{
try
{
await PlaceFunctionalNpcAsync(session, state, owner, spot);
}
finally
{
lock (owner.SyncRoot)
owner.Pending.Remove(spot.NpcFormworkId);
}
}
}
private static async Task RemoveFunctionalNpcAsync(
TcpSession session, WorldEntryState state, FunctionalNpcSessionState owner, ulong id,
FunctionalNpcSpot4229938? spot = null)
{
// ★ 捐赠端 GameRouter.FunctionalNpc.cs:218:NPC 退役时要从警察登记表里摘掉,
// 否则盘查链会一直以为这个 NPC 还在场上(控制台列表 / 押送目标都会残留)。
await UnregisterPoliceNpc4229938(session, id);
await session.NotifyAsync(MethodId.SyncAetherAINpcRemove,
UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = id }),
CancellationToken.None);
// ⛑ Second shot under the id we actually wrote into ClientStaticNpcInitData.NpcPid.
// The spawn packet carries three different ids (entity id / StaticNpcInfoId / NpcPid) and
// we do not know which one the client keys its remove on; a remove that silently misses
// leaves the old body standing and the next visit stacks a second copy on the same spot.
// Every formwork id is used by exactly one spot per raid, so this cannot over-delete.
if (spot is not null && spot.NpcFormworkId != 0)
{
await session.NotifyAsync(MethodId.SyncAetherAINpcRemove,
UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938
{
pid = spot.NpcFormworkId,
}),
CancellationToken.None);
session.Log.Info($"[FUNCNPC] retired also by formwork={spot.NpcFormworkId} ({spot.Name})");
}
lock (owner.SyncRoot)
owner.Active.Remove(id);
lock (state.SyncRoot)
{
state.StaticNpcPoses.Remove(id);
state.StaticNpcPreparedPlotEvents.Remove(id);
}
}
/// <summary>
/// ★★★ R93(2026-09-27)—— **落脚高度吸附**:把点位声明的 Y 换成「该 XZ 上真实地面/物件的高度」。
///
/// 为什么必须做:<c>FunctionalNpcSpots.json</c> 的 Y 是用 <b>CivilianDistricts 人行道点</b>
/// 反向找的(见该文件 note)。那套人行道点只在**主城平地**有数据,一旦点位的 XZ 落在
/// **高台/庙会/室内**,找出来的就是别处(常常是 y≈0/2 那一层)的 Y。
/// 实测标本(2026-09-27):Tea Shop Owner(40954014)/Tea Stall Owner(40954022) 声明 Y=2.00,
/// 而同 XZ 上 2528 件场景物一律 **y≈166~172** ⇒ NPC 被生成在**地下 167 m**,
/// 现象 = 用户所说的「摊位的npc呢」(看着像根本没刷,其实刷了、埋了)。
///
/// 数据源 = <c>config/scene_item_placements.json</c>(mtime 热重载,4877 件带真实 y)。
/// 取点位 XZ **半径 <paramref name="radius"/> 内最近的若干件**的 y 中位数当落脚高度:
/// 中位数对「同一点位旁边既有台面又有地面」的情况比最近邻稳。
/// 命中不到(荒地/新点位)⇒ **原样返回**,绝不猜。
/// </summary>
private static float SnapGroundY(float x, float y, float z,
out int samples, out float snapped, float radius = 8f)
{
samples = 0;
snapped = y;
List<SceneItemPlacement> items;
try { items = LoadSceneItemPlacements(); }
catch { return y; }
if (items.Count == 0)
return y;
var r2 = radius * radius;
var ys = new List<float>(16);
foreach (var it in items)
{
var dx = it.X - x;
var dz = it.Z - z;
if (dx * dx + dz * dz > r2)
continue;
ys.Add(it.Y);
}
if (ys.Count == 0)
return y;
ys.Sort();
// 只保留「高于声明值」的候选:声明值被打错成低层(y≈2)时,低层的散件会把中位数拖回去。
var above = ys.Where(v => v > y + 1f).ToList();
var pool = above.Count > 0 ? above : ys;
snapped = pool[pool.Count / 2];
samples = pool.Count;
return snapped;
}
/// <summary>
/// One placement: announce + remember. Returns the entity id, or 0 when the spot's ids are not
/// in the catalogs (a bad row in the json must not kill the batch).
/// </summary>
private static async Task<ulong> PlaceFunctionalNpcAsync(
TcpSession session, WorldEntryState state, FunctionalNpcSessionState owner,
FunctionalNpcSpot4229938 spot)
{
if (!DonorNpcCatalog4229938.TryGet(spot.NpcFormworkId, out _))
return 0;
if (spot.PoiActionId != 0 && !DonorPoiActionCatalog4229938.TryGet(spot.PoiActionId, out _))
return 0;
// ★ R93:落脚高度吸附(见 SnapGroundY 说明)。吸附不动时完全等价于原行为。
var y = SnapGroundY(spot.X, spot.Y, spot.Z, out var samples, out var snapped);
var yNote = samples > 0 && MathF.Abs(snapped - spot.Y) > 1f
? $" ySnap={spot.Y:F2}->{snapped:F2}(n={samples})"
: "";
var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
await SendCivilianAsync(session, new CivilianSpawnItem(spot.NpcFormworkId, spot.PoiActionId),
id, spot.X, spot.Y, spot.Z, spot.Facing);
lock (owner.SyncRoot)
owner.Active[id] = spot;
lock (state.SyncRoot)
{
state.StaticNpcPreparedPlotEvents[id] = 0;
state.StaticNpcPoses[id] = new StaticNpcPose(spot.X, spot.Y, spot.Z, spot.Facing);
}
// ★ 捐赠端 GameRouter.FunctionalNpc.cs:265-266:功能 NPC 也要进警察账本,
// 否则里希对它按原生 F 会走到 "untracked/unsupported target"。
await RegisterPoliceNpc4229938(session, id, spot.NpcFormworkId, new Vec3(spot.X, spot.Y, spot.Z));
_ = RefreshSpawnedPoliceNpcs4229938(session, [id], state.WorldEntryControlGeneration);
session.Log.Info($"[FUNCNPC] placed {spot.Name}({spot.NpcFormworkId}) poi={spot.PoiActionId} " +
$"at=({spot.X:F2},{spot.Y:F2},{spot.Z:F2}) facing={spot.Facing:F0} " +
$"entity={id} anchor={spot.Anchor}");
return id;
}
/// <summary>
/// Panel button: place every functional NPC of the current scene (nearest first when a radius
/// is given, radius &lt;= 0 = the whole scene). Anything already standing is skipped.
/// </summary>
internal static async Task<(bool Ok, string Message, ulong[] Ids, string Detail)> SpawnFunctionalNpcsAsync(
TcpSession session, float radius, bool includeSemantic)
{
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 = FunctionalNpcSpotCatalog4229938.LoadError;
if (loadError is not null)
return (false, $"功能 NPC 点位没加载起来:{loadError}", [], "");
var spots = FunctionalNpcSpotCatalog4229938.WithinRadius(
raidId, near.X, near.Z, radius, includeSemantic);
if (spots.Count == 0)
return (false,
$"当前场景(raid {raidId})没有可用的功能 NPC 点位。" +
$"(点位文件共 {FunctionalNpcSpotCatalog4229938.Count} 个;只在主城 23300888 / 23300999 有数据)" +
(FunctionalNpcSpotCatalog4229938.NonPersonSkipped > 0
? $";规则 199 拦下 {FunctionalNpcSpotCatalog4229938.NonPersonSkipped} 个非人模板(物件/敌人/临时通用模型)"
: ""), [], "");
var owner = FunctionalNpcStateOf(session);
var ids = new List<ulong>();
var lines = new List<string>();
var skipped = new List<string>();
string? failure = null;
try
{
await SendDonorAetherInitAsync(session);
var sent = 0;
foreach (var spot in spots)
{
lock (owner.SyncRoot)
{
if (owner.Active.Values.Any(a => a.NpcFormworkId == spot.NpcFormworkId))
{
skipped.Add($"{spot.Name}(已经在场)");
continue;
}
}
var id = await PlaceFunctionalNpcAsync(session, state, owner, spot);
if (id == 0)
{
skipped.Add($"{spot.Name}(模板或 POI 不在目录里)");
continue;
}
ids.Add(id);
var function = string.Join("/", spot.Labels.Count > 0 ? spot.Labels : spot.Actions);
lines.Add($" {spot.Name}({spot.NpcFormworkId})· {function} · {spot.Anchor}");
if (++sent % 20 == 0)
await Task.Delay(10);
}
}
catch (Exception ex)
{
failure = ex.Message;
}
var head = $"场景 {raidId} · 半径 {(radius <= 0 ? "不限" : radius.ToString("0") + "m")} · " +
$"命中 {spots.Count} 个点位,实际生成 {ids.Count} 人\n";
if (skipped.Count > 0)
head += "跳过 " + string.Join("; ", skipped) + "\n";
var detail = head + string.Join("\n", lines);
return failure is null
? (true, $"已铺 {ids.Count} 个功能 NPC", [.. ids], detail)
: (false, $"已送出 {ids.Count} 个后失败:{failure}", [.. ids], detail);
}
private static float Distance2(FunctionalNpcSpot4229938 spot, float x, float z)
{
var dx = spot.X - x;
var dz = spot.Z - z;
return dx * dx + dz * dz;
}
}