Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.Profiles.cs
T

598 lines
36 KiB
C#
Raw Normal View History

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;
/// <summary>Runtime player/combat hydration shared by world entry and character switching.</summary>
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) 二选一 ⇒ 没羊=召唤、有羊=念力砸击,<b>没有重击动作</b>。
// ⛑ 旋钮 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<KeyValuePair<uint, uint>> SpiritFightStyleOverrides(
WorldEntryState state,
uint templateId)
=> state.SpiritStyleOverrides
.Where(x => x.Key.SpiritId == templateId)
.Select(x => new KeyValuePair<uint, uint>(x.Key.FightStyleTypeId, x.Value));
/// <summary>
/// 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.
/// </summary>
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");
}
/// <summary><c>gameplay.hackerAbilitySet == "full"</c> (the default). Anything else = safe baseline.</summary>
private static bool FullHackerKitEnabled()
=> string.Equals(PrivateServerConfigStore.Current.Gameplay.HackerAbilitySet, "full", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// <c>gameplay.jobBadgeBuffs == "auto"</c> (the default). Publish the capability buffs carried by
/// every <c>UrbanBadgeConfig</c> row keyed to a career the character owns, instead of relying on
/// per-spirit hard-coding. "off" restores the old shape exactly.
/// </summary>
private static bool JobBadgeBuffsEnabled()
=> string.Equals(PrivateServerConfigStore.Current.Gameplay.JobBadgeBuffs, "auto", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Publishes the EonBug app's data backbone: <c>SyncHackerJobInfo</c> (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.
/// </summary>
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");
}
/// <summary>
/// 层 2a — publishes the police phone app's data backbone: <c>SyncSpiritPoliceJobInfo</c>
/// (64349458), i.e. spiritId plus the whole <c>SpiritPoliceJobInfo</c> (duty counters, dispatch
/// slots, live violations, case records, fake-file archive).
///
/// Client path: <c>GameToClientImpl.SyncSpiritPoliceJobInfo</c> ->
/// <c>gPoliceJobManager.panelMgr:OnSpiritPoliceJobInfoSync</c> (PolicePanelManager.lua:156),
/// which indexes serviceData / weeklyServiceData / violationInfo / dispatchInfo / fakeFileInfo /
/// caseInfo by spiritId and then dereferences <c>DutyBasicInfo.ServiceData</c> 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. <see
/// cref="RuntimePayloadFactory.PoliceJobInfo4229938"/> 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.
/// </summary>
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");
}
/// <summary>
/// Republish the combat profile after a direct character switch.
/// <see cref="PublishCombatProfile"/> is one-shot per world entry (its
/// <c>CombatProfilePublished</c> 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.
/// </summary>
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");
}
/// <summary>
/// Publishes the character's authored urban job from <c>FightSpiritConfig.DefaultUrbanJob</c>
/// 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 <c>UrbanJobGrantMode = "single"</c> 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
/// <c>UrbanJobOverrides</c> to hand one specific character a career it was not authored with.
/// </summary>
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<T> 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");
}
/// <summary>
/// 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: <c>HackerHackTypeConfig</c> (48 rows)
/// prices every hack in battery points, so with no battery the client greys out the middle-mouse
/// hack verb and never emits <c>AskHack</c> — 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 <c>UrbanJobOf</c> lookup as the capability buffs, so any character
/// authored as job 401 gets it without touching a spirit id.
/// </summary>
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");
}
/// <summary>
/// 主角原生飞索能力门 —— <c>SyncPaokuLimitChange</c>(68974941) + <c>AbilityLimitConfig</c> 3013。
///
/// ★ 为什么要这一条(2026-09-26 调研,证据全在我们自己的 dump.cs):
/// · <c>AbilityLimitConfig.HasFeisuoAbility = 3013</c>(<c>dump.cs:1026235</c>),
/// 挂的 GamePlayTag 是 <c>State_HasFeisuoAbility = 2318</c>(<c>dump.cs:1226889</c>);
/// 该配置行还带 <c>ButtonEnum[]</c>(C 键)与 <c>QueryId</c>(有效目标查询)
/// ⇒ 它是<b>能力开关</b>,不是「限制」。
/// · 消费方 <c>PlayerParkourLimitModule</c>(<c>dump.cs:585090-585135</c>)的
/// <c>SetParkourLimitConfigState(uint limitId, bool isAdd)</c> → <c>SetGamePlayTagState</c>。
/// · 飞索 buff 一直在发,但本服所有日志里 <c>feisuo</c> 命中恒为 0 ⇒ 客户端
/// <b>从来没进过飞索蹲伏态</b>(<c>AskEnterFeiSuoCrouch</c> 一次都没发过)⇒ C 键没反应、没有白圈。
///
/// ⛑⛑ <c>unitId</c> 必须是<b>当前实际控制单位</b>:切人后是新的那个。
/// 上面 <c>PublishInitialCapabilityBuffs</c> 用的是 <c>Profile.InitialUnitId</c>(固定初始单位),
/// 这里不能跟着写死 —— 否则切一次人门就挂在旧身体上(与规则 177 同源的坑)。
///
/// ⛑ 白圈不用我们画:客户端 <c>LX6.PaoKu.FeiSuoJobManager</c>(<c>dump.cs:242164</c>)
/// 每帧在本地扫静态/动态飞索点,门一开白圈自己出现。别去画假准星、别把任意墙面改成可飞跃点。
///
/// ⛑ 回滚:<c>gameplay.webTraversal.pushFeisuoAbilityGate = false</c>。
///
/// ⛑⛑ 2026-09-26 17:5x 补发<b>暗杀门 5051</b>:飞索门 3013 只解决「蹲得下去」,
/// 暗杀检测由另一行 <c>AbilityLimitConfig.UnlockAssassin = 5051</c>
/// (<c>dump.cs:1026213</c> → <c>State_UnlockAssassin = 1333</c>,<c>dump.cs:1226857</c>)门控。
/// 实机证据:只发 3013 时 <c>AskEnterFeiSuoCrouch</c> 命中 19 次、
/// <c>ReportCanAssassinateEnemies</c> 命中 <b>0</b> 次 ⇒ 光补裁决回包没用,门必须先开。
/// 两张门同表同构,走同一个 <c>SyncPaokuLimitChange</c> 的 limitIds 列表。
/// </summary>
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");
}
/// <summary>
/// 角色专属能力门 —— 同一条 <c>SyncPaokuLimitChange</c>(68974941),一次可发多张
/// <c>AbilityLimitConfig.Id</c>(<c>gameplay.webTraversal.spiritAbilityGates</c>)。
/// 当前配置:15021040 璎珑 → 3040「虐杀原形跑酷」(ParkourState 10062,动作分支含地面
/// 疾跑 574 / 跳跃 575 / 边缘 577 / 爬墙 2502·2503,所以平地按空格与 shift 都该有反应);
/// 15020990 → 5040 连跳 + 5052 大跳。
///
/// ★ 为什么不塞进 <see cref="PublishProtagonistGrapnelGate4229938"/>:那张方法有
/// <c>IsProtagonist</c> 闸,会把所有非主角挡在门外;而这张门<b>只该给璎珑一个人</b> ——
/// 其余角色的 ABP 未必带 <c>MovementModeType.Yinglong</c>(10) 的动画数据,
/// 挂上 tag 也可能没表现(甚至动作异常)。
///
/// ⛑ 挂在 <c>PublishCombatProfile</c> 尾部 ⇒ 首次进入与切角色两条路径都覆盖
/// (<c>PublishCombatProfileOnSwitch4229938</c> 先清一次性闸再跑一遍本方法,
/// 此时 unitId/templateId 已经是新的控制单位)。
/// ⛑ 切离璎珑时<b>不补发 isAdd=false 摘门</b>:切角色即换 unit,门是
/// <c>PlayerParkourLimitModule</c>(unit module)上的状态,新单位本来就不带,
/// 补一包只会是 no-op。真要摘就把开关关掉重进。
/// ⛑ 回滚:<c>gameplay.webTraversal.pushKillingAbilityGate = false</c>。
/// </summary>
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)}]");
}
/// <summary>
/// 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.
/// </summary>
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);
// 原生飞索能力门放在这里 = 一次调用覆盖两条路径:
// ① 首次进入主角(本方法由首次移动包触发的延迟水合跑起来);
// ② 切角色(<c>PublishCombatProfileOnSwitch4229938</c> 先清闸门再调本方法,
// 且此时 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");
}
}