599 lines
36 KiB
C#
599 lines
36 KiB
C#
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");
|
||
}
|
||
|
||
}
|