Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.ExtractionWeapons.cs

387 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}