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; /// One citizen to place: formwork (AgentConfig id) + the POI action it plays. internal sealed record CivilianSpawnItem(uint NpcFormworkId, uint PoiActionId); /// /// 让一个走市民通道刷出来的 NPC 穿上「某个可玩角色身上那套搭配」(切人留场用)。 /// /// 官方原生写法就在 NPC 出场包里,没有第二处: /// AgentSyncClientInfo.AIAgentInfo(LinkAIAgentInfo)→ /// Fashion(LinkAIFashionInfo = SuitId + WearInfo)→ /// WearInfo(OtherPlayerSpiritWearFashionsInfo,带单件 WearFashionInfoList)。 /// 这就是「把别人的角色放进大世界」那条路 —— 麻将/保龄球的 AI 对手读的也是 /// unit.ClientData.AIAgentInfo(MajiangGame_UI.lua:181 / BowlingCharacter.lua:414)。 /// /// ⛑ 市民通道顶层的 FashionSuitId 只能填套装 id,而玩家存档 /// config/player-progress.json 里 9/9 个角色 FunctionSuitId 全为 0 /// (纯散件搭配,4~10 件)⇒ 走套装那条路对「自定义搭配」根本没用,必须走这里。 /// /// ⛑ wire 顺序(Drmk.Proxy/proxy/lua/RPCSerializeAuto.lua:4706): /// Uid(u64) → FightSpiritId(u32) → Fashion(complex,nilable) → Nickname(string,nilable) /// → NameId(u32) → AvatarImageId(u32) → VehicleId(u32); /// Fashion = SuitId(u32) → WearInfo(complex,不可空)。与 /// DonorNpcLinkAIAgentInfo4229938 字段顺序一一对应,别改顺序。 /// internal sealed record CivilianLinkDress( ulong Uid, uint FightSpiritId, uint SuitId, IReadOnlyList FashionIds, byte HiddenParts = 0, string? Nickname = null); /// /// 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". /// internal sealed partial class GameRouter { /// /// 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. /// internal static IReadOnlyList CivilianGroups => CivilianGroupCatalog4229938.Groups; internal static CivilianGroup4229938? CivilianGroup(int id) => CivilianGroupCatalog4229938.Find(id); /// /// T0-28:**这个 AgentConfig 模板能不能当路人放出去**。三道独立筛子全过才算市民, /// 行人池(PedFormworkPool)和地块市民池(BuildItemPool)共用它, /// 这样两条线不会再出现"街上刷的是市民、地块刷的是店员"的不一致。 /// /// ⛑️ 为什么是三道而不是一道(`CivilianGroups.json` 本身不够用): /// 那张表是按"给每个 POI 动作配一个**体型匹配的人形**"建的 —— 只保证是人形 + 阵营 2, /// 不保证是路人,也不保证模型的脸不一样。所以要在它之上再筛。 /// 三道:① id 段白名单(4013)② **GeneralModelId 黑名单**(通用临时模型,最关键的一道) /// ③ 名字关键词黑名单(店员 / 刷怪 / 剧情职能)。 /// 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; } /// /// 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. /// internal static async Task<(bool Ok, string Message, ulong[] Ids)> SpawnCiviliansNearPlayerAsync( TcpSession session, IReadOnlyList 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(); 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]); } /// Position pushes per second. The donor's own NPC driver runs at 8 Hz. 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. /// /// Fill every plot around the player with citizens, how many decided by each plot's pedestrian /// flow at <hour>. Hour -1 means "use the world clock the client is currently showing". /// 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 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(); 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); } /// /// 在**官方过街等待区**的槽位上铺市民(2026-10-02 新增,配置 gameplay.civilianWaitArea)。 /// /// ★ 点位来源 = paths.pedSpawns(zonegraph_ped_spawns.csv,4070 槽), /// 真身是客户端 MassAI.MassCrowd.CrowdWaitAreaData:行人沿 ConnectedLanes 汇入、 /// 占一个槽位站定(Radius + bOccupied 防重叠)、等相位、走 CrossLaneHandle 过街。 /// ⛑ 那套是客户端原生 ECS,服务端驱动不了;我们只是把自建 NPC 放到同一批坐标上, /// 让画面上像「这撮人也在路口等灯」。 /// /// ★ 与 SpawnCiviliansByDistrictAsync 的分工:那边按「生态区 + 时段人流」铺出一整条街的人, /// 这边只在路口四角补一小撮;两者叠加才像真城市。两边都有关开关,互不干扰。 /// 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(); string? failure = null; var areas = new HashSet(); 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); } /// 等待区用的市民类别:路口是「通用站姿」,不按店铺业态细分。 private static readonly string[] WaitAreaCategories = ["站姿·通用"]; /// /// 路口等待区的 24 小时人流权重(0~1)。深夜几乎没人,早晚通勤双峰最高。 /// ★ 与地块那边的 HourlyFlow 是两套数据:等待区 CSV 不带人流维度,只能按常识给一条曲线。 /// 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)]; } /// /// 用水平最近的**地块站位**的 Y 当高度采样(等待区 CSV 的 y 恒为 0,必须补)。 /// /// ★ 两级查找:先按区中心 + 半径粗筛(681 个区),再在命中区里逐点比。 /// 全表 13786 点逐点扫也能出结果,但每个等待区点都扫一遍会到千万次量级。 /// ⛑ 找不到就退回 (通常是玩家当前 Y),不硬填 0。 /// 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; } /// /// 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. /// private static readonly ConcurrentDictionary DistrictItemPools = new(); private static CivilianSpawnItem[] DistrictItemPool(IReadOnlyList categories) { var key = string.Join("|", categories); // ConcurrentDictionary only has the keyed factory overload — there is no Func one. return DistrictItemPools.GetOrAdd(key, _ => BuildItemPool(categories)); } private static CivilianSpawnItem[] BuildItemPool(IReadOnlyList categories) { var usable = CivilianGroups.Where(g => !g.SystemOnly && g.Items.Count > 0).ToList(); List 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); } /// /// 这条 POI 动作在当前筛选配置下能不能用。查不到(表里没有这个 id)一律放行 —— /// 宁可放过也不要因为表缺失把整个池清空。 /// 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[] 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; } /// /// 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. /// 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); } /// /// 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). /// 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}")}"); } /// /// 切人留场专用:走 SyncRaidBattleUnitAgent(68402349) 通道把老角色刷成 NPC。 /// /// ⛑⛑ 为什么不用 SendCivilianAsync(Aether 静态 NPC 通道):那条线没有地方放单件衣服 —— /// 顶层只有 FashionSuitId(套装 id),而 AIAgentInfo.Fashion.WearInfo /// 客户端根本不读(09-25 两次实测:包发出去了、6 件衣服都在,但模型依旧默认服装)。 /// RaidBattleUnitAgent 同时带 SuitId + FashionIdList(单件列表) /// + 完整 46 字段 AgentSyncClientInfo,是唯一能指定单件的原生通道。 /// /// 序列照抄 GameAdminBridge.SpawnAgentAsync(dbnb-0.2 实机验证过的那条): /// SyncLogicAgentEnter → SyncManagedLogicAgent → SyncRaidBattleUnitAgent → /// SyncUnitOccur → SyncAgentCampInfo(camp=0) → SyncAttachBattleModule → POI。 /// ⛑ 友方三件套(规则 132):不发 SyncForceEnemyRelation、camp 填 0、 /// 也不注册进服务端敌人账本(这里压根不走那条路)。 /// 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(), 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}"); } }