Files

599 lines
36 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using 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");
}
}