using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 搜打撤「武器轮盘」的武器实体化 —— 把轮盘 bag 里的枪变成 gWeaponManager 认得的武器。 /// /// 为什么必须有这一层:搜打撤里有两套互不相干的武器系统。 /// · 轮盘 bag(99952004)= 物品存储。客户端 BackCircleAnantarkovStore 用 /// gExtractionShooterManager:GetItemInfoBySlot(slotIndex, wheel) 读它,只负责「画格子」。 /// · 实体化武器 = gWeaponManager.CurrWeaponSlots,由 SyncSpiritWeaponDetail / /// SyncSpiritAddWeaponAction 灌进来,只管「切枪」。 /// 把物品挪进轮盘 bag 只动了第一套。Tab / 滚轮切枪走的是 /// WeaponManager:GetNextCircleWeapon → 只遍历 CurrWeaponSlots, /// 而且 BackCircleAnantarkovStore.OnCircleButtonClick 是空的(点格子不切枪), /// 所以第二套没跟上时,不管往轮盘里放了多少把枪,Tab 永远只在拳头和角色自带那把之间转。 /// /// 下标口径(三处证据一致,见 ): /// CurrWeaponSlots 下标 0 = 拳头、1 = 私有武器、2 + k = 轮盘 CellX k。 /// /// 下发策略:整表(SyncSpiritWeaponDetail)最稳、增量(Add/RemoveWeaponAction)最省, /// 两种都留着并由配置切换(world.content.extraction.wheelSync)。 /// 增量一旦发现「当前武器要被摘掉」就自动退回整表,免得客户端 OnSwitchCurrentWeapon /// 找不到人而打 "Not Find Current Weapon"。 /// internal sealed partial class GameRouter { /// 轮盘武器实体化的下发方式。 internal enum ExtractionWheelSyncMode4229938 { /// 完全不下发(排障用,等价于修复前的行为)。 Off, /// 只发变化的格(SyncSpiritAddWeaponAction / SyncSpiritRemoveWeaponAction)。 Incremental, /// 每次变动重发整表(SyncSpiritWeaponDetail)。默认。 Full, } /// /// 当前下发方式。面板热改走 setter(不用改 json、不用重启),没动过时读配置。 /// /// 从 off 切回来必须丢一次增量基线 —— 期间客户端那张表可能已经被城镇 16 槽配装覆盖过, /// 服务端手上的 ExtractionWheelPublished 已经不可信,留着它会让下一次增量算出差集为空 /// ("客户端有表、服务端也以为发过了" ⇒ 什么都不发 ⇒ Tab 还是切不到)。 /// 丢基线的动作在 GameAdminBridge.SetExtractionOptions 里做。 /// private static ExtractionWheelSyncMode4229938? s_wheelSyncOverride; internal static ExtractionWheelSyncMode4229938 ExtractionWheelSync { get { if (s_wheelSyncOverride is { } forced) return forced; var raw = PrivateServerConfigStore.Current.World.Content.Extraction.WheelSync; return Enum.TryParse(raw, ignoreCase: true, out var mode) ? mode : ExtractionWheelSyncMode4229938.Full; } set { s_wheelSyncOverride = value; Console.WriteLine($"[EXTRACT] wheel sync mode set to {value}"); } } /// 认不出的配置值按 处理。 private static ExtractionWheelSyncMode4229938 WheelSyncMode => ExtractionWheelSync; /// /// 让 gWeaponManager 跟上轮盘 bag 的当前内容。轮盘 bag 每变动一次、进图、切角色都要调。 /// /// 静默跳过的三种情况(都不是错误): /// · 模式为 Off; /// · state.Ready 还没置起 —— 世界还没起,客户端 gWeaponManager 还没建, /// 这时候发过去会被丢掉,等世界就绪时那次调用才是有效的; /// · 当前不在搜打撤图里 —— 轮盘 bag 属于搜打撤玩法,主城里没有这套 UI, /// 乱发 SyncSpiritWeaponDetail 会把城镇的 16 槽武器盘覆盖成搜打撤的 9 槽。 /// internal static async Task PushExtractionWheelSyncAsync(TcpSession session, WorldEntryState state, string phase) { var mode = WheelSyncMode; if (mode == ExtractionWheelSyncMode4229938.Off) return; if (!state.Ready) { session.Log.Info($"[EXTRACT] wheel sync skipped ({phase}): world state not ready yet"); return; } if (!IsExtractionRaid(state.ActiveRaidId)) return; try { if (mode == ExtractionWheelSyncMode4229938.Incremental) await PushExtractionWheelDeltaAsync(session, state, phase); else await PushExtractionWheelSnapshotAsync(session, state, phase); } catch (Exception ex) { session.Log.Warn($"[EXTRACT] wheel sync ({phase}) failed: {ex.Message}"); } } /// /// 整表重发:SyncSpiritWeaponDetail(68255545)。客户端把它整体灌进 /// CurrWeaponSlots 并按 CurrentWeaponUid 切当前武器,所以顺带就能自愈 /// 「客户端已经把 WeaponManager 重建过」这类状态漂移。 /// internal static async Task PushExtractionWheelSnapshotAsync(TcpSession session, WorldEntryState state, string phase) { var (templateId, unitId, current) = WheelSyncTarget(state); if (templateId == 0) return; var body = BuildExtractionWheelSnapshot(session, state, unitId, templateId, current); await session.NotifyAsync(MethodId.SyncSpiritWeaponDetail, UxSerializer.Serialize(body), CancellationToken.None); RecordExtractionWheelPublished(session, state); var wheelCount = state.ExtractionWheelPublished.Count; // 服务端手上的武器必须和客户端切到的那把一致。进图时角色身上通常还是城镇那把, // 它不在轮盘表里,BuildExtractionWheelSnapshot 已经把它换成了私有武器, // 这里跟着更新,后续技能请求才能按同一把枪解析。 state.ActiveWeaponInstanceId = body.CurrentWeaponUid; session.Log.Info( $"[EXTRACT] wheel snapshot pushed ({phase}) spirit={templateId} unit={unitId} " + $"slots={body.WeaponSlots.Count} wheel={wheelCount} current={body.CurrentWeaponUid}"); } /// /// 把「此刻轮盘的内容」记成增量基线。整表下发之后必须调一次,否则下一次增量会拿着 /// 过期的镜像做差,把已经变过的格当成没变。 /// internal static void RecordExtractionWheelPublished(TcpSession session, WorldEntryState state) { var wheel = WheelWeaponsByIndex(session); lock (state.SyncRoot) { state.ExtractionWheelPublished.Clear(); foreach (var (index, weapon) in wheel) state.ExtractionWheelPublished[index] = weapon.InstanceId; } } /// /// 搜打撤版的武器表:[拳头, 私有武器, 轮盘 CellX 0..N-1]。 /// /// 城镇的 CombatCodec.SpiritWeaponSnapshot 用的是角色 16 槽配装,进图后要换成这一张 —— /// PublishWeaponSnapshot(切场景后首次移动也会走一次)必须按同一口径发, /// 否则城镇那张 16 槽表会把刚补好的轮盘覆盖掉,轮盘枪又从 CurrWeaponSlots 里消失。 /// internal static SceneMethods.SpiritWeaponDetail BuildExtractionWheelSnapshot( TcpSession session, WorldEntryState state, ulong unitId, uint templateId, ulong currentWeaponInstanceId) { var head = WheelHeadWeapons(state, templateId); return ExtractionShooterRuntime4229938.BuildSpiritWeaponDetailBody( ExtractionShooterRuntime4229938.StateOf(session), templateId, unitId, currentWeaponInstanceId, head.Fist, head.Private, detail => PrepareWheelWeaponDetail(state, templateId, detail)); } /// /// 搜打撤里 AskSwitchWeapon(index) 的解析:下标 0/1 取角色配装的拳头 / 私有武器, /// 下标 ≥ 2 取轮盘 bag 里 CellX = index - 2 那把。 /// /// 只有这一条路径能把轮盘枪切成手上这把 —— 客户端轮盘界面的点击是空的 /// (BackCircleAnantarkovStore.OnCircleButtonClick 什么都不做),切枪全靠 /// Tab / 滚轮走 GetNextCircleWeapon 遍历 CurrWeaponSlots 后发这条请求, /// 所以这里必须能把它按位置还原成袋子里那把枪。 /// private static CombatWeaponDefinition? ExtractionSwitchWeaponAt(TcpSession session, WorldEntryState state, int index) { if (index < 0) return null; var templateId = state.ActiveSpiritTemplateId; // 拳头 / 私有武器:永远是角色自己的配装,轮盘里放什么都不影响这两格。 if (index < ExtractionShooterRuntime4229938.WheelHomeOffset4229938) { var slots = WeaponSlotDefinitions(state, templateId); return index < slots.Count ? slots[index] : null; } var cellX = (uint)(index - ExtractionShooterRuntime4229938.WheelHomeOffset4229938); if (cellX >= ExtractionShooterRuntime4229938.WheelSlotCells4229938) return null; var weapon = ExtractionShooterRuntime4229938.WheelWeapons( ExtractionShooterRuntime4229938.StateOf(session)) .FirstOrDefault(x => x.CellX == cellX); if (weapon.InstanceId == 0) return null; // 先按「归属当前角色」查,查不到再退回不校验归属的账号查询(捐赠端 v1.7 同款顺序)。 return CombatCatalogRepository.WeaponByAccountInstance(weapon.InstanceId, templateId) ?? CombatCatalogRepository.Weapon(weapon.InstanceId); } /// /// 增量:把「服务端镜像」和「轮盘 bag 现状」做差,只发变了的格。 /// /// 有一个必须退整表的情形:当前武器被摘掉了。增量只发 Remove 的话客户端会把那一格置空, /// 但手上还拿着已不存在的 instance,之后所有技能请求都会解析失败。 /// 整表里带着合法的 CurrentWeaponUid,客户端自己会切过去,所以这里直接改走整表。 /// private static async Task PushExtractionWheelDeltaAsync(TcpSession session, WorldEntryState state, string phase) { var (templateId, unitId, current) = WheelSyncTarget(state); if (templateId == 0) return; var desired = WheelWeaponsByIndex(session); var changed = new List<(int Index, ulong Had, ExtractionShooterRuntime4229938.WheelWeapon? Now)>(); var needFull = false; ulong dropping = 0; lock (state.SyncRoot) { // 还没发过整表 → 没有基线。增量是「相对客户端现状」的差,没有现状就只能整表。 if (state.ExtractionWheelPublished.Count == 0) { needFull = true; } else { foreach (var index in state.ExtractionWheelPublished.Keys.Union(desired.Keys).OrderBy(x => x)) { var had = state.ExtractionWheelPublished.GetValueOrDefault(index); ExtractionShooterRuntime4229938.WheelWeapon? now = desired.TryGetValue(index, out var weapon) ? weapon : null; if (had == (now?.InstanceId ?? 0UL)) continue; if (had != 0 && had == current) dropping = had; changed.Add((index, had, now)); } } } if (needFull) { session.Log.Info($"[EXTRACT] wheel delta ({phase}) has no baseline -> full push"); await PushExtractionWheelSnapshotAsync(session, state, phase); return; } if (changed.Count == 0) { session.Log.Info($"[EXTRACT] wheel delta ({phase}) no change (wheel={desired.Count})"); return; } if (dropping != 0) { session.Log.Info( $"[EXTRACT] wheel delta ({phase}) would remove the equipped weapon {dropping} -> full push instead"); await PushExtractionWheelSnapshotAsync(session, state, phase); return; } var added = 0; foreach (var (index, had, now) in changed) { // 先摘后挂:同一个格换枪时实例 id 一定换了,只发 Add 的话旧的那把会一直挂在 // CurrWeaponSlots 里,Tab 转一圈会多出一把「幽灵枪」(选中它时技能全部解析失败)。 if (had != 0) { await session.NotifyAsync( MethodId.SyncSpiritRemoveWeaponAction, ExtractionShooterRuntime4229938.BuildRemoveWheelWeaponPayload(templateId, unitId, had), CancellationToken.None); } if (now is { } weapon) { await session.NotifyAsync( MethodId.SyncSpiritAddWeaponAction, ExtractionShooterRuntime4229938.BuildAddWheelWeaponPayload( templateId, unitId, weapon, detail => PrepareWheelWeaponDetail(state, templateId, detail)), CancellationToken.None); added++; } } int committed; lock (state.SyncRoot) { foreach (var (index, _, now) in changed) { if (now is { } weapon) state.ExtractionWheelPublished[index] = weapon.InstanceId; else state.ExtractionWheelPublished.Remove(index); } committed = state.ExtractionWheelPublished.Count; } session.Log.Info( $"[EXTRACT] wheel delta pushed ({phase}) changed={changed.Count} added={added} committed={committed} " + $"[{string.Join(" ", changed.Select(c => $"{c.Index}:{c.Had}->{c.Now?.InstanceId ?? 0}"))}]"); } /// 把轮盘 bag 的现状按 CurrWeaponSlots 下标排成表。 private static Dictionary WheelWeaponsByIndex(TcpSession session) { var map = new Dictionary(); foreach (var weapon in ExtractionShooterRuntime4229938.WheelWeapons( ExtractionShooterRuntime4229938.StateOf(session))) { map[(int)weapon.CellX + ExtractionShooterRuntime4229938.WheelHomeOffset4229938] = weapon; } return map; } /// 一次快照下发要用到的三件东西:角色模板、单位 id、当前武器实例。 private static (uint TemplateId, ulong UnitId, ulong CurrentWeapon) WheelSyncTarget(WorldEntryState state) { var templateId = state.ActiveSpiritTemplateId; var unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; return (templateId, unitId, state.ActiveWeaponInstanceId); } /// /// CurrWeaponSlots[0](拳头)和 [1](私有武器)—— 这两个位置不属于轮盘, /// 直接取角色自己的配装 Slots[0] / Slots[1],它们的流派 / 技能是作者配好的。 /// private static (SceneMethods.WeaponDetail? Fist, SceneMethods.WeaponDetail? Private) WheelHeadWeapons( WorldEntryState state, uint templateId) { var slots = WeaponSlotDefinitions(state, templateId); var fist = slots.Count > 0 ? slots[0] : null; var priv = slots.Count > 1 ? slots[1] : null; return (fist is null ? null : CombatCodec.WeaponDetail(fist), priv is null ? null : CombatCodec.WeaponDetail(priv)); } /// /// 序列化前给一件实体化武器盖上真实的弹药 / 耐久 / 流派 / 子弹。 /// /// 轮盘武器的这些值活在 WorldEntryState(Handler 层)里,和城镇 /// PublishWeaponSnapshot 用的是同一套状态,所以两边口径天然一致: /// 一把枪在城镇打完的弹量,进图后接着算,不会因为换了条下发路径就回满。 /// /// ⚠️ 用账号军械库查(CombatCatalogRepository.Weapon(instanceId))而不是只在角色配装里查: /// 轮盘里的枪是账号军械库的(PersonalWeapons 之外),查错会静默跳过 ⇒ /// 下发出去的枪 Durability/MagazineAmmo 全是模板默认值。 /// private static void PrepareWheelWeaponDetail(WorldEntryState state, uint templateId, SceneMethods.WeaponDetail detail) { var definition = CombatCatalogRepository.WeaponByAccountInstance(detail.InstanceId, templateId) ?? CombatCatalogRepository.Weapon(detail.InstanceId); if (definition is null) return; detail.FightStyleId = PublishedWeaponFightStyleId(state, definition); detail.MagazineAmmo = EnsureMagazine4229938(state, definition); detail.Durability = CurrentDurability4229938(state, definition, detail.MagazineAmmo); detail.BulletDatas.BulletId = EnsureBullet4229938(state, definition); if (definition.SeparateBullets && detail.BulletDatas.BulletId != 0) _ = EnsureReserve4229938(state, detail.BulletDatas.BulletId); } }