using Ananta.SDK.Rpc; using Ananta.Server.Configuration; using Ananta.Server.Handlers; using Ananta.Server.Gameplay; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.ClientData.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// Runtime player/combat hydration shared by world entry and character switching. internal sealed partial class GameRouter { async Task PublishSkillBindings( RpcContext ctx, ulong unitId, CombatWeaponDefinition weapon, CombatStyleDefinition style) { await ctx.NotifyAsync(MethodId.SyncChangeCommonSkill, CombatCodec.CommonBinding(unitId, style)); await ctx.NotifyAsync(MethodId.SyncChangeHeavyAttack, CombatCodec.HeavyAttackBinding(unitId, style)); await ctx.NotifyAsync(MethodId.SyncChangeDodgeSkill, CombatCodec.DodgeBinding(unitId, style)); await ctx.NotifyAsync(MethodId.SyncChangeControlSkill, CombatCodec.ControlBinding(unitId, style)); // ★ 梅卡妮卡:E 键(ActiveSkill)默认会被下发成流派的 51943205「街头格斗_重击」 // ⇒ 按 E 先出重击、服务端再兜底召羊(用户 2026-09-27 实机原话"这有点奇怪")。 // 改成 51937721「梅卡定制念力」后,客户端自己的 SkillReplace 会按 // IsHaveEnemyByDistance(40980002,10) 二选一 ⇒ 没羊=召唤、有羊=念力砸击,没有重击动作。 // ⛑ 旋钮 gameplay.mechanikaSheepActiveSkillId(置 0 恢复原样)。理由与风险见那条注释。 var overrideSkill = PrivateServerConfigStore.Current.Gameplay.MechanikaSheepActiveSkillId; var spiritId = GetStateIfExists(ctx.Session)?.ActiveSpiritTemplateId ?? 0; if (overrideSkill != 0 && spiritId == MechanikaSpirit4229938) { await ctx.NotifyAsync(MethodId.SyncChangeActiveSkill, WorldCodec.SkillBinding(unitId, overrideSkill)); ctx.Session.Log.Info($"[COMBAT] mechanika active-skill override unit={unitId} " + $"{weapon.ActiveSkill(style)} -> {overrideSkill} (E = 念力/召唤羊,不再走重击)"); } else await ctx.NotifyAsync(MethodId.SyncChangeActiveSkill, CombatCodec.ActiveBinding(unitId, weapon, style)); await ctx.NotifyAsync(MethodId.SyncChangeUniqueSkill, CombatCodec.UniqueBinding(unitId, weapon, style)); } async Task PublishCombatResources(RpcContext ctx, ulong unitId) { foreach (var (resourceId, maximum) in CombatCodec.AllResourceMaximums) { await ctx.NotifyAsync(MethodId.SyncFightResource, CombatCodec.FightResource(unitId, resourceId, maximum)); await ctx.NotifyAsync(MethodId.SyncFightResourceFreeState, CombatCodec.FightResourceFreeState(unitId, resourceId, true)); } } private static IEnumerable> SpiritFightStyleOverrides( WorldEntryState state, uint templateId) => state.SpiritStyleOverrides .Where(x => x.Key.SpiritId == templateId) .Select(x => new KeyValuePair(x.Key.FightStyleTypeId, x.Value)); /// /// Weapon one character should hold: the instance this account last equipped (replayed from the /// progress snapshot by RestoreCombatLoadout4229938) when it still resolves in the catalog, /// otherwise the authored default. /// private static CombatWeaponDefinition ResolveEquippedWeapon(WorldEntryState state, uint templateId) { var saved = state.LastWeaponBySpirit.GetValueOrDefault(templateId); if (saved != 0) { // Accept the same two lookups the switch handler uses: owned by this character, or // account-wide. A stale id simply falls through to the default. var restored = CombatCatalogRepository.Weapon(templateId, saved) ?? CombatCatalogRepository.Weapon(saved); if (restored is not null) return restored; } return CombatCodec.DefaultWeapon(templateId); } private static uint PublishedWeaponFightStyleId(WorldEntryState state, CombatWeaponDefinition weapon) { // The armory can store category-wide and per-instance choices separately, but the live // combat runtime also caches FightStyleId by weapon instance. Publishing the EFFECTIVE // style keeps FightStyleManager.GetUnitFightStyleByTemplateAndWeapon, ActionType and the // native SkillJumpGraph on the same martial art. Category changes refresh this cache. return ResolveWeaponStyle(state, weapon).Id; } async Task PublishMinimalActorPresentation4229938( RpcContext ctx, ulong unitId, uint templateId, string reason) { var state = GetWorldState(ctx); var weapon = ResolveEquippedWeapon(state, templateId); var style = ResolveWeaponStyle(state, weapon); state.ActiveWeaponInstanceId = weapon.InstanceId; state.ActiveFightStyleId = style.Id; state.LastWeaponBySpirit[templateId] = weapon.InstanceId; // Actor presentation is part of the ownership contract, not the legacy combat server. // The client already received the small referenced armory in SyncPlayerInfo; these packets // only bind the current model to its authored fashion/wheel/current weapon. // Pass the session so a restored outfit is republished; the parameterless form would // broadcast the authored defaults and undress the character after a re-login. await ctx.NotifyAsync(MethodId.SyncSetSpiritFashions, RuntimePayloadFactory.CharacterFashions(templateId, state)); await PublishWeaponSnapshot(ctx, unitId, templateId, weapon.InstanceId); await ctx.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon, CombatCodec.SpiritLastUsedWeapon(templateId, weapon.InstanceId)); await ctx.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction, CombatCodec.SpiritSwitchWeapon(unitId, weapon.InstanceId)); ctx.Session.Log.Info($"[ACTOR-MIN] presentation unit={unitId} template={templateId} weapon={weapon.TemplateId}/{weapon.InstanceId} style={style.Id} reason={reason} combatModules=false skills=false"); } async Task PublishWeaponSnapshot( RpcContext ctx, ulong unitId, uint templateId, ulong currentWeaponInstanceId) { // Armory and character wheels are separate client stores. Publish the shared account armory // once, refresh this character's mutable 16-slot view, then select the current instance. // Sending only SyncSpiritSwitchWeaponAction leaves CurrWeaponSlots nil and produces // "Not Find Current Weapon" in WeaponManager.lua. var state = GetWorldState(ctx); if (!state.AccountArmoryPublished) { // Minimal V4.1 puts only the weapon instances referenced by owned spirit wheels into // SyncPlayerInfo.InfoSpirit.InfoArmory.Weapons. PlayerInfoSpiritData feeds that compact subset // directly to gWeaponManager, so no incremental account-armory replay is needed here. state.AccountArmoryPublished = true; var actorArmoryCount = ClientConfigRepository.Characters() .Select(character => CombatCatalogRepository.TryLoadout(character.TemplateId)?.Slots) .Where(slots => slots is not null) .SelectMany(slots => slots!) .Where(weapon => weapon is not null && weapon.InstanceId != 0) .Select(weapon => weapon!.InstanceId) .Distinct() .Count(); ctx.Session.Log.Info($"[ARMORY-MIN] retained login actor-reference subset count={actorArmoryCount} fullAccount={CombatCatalogRepository.AccountWeapons.Count} incrementalAddReplay=false"); } // 搜打撤图里这张表必须是「拳头 + 私有武器 + 轮盘武器」,不能是角色的城镇 16 槽配装: // 切场景之后客户端的首次移动会再走一次这里,用城镇表发就等于把刚补好的轮盘表覆盖掉, // 轮盘枪会从 CurrWeaponSlots 里整批消失(Tab 又只能在拳头和自带武器之间转)。 var inExtraction = IsExtractionRaid(state.ActiveRaidId); var snapshot = inExtraction ? BuildExtractionWheelSnapshot(ctx.Session, state, unitId, templateId, currentWeaponInstanceId) : CombatCodec.SpiritWeaponSnapshot( unitId, templateId, currentWeaponInstanceId, WeaponSlotDefinitions(state, templateId)); foreach (var weapon in snapshot.WeaponSlots) { if (weapon is null) continue; if (CombatCodec.Weapon(templateId, weapon.InstanceId) is { } definition) { weapon.FightStyleId = PublishedWeaponFightStyleId(state, definition); weapon.MagazineAmmo = EnsureMagazine4229938(state, definition); weapon.Durability = CurrentDurability4229938(state, definition, weapon.MagazineAmmo); weapon.BulletDatas.BulletId = EnsureBullet4229938(state, definition); if (definition.SeparateBullets && weapon.BulletDatas.BulletId != 0) _ = EnsureReserve4229938(state, weapon.BulletDatas.BulletId); } } await ctx.NotifyAsync(MethodId.SyncSpiritWeaponDetail, snapshot); // 搜打撤里 CurrentWeaponUid 可能被换成表内第一件(角色手上还是城镇那把时退回私有武器), // 服务端手上的武器要跟着走,否则后续技能请求会按一把客户端并不持有的枪去解析。 if (inExtraction) { state.ActiveWeaponInstanceId = snapshot.CurrentWeaponUid; RecordExtractionWheelPublished(ctx.Session, state); } // FightStyleId is also cached in the account armory object. Reconcile each equipped weapon // when a character becomes current so a style that was valid for the previous model cannot // leak into this model's attack ActionSet. foreach (var detail in snapshot.WeaponSlots.Where(x => x is not null).Select(x => x!)) { await ctx.NotifyAsync(MethodId.SyncWeaponFightStyleChange, new GameMethods.SyncWeaponFightStyleChange { weaponInstanceId = detail.InstanceId, fightStyleId = detail.FightStyleId }); } } async Task PublishSafeRuntimeBuffSnapshot4229938(RpcContext ctx, ulong unitId, uint templateId, string phase) { await PublishSpiritJobRuntime4229938(ctx, templateId, phase); // 层 2a: the police phone app's data backbone, for characters on the police career. await PublishPoliceJobRuntime4229938(ctx, templateId, phase); // 2026-09-20 "unlock everything" pass: gameplay.hackerAbilitySet drives the kit shape. // "full" (default) republishes the 6/6 battery and the EonBug BBS payload alongside the // widened buff set; "safe" restores the round-1 baseline exactly (no battery, trio only). var fullKit = FullHackerKitEnabled(); if (fullKit) { await PublishHackerBatteryRuntime4229938(ctx, templateId, phase); await PublishHackerJobRuntime4229938(ctx, templateId, phase); } var state = GetWorldState(ctx); // CharacterCapabilities layers character-owned verbs (Dila's telekinetic flight) on top of // the protagonist traversal kit. For any template it does not know, the result is byte-identical // to the old WebTraversal call, so the login/switch path stays exactly as proven. var buffs = CharacterCapabilities.BuffIds(templateId, fullKit); // Data-driven career capabilities: every UrbanBadgeConfig row keyed to a career this // character owns contributes its Buff list. This is what makes an override like // "15020992 -> 501" actually grant something instead of only moving a phone icon. if (JobBadgeBuffsEnabled()) { var jobBuffs = BadgeCatalog4229938.BuffsForJobs( RuntimePayloadFactory.PublishedUrbanJobIds4229938(templateId)); if (jobBuffs.Length > 0) { buffs = buffs.Concat(jobBuffs).Distinct().OrderBy(x => x).ToArray(); ctx.Session.Log.Info($"[BUFFS-JOB] {phase} template={templateId} jobBuffs={string.Join(",", jobBuffs)}"); } } var firstInstanceId = state.NextBuffInstanceId; await ctx.NotifyAsync(MethodId.SyncUnitBuffList, WorldCodec.UnitBuffList(unitId, buffs, firstInstanceId)); state.NextBuffInstanceId += (uint)buffs.Count; // Do NOT bulk-activate the extracted buff catalogue. It contains 4k+ records for // bosses, NPCs, timelines, skin replacement, ControlList and ActionGroup switches. The client // applies those as executable behavior, not as an "unlocked buff catalogue". In the failing // capture this forced actionGroup=33 on main_A104001 and later crashed ReplaceSkinAction.End. // 白靖时缓的门闸排查:驾驶 HUD 那个按钮的判据只有一行 // (DriveControlDriverStore.ShowProfessionSkillButton = HasBuff(Pid, TimeScaleBadgeBuff)), // 所以"buff 到底有没有在位"是唯一能在这边取证的东西。 if (templateId == CharacterCapabilities.BaiJingSpiritId) ctx.Session.Log.Info($"[BAIJING-ZONE] gate unit={unitId} timeScaleGate52959495={buffs.Contains(CharacterCapabilities.BaiJingTimeScaleGateBuff)} " + $"cabBadge52606175={buffs.Contains(CharacterCapabilities.BaiJingCabDriverBadgeBuff)} " + $"probe=[{string.Join(",", PrivateServerConfigStore.Current.Gameplay.BaiJingTimeScaleProbeBuffs ?? [])}] " + $"count={buffs.Count} phase={phase}"); ctx.Session.Log.Info($"[BUFFS-MIN] {phase} unit={unitId} template={templateId} count={buffs.Count} firstInstance={firstInstanceId} dilaFlight={buffs.Contains(CharacterCapabilities.DilaFloatFlightBuff)} hackerSet={(CharacterCapabilities.CanHack(templateId) ? (fullKit ? "full" : "safe") : "none")} hackerTalent={(fullKit && CharacterCapabilities.CanHack(templateId) ? CharacterCapabilities.TalentBuffCount : 0)} divSuit={(buffs.Contains(PrivateServerConfigStore.Current.Gameplay.DivingSuitBuffId) ? PrivateServerConfigStore.Current.Gameplay.DivingSuitBuffId : 0)} bulk=false safeWebOnly=true legacyCatalogLoaded=false"); } /// gameplay.hackerAbilitySet == "full" (the default). Anything else = safe baseline. private static bool FullHackerKitEnabled() => string.Equals(PrivateServerConfigStore.Current.Gameplay.HackerAbilitySet, "full", StringComparison.OrdinalIgnoreCase); /// /// gameplay.jobBadgeBuffs == "auto" (the default). Publish the capability buffs carried by /// every UrbanBadgeConfig row keyed to a career the character owns, instead of relying on /// per-spirit hard-coding. "off" restores the old shape exactly. /// private static bool JobBadgeBuffsEnabled() => string.Equals(PrivateServerConfigStore.Current.Gameplay.JobBadgeBuffs, "auto", StringComparison.OrdinalIgnoreCase); /// /// Publishes the EonBug app's data backbone: SyncHackerJobInfo (64189208) — /// BBS handle + the full HackerPostConfig post list + rank + daily counters. Without this /// packet the app opens with rank "#-" and an empty forum (HackManager.HackerJobInfo stays /// nil) — exactly what the 7k donor ships, which is why its forum could never open. /// /// DTO path (wire re-verified against the lua reader, 2026-09-20): single class parameter, /// so the frame is [0xFF][fields] — same family as the battery packet. Reader[282] = /// HackerName -> PostInfos(ReadDict, raw Int32 count) -> Rank -> DailyCounts(Reader[852]: /// Money/Fan/Tetris). The old hand-written codec wrote 0x00 markers (SerializeObjectMarkNull /// = 0 — read as null) and omitted DailyCounts entirely. /// private static async Task PublishHackerJobRuntime4229938(RpcContext ctx, uint templateId, string phase, string? handleOverride = null) { if (!CharacterCapabilities.CanHack(templateId)) return; var handle = string.IsNullOrWhiteSpace(handleOverride) ? HackerHandle4229938(ctx.Session) : handleOverride; var posts = HackerPostCatalog4229938.PostIds(); await ctx.NotifyAsync(MethodId.IGameToClient_SyncHackerJobInfo, new GameMethods.HackerJobInfo4229938 { HackerName = handle, PostInfos = posts.ToDictionary(id => id, id => new GameMethods.HackerPostInfo4229938 { Id = id, State = 0, // HackerPostState.New — "not yet completed" HaveRead = true, // no red dots on a fresh unlock }), Rank = 1u, DailyCounts = new GameMethods.HackerDailyCount4229938(), }); ctx.Session.Log.Info($"[HACK-JOB] {phase} template={templateId} posts={posts.Length} rank=1 handle={handle} wire=dto"); } /// /// 层 2a — publishes the police phone app's data backbone: SyncSpiritPoliceJobInfo /// (64349458), i.e. spiritId plus the whole SpiritPoliceJobInfo (duty counters, dispatch /// slots, live violations, case records, fake-file archive). /// /// Client path: GameToClientImpl.SyncSpiritPoliceJobInfo -> /// gPoliceJobManager.panelMgr:OnSpiritPoliceJobInfoSync (PolicePanelManager.lua:156), /// which indexes serviceData / weeklyServiceData / violationInfo / dispatchInfo / fakeFileInfo / /// caseInfo by spiritId and then dereferences DutyBasicInfo.ServiceData with no nil check. /// So the payload must exist and every complex field inside it must be non-nil, but the /// collections may legitimately be empty. /// /// ⛑️ There is no C2S "give me my police state" request — the whole SyncPolice* family is /// server-push, and this full snapshot is the one the panel treats as authoritative /// (SyncPoliceServiceData 64502222, SyncPoliceDispatchInfos 64393704, /// SyncSpiritPoliceViolationInfos 64812240 and SyncSpiritPoliceCaseInfos 64848107 are the /// incremental updates). /// /// Data-driven on the career class (UrbanJobJobClassConfig 11300003 "NCCA Officer"), so any /// character published with job 301/302/303 gets it — Richie (15021021) is the authored one. /// /// T4-9: the payload is no longer a set of empty containers. fills the dispatch chips, a small /// fabricated case history and the homepage tally out of real tables (PoliceDispatchConfig / /// PoliceFineConfig / AgentConfig / PoliceConfig.static), which is what makes the Duty Terminal /// render content instead of a blank panel. Violations and the Mimic Database archive stay empty /// on purpose — see that method for why. /// async Task PublishPoliceJobRuntime4229938(RpcContext ctx, uint templateId, string phase) { if (!RuntimePayloadFactory.IsPoliceCharacter4229938(templateId)) return; var info = RuntimePayloadFactory.PoliceJobInfo4229938(templateId); await ctx.NotifyAsync(MethodId.SyncSpiritPoliceJobInfo, new GameMethods.SyncSpiritPoliceJobInfo4229938 { spiritId = templateId, policeJobInfo = info, }); ctx.Session.Log.Info( $"[POLICE-JOB] {phase} template={templateId} " + $"mode={PrivateServerConfigStore.Current.Gameplay.PolicePayloadMode} dispatch={info.DispatchInfos.Count} " + $"cases={info.CaseInfos.Count} violations={info.ViolationInfos.Count} " + $"fakeFiles={info.PoliceFakeFileInfo.UnlockFileInfoDict.Count} " + $"tally={info.DutyBasicInfo.ServiceData.DispatchTimes} wire=dto"); } /// /// Republish the combat profile after a direct character switch. /// is one-shot per world entry (its /// CombatProfilePublished gate exists so the deferred first-movement hydration cannot /// run twice), but every character owns a different skill set and the client learns the unique /// skill only from that profile. Clear the gate and run it again for the new character. /// async Task PublishCombatProfileOnSwitch4229938(RpcContext ctx, ulong unitId, uint templateId) { var state = GetWorldState(ctx); lock (state.SyncRoot) state.CombatProfilePublished = false; await PublishCombatProfile(ctx, unitId, templateId); ctx.Session.Log.Info($"[COMBAT] switch-republish unit={unitId} template={templateId} skills=true"); } /// /// Publishes the character's authored urban job from FightSpiritConfig.DefaultUrbanJob /// so the client's job → badge → capability chain resolves on its own. Data-driven on purpose: /// every character with an authored job gets its record, not just Seymour (the donor build /// hard-codes three spirit ids, and gets one of them wrong). /// Only 8 of the 109 playable characters carry a job today; the rest are Jobless and are skipped /// — which is exactly the point: under the default UrbanJobGrantMode = "single" the /// career-locked phone apps stay character-exclusive (Seymour/赛默迪斯科球人 → EonBug, /// Richie → Duty Terminal + Mimic Database, Cheng Niang → 重霄食谱). Switch the config to /// "multi" to blanket-unlock all 9 apps on every character, or list template ids under /// UrbanJobOverrides to hand one specific character a career it was not authored with. /// async Task PublishSpiritJobRuntime4229938(RpcContext ctx, uint templateId, string phase) { var jobInfo = RuntimePayloadFactory.SpiritJobInfo4229938(templateId); // Jobless (100) has no record — the login payload already covers CurrentJob for it. if (jobInfo.AvailableJobs.Count == 0) return; // DTO path (7k-verified wire, 2026-09-20): the "0x00 polarity" premise behind the old // hand-written codec was a misdiagnosis — SerializeObjectMarkNull = 0 means 0x00 is the // NULL marker; has-value markers are 0xFF exactly like the native C# packets. What // actually broke NotifyAsync back then was the missing Inline=true on the method DTO // (spurious top-level 0xFF misaligning all three parameters) plus the missing TypeMark=1 // on the TalentInfo dispatch (0x00 tag = null, swallowing 14 bytes per record). With // those two contract attributes in place the serializer reproduces the donor's 47-byte // capture byte-for-byte (see _ux_wire_check). await ctx.NotifyAsync(MethodId.SyncSpiritJobInfo, new GameMethods.SyncSpiritJobInfo4229938 { spiritId = templateId, spiritJobs = jobInfo.AvailableJobs, currentJob = jobInfo.CurrentJob, }); ctx.Session.Log.Info($"[JOB] {phase} template={templateId} current={jobInfo.CurrentJob} available={jobInfo.AvailableJobs.Count} wire=dto"); } /// /// Publishes the six-point hacker battery for characters whose authored job is Hacker(401). /// /// This is the missing half of the hacking kit on our side: HackerHackTypeConfig (48 rows) /// prices every hack in battery points, so with no battery the client greys out the middle-mouse /// hack verb and never emits AskHack — which in turn is the only other place we used to /// send the battery, so it could never recover on its own. The donor build publishes it from the /// buff snapshot path for exactly this reason. /// /// Data-driven on the same UrbanJobOf lookup as the capability buffs, so any character /// authored as job 401 gets it without touching a spirit id. /// async Task PublishHackerBatteryRuntime4229938(RpcContext ctx, uint templateId, string phase) { if (!CharacterCapabilities.CanHack(templateId)) return; await ctx.NotifyAsync(MethodId.SyncHackerBatteryCurrentAndTotal, new GameMethods.HackerBatteryCurrentAndTotalCount4229938 { BatteryTotalCount = CharacterCapabilities.HackerBatteryCapacity, BatteryCurrentCount = CharacterCapabilities.HackerBatteryCapacity, }); ctx.Session.Log.Info($"[HACK-BATTERY] {phase} template={templateId} job={CharacterCapabilities.HackerUrbanJob} battery={CharacterCapabilities.HackerBatteryCapacity}/{CharacterCapabilities.HackerBatteryCapacity}"); } async Task PublishInitialCapabilityBuffs(RpcContext ctx) { var state = GetWorldState(ctx); if (state.InitialCapabilityBuffsPublished) return; state.InitialCapabilityBuffsPublished = true; // Keep the opening/loading barrier on the proven small traversal capability set. // Never activate the entire BuffConfig as one player snapshot: it contains NPC/timeline actions. var firstInstanceId = state.NextBuffInstanceId; await ctx.NotifyAsync(MethodId.SyncUnitBuffList, WorldCodec.UnitBuffList(Profile.InitialUnitId, WebTraversal.InitialCapabilityBuffIds, firstInstanceId)); state.NextBuffInstanceId += (uint)WebTraversal.InitialCapabilityBuffIds.Count; ctx.Session.Log.Info($"[CAPABILITY] initial unit={Profile.InitialUnitId} count={WebTraversal.InitialCapabilityBuffIds.Count} loadingSafe=true allBuffsDeferred=true"); } /// /// 主角原生飞索能力门 —— SyncPaokuLimitChange(68974941) + AbilityLimitConfig 3013。 /// /// ★ 为什么要这一条(2026-09-26 调研,证据全在我们自己的 dump.cs): /// · AbilityLimitConfig.HasFeisuoAbility = 3013(dump.cs:1026235), /// 挂的 GamePlayTag 是 State_HasFeisuoAbility = 2318(dump.cs:1226889); /// 该配置行还带 ButtonEnum[](C 键)与 QueryId(有效目标查询) /// ⇒ 它是能力开关,不是「限制」。 /// · 消费方 PlayerParkourLimitModule(dump.cs:585090-585135)的 /// SetParkourLimitConfigState(uint limitId, bool isAdd) → SetGamePlayTagState。 /// · 飞索 buff 一直在发,但本服所有日志里 feisuo 命中恒为 0 ⇒ 客户端 /// 从来没进过飞索蹲伏态(AskEnterFeiSuoCrouch 一次都没发过)⇒ C 键没反应、没有白圈。 /// /// ⛑⛑ unitId 必须是当前实际控制单位:切人后是新的那个。 /// 上面 PublishInitialCapabilityBuffs 用的是 Profile.InitialUnitId(固定初始单位), /// 这里不能跟着写死 —— 否则切一次人门就挂在旧身体上(与规则 177 同源的坑)。 /// /// ⛑ 白圈不用我们画:客户端 LX6.PaoKu.FeiSuoJobManager(dump.cs:242164) /// 每帧在本地扫静态/动态飞索点,门一开白圈自己出现。别去画假准星、别把任意墙面改成可飞跃点。 /// /// ⛑ 回滚:gameplay.webTraversal.pushFeisuoAbilityGate = false。 /// /// ⛑⛑ 2026-09-26 17:5x 补发暗杀门 5051:飞索门 3013 只解决「蹲得下去」, /// 暗杀检测由另一行 AbilityLimitConfig.UnlockAssassin = 5051 /// (dump.cs:1026213 → State_UnlockAssassin = 1333,dump.cs:1226857)门控。 /// 实机证据:只发 3013 时 AskEnterFeiSuoCrouch 命中 19 次、 /// ReportCanAssassinateEnemies 命中 0 次 ⇒ 光补裁决回包没用,门必须先开。 /// 两张门同表同构,走同一个 SyncPaokuLimitChange 的 limitIds 列表。 /// async Task PublishProtagonistGrapnelGate4229938(RpcContext ctx, ulong unitId, uint templateId) { if (!WebTraversal.PushFeisuoAbilityGate) return; if (unitId == 0 || !WebTraversal.IsProtagonist(templateId)) return; var limitIds = WebTraversal.AbilityLimitIds; await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncPaokuLimitChange, new SceneMethods.SyncPaokuLimitChange4229938 { unitId = unitId, limitIds = limitIds.ToList(), isAdd = true, }); ctx.Session.Log.Info($"[WEB-GRAPNEL] unit={unitId} template={templateId} abilities=[{string.Join(",", limitIds)}] nativeTargetHud=true"); } /// /// 角色专属能力门 —— 同一条 SyncPaokuLimitChange(68974941),一次可发多张 /// AbilityLimitConfig.Id(gameplay.webTraversal.spiritAbilityGates)。 /// 当前配置:15021040 璎珑 → 3040「虐杀原形跑酷」(ParkourState 10062,动作分支含地面 /// 疾跑 574 / 跳跃 575 / 边缘 577 / 爬墙 2502·2503,所以平地按空格与 shift 都该有反应); /// 15020990 → 5040 连跳 + 5052 大跳。 /// /// ★ 为什么不塞进 :那张方法有 /// IsProtagonist 闸,会把所有非主角挡在门外;而这张门只该给璎珑一个人 —— /// 其余角色的 ABP 未必带 MovementModeType.Yinglong(10) 的动画数据, /// 挂上 tag 也可能没表现(甚至动作异常)。 /// /// ⛑ 挂在 PublishCombatProfile 尾部 ⇒ 首次进入与切角色两条路径都覆盖 /// (PublishCombatProfileOnSwitch4229938 先清一次性闸再跑一遍本方法, /// 此时 unitId/templateId 已经是新的控制单位)。 /// ⛑ 切离璎珑时不补发 isAdd=false 摘门:切角色即换 unit,门是 /// PlayerParkourLimitModule(unit module)上的状态,新单位本来就不带, /// 补一包只会是 no-op。真要摘就把开关关掉重进。 /// ⛑ 回滚:gameplay.webTraversal.pushKillingAbilityGate = false。 /// async Task PublishSpiritKillingGate4229938(RpcContext ctx, ulong unitId, uint templateId) { if (unitId == 0) return; // 角色专属能力门(AbilityLimitConfig.Id)。一个角色可以带多张,见 // gameplay.webTraversal.spiritAbilityGates;没配就回落 killing* 单值开关。 var limitIds = WebTraversal.AbilityGatesForSpirit(templateId); if (limitIds.Count == 0) return; await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncPaokuLimitChange, new SceneMethods.SyncPaokuLimitChange4229938 { unitId = unitId, limitIds = limitIds.ToList(), isAdd = true, }); ctx.Session.Log.Info($"[WEB-GATE] unit={unitId} template={templateId} abilities=[{string.Join(",", limitIds)}]"); } /// /// One-shot combat profile per world entry: HP/attrs/urban/fight-style unlocks, /// skill charges, resources, battle module, weapon snapshot, skill bindings. /// Until this runs, OnClientUseSkill rejects skill requests (CombatProfilePublished). /// Runs on first gameplay movement (deferred hydration), mirroring the reference build. /// async Task PublishCombatProfile(RpcContext ctx, ulong unitId, uint templateId) { var state = GetWorldState(ctx); lock (state.SyncRoot) { if (state.CombatProfilePublished) return; state.CombatProfilePublished = true; } var weapon = ResolveEquippedWeapon(state, templateId); var style = ResolveWeaponStyle(state, weapon); lock (state.SyncRoot) { state.ActiveWeaponInstanceId = weapon.InstanceId; state.ActiveFightStyleId = style.Id; state.LastWeaponBySpirit[templateId] = weapon.InstanceId; } // Hard rule 52: the attribute table lands FIRST, then the absolute HP value. Publishing hp // before attrs makes the client clamp the incoming value against the previous max, which is // one reason the bar never filled. await ctx.NotifyAsync(MethodId.SyncUnitAttrs, CombatCodec.UnitAttrs(unitId, CombatCodec.MaxHp)); await ctx.NotifyAsync(MethodId.SyncUnitHp, CombatCodec.UnitHp(unitId, CombatCodec.MaxHp)); await ctx.NotifyAsync(MethodId.SyncSpiritUnitUrbanAttrs, CombatCodec.UrbanAttrs(unitId)); await ctx.NotifyAsync(MethodId.SyncPlayerFightStyleUnLockInfo, CombatCodec.FightStyleUnlock()); // Carry the restored fight-style choices. The one-argument overload publishes only the // authored defaults and would silently undo a martial art the player picked last session. await ctx.NotifyAsync(MethodId.SyncSpiritFightStyleChangeAction, CombatCodec.FightStyleAction(templateId, SpiritFightStyleOverrides(state, templateId))); await ctx.NotifyAsync(MethodId.SyncPlayerAllSkillChargeData, CombatCodec.AllSkillCharges(unitId, weapon, style)); await PublishCombatResources(ctx, unitId); await ctx.NotifyAsync(MethodId.SyncAttachBattleModule, CombatCodec.AttachBattleModule(unitId)); await PublishWeaponSnapshot(ctx, unitId, templateId, weapon.InstanceId); await PublishSkillBindings(ctx, unitId, weapon, style); await ctx.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon, CombatCodec.SpiritLastUsedWeapon(templateId, weapon.InstanceId)); await ctx.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction, CombatCodec.SpiritSwitchWeapon(unitId, weapon.InstanceId)); await PublishInitialCapabilityBuffs(ctx); // 原生飞索能力门放在这里 = 一次调用覆盖两条路径: // ① 首次进入主角(本方法由首次移动包触发的延迟水合跑起来); // ② 切角色(PublishCombatProfileOnSwitch4229938 先清闸门再调本方法, // 且此时 unitId/templateId 已经是新的控制单位)。 await PublishProtagonistGrapnelGate4229938(ctx, unitId, templateId); // 角色专属跑酷门(璎珑 3040)。与飞索门分开:飞索门只对主角,这张只对璎珑。 await PublishSpiritKillingGate4229938(ctx, unitId, templateId); // ★ 捐赠端 GameRouter.Profiles.cs:118(进世界/换人时):进世界就把里希的 S002 root + // 职业 + F 按钮 bootstrap 起来。没有这一步,盘查链要等到"领取驾照"才启动 —— 那就是偏离。 // ⛑ 它内部也会发一次 SyncSpiritPoliceJobInfo(捐赠端的空壳包)。主线 // PublishPoliceJobRuntime4229938(真实数据版)是在"首次移动 / 直接换人"时补发的, // 时序上在它之后 ⇒ 最终客户端拿到的是真实数据版,不会退化成空面板。 await PublishLixiPolice4229938(ctx.Session, templateId); ctx.Session.Log.Info($"[COMBAT] profile unit={unitId} template={templateId} weapon={weapon.TemplateId}/{weapon.InstanceId} style={style.Id} skills=true"); } }