This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/Ananta.Server/Ananta.Core/Protocol/Client4229938/ExtractionShooterRuntime4229938.cs
T

1271 lines
53 KiB
C#
Raw Normal View History

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