593 lines
30 KiB
C#
593 lines
30 KiB
C#
using System.Linq;
|
||
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using Ananta.Server.RpcTypes.Client4229938;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// Playable characters standing in the city, each at whatever their own daily schedule says they
|
||
/// are doing right now — Richie on the treadmill, Garm at the claw machine, and so on.
|
||
///
|
||
/// Two reasons this file exists:
|
||
///
|
||
/// 1. Official has them roaming from the start (the 趴卜 schedule tab and the big-map markers exist
|
||
/// precisely so the player can go find them). We only ever materialised one at the moment the
|
||
/// player switched <em>away</em> from that character, so walking to a schedule coordinate found
|
||
/// nobody — which is exactly what the player reported on 09-25.
|
||
///
|
||
/// 2. The client unloads distant units and never re-adds them by itself: coming back to a spot from
|
||
/// far away shows an empty pavement until you switch the character away and back. So the server
|
||
/// has to own the distance bookkeeping — place on enter, retire on exit, place again on return.
|
||
/// That is not a new idea; it is the same follow-loop + hysteresis shape as
|
||
/// <see cref="GameRouter.EnsureFunctionalNpcs"/>, only with different coordinates.
|
||
///
|
||
/// Coordinates and the activity id come straight from the client's own tables
|
||
/// (see <see cref="NpcActivityCatalog4229938"/>): nothing here is invented.
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
private const string RosterNpcKey = "client4229938-roster-npcs";
|
||
|
||
internal sealed class RosterNpcSessionState
|
||
{
|
||
internal object SyncRoot { get; } = new();
|
||
internal CancellationTokenSource? Cts;
|
||
internal Task? Loop;
|
||
|
||
/// <summary>spiritId -> what was placed for it. Doubles as the "already placed" set.</summary>
|
||
internal Dictionary<uint, RosterNpcPlacement> Active { get; } = new();
|
||
|
||
/// <summary>Scene the <see cref="Active"/> entries belong to; a change wipes them.</summary>
|
||
internal uint Raid;
|
||
|
||
/// <summary>是否已经打过一次「名册里有多少人没有可落地活动」的诊断行(只打一次,别刷屏)。</summary>
|
||
internal bool Diagnosed;
|
||
}
|
||
|
||
internal sealed record RosterNpcPlacement(ulong UnitId, float X, float Y, float Z, uint ActivityId);
|
||
|
||
private static RosterNpcSessionState RosterNpcStateOf(TcpSession session)
|
||
{
|
||
if (session.Items.TryGetValue(RosterNpcKey, out var existing)
|
||
&& existing is RosterNpcSessionState typed)
|
||
return typed;
|
||
|
||
var created = new RosterNpcSessionState();
|
||
session.Items[RosterNpcKey] = created;
|
||
return created;
|
||
}
|
||
|
||
/// <summary>World-ready hook: start the follow loop. Idempotent; no-op when disabled.</summary>
|
||
internal static void EnsureRosterNpcs(TcpSession session)
|
||
{
|
||
if (!PrivateServerConfigStore.Current.Gameplay.RosterNpc.Enabled)
|
||
return;
|
||
|
||
var owner = RosterNpcStateOf(session);
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (owner.Loop is { IsCompleted: false })
|
||
return;
|
||
owner.Cts?.Dispose();
|
||
owner.Cts = new CancellationTokenSource();
|
||
owner.Loop = Task.Run(() => RunRosterNpcLoopAsync(session, owner, owner.Cts.Token));
|
||
}
|
||
}
|
||
|
||
/// <summary>Disconnect / scene switch: stop the loop. The client clears the entities itself.</summary>
|
||
internal static void StopRosterNpcs(TcpSession session)
|
||
{
|
||
if (!session.Items.TryGetValue(RosterNpcKey, out var existing)
|
||
|| existing is not RosterNpcSessionState owner)
|
||
return;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
owner.Cts?.Cancel();
|
||
owner.Cts?.Dispose();
|
||
owner.Cts = null;
|
||
owner.Active.Clear();
|
||
}
|
||
}
|
||
|
||
/// <summary>How many are currently standing. Panel display only.</summary>
|
||
internal static int RosterNpcActiveCount(TcpSession session)
|
||
{
|
||
var owner = RosterNpcStateOf(session);
|
||
lock (owner.SyncRoot)
|
||
return owner.Active.Count;
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★ 切人接管时**立即**撤掉常驻摆在日程点上的那具身体(09-26 00:4x 加)。
|
||
///
|
||
/// ⛑⛑ 为什么必须同步做:开了 <c>SwitchToScheduleSpot</c> 之后,玩家会直接瞬移到
|
||
/// 角色的日程点位。而那个点位上常驻化<b>很可能已经摆了一具同样的身体</b>
|
||
/// (他本来就「在城里过日子」)⇒ 瞬移过去就是「两个里希重叠」的老毛病(规则 166)。
|
||
/// 常驻循环 1.5 秒才跳一次,等它自己撤(<c>spirit == activeSpirit</c>)中间那一秒玩家
|
||
/// 就盯着两个自己 —— 所以切人时主动撤一次。
|
||
/// ⛑ 撤不掉也不至于更糟:<c>RemoveRosterNpcAsync</c> 已经是 Leave+Aether 双发(规则 166)。
|
||
/// </summary>
|
||
internal static async Task<bool> RetireRosterNpcForSwitchAsync(TcpSession session, uint spiritId)
|
||
{
|
||
var state = GetStateIfExists(session);
|
||
if (state is null)
|
||
return false;
|
||
|
||
var owner = RosterNpcStateOf(session);
|
||
ulong id;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Active.TryGetValue(spiritId, out var place))
|
||
return false;
|
||
id = place.UnitId;
|
||
}
|
||
|
||
await RemoveRosterNpcAsync(session, state, owner, spiritId);
|
||
session.Log.Info($"[ROSTER-NPC] retired spirit={spiritId} unit={id} (切人接管,立即撤掉日程点上的身体)");
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// spiritId -> unitId for everything currently on the field. The GM panel uses it to mark
|
||
/// which rows are actually standing in the world right now (as opposed to merely scheduled).
|
||
/// </summary>
|
||
internal static Dictionary<uint, ulong> RosterNpcPlacements(TcpSession session)
|
||
{
|
||
var owner = RosterNpcStateOf(session);
|
||
lock (owner.SyncRoot)
|
||
return owner.Active.ToDictionary(p => p.Key, p => p.Value.UnitId);
|
||
}
|
||
|
||
private static async Task RunRosterNpcLoopAsync(
|
||
TcpSession session, RosterNpcSessionState owner, CancellationToken token)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.RosterNpc;
|
||
var tick = Math.Max(500, cfg.TickMs);
|
||
session.Log.Info($"[ROSTER-NPC] follow loop started enter={cfg.EnterMeters:0}m " +
|
||
$"exit={cfg.ExitMeters:0}m tick={tick}ms dailyLife={cfg.DailyLife}");
|
||
|
||
while (!token.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
if (!IsBackgroundPushPaused(session))
|
||
await TickRosterNpcsAsync(session, owner, cfg);
|
||
}
|
||
catch (OperationCanceledException) { break; }
|
||
catch (ObjectDisposedException)
|
||
{
|
||
session.Log.Info("[ROSTER-NPC] loop stopped: session disposed");
|
||
break;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Info($"[ROSTER-NPC] tick failed: {ex.Message}");
|
||
}
|
||
|
||
try { await Task.Delay(tick, token); }
|
||
catch (OperationCanceledException) { break; }
|
||
}
|
||
}
|
||
|
||
private static async Task TickRosterNpcsAsync(
|
||
TcpSession session, RosterNpcSessionState owner, RosterNpcSettings cfg)
|
||
{
|
||
var state = GetStateIfExists(session);
|
||
if (state is null)
|
||
return;
|
||
|
||
uint raid;
|
||
uint activeSpirit;
|
||
float px, py, pz;
|
||
int daySecond;
|
||
// ⛑⛑ 切人**进行中**的标志(规则 166):`HasSwitchOldUnitPose` 在切人请求进来时置 true、
|
||
// 整个切人流程跑完才置 false(`GameRouter.Switching.cs:101 / :899`)。
|
||
// 常驻循环 1.5 秒一跳,很容易正好落在切人那几百毫秒里:
|
||
// 22:22:34 / 22:22:38 / 22:22:52 三次都是「切人还没跑完就摆了一个里希」,
|
||
// 于是跑步机上每切一次就多一具。进行中一律不摆(下一跳再补)。
|
||
bool switchInFlight;
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
|
||
return;
|
||
raid = state.ActiveRaidId;
|
||
activeSpirit = state.ActiveSpiritTemplateId;
|
||
px = state.LastReportedPlayerPosition.X;
|
||
py = state.LastReportedPlayerPosition.Y;
|
||
pz = state.LastReportedPlayerPosition.Z;
|
||
// ⛑ 时间基准收在 WorldEntryState.CurrentDaySecond 一处(规则 149:本地时间,非 UTC)。
|
||
daySecond = state.CurrentDaySecond();
|
||
switchInFlight = state.HasSwitchOldUnitPose || state.PendingSwitchTemplateId != 0;
|
||
}
|
||
if (!float.IsFinite(px) || !float.IsFinite(pz))
|
||
return;
|
||
|
||
// A scene switch wiped every entity on the client, so our bookkeeping goes with it.
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (owner.Raid != raid)
|
||
{
|
||
owner.Active.Clear();
|
||
owner.Raid = raid;
|
||
}
|
||
}
|
||
|
||
// 1. Retire what the player walked away from (hysteresis: exit > enter), AND what just
|
||
// changed activity — the clock moved on (panel time edit, or simply crossing into the
|
||
// next slice), so the body is standing in the wrong place doing the wrong thing.
|
||
// Step 2 re-places it at the new activity on this very tick.
|
||
// ⛑ 没有这一段的话,改游戏时间对**已经在场上**的角色完全没效果 ——
|
||
// 他们只在「走远 420m 再回来」时才重放,玩家会以为时间不影响日程。
|
||
var exit2 = cfg.ExitMeters * cfg.ExitMeters;
|
||
var gone = new List<uint>();
|
||
|
||
// 场上已经有了的身体(留场 NPC),用来防止叠人 —— 撤人那一步也要用,所以提前算。
|
||
HashSet<uint> leftBehind;
|
||
lock (state.SyncRoot)
|
||
leftBehind = state.LeftBehindCharacters.Keys.ToHashSet();
|
||
|
||
lock (owner.SyncRoot)
|
||
{
|
||
foreach (var (spirit, place) in owner.Active)
|
||
{
|
||
// ⛑⛑ 玩家**切回了**这个角色 ⇒ 我们摆在城里的那具身体必须撤掉。
|
||
// 不撤的话场上就有两个同一张脸:玩家操控的那个 + 我们按日程摆的那个,
|
||
// 而且切回的落点正是 `LeftBehindCharacters.Position`(也就是日程坐标)——
|
||
// 于是两个里希原地重叠(09-25 玩家报的就是这个)。
|
||
if (spirit == activeSpirit)
|
||
{
|
||
gone.Add(spirit);
|
||
continue;
|
||
}
|
||
|
||
// ⛑⛑ 这个角色已经归 Switching 的「留场」管了(我们在切人赛道上摆了一具),
|
||
// 常驻这具就是多余的,撤掉。少了这条,切走一次就永久多一具(规则 166)。
|
||
if (leftBehind.Contains(spirit))
|
||
{
|
||
gone.Add(spirit);
|
||
continue;
|
||
}
|
||
|
||
if (Dist2(place.X, place.Z, px, pz) > exit2)
|
||
{
|
||
gone.Add(spirit);
|
||
continue;
|
||
}
|
||
if (cfg.DailyLife)
|
||
{
|
||
var now = NpcActivityCatalog4229938.Resolve(
|
||
spirit, daySecond, raid, cfg.RequirePosition, px, py, pz);
|
||
if ((now.Entry?.Id ?? 0) != place.ActivityId)
|
||
gone.Add(spirit);
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (var spirit in gone)
|
||
{
|
||
await RemoveRosterNpcAsync(session, state, owner, spirit);
|
||
session.Log.Info($"[ROSTER-NPC] retired spirit={spirit} (walked away, slice changed, or player took control)");
|
||
}
|
||
|
||
// 2. Place what the player just walked into.
|
||
// ⛑⛑ 切人**进行中**一律不摆(规则 166):切人那几百毫秒里 activeSpirit /
|
||
// LeftBehindCharacters 都还没更新完,这一跳会照旧判「该摆」⇒ 每切一次多一具。
|
||
// 下一跳(1.5s 后)状态已经稳定,该摆的自然会摆上。
|
||
if (switchInFlight)
|
||
{
|
||
session.Log.Info("[ROSTER-NPC] skip place — 切人进行中");
|
||
return;
|
||
}
|
||
|
||
// ★★ 游戏时间跨进新的日程时段 ⇒ 把「被玩家操控过、切走后赖在原地的」角色
|
||
// **交还常驻化去演新活动**(用户 09-26 05:0x 要的;开关 LeftBehindFollowSchedule)。
|
||
//
|
||
// 放在 switchInFlight 判断之后:切人那几百毫秒里 LeftBehindCharacters 还没稳定,别插手
|
||
// (规则 166 的同款教训 —— 跑步机上每切一次多一具就是这么来的)。
|
||
//
|
||
// 判据(**两条,任一满足即放行**):
|
||
// ① 时段翻篇:现在时段表(Timed)的活动 id ≠ 切走时记的 TimedActivityId。
|
||
// ⛑ 用 Timed 不用 Entry 的理由见下面 nowTimedId 那一段(用 Entry 会恒等、永远不触发)。
|
||
// ② 玩家主动推时间:现在 TimeEpoch ≠ 切走时记的 TimeEpoch(开关 LeftBehindFollowOnTimeJump)。
|
||
// ⛑ 光有 ① 不够:时刻表一段就是 4~7 小时,玩家在 06:13 用「度过时间」跳到 06:00,
|
||
// 里希仍在同一段(00:00-07:00)⇒ ① 判不出来 ⇒ 玩家看到「我明明推了时间他还在原地」
|
||
// (2026-09-26 06:1x 实机)。② 抓的正是这种"主动推时间"的动作。
|
||
// ⛑ 不能拿 daySecond 直接比:实时也是每秒 +1,那样玩家逛 10 分钟角色就自己跑了。
|
||
if (cfg.DailyLife &&
|
||
PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.LeftBehindFollowSchedule)
|
||
{
|
||
uint nowEpoch;
|
||
lock (state.SyncRoot)
|
||
nowEpoch = state.TimeEpoch;
|
||
var followOnJump = PrivateServerConfigStore.Current.Gameplay
|
||
.SwitchAnimations.LeftBehindFollowOnTimeJump;
|
||
|
||
List<(uint Spirit, ulong UnitId, uint TimedActivityId, uint TimeEpoch)> behind;
|
||
lock (state.SyncRoot)
|
||
behind = state.LeftBehindCharacters
|
||
.Where(kv => kv.Key != activeSpirit)
|
||
.Select(kv => (kv.Key, kv.Value.UnitId, kv.Value.TimedActivityId, kv.Value.TimeEpoch))
|
||
.ToList();
|
||
|
||
foreach (var (spirit, unitId, timedActivityId, epochAtSwitch) in behind)
|
||
{
|
||
var now = NpcActivityCatalog4229938.Resolve(
|
||
spirit, daySecond, raid, cfg.RequirePosition, px, py, pz);
|
||
// ⛑⛑⛑ 判据必须是 **Timed**(时段表当下那一段指的活动),不能是 Entry。
|
||
// Entry 在「该时段活动没编坐标」时会被 FirstPositioned 兜底成一条**与时间无关**的
|
||
// 活动(同 tag 在本 raid 里 Id 最小的有坐标那条)⇒ 白天和夜里算出来是同一个 id
|
||
// ⇒ 判据恒等 ⇒ 玩家把时间从 05:45 跳到 18:00,一个角色都不会动
|
||
//(2026-09-26 06:0x 实机翻车:Mechanika 11400031→11400031、里希 11400012→11400012)。
|
||
var nowTimedId = now?.Timed?.Id ?? 0u;
|
||
var nowEntryId = now?.Entry?.Id ?? 0u;
|
||
var sliceChanged = nowTimedId != timedActivityId;
|
||
var timeJumped = followOnJump && nowEpoch != epochAtSwitch;
|
||
// 时段没变、玩家也没主动推时间 ⇒ 继续赖在他最后被操控的地方(前一条需求要的效果)。
|
||
if (!sliceChanged && !timeJumped)
|
||
continue;
|
||
var why = sliceChanged
|
||
? (timeJumped ? "时段翻篇+玩家推时间" : "时段翻篇")
|
||
: "玩家推时间(同一时段内)";
|
||
// ⛑ 新时段没有可落地的坐标就先不动:撤掉容易,摆不回去人就凭空消失了。
|
||
// ⛑ 但**必须打日志** —— 否则「到底跨没跨」完全不可观测,只能靠猜
|
||
//(52 个角色里当前时段有坐标的往往只有十几个,剩下 36 个 no-activity 是常态)。
|
||
if (now?.Placeable != true)
|
||
{
|
||
session.Log.Info($"[LEFTBEHIND-SLICE] spirit={spirit} slice {timedActivityId} -> {nowTimedId} " +
|
||
$"epoch {epochAtSwitch}->{nowEpoch} ({why}) daySecond={daySecond} " +
|
||
$"新时段无坐标(no-activity) ⇒ 保持原地");
|
||
continue;
|
||
}
|
||
|
||
// ① 撤掉留场那具身体(留场单位在 retirable 段,SyncLogicAgentLeave 收得掉,规则 165)
|
||
await session.NotifyAsync(MethodId.SyncLogicAgentLeave,
|
||
UxSerializer.Serialize(WorldCodec.LogicAgentLeave(unitId)), CancellationToken.None);
|
||
// ② 退出留场管理 ⇒ step 2 的距离筛选不再拦他,但也别让他再被摆第二具
|
||
// (PlaceRosterNpcAsync 会记账到 owner.Active,step 2 的 busy 检查会跳过)
|
||
lock (state.SyncRoot)
|
||
state.LeftBehindCharacters.Remove(spirit);
|
||
|
||
session.Log.Info($"[LEFTBEHIND-SLICE] spirit={spirit} unit={unitId} " +
|
||
$"slice {timedActivityId} -> {nowTimedId} " +
|
||
$"epoch {epochAtSwitch}->{nowEpoch} ({why}) " +
|
||
$"落到 {nowEntryId}/{now?.Entry?.Performance}({now?.Mode}) " +
|
||
$"daySecond={daySecond} ⇒ 交还常驻化去演新活动");
|
||
|
||
// ③ 直接摆,**不走 EnterMeters 距离筛选** —— 否则玩家离新日程点一远人就消失。
|
||
// ⛑⛑ 摆放前必须先发 donor aether init(常驻化 step 2 在 :363 也是先发它再摆的),
|
||
// 漏了这一步摆出来的身体可能不完整。
|
||
await SendDonorAetherInitAsync(session);
|
||
await PlaceRosterNpcAsync(session, state, owner, spirit, now, cfg, "leftbehind-slice");
|
||
}
|
||
}
|
||
|
||
var enter2 = cfg.EnterMeters * cfg.EnterMeters;
|
||
var wanted = new List<(uint Spirit, NpcActivityChoice? Choice, string Mode)>();
|
||
var noSlot = 0;
|
||
|
||
List<StaticNpcPose> taken;
|
||
lock (state.SyncRoot)
|
||
taken = state.StaticNpcPoses.Values.ToList();
|
||
|
||
lock (owner.SyncRoot)
|
||
{
|
||
var busy = owner.Active.Keys;
|
||
foreach (var character in ClientConfigRepository.Characters())
|
||
{
|
||
var spirit = character.TemplateId;
|
||
// 玩家正操控的那个不要放第二个 —— 场上会有两个同一张脸。
|
||
if (spirit == 0 || spirit == activeSpirit || busy.Contains(spirit))
|
||
continue;
|
||
|
||
// ⛑⛑ 切人留场已经把**同一个角色**摆了一具身体在世界里(就站在他的日程点位上),
|
||
// 这里再加一具就是「两个里希重叠」。留场那具归 Switching 管,我们不要再放。
|
||
if (leftBehind.Contains(spirit))
|
||
continue;
|
||
|
||
var agentId = GameAdminBridge.AgentIdForSpirit(spirit);
|
||
if (agentId == 0 || !DonorNpcCatalog4229938.TryGet(agentId, out _))
|
||
continue;
|
||
|
||
var choice = cfg.DailyLife
|
||
? NpcActivityCatalog4229938.Resolve(
|
||
spirit, daySecond, raid, cfg.RequirePosition, px, py, pz)
|
||
: null;
|
||
var mode = choice?.Mode ?? "daily-life-off";
|
||
|
||
// ⛑⛑ 既没有可落地的活动、也没有手工常驻点 ⇒ **不要放人**。以前这里会落到
|
||
// 「玩家位置 +3m」当兜底,结果名单里查不到日程的角色全部贴着玩家刷出来 ——
|
||
// 登录刷一圈,每传送一次再刷一圈。官方数据是「这个点没人就是没人」,照做。
|
||
if (choice?.Placeable != true && !cfg.PlaceWithoutActivity)
|
||
{
|
||
noSlot++;
|
||
continue;
|
||
}
|
||
|
||
var x = choice?.PlaceX ?? px;
|
||
var z = choice?.PlaceZ ?? pz;
|
||
if (Dist2(x, z, px, pz) > enter2)
|
||
continue;
|
||
|
||
// ⛑ 那个点位上已经站了一个 NPC(市民 / 功能 NPC / 别的角色)⇒ 不叠人。
|
||
// 用 3D 距离:同一栋楼 1 楼和 2 楼本来就该各自站一个人,只有真的贴在一起才算冲突。
|
||
var y = choice?.PlaceY ?? py;
|
||
if (cfg.MinSeparationMeters > 0f && Taken(x, y, z, taken, cfg.MinSeparationMeters))
|
||
{
|
||
session.Log.Info($"[ROSTER-NPC] skip spirit={spirit} — 点位 ({x:0.##},{y:0.##},{z:0.##}) " +
|
||
$"{cfg.MinSeparationMeters:0.#}m 内已经站着别的 NPC");
|
||
continue;
|
||
}
|
||
|
||
wanted.Add((spirit, choice, mode));
|
||
}
|
||
|
||
wanted = wanted.OrderBy(w => Dist2(w.Choice?.PlaceX ?? px, w.Choice?.PlaceZ ?? pz, px, pz))
|
||
.Take(Math.Max(1, cfg.PerTick))
|
||
.ToList();
|
||
|
||
if (!owner.Diagnosed)
|
||
{
|
||
owner.Diagnosed = true;
|
||
session.Log.Info($"[ROSTER-NPC] roster={ClientConfigRepository.Characters().Count} " +
|
||
$"no-activity={noSlot} placeWithoutActivity={cfg.PlaceWithoutActivity} " +
|
||
$"raid={raid} daySecond={daySecond} —— 只放当前时段有坐标的角色,其余不落地");
|
||
}
|
||
}
|
||
|
||
if (wanted.Count == 0)
|
||
return;
|
||
|
||
await SendDonorAetherInitAsync(session);
|
||
foreach (var (spirit, choice, mode) in wanted)
|
||
await PlaceRosterNpcAsync(session, state, owner, spirit, choice, cfg, mode);
|
||
}
|
||
|
||
private static async Task PlaceRosterNpcAsync(
|
||
TcpSession session, WorldEntryState state, RosterNpcSessionState owner,
|
||
uint spiritId, NpcActivityChoice? choice, RosterNpcSettings cfg, string mode = "?")
|
||
{
|
||
var agentId = GameAdminBridge.AgentIdForSpirit(spiritId);
|
||
if (agentId == 0)
|
||
return;
|
||
|
||
var activity = choice?.Entry;
|
||
|
||
// ⛑ 走到这里 choice.Placeable 一定为真 —— 除非显式开了 PlaceWithoutActivity,
|
||
// 那时只能站在玩家旁边待机(这是 09-25「一圈人贴着玩家刷出来」的老行为,默认已关)。
|
||
var x = choice?.PlaceX ?? 0f;
|
||
var y = choice?.PlaceY ?? 0f;
|
||
var z = choice?.PlaceZ ?? 0f;
|
||
if (choice?.Placeable != true)
|
||
{
|
||
lock (state.SyncRoot)
|
||
{
|
||
x = state.LastReportedPlayerPosition.X + 3f;
|
||
y = state.LastReportedPlayerPosition.Y;
|
||
z = state.LastReportedPlayerPosition.Z + 3f;
|
||
}
|
||
}
|
||
|
||
// ★★ 出场表现:活动级覆盖(按活动 id)> 角色级覆盖(按 spiritId)> 默认。
|
||
// 里希那套(POI 198 + 不带活动 id + 朝向 220.5 + 不发手机)现在对所有有日程的角色
|
||
// 都能用:在 `gameplay.rosterNpc.activityPoiOverrides` 里按活动 id 配即可
|
||
// (表里那 78 条是按中文名自动匹配生成的,猜错的改 poiActionId 就行,热加载)。
|
||
// 唯一裁决点在 `GameRouter.NpcLife.ResolveActivityPresentation` —— 别在这里另写一套。
|
||
var (poiActionId, activityCfgId, facingOrNull, skipPhone) =
|
||
ResolveActivityPresentation(spiritId, activity?.Id ?? 0u, cfg.PoiActionId);
|
||
var facing = facingOrNull ?? 0f;
|
||
|
||
// ★ 活动级坐标微调(官方 MapPosition 跟场景家具差几十厘米时的实机挪动,热加载)。
|
||
// ⛑ 只在真按活动坐标落地时生效(activity 非空)—— PlaceWithoutActivity 那条
|
||
// 「贴着玩家站」的兜底不参与,否则会出现"围着玩家站成一圈再整体平移"的怪相。
|
||
if (activity is not null)
|
||
(x, y, z) = ApplyActivityOffset(activity.Id, x, y, z);
|
||
|
||
// ⛑ 号段可切(gameplay.rosterNpc.retirableUidSegment,热加载):默认与市民同段,
|
||
// 若 Aether 那条移除帧确实删不掉,改成 true 走 0x300000000000(Leave 已验证可收)。
|
||
var id = cfg.RetirableUidSegment
|
||
? GameAdminBridge.NextRetirableNpcUid()
|
||
: unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
|
||
if (cfg.AgentUnitChannel)
|
||
{
|
||
await SendLeftBehindAgentAsync(session, agentId, id, x, y, z, facing,
|
||
poiActionId, null, forceGo: true, activityCfgId: activityCfgId);
|
||
}
|
||
else
|
||
{
|
||
await SendCivilianAsync(session,
|
||
new CivilianSpawnItem(agentId, poiActionId),
|
||
id, x, y, z, facing, null, activityCfgId, skipPhone);
|
||
}
|
||
|
||
// ⛑⛑ 账本里存的是**真实活动 id**,不是出场包里那个 activityCfgId。
|
||
// 上面 `poiOverrides.sendActivityCfgId=false` 会把出场包的活动 id 抹成 0
|
||
// (为了不进生活日程状态、去掉 ambient 那层打电话),但 `RunRosterNpcLoopAsync:214`
|
||
// 是用 `place.ActivityId` 和当前时段的活动比对来判「时段变了要撤人」的 ——
|
||
// 记 0 ⇒ 每个 tick 都判定 slice changed ⇒ 1.5 秒 retire+place 一次,
|
||
// 客户端单位又没被撤掉 ⇒ 跑步机上堆出一排里希(09-25 20:1x 实机)。
|
||
lock (owner.SyncRoot)
|
||
owner.Active[spiritId] = new RosterNpcPlacement(id, x, y, z, activity?.Id ?? 0);
|
||
|
||
// ★ 生活日程正式通道(硬规则 160):光有活动 id 客户端只会播 ambient(站着玩手机),
|
||
// 还得告诉它「这个 agentTag 现在占着几号 spoon 位、站哪儿」—— SyncFavorNpcSpoonAgentId
|
||
// (64003213)。默认关,见 gameplay.npcLife。
|
||
await EnsureNpcSpoonAsync(session, spiritId, agentId, activity?.Id ?? 0, x, y, z);
|
||
|
||
// 兜底通道(可选,默认关):直接给这个单位播一个 POI 动作,例如
|
||
// 198「站姿_运动_跑步机跑步」。见 gameplay.npcLife.poiActionId。
|
||
await EnsureNpcPoiActionAsync(session, id);
|
||
|
||
var what = activity is not null
|
||
? $"{activity.Id}/{activity.Performance}"
|
||
: (string.IsNullOrEmpty(choice?.Spot?.Note) ? "none(standby)" : choice!.Spot!.Note);
|
||
|
||
// ⛑ poi / activityCfgId 是**裁决之后**的结果(不是配置原值),排查「他怎么在演这个」
|
||
// 全靠这一行:activityCfgId=0 表示本条活动配了 POI、走了「不带活动 id」那条路。
|
||
session.Log.Info($"[ROSTER-NPC] placed spirit={spiritId} agent={agentId} unit={id} " +
|
||
$"mode={mode} activity={what} " +
|
||
$"poi={poiActionId} activityCfgId={activityCfgId} facing={facing:0.##} skipPhone={skipPhone} " +
|
||
$"raid={activity?.Raid ?? choice?.Spot?.Raid ?? 0} at=({x:0.##},{y:0.##},{z:0.##})");
|
||
}
|
||
|
||
private static async Task RemoveRosterNpcAsync(
|
||
TcpSession session, WorldEntryState state, RosterNpcSessionState owner, uint spiritId)
|
||
{
|
||
ulong id;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Active.TryGetValue(spiritId, out var place))
|
||
return;
|
||
id = place.UnitId;
|
||
}
|
||
|
||
// ⛑⛑⛑ 两个帧都发(规则 166)。原来只发 `SyncAetherAINpcRemove`(68332284),
|
||
// 而它对 `StaticNpcIdFloor`(8300000000000000000) 这具身体**根本没删掉** ——
|
||
// 09-25 22:2x 实机:日志里 `[ROSTER-NPC] retired spirit=15021021` 打了 5 次,
|
||
// 玩家那边跑步机上的里希一具都没少(「跑步机上的里希没有被回收」)。
|
||
// `SendCivilianAsync` 建这具身体时是发了 `SyncManagedLogicAgent` 的(它就是个
|
||
// logic agent),所以补一条通用回收帧 `SyncLogicAgentLeave`(68281244);
|
||
// Aether 那条保留(对真正的 Aether 行人它才是对的通道),两条一起发互不冲突。
|
||
await session.NotifyAsync(MethodId.SyncLogicAgentLeave,
|
||
UxSerializer.Serialize(WorldCodec.LogicAgentLeave(id)),
|
||
CancellationToken.None);
|
||
|
||
await session.NotifyAsync(MethodId.SyncAetherAINpcRemove,
|
||
UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = id }),
|
||
CancellationToken.None);
|
||
|
||
lock (owner.SyncRoot)
|
||
owner.Active.Remove(spiritId);
|
||
lock (state.SyncRoot)
|
||
{
|
||
state.StaticNpcPoses.Remove(id);
|
||
state.StaticNpcPreparedPlotEvents.Remove(id);
|
||
}
|
||
}
|
||
|
||
private static float Dist2(float x, float z, float px, float pz)
|
||
=> (x - px) * (x - px) + (z - pz) * (z - pz);
|
||
|
||
/// <summary>
|
||
/// 目标点位 <paramref name="separation"/> 米内(3D)是否已经站着别的 NPC。
|
||
/// 用 3D 是为了让「同一栋楼 1 楼和 2 楼各站一个」这种正常情况不误判 —— 只有真的贴在一起才算冲突。
|
||
/// </summary>
|
||
private static bool Taken(float x, float y, float z, List<StaticNpcPose> poses, float separation)
|
||
{
|
||
var limit = separation * separation;
|
||
foreach (var p in poses)
|
||
{
|
||
var dx = p.X - x;
|
||
var dy = p.Y - y;
|
||
var dz = p.Z - z;
|
||
if (dx * dx + dy * dy + dz * dz < limit)
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
}
|