feat: Upgrade server with HyNB v2.4 modular architecture, minigames, admin GM web panel, companion summons, and car radio
This commit is contained in:
1 parent
74e2963a12
commit
3e5adf1dc5
1571 files changed
+336468
-6348
No files matched your search
@@ -1,9 +1,11 @@
|
||||
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;
|
||||
|
||||
@@ -18,29 +20,27 @@ internal sealed partial class GameRouter
|
||||
CombatWeaponDefinition weapon,
|
||||
CombatStyleDefinition style)
|
||||
{
|
||||
if (weapon.SpiritTemplateId == 15020992) // Taffy (塔菲)
|
||||
{
|
||||
// Taffy signature skills:
|
||||
// 51935209: 塔菲普攻1 (Hammer strikes)
|
||||
// 51935213: 塔菲重击 (Heavy slam)
|
||||
// 51935207: 通用闪避 (Dodge)
|
||||
// 51935230: 塔菲主动技_上车A (Taffy Monowheel Mount!)
|
||||
// 51935224: 塔菲大招 (超次元神速连打)
|
||||
await ctx.NotifyAsync(MethodId.SyncChangeCommonSkill, WorldCodec.SkillBinding(unitId, 51935209));
|
||||
await ctx.NotifyAsync(MethodId.SyncChangeHeavyAttack, WorldCodec.SkillBinding(unitId, 51935213));
|
||||
await ctx.NotifyAsync(MethodId.SyncChangeDodgeSkill, WorldCodec.SkillBinding(unitId, 51935207));
|
||||
await ctx.NotifyAsync(MethodId.SyncChangeControlSkill, CombatCodec.ControlBinding(unitId, style));
|
||||
await ctx.NotifyAsync(MethodId.SyncChangeActiveSkill, WorldCodec.SkillBinding(unitId, 51935230));
|
||||
await ctx.NotifyAsync(MethodId.SyncChangeUniqueSkill, WorldCodec.SkillBinding(unitId, 51935224));
|
||||
ctx.Session.Log.Info($"[COMBAT] bound signature Taffy Monowheel skill profile for unit={unitId}");
|
||||
return;
|
||||
}
|
||||
|
||||
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));
|
||||
await ctx.NotifyAsync(MethodId.SyncChangeActiveSkill, CombatCodec.ActiveBinding(unitId, weapon, 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));
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ internal sealed partial class GameRouter
|
||||
await ctx.NotifyAsync(MethodId.SyncFightResource,
|
||||
CombatCodec.FightResource(unitId, resourceId, maximum));
|
||||
await ctx.NotifyAsync(MethodId.SyncFightResourceFreeState,
|
||||
CombatCodec.FightResourceFreeState(unitId, resourceId, false));
|
||||
CombatCodec.FightResourceFreeState(unitId, resourceId, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,27 @@ internal sealed partial class GameRouter
|
||||
.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
|
||||
@@ -78,7 +99,7 @@ internal sealed partial class GameRouter
|
||||
string reason)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
var weapon = CombatCodec.DefaultWeapon(templateId);
|
||||
var weapon = ResolveEquippedWeapon(state, templateId);
|
||||
var style = ResolveWeaponStyle(state, weapon);
|
||||
|
||||
state.ActiveWeaponInstanceId = weapon.InstanceId;
|
||||
@@ -88,24 +109,17 @@ internal sealed partial class GameRouter
|
||||
// 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.
|
||||
var wearFashions = state.CustomWornFashionsBySpirit.TryGetValue(templateId, out var customWear)
|
||||
? new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = templateId,
|
||||
source = 0,
|
||||
spiritWearFashionsInfo = customWear,
|
||||
}
|
||||
: RuntimePayloadFactory.CharacterFashions(templateId);
|
||||
|
||||
await ctx.NotifyAsync(MethodId.SyncSetSpiritFashions, wearFashions);
|
||||
// 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));
|
||||
await PublishSelectedWeaponProfile(ctx, unitId, templateId, weapon, style);
|
||||
|
||||
ctx.Session.Log.Info($"[ACTOR-MIN] presentation unit={unitId} template={templateId} weapon={weapon.TemplateId}/{weapon.InstanceId} style={style.Id} reason={reason} combatModules=true skills=true");
|
||||
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(
|
||||
@@ -126,7 +140,9 @@ internal sealed partial class GameRouter
|
||||
// directly to gWeaponManager, so no incremental account-armory replay is needed here.
|
||||
state.AccountArmoryPublished = true;
|
||||
var actorArmoryCount = ClientConfigRepository.Characters()
|
||||
.SelectMany(character => CombatCodec.Loadout(character.TemplateId).Slots)
|
||||
.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()
|
||||
@@ -134,11 +150,17 @@ internal sealed partial class GameRouter
|
||||
ctx.Session.Log.Info($"[ARMORY-MIN] retained login actor-reference subset count={actorArmoryCount} fullAccount={CombatCatalogRepository.AccountWeapons.Count} incrementalAddReplay=false");
|
||||
}
|
||||
|
||||
var snapshot = CombatCodec.SpiritWeaponSnapshot(
|
||||
unitId,
|
||||
templateId,
|
||||
currentWeaponInstanceId,
|
||||
WeaponSlotDefinitions(state, templateId));
|
||||
// 搜打撤图里这张表必须是「拳头 + 私有武器 + 轮盘武器」,不能是角色的城镇 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)
|
||||
@@ -154,6 +176,13 @@ internal sealed partial class GameRouter
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -170,8 +199,36 @@ internal sealed partial class GameRouter
|
||||
|
||||
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);
|
||||
var buffs = WebTraversal.CapabilityBuffIds(templateId);
|
||||
// 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));
|
||||
@@ -181,11 +238,196 @@ internal sealed partial class GameRouter
|
||||
// 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.
|
||||
ctx.Session.Log.Info($"[BUFFS-MIN] {phase} unit={unitId} template={templateId} count={buffs.Count} firstInstance={firstInstanceId} bulk=false safeWebOnly=true legacyCatalogLoaded=false");
|
||||
// 白靖时缓的门闸排查:驾驶 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");
|
||||
}
|
||||
|
||||
async Task PublishInitialCapabilityBuffs(RpcContext ctx)
|
||||
/// <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;
|
||||
@@ -194,12 +436,163 @@ internal sealed partial class GameRouter
|
||||
|
||||
// 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 targetUnitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
|
||||
var firstInstanceId = state.NextBuffInstanceId;
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitBuffList,
|
||||
WorldCodec.UnitBuffList(targetUnitId, WebTraversal.InitialCapabilityBuffIds, firstInstanceId));
|
||||
WorldCodec.UnitBuffList(Profile.InitialUnitId, WebTraversal.InitialCapabilityBuffIds, firstInstanceId));
|
||||
state.NextBuffInstanceId += (uint)WebTraversal.InitialCapabilityBuffIds.Count;
|
||||
ctx.Session.Log.Info($"[CAPABILITY] initial unit={targetUnitId} count={WebTraversal.InitialCapabilityBuffIds.Count} loadingSafe=true allBuffsDeferred=true");
|
||||
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");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user