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

455 lines
20 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.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;
}
}