Files

934 lines
48 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 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}");
}
}