387 lines
18 KiB
C#
387 lines
18 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 搜打撤「武器轮盘」的武器实体化 —— 把轮盘 bag 里的枪变成 gWeaponManager 认得的武器。
|
||
///
|
||
/// <b>为什么必须有这一层</b>:搜打撤里有两套互不相干的武器系统。
|
||
/// · 轮盘 bag(99952004)= 物品存储。客户端 BackCircleAnantarkovStore 用
|
||
/// gExtractionShooterManager:GetItemInfoBySlot(slotIndex, wheel) 读它,只负责「画格子」。
|
||
/// · 实体化武器 = gWeaponManager.CurrWeaponSlots,由 SyncSpiritWeaponDetail /
|
||
/// SyncSpiritAddWeaponAction 灌进来,只管「切枪」。
|
||
/// 把物品挪进轮盘 bag 只动了第一套。Tab / 滚轮切枪走的是
|
||
/// WeaponManager:GetNextCircleWeapon → 只遍历 CurrWeaponSlots,
|
||
/// 而且 BackCircleAnantarkovStore.OnCircleButtonClick 是空的(点格子不切枪),
|
||
/// 所以第二套没跟上时,不管往轮盘里放了多少把枪,Tab 永远只在拳头和角色自带那把之间转。
|
||
///
|
||
/// <b>下标口径</b>(三处证据一致,见 <see cref="ExtractionShooterRuntime4229938.WheelHomeOffset4229938"/>):
|
||
/// CurrWeaponSlots 下标 0 = 拳头、1 = 私有武器、2 + k = 轮盘 CellX k。
|
||
///
|
||
/// <b>下发策略</b>:整表(SyncSpiritWeaponDetail)最稳、增量(Add/RemoveWeaponAction)最省,
|
||
/// 两种都留着并由配置切换(<c>world.content.extraction.wheelSync</c>)。
|
||
/// 增量一旦发现「当前武器要被摘掉」就自动退回整表,免得客户端 OnSwitchCurrentWeapon
|
||
/// 找不到人而打 "Not Find Current Weapon"。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>轮盘武器实体化的下发方式。</summary>
|
||
internal enum ExtractionWheelSyncMode4229938
|
||
{
|
||
/// <summary>完全不下发(排障用,等价于修复前的行为)。</summary>
|
||
Off,
|
||
|
||
/// <summary>只发变化的格(SyncSpiritAddWeaponAction / SyncSpiritRemoveWeaponAction)。</summary>
|
||
Incremental,
|
||
|
||
/// <summary>每次变动重发整表(SyncSpiritWeaponDetail)。默认。</summary>
|
||
Full,
|
||
}
|
||
|
||
/// <summary>
|
||
/// 当前下发方式。面板热改走 setter(不用改 json、不用重启),没动过时读配置。
|
||
///
|
||
/// <b>从 off 切回来必须丢一次增量基线</b> —— 期间客户端那张表可能已经被城镇 16 槽配装覆盖过,
|
||
/// 服务端手上的 <c>ExtractionWheelPublished</c> 已经不可信,留着它会让下一次增量算出差集为空
|
||
/// ("客户端有表、服务端也以为发过了" ⇒ 什么都不发 ⇒ Tab 还是切不到)。
|
||
/// 丢基线的动作在 <c>GameAdminBridge.SetExtractionOptions</c> 里做。
|
||
/// </summary>
|
||
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<ExtractionWheelSyncMode4229938>(raw, ignoreCase: true, out var mode)
|
||
? mode
|
||
: ExtractionWheelSyncMode4229938.Full;
|
||
}
|
||
set
|
||
{
|
||
s_wheelSyncOverride = value;
|
||
Console.WriteLine($"[EXTRACT] wheel sync mode set to {value}");
|
||
}
|
||
}
|
||
|
||
/// <summary>认不出的配置值按 <see cref="ExtractionWheelSyncMode4229938.Full"/> 处理。</summary>
|
||
private static ExtractionWheelSyncMode4229938 WheelSyncMode => ExtractionWheelSync;
|
||
|
||
/// <summary>
|
||
/// 让 gWeaponManager 跟上轮盘 bag 的当前内容。轮盘 bag 每变动一次、进图、切角色都要调。
|
||
///
|
||
/// 静默跳过的三种情况(都不是错误):
|
||
/// · 模式为 Off;
|
||
/// · state.Ready 还没置起 —— 世界还没起,客户端 gWeaponManager 还没建,
|
||
/// 这时候发过去会被丢掉,等世界就绪时那次调用才是有效的;
|
||
/// · 当前不在搜打撤图里 —— 轮盘 bag 属于搜打撤玩法,主城里没有这套 UI,
|
||
/// 乱发 SyncSpiritWeaponDetail 会把城镇的 16 槽武器盘覆盖成搜打撤的 9 槽。
|
||
/// </summary>
|
||
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}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 整表重发:SyncSpiritWeaponDetail(68255545)。客户端把它整体灌进
|
||
/// CurrWeaponSlots 并按 CurrentWeaponUid 切当前武器,所以顺带就能自愈
|
||
/// 「客户端已经把 WeaponManager 重建过」这类状态漂移。
|
||
/// </summary>
|
||
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}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把「此刻轮盘的内容」记成增量基线。整表下发之后必须调一次,否则下一次增量会拿着
|
||
/// 过期的镜像做差,把已经变过的格当成没变。
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 搜打撤版的武器表:[拳头, 私有武器, 轮盘 CellX 0..N-1]。
|
||
///
|
||
/// 城镇的 CombatCodec.SpiritWeaponSnapshot 用的是角色 16 槽配装,进图后要换成这一张 ——
|
||
/// PublishWeaponSnapshot(切场景后首次移动也会走一次)必须按同一口径发,
|
||
/// 否则城镇那张 16 槽表会把刚补好的轮盘覆盖掉,轮盘枪又从 CurrWeaponSlots 里消失。
|
||
/// </summary>
|
||
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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 搜打撤里 AskSwitchWeapon(index) 的解析:下标 0/1 取角色配装的拳头 / 私有武器,
|
||
/// 下标 ≥ 2 取轮盘 bag 里 CellX = index - 2 那把。
|
||
///
|
||
/// 只有这一条路径能把轮盘枪切成手上这把 —— 客户端轮盘界面的点击是空的
|
||
/// (BackCircleAnantarkovStore.OnCircleButtonClick 什么都不做),切枪全靠
|
||
/// Tab / 滚轮走 GetNextCircleWeapon 遍历 CurrWeaponSlots 后发这条请求,
|
||
/// 所以这里必须能把它按位置还原成袋子里那把枪。
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 增量:把「服务端镜像」和「轮盘 bag 现状」做差,只发变了的格。
|
||
///
|
||
/// 有一个必须退整表的情形:当前武器被摘掉了。增量只发 Remove 的话客户端会把那一格置空,
|
||
/// 但手上还拿着已不存在的 instance,之后所有技能请求都会解析失败。
|
||
/// 整表里带着合法的 CurrentWeaponUid,客户端自己会切过去,所以这里直接改走整表。
|
||
/// </summary>
|
||
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}"))}]");
|
||
}
|
||
|
||
/// <summary>把轮盘 bag 的现状按 CurrWeaponSlots 下标排成表。</summary>
|
||
private static Dictionary<int, ExtractionShooterRuntime4229938.WheelWeapon> WheelWeaponsByIndex(TcpSession session)
|
||
{
|
||
var map = new Dictionary<int, ExtractionShooterRuntime4229938.WheelWeapon>();
|
||
foreach (var weapon in ExtractionShooterRuntime4229938.WheelWeapons(
|
||
ExtractionShooterRuntime4229938.StateOf(session)))
|
||
{
|
||
map[(int)weapon.CellX + ExtractionShooterRuntime4229938.WheelHomeOffset4229938] = weapon;
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/// <summary>一次快照下发要用到的三件东西:角色模板、单位 id、当前武器实例。</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// CurrWeaponSlots[0](拳头)和 [1](私有武器)—— 这两个位置不属于轮盘,
|
||
/// 直接取角色自己的配装 Slots[0] / Slots[1],它们的流派 / 技能是作者配好的。
|
||
/// </summary>
|
||
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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 序列化前给一件实体化武器盖上真实的弹药 / 耐久 / 流派 / 子弹。
|
||
///
|
||
/// 轮盘武器的这些值活在 WorldEntryState(Handler 层)里,和城镇
|
||
/// PublishWeaponSnapshot 用的是同一套状态,所以两边口径天然一致:
|
||
/// 一把枪在城镇打完的弹量,进图后接着算,不会因为换了条下发路径就回满。
|
||
///
|
||
/// ⚠️ 用账号军械库查(<c>CombatCatalogRepository.Weapon(instanceId)</c>)而不是只在角色配装里查:
|
||
/// 轮盘里的枪是账号军械库的(PersonalWeapons 之外),查错会静默跳过 ⇒
|
||
/// 下发出去的枪 Durability/MagazineAmmo 全是模板默认值。
|
||
/// </summary>
|
||
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);
|
||
}
|
||
}
|