This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.Civilians.cs
T

933 lines
48 KiB
C#
Raw Normal View History

using System.Collections.Concurrent;
using System.Globalization;
using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>One citizen to place: formwork (AgentConfig id) + the POI action it plays.</summary>
internal sealed record CivilianSpawnItem(uint NpcFormworkId, uint PoiActionId);
/// <summary>
/// 让一个走市民通道刷出来的 NPC 穿上「某个可玩角色身上那套搭配」(切人留场用)。
///
/// 官方原生写法就在 NPC 出场包里,没有第二处:
/// <c>AgentSyncClientInfo.AIAgentInfo</c>(<c>LinkAIAgentInfo</c>)→
/// <c>Fashion</c>(<c>LinkAIFashionInfo</c> = <c>SuitId</c> + <c>WearInfo</c>)→
/// <c>WearInfo</c>(<c>OtherPlayerSpiritWearFashionsInfo</c>,带<b>单件</b> <c>WearFashionInfoList</c>)。
/// 这就是「把别人的角色放进大世界」那条路 —— 麻将/保龄球的 AI 对手读的也是
/// <c>unit.ClientData.AIAgentInfo</c>(<c>MajiangGame_UI.lua:181</c> / <c>BowlingCharacter.lua:414</c>)。
///
/// ⛑ 市民通道顶层的 <c>FashionSuitId</c> 只能填<b>套装</b> id,而玩家存档
/// <c>config/player-progress.json</c> 里 9/9 个角色 <c>FunctionSuitId</c> 全为 0
/// (纯散件搭配,4~10 件)⇒ 走套装那条路对「自定义搭配」根本没用,必须走这里。
///
/// ⛑ wire 顺序(<c>Drmk.Proxy/proxy/lua/RPCSerializeAuto.lua:4706</c>):
/// Uid(u64) → FightSpiritId(u32) → Fashion(complex,nilable) → Nickname(string,nilable)
/// → NameId(u32) → AvatarImageId(u32) → VehicleId(u32);
/// Fashion = SuitId(u32) → WearInfo(complex,<b>不可空</b>)。与
/// <c>DonorNpcLinkAIAgentInfo4229938</c> 字段顺序一一对应,别改顺序。
/// </summary>
internal sealed record CivilianLinkDress(
ulong Uid,
uint FightSpiritId,
uint SuitId,
IReadOnlyList<uint> FashionIds,
byte HiddenParts = 0,
string? Nickname = null);
/// <summary>
/// Fourth entity channel: citizens owned by the client's Aether crowd simulation.
///
/// The server's whole job here is to announce these entities once and then remember that it did:
/// walking, obstacle avoidance and hit reactions are entirely client-side (that is why the donor
/// build gets full pedestrian behaviour without simulating anything). Nothing in this file ticks,
/// moves or fights an entity — and we deliberately do NOT re-send anything periodically, because
/// replaying the POI would cancel the client's own reactions.
///
/// Aether init (SyncAetherAIInitDatas) IS re-sent before every batch — the donor does that too,
/// and dropping it once cost us a full day of "panel says ok, world stays empty".
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>
/// The preset crowd groups, read from Configs/CivilianGroups.json (see
/// CivilianGroupCatalog4229938). 199 groups of 20 — together they cover every POI action the
/// client knows, with a humanoid faction-2 NPC matched to each action's body type.
/// </summary>
internal static IReadOnlyList<CivilianGroup4229938> CivilianGroups => CivilianGroupCatalog4229938.Groups;
internal static CivilianGroup4229938? CivilianGroup(int id) => CivilianGroupCatalog4229938.Find(id);
/// <summary>
/// T0-28:**这个 AgentConfig 模板能不能当路人放出去**。三道独立筛子全过才算市民,
/// 行人池(<c>PedFormworkPool</c>)和地块市民池(<c>BuildItemPool</c>)共用它,
/// 这样两条线不会再出现"街上刷的是市民、地块刷的是店员"的不一致。
///
/// ⛑️ 为什么是三道而不是一道(`CivilianGroups.json` 本身不够用):
/// 那张表是按"给每个 POI 动作配一个**体型匹配的人形**"建的 —— 只保证是人形 + 阵营 2,
/// 不保证是路人,也不保证模型的脸不一样。所以要在它之上再筛。
/// 三道:① id 段白名单(4013)② **GeneralModelId 黑名单**(通用临时模型,最关键的一道)
/// ③ 名字关键词黑名单(店员 / 刷怪 / 剧情职能)。
/// </summary>
internal static bool IsCivilianFormwork(uint id)
{
if (id == 0)
return false;
// ⛑️ 这些开关在 `AetherSettings` 上(不在 PrivateServerConfig 根上),别写错层级。
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
var prefixes = cfg.AmbientPedsFormworkIdPrefixes ?? [];
var blockModels = cfg.AmbientPedsFormworkModelBlocklist ?? [];
var blockNames = cfg.AmbientPedsFormworkNameBlocklist ?? [];
var text = id.ToString(CultureInfo.InvariantCulture);
if (prefixes.Length > 0
&& !Array.Exists(prefixes, p => !string.IsNullOrEmpty(p) && text.StartsWith(p, StringComparison.Ordinal)))
return false;
// 查不到表就只剩①②可用(不该发生:NpcCatalog 覆盖了池里 100% 的 id)。
if (!DonorNpcCatalog4229938.TryGet(id, out var entry))
return true;
// 通用临时模型:多个行共用同一具模特 86160001~04 ⇒ 刷出来是全城 522 张同一张脸。
if (blockModels.Length > 0 && Array.IndexOf(blockModels, entry.GeneralModelId) >= 0)
return false;
if (blockNames.Length > 0
&& Array.Exists(blockNames, w => !string.IsNullOrEmpty(w)
&& entry.Name.Contains(w, StringComparison.OrdinalIgnoreCase)))
return false;
return true;
}
/// <summary>
/// Place citizens in front of the player on a jittered grid: the slots are shuffled so the
/// group does not read as a parade formation, and each slot is offset by at most a quarter of
/// the spacing so two citizens can never land on the same spot.
/// Returns every generated entity id.
/// </summary>
internal static async Task<(bool Ok, string Message, ulong[] Ids)> SpawnCiviliansNearPlayerAsync(
TcpSession session, IReadOnlyList<CivilianSpawnItem> items, float spacing = 2.6f)
{
if (items.Count < 1)
return (false, "至少要有一个市民", []);
if (!float.IsFinite(spacing) || spacing < 1f || spacing > 20f)
return (false, "间距必须在 1~20 米之间", []);
foreach (var item in items)
{
if (item is null || item.NpcFormworkId > int.MaxValue || !DonorNpcCatalog4229938.TryGet(item.NpcFormworkId, out _))
return (false, $"未知的 NPC 模板 {item?.NpcFormworkId}(应为 AgentConfig Id)", []);
if (item.PoiActionId != 0 && !DonorPoiActionCatalog4229938.TryGet(item.PoiActionId, out _))
return (false, $"未知的 POI 动作 {item.PoiActionId}", []);
}
var state = GetStateIfExists(session);
if (state is null)
return (false, "没有玩家世界状态", []);
Vec3 near;
float facing;
lock (state.SyncRoot)
{
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
return (false, "等玩家在场景里站稳(已知道坐标)再生成", []);
near = state.LastReportedPlayerPosition;
facing = state.LastReportedPlayerRotation.Y;
}
if (!float.IsFinite(near.X) || !float.IsFinite(near.Y) || !float.IsFinite(near.Z) || !float.IsFinite(facing))
return (false, "玩家坐标无效", []);
var yaw = facing * MathF.PI / 180f;
var npcFacing = (facing + 180f) % 360f;
if (npcFacing > 180f)
npcFacing -= 360f;
// Wide rectangle of slots in front of the player, then shuffled: nobody can predict where a
// given citizen ends up. Capped at 6 rows on purpose — a sqrt() patch would put a 60-head
// group 8 rows deep (~18 m at the default spacing), well past where the crowd is readable.
const int maxRows = 6;
var cols = Math.Max(1, (items.Count + maxRows - 1) / maxRows);
var slots = Enumerable.Range(0, items.Count).ToArray();
for (var i = slots.Length - 1; i > 0; i--)
{
var j = Random.Shared.Next(i + 1);
(slots[i], slots[j]) = (slots[j], slots[i]);
}
// Half a slot of jitter keeps neighbours at least half a spacing apart, so they never
// overlap even when two animations both shuffle their feet.
var jitter = spacing * 0.25f;
var ids = new List<ulong>();
try
{
// The crowd manager will not materialise a pedestrian until it has seen Aether init.
// The donor re-sends it on every batch, not once per scene; see SendDonorAetherInitAsync.
await SendDonorAetherInitAsync(session);
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
var slot = slots[i];
var forward = 3f + (slot / cols) * spacing + (Random.Shared.NextSingle() - 0.5f) * 2f * jitter;
var right = ((slot % cols) - (cols - 1) / 2f) * spacing + (Random.Shared.NextSingle() - 0.5f) * 2f * jitter;
var x = near.X + MathF.Sin(yaw) * forward + MathF.Cos(yaw) * right;
var z = near.Z + MathF.Cos(yaw) * forward - MathF.Sin(yaw) * right;
// Shares the donor's entity-id counter on purpose. Keeping our own would restart the
// sequence at the same base and hand out ids already in use by the NPC-spawn card,
// which is how "spawn one" once produced a duplicate-looking crowd.
var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
await SendCivilianAsync(session, item, id, x, near.Y, z, npcFacing);
ids.Add(id);
// Same bookkeeping the donor does per NPC: the static-NPC lifecycle expects an entry
// in the prepared-plot-event map before it will advance the pedestrian.
lock (state.SyncRoot)
{
state.StaticNpcPreparedPlotEvents[id] = 0;
state.StaticNpcPoses[id] = new StaticNpcPose(x, near.Y, z, npcFacing);
}
}
}
catch (Exception ex)
{
return (false, $"已送出 {ids.Count}/{items.Count} 个后失败:{ex.Message}", [.. ids]);
}
// Nothing persisted: citizens are thrown away when the scene unloads, same as the donor card.
return (true, $"已在身前生成 {ids.Count} 个市民", [.. ids]);
}
/// <summary>Position pushes per second. The donor's own NPC driver runs at 8 Hz.</summary>
private const float WalkHz = 8f;
// ---- Seeding citizens onto the real land plots (T0-8) ------------------------------------
// Same four-step announce sequence as SpawnCiviliansNearPlayerAsync; the only difference is
// where the positions come from. Instead of a grid in front of the player we ask
// CivilianDistrictCatalog4229938 for the actual plots around them, then let each plot's own
// 24 h pedestrian curve decide how many of its pavement spots get someone on them.
//
// The catalog is data produced by _gen_civilian_districts.py from the client's own urban
// diversity settings + pedestrian lane network, so a citizen ends up on pavement with a real
// ground height rather than floating at the player's Y.
/// <summary>
/// Fill every plot around the player with citizens, how many decided by each plot's pedestrian
/// flow at &lt;hour&gt;. Hour -1 means "use the world clock the client is currently showing".
/// </summary>
internal static async Task<(bool Ok, string Message, ulong[] Ids, string Detail)> SpawnCiviliansByDistrictAsync(
TcpSession session, float radius, int maxTotal, int hour = -1)
{
if (!float.IsFinite(radius) || radius < 20f || radius > 600f)
return (false, "半径必须在 20~600 米之间", [], "");
if (maxTotal < 1 || maxTotal > 600)
return (false, "总数上限必须在 1~600 之间", [], "");
var state = GetStateIfExists(session);
if (state is null)
return (false, "没有玩家世界状态", [], "");
Vec3 near;
int hourNow;
lock (state.SyncRoot)
{
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
return (false, "等玩家在场景里站稳(已知道坐标)再生成", [], "");
near = state.LastReportedPlayerPosition;
hourNow = hour >= 0 ? Math.Clamp(hour, 0, 23) : (int)Math.Clamp(state.TimeHour, 0u, 23u);
}
if (!float.IsFinite(near.X) || !float.IsFinite(near.Z))
return (false, "玩家坐标无效", [], "");
var candidates = CivilianDistrictCatalog4229938.WithinRadius(near.X, near.Z, radius);
if (candidates.Count == 0)
return (false, $"半径 {radius:0} 米内没有地块站位(把半径调大,或走到有街道的地方再点)", [], "");
// Every plot asks for `spots × flow at this hour × density`. Plots are served nearest-first
// and the whole batch stops once the budget is gone, so what the player can actually see is
// always populated first. DENSITY < 1 because filling every pavement spot reads as a queue,
// not a street — even at the plot's own peak hour.
const float density = 0.55f;
const float flowFloor = 0.05f; // below this a plot is genuinely empty at this hour
var available = 0;
var plan = new List<(CivilianDistrict4229938 District, List<Vec3> Spots, int Count)>();
var budget = maxTotal;
foreach (var (district, spots) in candidates)
{
if (budget <= 0)
break;
available += spots.Count;
var flow = district.FlowAt(hourNow);
if (flow < flowFloor)
continue;
var want = (int)MathF.Round(spots.Count * (float)flow * density, MidpointRounding.AwayFromZero);
want = Math.Clamp(want, 1, Math.Min(spots.Count, budget));
plan.Add((district, spots, want));
budget -= want;
}
if (plan.Count == 0)
return (false,
$"{hourNow} 点时这些地块的人流都接近 0(深夜),没人可放。" +
$"把「时段」换成 8 或 18 再点,或者先到面板上把游戏时间调亮。", [], "");
var ids = new List<ulong>();
var perType = new List<(string Type, int Plots, int Count)>();
string? failure = null;
try
{
await SendDonorAetherInitAsync(session);
var sent = 0;
foreach (var (district, spots, count) in plan)
{
var pool = DistrictItemPool(district.Categories);
if (pool.Length == 0)
continue;
// Spread across the plot instead of bunching on its nearest edge: spots arrive
// sorted by distance to the player, so taking a strided walk through the list keeps
// them visible while still covering the street rather than one corner of it.
var stride = spots.Count / (float)count;
for (var k = 0; k < count; k++)
{
var spot = spots[Math.Min(spots.Count - 1, (int)(k * stride))];
var facing = NormaliseYaw(Random.Shared.NextSingle() * 360f);
var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
await SendCivilianAsync(session, pool[k % pool.Length], id,
spot.X, spot.Y, spot.Z, facing);
ids.Add(id);
lock (state.SyncRoot)
{
state.StaticNpcPreparedPlotEvents[id] = 0;
state.StaticNpcPoses[id] = new StaticNpcPose(spot.X, spot.Y, spot.Z, facing);
}
// Hundreds of entities arrive as hundreds of packets; without a breather the
// client's own processing of the previous ones falls behind and the tail of the
// batch gets dropped.
if (++sent % 20 == 0)
await Task.Delay(10);
}
var type = string.IsNullOrWhiteSpace(district.LandPlotType) ? "(未分类)" : district.LandPlotType;
var hit = perType.FindIndex(t => t.Type == type);
if (hit >= 0)
perType[hit] = (type, perType[hit].Plots + 1, perType[hit].Count + count);
else
perType.Add((type, 1, count));
}
}
catch (Exception ex)
{
failure = ex.Message;
}
var detail = string.Join("\n", perType.OrderByDescending(t => t.Count)
.Select(t => $" {t.Type}:{t.Plots} 个地块 → {t.Count} 人"));
var head = $"{hourNow} 点人流 · 半径 {radius:0}m 内 {plan.Count}/{candidates.Count} 个地块有人" +
$"(可用站位 {available}),实际生成 {ids.Count} 人\n";
return failure is null
? (true, $"已按地块生成 {ids.Count} 个市民", [.. ids], head + detail)
: (false, $"已送出 {ids.Count} 个后失败:{failure}", [.. ids], head + detail);
}
/// <summary>
/// 在**官方过街等待区**的槽位上铺市民(2026-10-02 新增,配置 <c>gameplay.civilianWaitArea</c>)。
///
/// ★ 点位来源 = <c>paths.pedSpawns</c>(<c>zonegraph_ped_spawns.csv</c>,4070 槽),
/// 真身是客户端 <c>MassAI.MassCrowd.CrowdWaitAreaData</c>:行人沿 ConnectedLanes 汇入、
/// 占一个槽位站定(Radius + bOccupied 防重叠)、等相位、走 CrossLaneHandle 过街。
/// ⛑ 那套是客户端原生 ECS,<b>服务端驱动不了</b>;我们只是把自建 NPC 放到同一批坐标上,
/// 让画面上像「这撮人也在路口等灯」。
///
/// ★ 与 <c>SpawnCiviliansByDistrictAsync</c> 的分工:那边按「生态区 + 时段人流」铺出一整条街的人,
/// 这边只在路口四角补一小撮;两者叠加才像真城市。两边都有关开关,互不干扰。
/// </summary>
internal static async Task<(bool Ok, string Message, ulong[] Ids, string Detail)> SpawnCiviliansAtWaitAreasAsync(
TcpSession session, float radius, int maxTotal, int hour = -1)
{
var wa = PrivateServerConfigStore.Current.Gameplay.CivilianWaitArea;
if (!wa.Enabled)
return (false, "未启用:先把 gameplay.civilianWaitArea.enabled 设为 true", [], "");
if (!PedSpawnTable4229938.Available)
return (false, "没有等待区数据:paths.pedSpawns 指向的 CSV 不存在或是空的", [], "");
if (!float.IsFinite(radius) || radius < 20f || radius > 600f)
return (false, "半径必须在 20~600 米之间", [], "");
if (maxTotal < 1 || maxTotal > 600)
return (false, "总数上限必须在 1~600 之间", [], "");
var state = GetStateIfExists(session);
if (state is null)
return (false, "没有玩家世界状态", [], "");
Vec3 near;
int hourNow;
lock (state.SyncRoot)
{
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
return (false, "等玩家在场景里站稳(已知道坐标)再生成", [], "");
near = state.LastReportedPlayerPosition;
hourNow = hour >= 0 ? Math.Clamp(hour, 0, 23) : (int)Math.Clamp(state.TimeHour, 0u, 23u);
}
if (!float.IsFinite(near.X) || !float.IsFinite(near.Z))
return (false, "玩家坐标无效", [], "");
var perArea = Math.Max(1, wa.MaxPerArea);
// 白天高峰才有人在路口等灯;夜里等待区应当是空的(与地块的 HourlyFlow 同一套直觉)。
// 0~5 点几乎没人,7~9 / 17~19 是通勤双峰。写成一张 24 小时权重表,避免深夜刷出一堆人。
var flow = WaitAreaFlowAt(hourNow);
if (flow < 0.05f)
return (false, $"{hourNow} 点路口几乎没人(夜深),换个时段再试", [], "");
var want = Math.Clamp((int)MathF.Round(maxTotal * flow, MidpointRounding.AwayFromZero), 1, maxTotal);
var points = PedSpawnTable4229938.NearSpread(near.X, near.Z, radius, want,
accept: null, maxPerArea: perArea);
if (points.Count == 0)
return (false, $"半径 {radius:0} 米内没有等待区槽位(走远一点,或把半径调大)", [], "");
var pool = DistrictItemPool(WaitAreaCategories);
if (pool.Length == 0)
return (false, "没有可用的市民模板( CivilianGroups 里的通用站位组是空的)", [], "");
var ids = new List<ulong>();
string? failure = null;
var areas = new HashSet<int>();
try
{
await SendDonorAetherInitAsync(session);
var sent = 0;
foreach (var p in points)
{
var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
// ★★ Y 必须补:等待区 CSV 的 y 恒为 0(客户端那边靠 ZoneGraph 自己贴地,
// 我们发出生包时**不贴地**)。而城市有地形起伏(我方地块站位 y 跨 -25.7~155.7),
// 直接填 0 ⇒ 人要么埋在地里、要么浮在半空。
// 补法:借水平最近的那个地块站位的 y(存量 13786 个点,本来就有真高度)。
var y = GroundYAt(p.X, p.Z, near.Y);
// ★ 朝向用槽位自带的 Forward —— 这就是「等灯的人面朝马路」的来源,
// 比随机 yaw 更像回事(对应 CrowdWaitSlot.Forward)。
var facing = NormaliseYaw(p.FacingDeg);
await SendCivilianAsync(session, pool[sent % pool.Length], id, p.X, y, p.Z, facing);
ids.Add(id);
areas.Add(p.WaitAreaIndex);
lock (state.SyncRoot)
{
state.StaticNpcPreparedPlotEvents[id] = 0;
state.StaticNpcPoses[id] = new StaticNpcPose(p.X, y, p.Z, facing);
}
if (++sent % 20 == 0)
await Task.Delay(10);
}
}
catch (Exception ex)
{
failure = ex.Message;
}
var detail = $" {hourNow} 点人流系数 {flow:0.00} · 半径 {radius:0}m 内 {areas.Count} 个等待区" +
$",每区最多 {perArea} 人 → 实际生成 {ids.Count} 人(候选槽位 {points.Count})";
var head = "等待区铺人:\n";
return failure is null
? (true, $"已在 {areas.Count} 个路口等待区生成 {ids.Count} 个市民", [.. ids], head + detail)
: (false, $"已送出 {ids.Count} 个后失败:{failure}", [.. ids], head + detail);
}
/// <summary>等待区用的市民类别:路口是「通用站姿」,不按店铺业态细分。</summary>
private static readonly string[] WaitAreaCategories = ["站姿·通用"];
/// <summary>
/// 路口等待区的 24 小时人流权重(0~1)。深夜几乎没人,早晚通勤双峰最高。
/// ★ 与地块那边的 <c>HourlyFlow</c> 是两套数据:等待区 CSV 不带人流维度,只能按常识给一条曲线。
/// </summary>
private static float WaitAreaFlowAt(int hour)
{
// 0,1,...,23 —— 深夜 0.02、早高峰 8 点 1.0、午间 0.7、晚高峰 18 点 1.0、夜间回落。
float[] curve =
[
0.02f, 0.02f, 0.02f, 0.02f, 0.05f, 0.15f, // 00-05
0.35f, 0.70f, 1.00f, 0.75f, 0.60f, 0.65f, // 06-11
0.70f, 0.65f, 0.60f, 0.65f, 0.80f, 0.95f, // 12-17
1.00f, 0.80f, 0.55f, 0.35f, 0.20f, 0.08f, // 18-23
];
return curve[Math.Clamp(hour, 0, 23)];
}
/// <summary>
/// 用水平最近的**地块站位**的 Y 当高度采样(等待区 CSV 的 y 恒为 0,必须补)。
///
/// ★ 两级查找:先按区中心 + 半径粗筛(681 个区),再在命中区里逐点比。
/// 全表 13786 点逐点扫也能出结果,但每个等待区点都扫一遍会到千万次量级。
/// ⛑ 找不到就退回 <paramref name="fallbackY"/>(通常是玩家当前 Y),不硬填 0。
/// </summary>
private static float GroundYAt(float x, float z, float fallbackY)
{
var bestY = float.NaN;
var bestD2 = float.MaxValue;
foreach (var d in CivilianDistrictCatalog4229938.Districts)
{
var reach = d.Radius + 80f;
var dcx = d.CenterX - x;
var dcz = d.CenterZ - z;
if (dcx * dcx + dcz * dcz > reach * reach)
continue;
foreach (var s in d.Spots)
{
var dx = s.X - x;
var dz = s.Z - z;
var d2 = dx * dx + dz * dz;
if (d2 >= bestD2)
continue;
bestD2 = d2;
bestY = s.Y;
}
}
return float.IsNaN(bestY) ? fallbackY : bestY;
}
private static float NormaliseYaw(float degrees)
{
var wrapped = degrees % 360f;
if (wrapped > 180f)
wrapped -= 360f;
else if (wrapped < -180f)
wrapped += 360f;
return wrapped;
}
/// <summary>
/// The citizens a plot may draw from: the first preferred category that exists in the preset
/// groups wins, falling back to every non-system group. ★ 系统行为 groups are never used — they
/// are companion / dialogue drivers and their owners sit still looking switched off.
/// </summary>
private static readonly ConcurrentDictionary<string, CivilianSpawnItem[]> DistrictItemPools = new();
private static CivilianSpawnItem[] DistrictItemPool(IReadOnlyList<string> categories)
{
var key = string.Join("|", categories);
// ConcurrentDictionary only has the keyed factory overload — there is no Func<TValue> one.
return DistrictItemPools.GetOrAdd(key, _ => BuildItemPool(categories));
}
private static CivilianSpawnItem[] BuildItemPool(IReadOnlyList<string> categories)
{
var usable = CivilianGroups.Where(g => !g.SystemOnly && g.Items.Count > 0).ToList();
List<CivilianGroup4229938> chosen = usable;
foreach (var category in categories)
{
var matched = usable.Where(g => string.Equals(g.Category, category, StringComparison.Ordinal)).ToList();
if (matched.Count == 0)
continue;
chosen = matched;
break;
}
// T0-28:过滤后还要保证这个地块的对应类别**还有人可用**(每个类别实测剩 13~46 个,
// 不会被抽空)。真抽空了就退回未过滤 —— 宁可放个模型普通的市民,也不要整块地没人。
var filtered = chosen
.SelectMany(g => g.Items)
.Where(i => IsCivilianFormwork(i.NpcFormworkId))
.Select(i => new CivilianSpawnItem(i.NpcFormworkId, i.PoiActionId))
.ToArray();
if (filtered.Length == 0)
filtered = chosen
.SelectMany(g => g.Items)
.Select(i => new CivilianSpawnItem(i.NpcFormworkId, i.PoiActionId))
.ToArray();
// ★ 2026-10-02 新增:POI 二次筛选(用 UrbanDiversityPOIActionConfig 全字段表)。
// 两个开关**默认全 false** ⇒ 不打开就与今天逐条一致。见 CivilianPoiSettings。
// ⛑ 这里没有 session 可打日志(池是按类别缓存的静态方法),效果只能在游戏里看;
// 想确认有没有生效,直接对比打开前后同一地块铺出来的人。
var poiCfg = PrivateServerConfigStore.Current.Gameplay.CivilianPoi;
if (poiCfg.ExcludeRootMotion || poiCfg.ExcludeProps)
{
var kept = filtered.Where(i => CivilianPoiAllowed4229938(i.PoiActionId, poiCfg)).ToArray();
// 与上面同一条兜底原则:宁可放个没筛过的市民,也不要整块地没人。
if (kept.Length > 0)
filtered = kept;
}
return Shuffle(filtered);
}
/// <summary>
/// 这条 POI 动作在当前筛选配置下能不能用。查不到(表里没有这个 id)一律<b>放行</b> ——
/// 宁可放过也不要因为表缺失把整个池清空。
/// </summary>
private static bool CivilianPoiAllowed4229938(uint poiActionId, CivilianPoiSettings cfg)
{
if (poiActionId == 0)
return true;
if (!UrbanDiversityPoiActionCatalog4229938.TryGet(poiActionId, out var poi))
return true;
if (cfg.ExcludeRootMotion && poi.ApplyRootMotion)
return false;
if (cfg.ExcludeProps && poi.HasProp)
return false;
return true;
}
private static T[] Shuffle<T>(T[] source)
{
for (var i = source.Length - 1; i > 0; i--)
{
var j = Random.Shared.Next(i + 1);
(source[i], source[j]) = (source[j], source[i]);
}
return source;
}
/// <summary>
/// Server-driven walk for the citizens spawned this session.
///
/// This exists to settle one specific question: whether the client plays a gait for movement the
/// server pushes, or only interpolates between points. SyncUnitPositionAndFacing carries a moveId
/// (UX.Game.BattleMoveActionType) and every call site in this build used to send 0 = Unknown,
/// which is exactly what "sliding" looks like from the outside. Pushing the same trajectory with
/// moveId=0 and moveId=2 (WalkFront) and watching both is the only way to tell.
///
/// Citizens are the test vehicle because their ids are ours and their channel is verified. This
/// does not take movement away from the client's Aether crowd — it only pushes positions on top,
/// and nothing here runs unless the operator asks for it.
/// </summary>
internal static async Task<(bool Ok, string Message, int Count)> WalkCiviliansAsync(
TcpSession session, byte moveId, float distance, float durationSeconds)
{
var state = GetStateIfExists(session);
if (state is null)
return (false, "no live game session (is the client in the world?)", 0);
List<(ulong Id, StaticNpcPose Pose)> walkers;
lock (state.SyncRoot)
walkers = state.StaticNpcPoses.Select(kv => (kv.Key, kv.Value)).OrderBy(x => x.Key).ToList();
if (walkers.Count == 0)
return (false, "还没有市民 —— 先用上面的按钮生成一组", 0);
var steps = Math.Max(1, (int)MathF.Round(durationSeconds * WalkHz));
var stepDistance = distance / steps;
var delayMs = (int)MathF.Round(1000f / WalkHz);
for (var step = 1; step <= steps; step++)
{
var isLast = step == steps;
foreach (var (id, pose) in walkers)
{
var travelled = stepDistance * step;
var x = pose.X + MathF.Sin(pose.Facing) * travelled;
var z = pose.Z + MathF.Cos(pose.Facing) * travelled;
// The final push stops the gait (Idle) and clears continueMove; without that the
// client keeps the locomotion state alive forever and the unit walks on the spot.
await session.NotifyAsync(
Ananta.Server.Protocol.Client4229938.MethodId.SyncUnitPositionAndFacing,
UxSerializer.Serialize(WorldCodec.Walking(
id, new Vec3(x, pose.Y, z), pose.Facing,
continueMove: !isLast,
moveId: isLast ? WorldCodec.MoveAction.Idle : moveId)),
CancellationToken.None);
}
if (!isLast) await Task.Delay(delayMs);
}
// Remember where they ended up, so a second walk continues from here instead of snapping
// back to the spawn point.
lock (state.SyncRoot)
{
foreach (var (id, pose) in walkers)
state.StaticNpcPoses[id] = pose with
{
X = pose.X + MathF.Sin(pose.Facing) * distance,
Z = pose.Z + MathF.Cos(pose.Facing) * distance,
};
}
return (true,
$"已推动 {walkers.Count} 个市民走 {distance:F1}m:moveId={moveId} " +
$"({WorldCodec.MoveAction.NameOf(moveId)}),{steps} 步 @ {WalkHz:0}Hz",
walkers.Count);
}
/// <summary>
/// The four-step announce sequence for one citizen. Copied field-by-field from the donor build
/// that was verified in-game; do not "simplify" it — dropping SyncManagedLogicAgent leaves the
/// unit without a simulation owner and the full-model state machine gets disabled right after
/// the ECS→GO handoff (which is exactly what T-pose looks like from the outside).
/// </summary>
internal static async Task SendCivilianAsync(
TcpSession session, CivilianSpawnItem item, ulong entityId,
float x, float y, float z, float facing,
CivilianLinkDress? dress = null, uint activityCfgId = 0, bool skipPhone = false,
// ★★ 第 38/46 轮(自 v2.1 捐赠端移植,2026-09-29):skipHackPhone = 跳过「给手机 + usage 6/1」
// 那一步 —— 它会**覆盖** POI 刚推下去的 belongings/usage(把"吃烧烤/喝可乐"改成"玩手机")
// ⇒ 靠 POI 表达场景行为的 NPC(派对/露营)必须跳过它,否则预制动作等于白配。
// 仍然保留 MarkNpcHackableOnSpawnAsync 本体 ⇒ 可骇标记照打(面板显示名字、没有按钮)。
// aiSettingOverride / treeNameOverride = 可选覆盖(0/"" = 原行为)。
bool skipHackPhone = false,
uint aiSettingOverride = 0,
string? treeNameOverride = null)
{
DonorNpcCatalog4229938.TryGet(item.NpcFormworkId, out var npc);
DonorPoiActionCatalog4229938.TryGet(item.PoiActionId, out var poi);
var mainActionId = poi?.SelectMainAction(entityId) ?? 0;
// Same nested contract shape as the donor path, built by the donor's own factory so the two
// can never drift apart again. See the note in GameRouter.DonorNpc.cs.
var syncInfo = CreateDonorStaticNpcSyncInfo(
new DonorNpcSpawnItem(item.NpcFormworkId, item.PoiActionId), npc, poi, mainActionId, dress,
activityCfgId, aiSettingOverride, treeNameOverride);
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAetherAIStaticNpcAddData,
UxSerializer.Serialize(new GameMethods.DonorClientStaticNpcInitData4229938
{
StaticNpcInfoId = item.NpcFormworkId,
NpcFormworkId = item.NpcFormworkId,
AgentPersonaId = npc?.AgentPersonaId ?? 0,
SPoiActionId = 0,
CPoiActionId = item.PoiActionId,
UrbanDiversityId = 88888000,
IgnoreAllStim = false,
TaskRelated = false,
NpcPid = (int)item.NpcFormworkId,
AgentSyncClientInfo = syncInfo,
ForceGo = true,
SourceType = 1, // UX.Game.StaticNpcSourceType.Spoon
Id = entityId,
Position = new() { X = x, Y = y, Z = z },
Facing = facing,
EulerAngles = new() { X = 0f, Y = facing, Z = 0f },
}), CancellationToken.None);
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncManagedLogicAgent,
UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(entityId, Profile.PlayerPid, 0)), CancellationToken.None);
if (poi is { UsageId: not 0 })
{
// The usage loader needs owned items before it can bind a prop (phone, drink, burger).
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAgentBelongings,
UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongings4229938
{
Id = entityId,
Items = poi.BelongingSceneItems.Select(configId => new GameMethods.DonorNpcBelongingItem4229938
{
InstanceId = unchecked((ulong)IdCounterStore.Next(NpcBelongingIdCounter, NpcBelongingIdFloor)),
ConfigId = configId,
}).ToList(),
DebugAgentId = item.NpcFormworkId,
}), CancellationToken.None);
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAgentUseBelongingChanged,
UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongingUsage4229938
{
Id = entityId,
UsageIds = [poi.UsageId],
}), CancellationToken.None);
}
if (item.PoiActionId != 0)
{
// Stored once in the logical POI module at normal speed. Never replay it: reactions
// (a car passing, the player bumping into them) have to be able to interrupt it.
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.IGameSceneToClient_SyncAgentPlayPOIAction,
UxSerializer.Serialize(new GameMethods.DonorClientNpcPoiActionData4229938
{
Id = entityId,
CPoiActionId = item.PoiActionId,
IsActive = true,
PlayPoiSpeed = 1f,
}), CancellationToken.None);
}
// T2-32 路人骇入:单位已经建好,现在才标可骇(C# 侧要能 GetUnit(unitId) 查到它)。
// POI 已经在拿手机且正处在可骇 usage(1/2/6)时不必再插一手 —— 它本来就该有按钮;
// 其余情况(POI 是拍照/打字/吃东西,或压根没有 usage)换成手机 + 可骇 usage。
var poiUsageAlreadyHackable = poi is { UsageId: not 0 } p && HackableUsageIds.Contains(p.UsageId);
await MarkNpcHackableOnSpawnAsync(session, entityId, HackNpcChannel.Static,
skipPhone: skipPhone || skipHackPhone || poiUsageAlreadyHackable);
session.Log.Info($"[CIVILIAN] spawn formwork={item.NpcFormworkId} model={npc?.GeneralModelId ?? 0} " +
$"persona={npc?.AgentPersonaId ?? 0} poi={item.PoiActionId} action={mainActionId} " +
$"entity={entityId} at=({x:F2},{y:F2},{z:F2}) facing={facing:F2} " +
$"skipHackPhone={skipHackPhone} " +
$"dress={(dress is null
? "none"
: $"uid={dress.Uid} spirit={dress.FightSpiritId} suit={dress.SuitId} " +
$"pieces={string.Join(',', dress.FashionIds)} hidden={dress.HiddenParts}")}");
}
/// <summary>
/// 切人留场专用:走 <c>SyncRaidBattleUnitAgent</c>(68402349) 通道把老角色刷成 NPC。
///
/// ⛑⛑ 为什么不用 <c>SendCivilianAsync</c>(Aether 静态 NPC 通道):那条线<b>没有地方放单件衣服</b> ——
/// 顶层只有 <c>FashionSuitId</c>(套装 id),而 <c>AIAgentInfo.Fashion.WearInfo</c>
/// 客户端根本不读(09-25 两次实测:包发出去了、6 件衣服都在,但模型依旧默认服装)。
/// <c>RaidBattleUnitAgent</c> 同时带 <c>SuitId</c> + <b><c>FashionIdList</c></b>(单件列表)
/// + 完整 46 字段 <c>AgentSyncClientInfo</c>,是唯一能指定单件的原生通道。
///
/// 序列照抄 <c>GameAdminBridge.SpawnAgentAsync</c>(dbnb-0.2 实机验证过的那条):
/// SyncLogicAgentEnter → SyncManagedLogicAgent → SyncRaidBattleUnitAgent →
/// SyncUnitOccur → SyncAgentCampInfo(camp=0) → SyncAttachBattleModule → POI。
/// ⛑ 友方三件套(规则 132):<b>不发</b> SyncForceEnemyRelation、camp 填 0、
/// 也不注册进服务端敌人账本(这里压根不走那条路)。
/// </summary>
internal static async Task SendLeftBehindAgentAsync(
TcpSession session, uint agentId, ulong unitId,
float x, float y, float z, float facing,
uint poiActionId, CivilianLinkDress? dress, bool forceGo, uint activityCfgId = 0)
{
DonorNpcCatalog4229938.TryGet(agentId, out var npc);
DonorPoiActionCatalog4229938.TryGet(poiActionId, out var poi);
var mainActionId = poi?.SelectMainAction(unitId) ?? 0;
// ⛑ 46 字段版本(NpcSpawnMethods.AgentSyncClientInfo),与 43 字段的
// DonorNpcAgentSyncClientInfo4229938 是**两个不同的 wire**,别混用。
var syncInfo = new SceneMethods.AgentSyncClientInfo
{
isApproachNpc = true,
TriggerLeaveEvent = true,
approachDistance = 10000,
LeaveDistance = 10000,
stimIDList = [],
spawnEffectId = [],
indoorList = [],
roomIds = [],
returnPoiActionIds = poiActionId == 0 ? [] : [poiActionId],
sitIndex = -1,
SpoonAgentId = -1,
// 平民走 Main_Common_NPC(SpawnAgentAsync 里 hostile=false 用的就是它);
// 空 treeName ⇒ 客户端解不出行为树 ⇒ T-pose。
treeName = "Main_Common_NPC",
AISetting = npc?.AiSettingId ?? 0,
InteractId = npc?.InteractSettingId ?? 0,
actionId = mainActionId != 0 ? mainActionId : DefaultNpcActionId,
actionGroupId = poi?.MainActionGroup ?? DefaultNpcActionGroupId,
initPoiActionId = poiActionId,
useDefaultPoiOnReturn = poiActionId != 0,
// 不进战斗:beHitType=1(挨打有反应但掉血走战斗结算的那套关掉)
beHitType = 1,
isAttackInSafeMode = true,
forbidAetherAI = true,
agentStimType = 0,
forbidStimulateType = 0,
// ★ 过日子接线点:非 0 = 客户端这个 NPC 走「生活日程」状态(去播那条活动的表演),
// 会盖掉上面的 ambient POI;0 = 老样子(只会站着待机)。
// 值是 AgentDataSetsActivityConfig.Id,来自 NpcActivityCatalog.json。
AgentDataSetsActivityCfgId = activityCfgId,
FashionSuitId = dress is { SuitId: not 0 } ? dress.SuitId : 11190001,
AIAgentInfo = new SceneMethods.LinkAIAgentInfo
{
Uid = unitId,
FightSpiritId = dress?.FightSpiritId ?? 0,
Fashion = dress is null ? null : new SceneMethods.LinkAIFashionInfo
{
SuitId = dress.SuitId,
WearInfo = new Auto.OtherPlayerSpiritWearFashionsInfo
{
WearFashionColoringInfoDict = new Dictionary<uint, Auto.FashionColoringInfo>(),
WearFashionInfoList = dress.FashionIds.Where(id => id != 0)
.Select(id => new Auto.WearFashionInfo { FashionId = id }).ToList(),
WearFashionEditInfoList = [],
HiddenParts = dress.HiddenParts,
EditedHiddenParts = 0,
},
},
Nickname = dress?.Nickname,
},
};
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncLogicAgentEnter,
UxSerializer.Serialize(WorldCodec.LogicAgentEnter(unitId)), CancellationToken.None);
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncManagedLogicAgent,
UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(unitId, Profile.PlayerPid, 0)), CancellationToken.None);
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncRaidBattleUnitAgent,
UxSerializer.Serialize(new SceneMethods.RaidBattleUnitAgent
{
SkillId = -1,
HSummonIndex = -1,
SpoonAgentId = -1,
// ★★★ 单件衣服就在这里 —— 市民通道没有这个字段。
SuitId = dress?.SuitId ?? 0,
FashionIdList = (dress?.FashionIds ?? []).Where(id => id != 0).ToList(),
VehicleIndex = -1,
IsBorn = true,
BattleAiS = false,
agentSyncClientInfo = syncInfo,
WeaponId = 0,
SpawnType = 1, // AgentSpawnType.Gm
AIAgentInfo = syncInfo.AIAgentInfo,
Id = unitId,
TemplateId = agentId,
Position = new Auto.UXVector3 { X = x, Y = y, Z = z },
FacingDirection = facing,
ManagedPid = Profile.PlayerPid,
GroundData = new Auto.MoveActionGroundData
{
MoveGroundType = 0,
MoveGroundId = 0,
MetaInfo = 0,
LocalPos = new Auto.UXVector3(),
},
}), CancellationToken.None);
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncUnitOccur,
UxSerializer.Serialize(new SceneMethods.SyncUnitOccur
{
enemyId = unitId,
occurId = 0,
dullSecs = 0f,
position = new Auto.UXVector3 { X = x, Y = y, Z = z },
rotateY = facing,
}), CancellationToken.None);
// camp 0 = 平民/未知(4 = AllEnemy)。规则 132:友方 NPC 必须 0,
// 否则玩家的本地战斗 AI 会把"自己人"当可攻击目标。
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAgentCampInfo,
UxSerializer.Serialize(new SceneMethods.SyncAgentCampInfo { pid = unitId, camp = 0 }),
CancellationToken.None);
// 骨骼绑定:少了它单位就是 T-pose。
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAttachBattleModule,
UxSerializer.Serialize(new SceneMethods.SyncAttachBattleModule { unitId = unitId }),
CancellationToken.None);
if (forceGo)
{
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.IGameSceneToClient_SyncAgentForceGo,
UxSerializer.Serialize(new SceneMethods.SyncAgentForceGo4229938
{
id = unitId,
isForceGo = true,
}), CancellationToken.None);
}
if (poiActionId != 0)
{
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.IGameSceneToClient_SyncAgentPlayPOIAction,
UxSerializer.Serialize(new GameMethods.DonorClientNpcPoiActionData4229938
{
Id = unitId,
CPoiActionId = poiActionId,
IsActive = true,
PlayPoiSpeed = 1f,
}), CancellationToken.None);
}
session.Log.Info($"[LEFTBEHIND-AGENT] formwork={agentId} model={npc?.GeneralModelId ?? 0} " +
$"persona={npc?.AgentPersonaId ?? 0} ai={npc?.AiSettingId ?? 0} unit={unitId} " +
$"poi={poiActionId} tree=Main_Common_NPC camp=0 forceGo={forceGo} " +
$"activity={activityCfgId} " +
$"suit={dress?.SuitId ?? 0} pieces={(dress is null ? "none" : string.Join(',', dress.FashionIds))} " +
$"at=({x:F2},{y:F2},{z:F2}) facing={facing:F2}");
}
}