Files
Ananta-ps/Ananta.Server/Ananta.Core/Protocol/Client4229938/ExtractionShooterRuntime4229938.cs
T

1272 lines
53 KiB
C#
Raw 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.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>一件落地的物品。<c>ConfigId</c> 是配置行 id(9993xxxx),下发时才换成 ConsumableId。</summary>
internal sealed class ExtractionShooterItem
{
internal uint ConfigId;
internal uint StackCount = 1;
internal int CellX;
internal int CellY;
internal bool IsRotated;
internal bool IsLocked;
internal ulong BindPid;
/// <summary>按旋转换算后的实际占格 (宽, 高)。配置缺失时按 1x1 兜底。</summary>
internal (int W, int H) Footprint()
{
var meta = ExtractionShooterCatalog4229938.Item(ConfigId);
var vx = meta?.VolumeX ?? 1;
var vy = meta?.VolumeY ?? 1;
return ExtractionGrid4229938.Rotate(vx, vy, IsRotated);
}
/// <summary>不旋转换算(打包新的 items 时用)。</summary>
internal static (int W, int H) FootprintOf(uint configId, bool isRotated)
{
var meta = ExtractionShooterCatalog4229938.Item(configId);
return ExtractionGrid4229938.Rotate(meta?.VolumeX ?? 1, meta?.VolumeY ?? 1, isRotated);
}
internal void PlaceAt(int x, int y, bool rotated)
{
CellX = x;
CellY = y;
IsRotated = rotated;
}
}
internal static class ExtractionShooterGrid
{
/// <summary>命中 (x,y) 的物品 —— 注意 x,y 可能落在某个大件的覆盖区中间,不是原点。</summary>
internal static ExtractionShooterItem? ItemAt(IEnumerable<ExtractionShooterItem> items, int x, int y)
=> items.FirstOrDefault(item =>
{
var (w, h) = item.Footprint();
return x >= item.CellX && x < item.CellX + w && y >= item.CellY && y < item.CellY + h;
});
}
/// <summary>一个 bag 的服务端镜像:格数 + 占位图 + 物品清单。</summary>
internal sealed class ExtractionShooterBagState
{
internal uint BagId;
internal ExtractionShooterBagMeta Meta = null!;
internal List<ExtractionShooterItem> Items = [];
internal ExtractionGrid4229938 Grid = new(1, 1);
internal bool IsSlotGroup => ExtractionShooterCatalog4229938.IsSlotGroup(Meta);
/// <summary>物品清单改过之后重建占位图。</summary>
internal void RebuildOccupancy()
{
Grid = new ExtractionGrid4229938(Meta.CapacityX <= 0 ? 1 : Meta.CapacityX,
Meta.CapacityY <= 0 ? 1 : Meta.CapacityY);
OccupyAll();
}
internal void OccupyAll()
{
foreach (var item in Items)
Grid.Occupy(item.CellX, item.CellY, item.Footprint().W, item.Footprint().H);
}
}
/// <summary>一个容器实例。实例 id 由客户端给出(8 字节),服务端只认这一份。</summary>
internal sealed class ExtractionShooterContainerState
{
internal ulong InstanceId;
internal uint CfgId;
internal int SizeX = 1;
internal int SizeY = 1;
internal List<ExtractionShooterItem> Items = [];
internal ExtractionGrid4229938 Grid = new(1, 1);
/// <summary>pid → 该玩家搜索完成的服务器秒数。<b>空 = 未搜索 = 客户端隐藏图标</b>。</summary>
internal Dictionary<ulong, uint> SearchFinish = [];
/// <summary>
/// 已经把 <see cref="SearchFinish"/> 翻成过去的 pid。
///
/// ⛑️ 这一位是「能不能看见东西」的开关:搜索刚发起时 <see cref="SearchFinish"/> 落在<b>未来</b>
/// (客户端据此播读条),只有等时间到了、把它翻回过去,物品才会真正显示。
/// 捐赠端 v1.7 的实机日志(09-24 17:07:04 → 17:07:05 → 17:07:06)逐帧证明了这个顺序:
/// 推「未来」帧 → 隔 N 秒推「已揭晓」帧 → 玩家才取得到东西。
/// </summary>
internal HashSet<ulong> Revealed = [];
/// <summary>整箱揭晓的基准延时:箱内最长的那件 SearchTime。</summary>
internal int RevealDelaySeconds = 1;
internal void RebuildOccupancy()
{
Grid = new ExtractionGrid4229938(SizeX, SizeY);
foreach (var item in Items)
Grid.Occupy(item.CellX, item.CellY, item.Footprint().W, item.Footprint().H);
}
}
/// <summary>
/// 每条连接的搜打撤状态,存 <c>session.Items</c>(与 <c>GameRouter.SceneItemGrid</c> 同套路)。
/// </summary>
internal sealed class ExtractionShooterSessionState
{
internal object SyncRoot { get; } = new();
internal Dictionary<uint, ExtractionShooterBagState> Bags = [];
internal Dictionary<ulong, ExtractionShooterContainerState> Containers = [];
internal HashSet<uint> UnlockedExpansions = [];
internal HashSet<uint> UnlockedBags = [];
internal uint TokenCount;
internal bool Seeded;
/// <summary>
/// 本局开始的时间戳(秒)—— 结算帧 <c>SyncRaidSettlement</c> 里的
/// <c>EnterRaidTime</c> / <c>BattleTime</c> 就取它。
/// <see cref="Seed"/> 每次重跑都会刷新(换局),<see cref="Reset"/> 不清它,
/// 因为退图那一刀要用「退图时间 - 进图时间」算这一局打了多久。
/// </summary>
internal uint EnterRaidSeconds;
}
/// <summary>
/// 搜打撤运行时:编目 → per-session 状态 → wire payload。
///
/// 本轮覆盖:登录帧注入、进图、容器刷物与搜索、背包搬运 / 丢弃 / 锁定 / 排序 / 入仓、容器↔背包搬运。
/// <b>不含</b>武器实体化(轮盘 Tab 切枪)—— 那需要 <c>CombatCatalogRepository</c> 的实例 id
/// 与 SyncSpiritWeaponDetail 整表,是第二轮的事。
///
/// 三条容易漏的:
/// 1. <b>下发 id 用 ConsumableId</b>(<see cref="ExtractionShooterCatalog4229938.WireIdFor"/>),
/// 配置行 id 只在服务端内部记。
/// 2. <b>搜索时间戳必须落在未来</b>:客户端 wait 秒数被钳负为 0 ⇒ 过去时间戳 = wait(0) 空转,
/// 动画一帧不播。
/// 3. <b>堆叠数量按服务端规则生成</b>(<see cref="StackCountForLocked"/>)。写 1 会让客户端把角标
/// 整个藏掉(<c>showCountCtrl = StackCount &lt;= 1</c>),看起来像"子弹只有 1 发"。
/// </summary>
internal static class ExtractionShooterRuntime4229938
{
private const string SessionKey = "client4229938-extraction-shooter";
internal static ExtractionShooterSessionState StateOf(Ananta.SDK.Network.TcpSession session)
{
if (session.Items.TryGetValue(SessionKey, out var existing)
&& existing is ExtractionShooterSessionState typed)
return typed;
var created = new ExtractionShooterSessionState();
session.Items[SessionKey] = created;
return created;
}
// ------------------------------------------------------------------ seeding
/// <summary>本局开放的包。取自 GamePlayTypeConfig 的 StorageSet / EquipSlotSet。</summary>
internal static readonly uint[] DefaultBagIds =
[
ExtractionShooterCatalog4229938.BackpackBagId, // 99952001 背包 4x4
ExtractionShooterCatalog4229938.SafeBoxBagId, // 99952002 安全箱 2x1
ExtractionShooterCatalog4229938.FirstStashBagId, // 99952003 一号仓库 7x15
ExtractionShooterCatalog4229938.WheelBagId, // 99952004 武器轮盘 8 槽
];
/// <summary>开局建包。幂等 —— <see cref="Reset"/> 之后也能再跑一遍。</summary>
internal static int Seed(ExtractionShooterSessionState owner)
{
lock (owner.SyncRoot)
{
owner.Bags.Clear();
foreach (var id in DefaultBagIds)
{
var meta = ExtractionShooterCatalog4229938.Bags.FirstOrDefault(b => b.Id == id);
if (meta is null)
continue;
var bag = new ExtractionShooterBagState { BagId = id, Meta = meta };
bag.RebuildOccupancy();
owner.Bags[id] = bag;
owner.UnlockedBags.Add(id);
}
owner.Seeded = true;
// 换局 ⟹ 重新计时;这一句是上个私有字段 EnterRaidSeconds 的唯一写入点。
owner.EnterRaidSeconds = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return owner.Bags.Count;
}
}
/// <summary>换局 / 退图:丢掉容器与全部物品。<b>不重置</b>已解锁的包。</summary>
internal static void Reset(ExtractionShooterSessionState owner)
{
lock (owner.SyncRoot)
{
owner.Containers.Clear();
foreach (var bag in owner.Bags.Values)
{
bag.Items.Clear();
bag.RebuildOccupancy();
}
owner.Seeded = false;
}
}
// ------------------------------------------------------------------ login frame
/// <summary>
/// 把状态镜像写进登录帧的那个 <c>PlayerExtractionShooterInfo</c>。
///
/// ⛑ <c>Bags</c> 与 <c>SlotGroups</c> 必须<b>二选一</b>:客户端
/// <c>GetBagInfoByConfigId = Bags[id] or SlotGroups[id]</c>(Bags 优先),而渲染轮盘必调的
/// <c>CheckSlotIndexHasUnlocked</c> 读 <c>AddUnlockedCount</c> —— 只有 SlotGroupInfo 有。
/// 两边都发会让后者直接崩面板。
/// </summary>
internal static void FillLoginInfo(ExtractionShooterSessionState owner,
Ananta.Server.RpcTypes.Client4229938.Auto.PlayerExtractionShooterInfo info)
{
lock (owner.SyncRoot)
{
foreach (var bag in owner.Bags.Values)
{
var items = bag.Items.Select(BuildItemInfo).ToList();
if (bag.IsSlotGroup)
{
info.SlotGroups[bag.BagId] =
new Ananta.Server.RpcTypes.Client4229938.Auto.ExtractionShooterSlotGroupInfo
{
BagId = bag.BagId,
ItemInfoList = items,
AddUnlockedCount = (uint)bag.Meta.SlotInitial,
AddMaxCapacity = (uint)bag.Meta.SlotMax,
};
}
else
{
info.Bags[bag.BagId] =
new Ananta.Server.RpcTypes.Client4229938.Auto.ExtractionShooterBagInfo
{
BagId = bag.BagId,
ItemInfoList = items,
};
}
}
foreach (var id in owner.UnlockedBags)
info.UnlockedBagIds[id] = true;
foreach (var id in owner.UnlockedExpansions)
info.UnlockedExpansionIds[id] = true;
info.TokenCount = owner.TokenCount;
}
}
private static Ananta.Server.RpcTypes.Client4229938.Auto.ExtractionShooterItemInfo BuildItemInfo(
ExtractionShooterItem item)
=> new()
{
Id = ExtractionShooterCatalog4229938.WireIdFor(item.ConfigId),
StackCount = item.StackCount,
CellX = (uint)item.CellX,
CellY = (uint)item.CellY,
IsRotated = item.IsRotated,
IsLocked = item.IsLocked,
BindPid = item.BindPid,
ShieldValue = 0f,
// 武器(ReadInfoType=1)带上 WeaponData,客户端才连得上「这一格」和「那件实体化武器」:
// BackCircleAnantarkovStore.OnRenderPopupItemDetail 传 UniqueId = itemInfo.WeaponData.InstanceId,
// 而 GetTarkovWeaponByInstanceId 是遍历所有包找 WeaponData.InstanceId == itemUid 的那一件 ——
// 恒 null 时枪的 tooltip 永远退化成普通物品描述。
// ⛑ InstanceId 必须来自账号军械库(WeaponInstanceOf),自造 ⇒ 技能全掉回默认武器。
WeaponData = WheelWeaponData(ExtractionShooterCatalog4229938.WireIdFor(item.ConfigId)),
CollectionBook = null,
};
// ------------------------------------------------------------------ loot roll
/// <summary>
/// 堆叠数量。客户端 <c>showCountCtrl = StackCount &lt;= 1</c> 会把角标藏掉,所以
/// "子弹只显示 1 发"其实是这条 —— 不是少发了。
/// </summary>
internal static uint StackCountForLocked(int maxStack, Random rng)
{
if (maxStack <= 1)
return 1;
if (maxStack <= 20)
return (uint)rng.Next(1, maxStack + 1);
return (uint)rng.Next(maxStack * 2 / 5, maxStack + 1);
}
private static int WeightedIndex(IReadOnlyList<int> weights, Random rng)
{
long total = 0;
foreach (var w in weights)
total += w;
if (total <= 0)
return weights.Count - 1;
var roll = rng.NextInt64(total);
long acc = 0;
for (var i = 0; i < weights.Count; i++)
{
acc += weights[i];
if (roll < acc)
return i;
}
return weights.Count - 1;
}
/// <summary>从某个随机组里抽一件物品配置 id。组不存在或为空时返回 0。</summary>
private static uint RollFromGroup(uint groupId, Random rng)
{
var group = ExtractionShooterCatalog4229938.Group(groupId);
if (group is null || group.Items.Count == 0)
return 0;
var idx = WeightedIndex(group.Items.Select(i => i.Weight).ToList(), rng);
return group.Items[idx].ItemId;
}
/// <summary>
/// 战利品的确定性 RNG:同一个容器实例在这一局内怎么搜都一样,换一局重新洗
/// (盐来自 <see cref="ExtractionShooterCatalog4229938.SessionSalt"/>)。
/// </summary>
private static Random RngFor(ulong instanceId)
{
var mixed = unchecked((long)(instanceId ^ ExtractionShooterCatalog4229938.SessionSalt));
// Random 的种子是 int —— 把两个 32 位折起来,别让低位差异被高位吃掉。
return new Random(unchecked((int)(mixed ^ (mixed >> 32))));
}
// ------------------------------------------------------------------ containers
/// <summary>
/// 刷一个容器(已存在就直接返回)。<b>容量选择不加盐</b>(同一个箱子应该一直是那个格子数),
/// <b>内容加盐</b>(跨局重刷)。返回 null = 候选池为空,这个箱子真是空的。
/// </summary>
internal static ExtractionShooterContainerState? EnsureContainer(
ExtractionShooterSessionState owner, ulong instanceId)
{
lock (owner.SyncRoot)
{
if (owner.Containers.TryGetValue(instanceId, out var existing))
return existing;
var meta = ExtractionShooterCatalog4229938.PickContainer(instanceId);
if (meta is null)
return null;
var rng = RngFor(instanceId);
var state = new ExtractionShooterContainerState
{
InstanceId = instanceId,
CfgId = meta.Id,
SizeX = meta.CapacityX,
SizeY = meta.CapacityY,
};
state.RebuildOccupancy();
var target = meta.QuantityMin;
if (meta.QuantityMax > meta.QuantityMin)
target = rng.Next(meta.QuantityMin, meta.QuantityMax + 1);
target = Math.Max(target, 0);
var clamp = PrivateServerConfigStore.Current.World.Content.Extraction.MaxRollPer;
if (clamp > 0 && target > clamp)
target = clamp;
var chosen = new List<uint>();
// 必刷优先:这些是关卡设计要保证的钥匙类道具。
foreach (var certain in meta.CertainItemList)
if (certain != 0)
chosen.Add(certain);
while (chosen.Count < target && meta.RandomItemGroups.Count > 0)
{
var gi = WeightedIndex(meta.RandomItemGroups.Select(g => g.Weight).ToList(), rng);
var rolled = RollFromGroup(meta.RandomItemGroups[gi].GroupId, rng);
if (rolled == 0)
break; // 组是空的,再抽也是空 —— 别死循环。
chosen.Add(rolled);
}
foreach (var configId in chosen)
{
var itemMeta = ExtractionShooterCatalog4229938.Item(configId);
if (itemMeta is null)
continue;
if (!state.Grid.TryFirstFitWithRotation(itemMeta.VolumeX, itemMeta.VolumeY,
out var x, out var y, out var rotated))
continue; // 箱子放不下了 —— 客户端也是这么截断的。
var item = new ExtractionShooterItem
{
ConfigId = configId,
StackCount = StackCountForLocked(itemMeta.MaxStackNum, rng),
};
item.PlaceAt(x, y, rotated);
state.Grid.Occupy(x, y, item.Footprint().W, item.Footprint().H);
state.Items.Add(item);
state.RevealDelaySeconds = Math.Max(state.RevealDelaySeconds, itemMeta.SearchTime);
}
owner.Containers[instanceId] = state;
return state;
}
}
/// <summary>
/// 搜索一次容器。返回 false = 已经搜过了(或容器不存在)—— 客户端重复搜不该再揭一遍。
///
/// ⛑ 时间戳<b>必须落在未来</b>(now + 每件自己的 SearchTime)。
/// </summary>
internal static bool TrySearchContainer(ExtractionShooterSessionState owner, ulong instanceId,
ulong pid, uint nowSeconds)
{
var state = EnsureContainer(owner, instanceId);
if (state is null)
return false;
lock (owner.SyncRoot)
{
if (state.SearchFinish.ContainsKey(pid))
return false;
var finish = (uint)(nowSeconds + Math.Max(1, state.RevealDelaySeconds));
foreach (var item in state.Items)
{
var meta = ExtractionShooterCatalog4229938.Item(item.ConfigId);
if (meta is not null)
finish = Math.Max(finish, nowSeconds + (uint)Math.Max(1, meta.SearchTime));
}
// 时间戳挂在玩家自己的 pid 上 ⇒ 多人各自进度独立。
state.SearchFinish[pid] = finish;
return true;
}
}
internal static bool IsSearchFinished(ExtractionShooterContainerState container, ulong pid, uint nowSeconds)
=> container.SearchFinish.TryGetValue(pid, out var until) && nowSeconds >= until;
/// <summary>这个 pid 在这个箱子上是否已经揭晓过(揭晓过就不用再补推)。</summary>
internal static bool IsContainerRevealed(ExtractionShooterSessionState owner, ulong instanceId, ulong pid)
{
lock (owner.SyncRoot)
return owner.Containers.TryGetValue(instanceId, out var state) && state.Revealed.Contains(pid);
}
/// <summary>
/// 读条时长(秒)。取整箱最长的那件 SearchTime,至少 1 秒 —— 传 0 会立刻揭晓,读条动画一帧不播。
/// </summary>
internal static int ContainerSearchDelaySeconds(ExtractionShooterSessionState owner, ulong instanceId)
{
lock (owner.SyncRoot)
return owner.Containers.TryGetValue(instanceId, out var state)
? Math.Max(1, state.RevealDelaySeconds)
: 0;
}
/// <summary>
/// 把一个箱子对某个 pid 标成「已搜完」:把完成时间戳翻到<b>过去</b>(now - 5),
/// 客户端据此把物品从「读条中」切成「可拿」。
///
/// 返回 false = 容器不存在 / 本来就揭过了 —— 调用方据此跳过补推,别白推一帧。
/// </summary>
internal static bool TryRevealContainer(ExtractionShooterSessionState owner, ulong instanceId,
ulong pid, uint nowSeconds)
{
lock (owner.SyncRoot)
{
if (!owner.Containers.TryGetValue(instanceId, out var state))
return false;
if (!state.Revealed.Add(pid))
return false;
state.SearchFinish[pid] = nowSeconds > 5 ? nowSeconds - 5 : 0u;
return true;
}
}
/// <summary>
/// 容器的 wire payload。未搜索(<c>SearchFinish</c> 为空)时字典留空 —— 客户端据此隐藏图标。
/// 每一项都挂同一份「谁搜完了」的字典:客户端是按项分发的。
/// </summary>
internal static SceneMethods.ExtractionShooterContainerInfo BuildContainerInfo(
ExtractionShooterContainerState container)
{
var info = new SceneMethods.ExtractionShooterContainerInfo { CfgId = container.CfgId };
lock (container.Items)
{
foreach (var item in container.Items)
{
var row = new SceneMethods.ExtractionShooterContainerItemInfo
{
Id = ExtractionShooterCatalog4229938.WireIdFor(item.ConfigId),
StackCount = item.StackCount,
CellX = (uint)item.CellX,
CellY = (uint)item.CellY,
IsRotated = item.IsRotated,
IsLocked = item.IsLocked,
BindPid = item.BindPid,
ShieldValue = 0f,
WeaponData = null,
CollectionBook = null,
};
foreach (var kv in container.SearchFinish)
row.Pid2SearchFinishTimeDict[kv.Key] = kv.Value;
info.ItemList.Add(row);
}
}
return info;
}
// ------------------------------------------------------------------ bag ops
internal static ExtractionShooterItem? ItemAt(ExtractionShooterBagState bag, int x, int y)
=> ExtractionShooterGrid.ItemAt(bag.Items, x, y);
private static bool TryFit(ExtractionShooterBagState bag, uint configId, bool rotated,
int atX, int atY)
{
var meta = ExtractionShooterCatalog4229938.Item(configId);
if (meta is null)
return false;
var (w, h) = ExtractionGrid4229938.Rotate(meta.VolumeX, meta.VolumeY, rotated);
return bag.Grid.CanPlace(atX, atY, w, h);
}
private static bool TryFitFree(ExtractionShooterBagState bag, uint configId,
out int x, out int y, out bool rotated)
{
var meta = ExtractionShooterCatalog4229938.Item(configId);
if (meta is null)
{
x = y = 0;
rotated = false;
return false;
}
return bag.Grid.TryFirstFitWithRotation(meta.VolumeX, meta.VolumeY, out x, out y, out rotated);
}
/// <summary>
/// 六合一搬运(handoff §6.4「装备 / 卸下 / 放入安全箱 / 携带 / 放回仓库 / 旋转」全走这一条):
/// 同包同格 = 旋转、同包异格 = 挪、跨包 = 搬。
/// </summary>
internal static bool TryMoveBagItem(ExtractionShooterSessionState owner,
uint srcBag, int fromX, int fromY, uint destBag, int toX, int toY, bool rotated)
{
lock (owner.SyncRoot)
{
if (!owner.Bags.TryGetValue(srcBag, out var src) || !owner.Bags.TryGetValue(destBag, out var dst))
return false;
var item = ItemAt(src, fromX, fromY);
if (item is null)
return false;
var meta = ExtractionShooterCatalog4229938.Item(item.ConfigId);
if (meta is null)
return false;
var (oldW, oldH) = item.Footprint();
var (newW, newH) = ExtractionGrid4229938.Rotate(meta.VolumeX, meta.VolumeY, rotated);
if (ReferenceEquals(src, dst))
{
src.Grid.Release(item.CellX, item.CellY, oldW, oldH);
if (!src.Grid.CanPlace(toX, toY, newW, newH))
{
src.Grid.Occupy(item.CellX, item.CellY, oldW, oldH);
return false;
}
src.Grid.Occupy(toX, toY, newW, newH);
item.PlaceAt(toX, toY, rotated);
return true;
}
// 跨包:目标包接不下就<b>不动源包</b>,避免物品凭空消失。
if (dst.Meta.AllowedItemTypes.Count > 0 && !dst.Meta.AllowedItemTypes.Contains((uint)meta.Type))
return false;
if (!dst.Grid.CanPlace(toX, toY, newW, newH))
return false;
src.Grid.Release(item.CellX, item.CellY, oldW, oldH);
src.Items.Remove(item);
dst.Grid.Occupy(toX, toY, newW, newH);
item.PlaceAt(toX, toY, rotated);
dst.Items.Add(item);
return true;
}
}
internal static bool TryRemoveBagItem(ExtractionShooterSessionState owner, uint bagId, int x, int y)
{
lock (owner.SyncRoot)
{
if (!owner.Bags.TryGetValue(bagId, out var bag))
return false;
var item = ItemAt(bag, x, y);
if (item is null)
return false;
// 绑定的一律不让扔:那是退还型的任务物品,客户端本来也不发这种请求。
if (item.BindPid != 0)
return false;
bag.Grid.Release(item.CellX, item.CellY, item.Footprint().W, item.Footprint().H);
bag.Items.Remove(item);
return true;
}
}
internal static bool TryLockBagItem(ExtractionShooterSessionState owner, uint bagId, int x, int y, bool locked)
{
lock (owner.SyncRoot)
{
if (!owner.Bags.TryGetValue(bagId, out var bag))
return false;
var item = ItemAt(bag, x, y);
if (item is null)
return false;
item.IsLocked = locked;
return true;
}
}
/// <summary>
/// 拆分:在原位的邻位再生成一件。
/// </summary>
internal static bool TrySplitBagItem(ExtractionShooterSessionState owner, uint bagId, int x, int y,
uint takeCount, bool rot)
{
lock (owner.SyncRoot)
{
if (!owner.Bags.TryGetValue(bagId, out var bag))
return false;
var item = ItemAt(bag, x, y);
if (item is null || item.StackCount <= 1)
return false;
takeCount = Math.Min(takeCount == 0 ? item.StackCount - 1 : takeCount, item.StackCount - 1);
if (takeCount == 0)
return false;
if (!TryFitFree(bag, item.ConfigId, out var nx, out var ny, out var rotated))
return false;
item.StackCount -= takeCount;
var split = new ExtractionShooterItem
{
ConfigId = item.ConfigId,
StackCount = takeCount,
};
split.PlaceAt(nx, ny, rotated);
bag.Grid.Occupy(nx, ny, split.Footprint().W, split.Footprint().H);
bag.Items.Add(split);
return true;
}
}
/// <summary>
/// 整理:按占格面积降序重排(大件先塞)—— 这是所有背包 UI 的玩家直觉。
/// 排完必须整包重推:客户端的渲染序号与服务端的 CellX/CellY 已经不同了。
/// </summary>
internal static bool TrySortBag(ExtractionShooterSessionState owner, uint bagId)
{
lock (owner.SyncRoot)
{
if (!owner.Bags.TryGetValue(bagId, out var bag))
return false;
// 轮盘 / 战备 / 扩容是按 CellX 当槽位序号用的,重排会把"第几格"打乱。
if (bag.IsSlotGroup)
return false;
var ordered = bag.Items
.OrderByDescending(i => { var f = i.Footprint(); return f.W * f.H; })
.ThenBy(i => i.ConfigId)
.ToList();
bag.Items.Clear();
bag.RebuildOccupancy();
foreach (var item in ordered)
{
if (!TryFitFree(bag, item.ConfigId, out var x, out var y, out var rotated))
continue; // 理论上不会发生:这些件本来就在包里。
item.PlaceAt(x, y, rotated);
bag.Grid.Occupy(x, y, item.Footprint().W, item.Footprint().H);
bag.Items.Add(item);
}
return true;
}
}
/// <summary>找第一个能放下的空位,生成一件新物品(GM 发物品用)。</summary>
internal static ExtractionShooterItem? TryAddItem(ExtractionShooterSessionState owner, uint bagId,
uint configId, uint stackCount)
{
var meta = ExtractionShooterCatalog4229938.Item(configId);
if (meta is null)
return null;
lock (owner.SyncRoot)
{
if (!owner.Bags.TryGetValue(bagId, out var bag))
return null;
if (bag.Meta.AllowedItemTypes.Count > 0 && !bag.Meta.AllowedItemTypes.Contains((uint)meta.Type))
return null;
if (!TryFitFree(bag, configId, out var x, out var y, out var rotated))
return null;
var item = new ExtractionShooterItem
{
ConfigId = configId,
StackCount = stackCount == 0 ? StackCountForLocked(meta.MaxStackNum, Random.Shared) : stackCount,
};
item.PlaceAt(x, y, rotated);
bag.Grid.Occupy(x, y, item.Footprint().W, item.Footprint().H);
bag.Items.Add(item);
return item;
}
}
/// <summary>
/// 容器 → 背包 / 仓库(67257572)。
///
/// ⛑ 参数是客户端发的那一串:<c>container, fromX, fromY, destBag, toX, toY, isRotated</c>。
/// 第一版把第 4 个字段当成 "源 x",导致 destBag 读到的是 fromX(垃圾值)⇒ 任何搬运都失败
/// —— 这就是捐赠端实机看到的「容器里的东西搬不到背包 / 双击像复制一份」(§14.8 / §14.10)。
///
/// toX / toY 是客户端自己算好的落点。放得下就用它;放不下退回 first-fit ——
/// 客户端也会自己校验一遍,两边不一致的表现是「拖过去弹回」,所以服务端宽容一点没坏处。
/// </summary>
internal static bool TryShiftContainerItemToBag(ExtractionShooterSessionState owner, ulong instanceId,
int fromX, int fromY, uint destBag, int toX, int toY, bool isRotated)
{
var container = EnsureContainer(owner, instanceId);
if (container is null)
return false;
lock (owner.SyncRoot)
{
var srcItem = ExtractionShooterGrid.ItemAt(container.Items, fromX, fromY);
if (srcItem is null)
return false;
if (!owner.Bags.TryGetValue(destBag, out var dst))
return false;
// meta 取不到就别放行 —— 后面的 Footprint / 类型过滤都要用它,
// donor 当年就是这里 NRE 崩在处理器里(§14.8「顺带修的」)。
if (ExtractionShooterCatalog4229938.Item(srcItem.ConfigId) is null)
return false;
if (dst.Meta.AllowedItemTypes.Count > 0
&& !dst.Meta.AllowedItemTypes.Contains((uint)(
ExtractionShooterCatalog4229938.Item(srcItem.ConfigId)?.Type ?? -1)))
return false;
if (!TryPlaceIntoBag(dst, srcItem.ConfigId, toX, toY, isRotated, out var dx, out var dy, out var rotated))
return false; // 背包塞不下 —— 容器里那件保持不动。
container.Items.Remove(srcItem);
container.RebuildOccupancy();
srcItem.PlaceAt(dx, dy, rotated);
dst.Grid.Occupy(dx, dy, srcItem.Footprint().W, srcItem.Footprint().H);
dst.Items.Add(srcItem);
return true;
}
}
/// <summary>
/// 容器 → 轮盘 / 战备槽(67688819):<c>container, fromX, fromY, slotBag, slotIndex</c>。
///
/// 槽位组的约定(客户端 <c>AskExtractionShooterShiftSlotItemToContainer(bag, itemInfo.CellX, …)</c> 为证):
/// <b><c>itemInfo.CellX</c> 就是槽位下标</b>,所以这里 <c>CellX = slotIndex、CellY = 0、不旋转</c>。
/// </summary>
internal static bool TryShiftContainerItemToSlot(ExtractionShooterSessionState owner, ulong instanceId,
int fromX, int fromY, uint slotBag, uint slotIndex)
{
lock (owner.SyncRoot)
{
var container = EnsureContainer(owner, instanceId);
if (container is null)
return false;
var srcItem = ExtractionShooterGrid.ItemAt(container.Items, fromX, fromY);
if (srcItem is null)
return false;
if (!owner.Bags.TryGetValue(slotBag, out var dst))
return false;
if (ExtractionShooterCatalog4229938.Item(srcItem.ConfigId) is null)
return false;
dst.RebuildOccupancy();
var (w, h) = ExtractionShooterItem.FootprintOf(srcItem.ConfigId, false);
if (!dst.Grid.CanPlace((int)slotIndex, 0, w, h))
return false;
container.Items.Remove(srcItem);
container.RebuildOccupancy();
srcItem.PlaceAt((int)slotIndex, 0, false);
dst.Grid.Occupy((int)slotIndex, 0, w, h);
dst.Items.Add(srcItem);
return true;
}
}
/// <summary>
/// 背包 → 容器(67512840):<c>srcBag, fromX, fromY, container, toX, toY, isRotated</c>。
/// ⛑ 这条是 <b>bag 在前</b> —— 和 <see cref="TryShiftContainerItemToBag"/> 刚好是镜像。
/// </summary>
internal static bool TryShiftBagItemToContainer(ExtractionShooterSessionState owner, uint srcBag,
int fromX, int fromY, ulong instanceId, int toX, int toY, bool isRotated)
{
lock (owner.SyncRoot)
{
if (!owner.Bags.TryGetValue(srcBag, out var src))
return false;
var item = ItemAt(src, fromX, fromY);
if (item is null)
return false;
var container = EnsureContainer(owner, instanceId);
if (container is null)
return false;
container.RebuildOccupancy();
if (!TryPlaceIntoContainer(container, item, toX, toY, isRotated))
return false;
src.Items.Remove(item);
src.RebuildOccupancy();
return true;
}
}
/// <summary>
/// 轮盘 / 战备槽 → 容器(67054596):<c>srcBag, fromSlotIndex, container, toX, toY, isRotated</c>。
/// 源那一侧的坐标只有一个槽位下标(<c>CellX = 槽位下标、CellY = 0</c>,见
/// <see cref="TryShiftContainerItemToSlot"/> 的说明)—— 没有单独的 y。
/// </summary>
internal static bool TryShiftSlotItemToContainer(ExtractionShooterSessionState owner, uint srcBag,
uint fromSlotIndex, ulong instanceId, int toX, int toY, bool isRotated)
=> TryShiftBagItemToContainer(owner, srcBag, (int)fromSlotIndex, 0, instanceId, toX, toY, isRotated);
/// <summary>
/// 容器内挪位 / 原地旋转(67007990):<c>container, fromX, fromY, toX, toY, isRotated</c>。
///
/// 做法和背包挪位一致:先摘下来重建占位图,再按新位置试放,放不下就<b>放回原位</b>
/// —— 直接改坐标而不重算占位是「两件物品叠在一起」的经典来源。
/// </summary>
internal static bool TryMoveContainerItem(ExtractionShooterSessionState owner, ulong instanceId,
int fromX, int fromY, int toX, int toY, bool isRotated)
{
lock (owner.SyncRoot)
{
var container = EnsureContainer(owner, instanceId);
if (container is null)
return false;
var item = ExtractionShooterGrid.ItemAt(container.Items, fromX, fromY);
if (item is null)
return false;
container.Items.Remove(item);
container.RebuildOccupancy();
if (TryPlaceIntoContainer(container, item, toX, toY, isRotated))
return true;
// 放不下 —— 按原位恢复,别让东西凭空消失。
TryPlaceIntoContainer(container, item, fromX, fromY, item.IsRotated);
return false;
}
}
/// <summary>容器内删除(67825663):<c>container, x, y</c>。</summary>
internal static bool TryRemoveContainerItem(ExtractionShooterSessionState owner, ulong instanceId, int x, int y)
{
lock (owner.SyncRoot)
{
var container = EnsureContainer(owner, instanceId);
if (container is null)
return false;
var item = ExtractionShooterGrid.ItemAt(container.Items, x, y);
if (item is null)
return false;
container.Items.Remove(item);
container.RebuildOccupancy();
return true;
}
}
/// <summary>往容器里放一件:优先用客户端给的格子,放不下退回 first-fit。</summary>
private static bool TryPlaceIntoContainer(ExtractionShooterContainerState container,
ExtractionShooterItem item, int toX, int toY, bool isRotated)
{
var meta = ExtractionShooterCatalog4229938.Item(item.ConfigId);
if (meta is null)
return false;
var (w, h) = ExtractionShooterItem.FootprintOf(item.ConfigId, isRotated);
if (container.Grid.CanPlace(toX, toY, w, h))
{
item.PlaceAt(toX, toY, isRotated);
container.Grid.Occupy(toX, toY, w, h);
container.Items.Add(item);
return true;
}
if (!container.Grid.TryFirstFitWithRotation(meta.VolumeX, meta.VolumeY, out var x, out var y, out var rotated))
return false;
item.PlaceAt(x, y, rotated);
container.Grid.Occupy(x, y, item.Footprint().W, item.Footprint().H);
container.Items.Add(item);
return true;
}
/// <summary>往包里放一件:优先用客户端给的格子,放不下退回 first-fit。</summary>
private static bool TryPlaceIntoBag(ExtractionShooterBagState bag, uint configId,
int toX, int toY, bool isRotated, out int outX, out int outY, out bool outRotated)
{
var meta = ExtractionShooterCatalog4229938.Item(configId);
if (meta is not null)
{
var (w, h) = ExtractionShooterItem.FootprintOf(configId, isRotated);
if (bag.Grid.CanPlace(toX, toY, w, h))
{
outX = toX;
outY = toY;
outRotated = isRotated;
return true;
}
}
return TryFitFree(bag, configId, out outX, out outY, out outRotated);
}
// ⛑ 旧的 TryPutIntoContainer / TryTakeFromContainer 已被上面这五个取代:
// 它们少了 fromX / fromY(不知道从哪格拿)和 toX / toY(客户端算好的落点),
// 而且服务端自己黑箱 first-fit 会和客户端的落点不一致。留着只是等着被误调用。
/// <summary>
/// 「全部入仓」:背包 / 安全箱里所有东西挪进一号仓库,塞不下的原地留住。
/// </summary>
internal static int TryTransferAllToStash(ExtractionShooterSessionState owner)
{
lock (owner.SyncRoot)
{
if (!owner.Bags.TryGetValue(ExtractionShooterCatalog4229938.FirstStashBagId, out var stash))
return 0;
var moved = 0;
foreach (var bagId in new[]
{
ExtractionShooterCatalog4229938.BackpackBagId,
ExtractionShooterCatalog4229938.SafeBoxBagId,
})
{
if (!owner.Bags.TryGetValue(bagId, out var bag))
continue;
var snapshot = bag.Items.ToList();
bag.Items.Clear();
bag.RebuildOccupancy();
foreach (var item in snapshot)
{
if (!TryFitFree(stash, item.ConfigId, out var x, out var y, out var rotated))
continue; // 仓库满了,这件留在原地。
item.PlaceAt(x, y, rotated);
stash.Grid.Occupy(x, y, item.Footprint().W, item.Footprint().H);
stash.Items.Add(item);
moved++;
}
}
return moved;
}
}
/// <summary>
/// 扩容。只有 Type=1(给 <c>BelongBagId</c> 加一行)会影响格数;
/// Type=2 是解锁一个新的仓库包,交给调用方去 <see cref="Seed"/> 之外补一条 UnlockedBags。
/// 返回受影响的目标包 id,0 = 这条扩容不存在。
/// </summary>
internal static (uint BagId, int NewSizeY) TryExpandBag(ExtractionShooterSessionState owner, uint expansionId)
{
var meta = ExtractionShooterCatalog4229938.Expansions.FirstOrDefault(e => e.Id == expansionId);
if (meta is null)
return (0, 0);
lock (owner.SyncRoot)
{
owner.UnlockedExpansions.Add(expansionId);
if (meta.Type == 2 && meta.BagAddId != 0)
{
owner.UnlockedBags.Add(meta.BagAddId);
return (meta.BagAddId, 0);
}
if (meta.Type != 1 || meta.BelongBagId == 0)
return (meta.BelongBagId, 0);
if (!owner.Bags.TryGetValue(meta.BelongBagId, out var bag))
return (meta.BelongBagId, 0);
// 加一行 → Y +1(表里 Type=1 的条目 ExpandGrids 都是 {0,1})。
var rows = Math.Max(1, meta.ExpandY);
bag.Meta = bag.Meta with { CapacityY = bag.Meta.CapacityY + rows };
bag.RebuildOccupancy();
return (bag.BagId, bag.Meta.CapacityY);
}
}
/// <summary>背包容量的 wire 参数:[x, y] 两个 i32(不是「每格写一个 1」)。</summary>
internal static List<int> CapacityOf(ExtractionShooterBagState bag)
=> [bag.Meta.CapacityX, bag.Meta.CapacityY];
// ================================================================== 武器轮盘(第二轮)
/// <summary>
/// CurrWeaponSlots 里轮盘武器之前的固定前导长度:下标 0 = 拳头、下标 1 = 私有武器。
///
/// ⛑ 三处独立证据都指向「0 是拳头、1 是私有武器」,不是推的:
/// · 客户端 WeaponManager.lua 顶部常量 FistWeaponIndex = 1 / PrivateWeaponIndex = 2,
/// 发送时统一 AskSwitchWeapon(Index - 1) ⇒ 线上 index 0 = 拳头、1 = 私有武器;
/// · SyncCurrWeaponSlotsAdd(SlotIndex, Weapon) 写的是 CurrWeaponSlots[SlotIndex + 1],同一个口径;
/// · 服务端自己的 CombatLoadout.Slots 也是 [0] = 拳头、[1] = 私有武器。
/// 所以轮盘 CellX c 落在下标 c + 2。
///
/// ⚠️ 别写成 1:那样轮盘第一格会压在私有武器那一格上,Tab 切「私有武器」时掏出的是轮盘第一把枪,
/// 而 AskSwitchWeaponToPrivateWeapon 会直接打空返回。
/// </summary>
internal const int WheelHomeOffset4229938 = 2;
/// <summary>
/// 轮盘里能放武器的格数(CellX 0..N-1)。
/// WheelWeaponCfg(99952004)SlotCapacity{initial:8, max:8} —— 8 个槽位里有 1 个是私有武器槽
/// (客户端 GetCellIndexBySlotIndex 把它扣掉了)⇒ 真正能放轮盘武器的是 7 格。
/// </summary>
internal static int WheelSlotCells4229938
{
get
{
var bag = ExtractionShooterCatalog4229938.Bags
.FirstOrDefault(b => b.Id == ExtractionShooterCatalog4229938.WheelBagId);
var slots = bag is null ? 8 : bag.SlotMax;
return Math.Clamp(slots - 1, 1, 16);
}
}
/// <summary>轮盘里的一件武器:格子 + 线上物品 id + SceneitemConfig 模板 + 账号军械实例 id。</summary>
internal readonly record struct WheelWeapon(
uint CellX, uint WireItemId, uint SceneItemTemplateId, ulong InstanceId);
/// <summary>
/// 轮盘 bag 里当前所有「能实体化成武器」的物品,按 CellX 升序。
///
/// 牛奶 / 药剂这类也允许放进轮盘,但它们的 SceneitemConfig 不是军械武器
/// (FightSkillType = 0 ⇒ AccountInstanceId 返回 0)⇒ 会被过滤掉,
/// 客户端那边仍然正常显示为轮盘物品,只是不参与 Tab 切枪。
/// </summary>
internal static IReadOnlyList<WheelWeapon> WheelWeapons(ExtractionShooterSessionState owner)
{
lock (owner.SyncRoot)
{
if (!owner.Bags.TryGetValue(ExtractionShooterCatalog4229938.WheelBagId, out var bag)
|| bag.Items.Count == 0)
return [];
var result = new List<WheelWeapon>(bag.Items.Count);
foreach (var item in bag.Items)
{
var wire = ExtractionShooterCatalog4229938.WireIdFor(item.ConfigId);
var meta = ExtractionShooterCatalog4229938.Item(item.ConfigId);
var instanceId = ExtractionShooterCatalog4229938.WeaponInstanceOf(wire);
if (meta is null || meta.RealSceneItemModelId == 0 || instanceId == 0)
continue; // 不是武器:不实体化
result.Add(new WheelWeapon((uint)item.CellX, wire, meta.RealSceneItemModelId, instanceId));
}
result.Sort((a, b) => a.CellX.CompareTo(b.CellX));
return result;
}
}
/// <summary>轮盘 bag 里那一格上的物品(null = 空槽)。</summary>
internal static (uint ItemId, uint StackCount)? WheelItemAt(ExtractionShooterSessionState owner, uint cellX)
{
lock (owner.SyncRoot)
{
if (!owner.Bags.TryGetValue(ExtractionShooterCatalog4229938.WheelBagId, out var bag))
return null;
var slot = bag.Items.FirstOrDefault(i => i.CellX == cellX && i.CellY == 0);
return slot is null
? null
: (ExtractionShooterCatalog4229938.WireIdFor(slot.ConfigId), slot.StackCount);
}
}
/// <summary>
/// 一件轮盘武器 → WeaponDetail。实例 id 复用账号军械库那一份(见 <see cref="WheelHomeOffset4229938"/> 注释里的坑 2)。
/// </summary>
private static SceneMethods.WeaponDetail? BuildWheelWeaponDetail(WheelWeapon weapon, uint spiritTemplateId)
{
var definition = CombatCatalogRepository.Weapon(weapon.InstanceId)
?? CombatCatalogRepository.Weapon(spiritTemplateId, weapon.InstanceId);
if (definition is null)
return null;
var detail = CombatCodec.WeaponDetail(definition);
detail.TemplateId = weapon.SceneItemTemplateId;
detail.InstanceId = weapon.InstanceId;
return detail;
}
/// <summary>轮盘里的全部武器实体化详情,按 CurrWeaponSlots 下标排好(下标 = CellX + 2)。</summary>
internal static Dictionary<int, SceneMethods.WeaponDetail> WheelWeaponDetails(
ExtractionShooterSessionState owner, uint spiritTemplateId)
{
var result = new Dictionary<int, SceneMethods.WeaponDetail>();
foreach (var weapon in WheelWeapons(owner))
{
if (BuildWheelWeaponDetail(weapon, spiritTemplateId) is { } detail)
result[(int)weapon.CellX + WheelHomeOffset4229938] = detail;
}
return result;
}
/// <summary>
/// SyncSpiritWeaponDetail(68255545) 的负载 —— 一次给出整张实体化武器表。
///
/// 表长固定为 2 + <see cref="WheelSlotCells4229938"/>:
/// · 下标 0 = 拳头。<b>必须给且非 null</b> —— 客户端 AskSwitchWeaponToFistWeapon 会先判
/// weapons[FistWeaponIndex] == nil 然后静默返回,空着就等于「切不回拳头」。
/// · 下标 1 = 私有武器。
/// · 下标 2..N+1 = 轮盘 CellX 0..N-1。空槽写 null。
/// 长度固定而不是按实际件数裁剪:Tab / 滚轮切枪只按 CurrWeaponSlots 的<b>位置</b>走,
/// 位置一抖轮盘顺序就跟着抖。
/// </summary>
/// <param name="prepare">
/// 每件非空武器在序列化前的最后一道加工。弹药 / 耐久 / 流派活在 WorldEntryState(Handler 层),
/// 本类不该知道,所以由调用方回调盖上去。
/// </param>
internal static SceneMethods.SpiritWeaponDetail BuildSpiritWeaponDetailBody(
ExtractionShooterSessionState owner,
uint spiritTemplateId,
ulong unitId,
ulong currentWeaponInstanceId,
SceneMethods.WeaponDetail? fistWeapon,
SceneMethods.WeaponDetail? privateWeapon,
Action<SceneMethods.WeaponDetail>? prepare = null)
{
var slots = new List<SceneMethods.WeaponDetail?>(WheelHomeOffset4229938 + WheelSlotCells4229938)
{
fistWeapon,
privateWeapon,
};
var byIndex = WheelWeaponDetails(owner, spiritTemplateId);
for (var cell = 0; cell < WheelSlotCells4229938; cell++)
{
var index = cell + WheelHomeOffset4229938;
slots.Add(byIndex.TryGetValue(index, out var detail) ? detail : null);
}
// 当前武器必须落在表里,否则客户端 OnSwitchCurrentWeapon 找不到它会打
// "Not Find Current Weapon"。落在轮盘武器上就保持,否则退回私有武器。
var current = currentWeaponInstanceId;
if (!slots.Any(s => s is not null && s.InstanceId == current))
current = privateWeapon?.InstanceId ?? fistWeapon?.InstanceId ?? 0UL;
var body = new SceneMethods.SpiritWeaponDetail
{
SpiritTid = spiritTemplateId,
SpiritUid = unitId,
CurrentWeaponUid = current,
WeaponSlots = slots,
CurrentTempWeapon = null,
TempWeaponSlots = null,
VirtualWeaponSlots = [],
};
if (prepare is not null)
{
foreach (var slot in body.WeaponSlots)
{
if (slot is not null)
prepare(slot);
}
}
return body;
}
/// <summary>
/// SyncSpiritAddWeaponAction(68055102) 的负载 —— 单件增量。
/// SlotIndex 是 <b>0 基</b>下标,轮盘 CellX c 对应 c + 2(0/1 是拳头和私有武器,不该被增量覆盖)。
/// </summary>
internal static byte[] BuildAddWheelWeaponPayload(
uint spiritTemplateId, ulong unitId, WheelWeapon weapon,
Action<SceneMethods.WeaponDetail>? prepare = null)
{
var detail = BuildWheelWeaponDetail(weapon, spiritTemplateId)
?? throw new InvalidOperationException($"轮盘武器 {weapon.WireItemId} 无法解析成军械武器");
prepare?.Invoke(detail);
return UxSerializer.Serialize(new SceneMethods.SpiritAddWeaponAction
{
SpiritTid = spiritTemplateId,
SpiritUid = unitId,
SlotIndex = (int)weapon.CellX + WheelHomeOffset4229938,
Weapon = detail,
});
}
/// <summary>
/// SyncSpiritRemoveWeaponAction(68663624) 的负载 —— 单件增量移除。
/// Key 是 WeaponUid(InstanceId),Reason = Remove(5) —— 只卸下,不改语义为「丢到地上」。
/// </summary>
internal static byte[] BuildRemoveWheelWeaponPayload(uint spiritTemplateId, ulong unitId, ulong instanceId)
=> UxSerializer.Serialize(CombatCodec.SpiritRemoveWeapon(spiritTemplateId, unitId, instanceId));
/// <summary>
/// 轮盘物品要不要带 <c>WeaponData</c> 的<b>运行时值</b>。
///
/// 面板改这个比改配置省事(不用改 json、不用重启),GM 面板那条 checkbox 走的就是这里的 setter。
/// 没动过时 null ⇒ 回落到 <c>world.content.extraction.wheelWeaponData</c>,重启就恢复配置值。
/// 关掉它专门用于回退验证:枪的 tooltip 会退化成普通物品描述,能一眼看出 tooltip 那条链路
/// 到底走的是 <c>ItemInfo.WeaponData</c> 还是虚构出来的东西。
/// </summary>
private static bool? s_wheelWeaponDataOverride;
internal static bool WheelWeaponDataEnabled
{
get => s_wheelWeaponDataOverride
?? PrivateServerConfigStore.Current.World.Content.Extraction.WheelWeaponData;
set => s_wheelWeaponDataOverride = value;
}
/// <summary>
/// 轮盘物品要带的 ItemInfo.WeaponData(可为 null)。只对 ReadInfoType == 1 的类型填
/// (实测就是武器与医疗道具),且必须能解析出军械实例 id。
/// </summary>
internal static Ananta.Server.RpcTypes.Client4229938.Auto.WeaponData? WheelWeaponData(uint wireId)
{
if (!WheelWeaponDataEnabled)
return null;
var meta = ExtractionShooterCatalog4229938.ItemByWire(wireId);
if (meta is null || !ExtractionShooterCatalog4229938.CarriesInfo(meta))
return null;
var instanceId = ExtractionShooterCatalog4229938.WeaponInstanceOf(wireId);
if (instanceId == 0)
return null;
var definition = CombatCatalogRepository.Weapon(instanceId);
return definition is null ? null : RuntimePayloadFactory.WeaponData(definition);
}
}