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);
}
}