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;
/// 一件落地的物品。ConfigId 是配置行 id(9993xxxx),下发时才换成 ConsumableId。
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;
/// 按旋转换算后的实际占格 (宽, 高)。配置缺失时按 1x1 兜底。
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);
}
/// 不旋转换算(打包新的 items 时用)。
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
{
/// 命中 (x,y) 的物品 —— 注意 x,y 可能落在某个大件的覆盖区中间,不是原点。
internal static ExtractionShooterItem? ItemAt(IEnumerable 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;
});
}
/// 一个 bag 的服务端镜像:格数 + 占位图 + 物品清单。
internal sealed class ExtractionShooterBagState
{
internal uint BagId;
internal ExtractionShooterBagMeta Meta = null!;
internal List Items = [];
internal ExtractionGrid4229938 Grid = new(1, 1);
internal bool IsSlotGroup => ExtractionShooterCatalog4229938.IsSlotGroup(Meta);
/// 物品清单改过之后重建占位图。
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);
}
}
/// 一个容器实例。实例 id 由客户端给出(8 字节),服务端只认这一份。
internal sealed class ExtractionShooterContainerState
{
internal ulong InstanceId;
internal uint CfgId;
internal int SizeX = 1;
internal int SizeY = 1;
internal List Items = [];
internal ExtractionGrid4229938 Grid = new(1, 1);
/// pid → 该玩家搜索完成的服务器秒数。空 = 未搜索 = 客户端隐藏图标。
internal Dictionary SearchFinish = [];
///
/// 已经把 翻成过去的 pid。
///
/// ⛑️ 这一位是「能不能看见东西」的开关:搜索刚发起时 落在未来
/// (客户端据此播读条),只有等时间到了、把它翻回过去,物品才会真正显示。
/// 捐赠端 v1.7 的实机日志(09-24 17:07:04 → 17:07:05 → 17:07:06)逐帧证明了这个顺序:
/// 推「未来」帧 → 隔 N 秒推「已揭晓」帧 → 玩家才取得到东西。
///
internal HashSet Revealed = [];
/// 整箱揭晓的基准延时:箱内最长的那件 SearchTime。
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);
}
}
///
/// 每条连接的搜打撤状态,存 session.Items(与 GameRouter.SceneItemGrid 同套路)。
///
internal sealed class ExtractionShooterSessionState
{
internal object SyncRoot { get; } = new();
internal Dictionary Bags = [];
internal Dictionary Containers = [];
internal HashSet UnlockedExpansions = [];
internal HashSet UnlockedBags = [];
internal uint TokenCount;
internal bool Seeded;
///
/// 本局开始的时间戳(秒)—— 结算帧 SyncRaidSettlement 里的
/// EnterRaidTime / BattleTime 就取它。
/// 每次重跑都会刷新(换局), 不清它,
/// 因为退图那一刀要用「退图时间 - 进图时间」算这一局打了多久。
///
internal uint EnterRaidSeconds;
}
///
/// 搜打撤运行时:编目 → per-session 状态 → wire payload。
///
/// 本轮覆盖:登录帧注入、进图、容器刷物与搜索、背包搬运 / 丢弃 / 锁定 / 排序 / 入仓、容器↔背包搬运。
/// 不含武器实体化(轮盘 Tab 切枪)—— 那需要 CombatCatalogRepository 的实例 id
/// 与 SyncSpiritWeaponDetail 整表,是第二轮的事。
///
/// 三条容易漏的:
/// 1. 下发 id 用 ConsumableId(),
/// 配置行 id 只在服务端内部记。
/// 2. 搜索时间戳必须落在未来:客户端 wait 秒数被钳负为 0 ⇒ 过去时间戳 = wait(0) 空转,
/// 动画一帧不播。
/// 3. 堆叠数量按服务端规则生成()。写 1 会让客户端把角标
/// 整个藏掉(showCountCtrl = StackCount <= 1),看起来像"子弹只有 1 发"。
///
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
/// 本局开放的包。取自 GamePlayTypeConfig 的 StorageSet / EquipSlotSet。
internal static readonly uint[] DefaultBagIds =
[
ExtractionShooterCatalog4229938.BackpackBagId, // 99952001 背包 4x4
ExtractionShooterCatalog4229938.SafeBoxBagId, // 99952002 安全箱 2x1
ExtractionShooterCatalog4229938.FirstStashBagId, // 99952003 一号仓库 7x15
ExtractionShooterCatalog4229938.WheelBagId, // 99952004 武器轮盘 8 槽
];
/// 开局建包。幂等 —— 之后也能再跑一遍。
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;
}
}
/// 换局 / 退图:丢掉容器与全部物品。不重置已解锁的包。
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
///
/// 把状态镜像写进登录帧的那个 PlayerExtractionShooterInfo。
///
/// ⛑ Bags 与 SlotGroups 必须二选一:客户端
/// GetBagInfoByConfigId = Bags[id] or SlotGroups[id](Bags 优先),而渲染轮盘必调的
/// CheckSlotIndexHasUnlocked 读 AddUnlockedCount —— 只有 SlotGroupInfo 有。
/// 两边都发会让后者直接崩面板。
///
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
///
/// 堆叠数量。客户端 showCountCtrl = StackCount <= 1 会把角标藏掉,所以
/// "子弹只显示 1 发"其实是这条 —— 不是少发了。
///
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 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;
}
/// 从某个随机组里抽一件物品配置 id。组不存在或为空时返回 0。
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;
}
///
/// 战利品的确定性 RNG:同一个容器实例在这一局内怎么搜都一样,换一局重新洗
/// (盐来自 )。
///
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
///
/// 刷一个容器(已存在就直接返回)。容量选择不加盐(同一个箱子应该一直是那个格子数),
/// 内容加盐(跨局重刷)。返回 null = 候选池为空,这个箱子真是空的。
///
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();
// 必刷优先:这些是关卡设计要保证的钥匙类道具。
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;
}
}
///
/// 搜索一次容器。返回 false = 已经搜过了(或容器不存在)—— 客户端重复搜不该再揭一遍。
///
/// ⛑ 时间戳必须落在未来(now + 每件自己的 SearchTime)。
///
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;
/// 这个 pid 在这个箱子上是否已经揭晓过(揭晓过就不用再补推)。
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);
}
///
/// 读条时长(秒)。取整箱最长的那件 SearchTime,至少 1 秒 —— 传 0 会立刻揭晓,读条动画一帧不播。
///
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;
}
///
/// 把一个箱子对某个 pid 标成「已搜完」:把完成时间戳翻到过去(now - 5),
/// 客户端据此把物品从「读条中」切成「可拿」。
///
/// 返回 false = 容器不存在 / 本来就揭过了 —— 调用方据此跳过补推,别白推一帧。
///
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;
}
}
///
/// 容器的 wire payload。未搜索(SearchFinish 为空)时字典留空 —— 客户端据此隐藏图标。
/// 每一项都挂同一份「谁搜完了」的字典:客户端是按项分发的。
///
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);
}
///
/// 六合一搬运(handoff §6.4「装备 / 卸下 / 放入安全箱 / 携带 / 放回仓库 / 旋转」全走这一条):
/// 同包同格 = 旋转、同包异格 = 挪、跨包 = 搬。
///
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;
}
// 跨包:目标包接不下就不动源包,避免物品凭空消失。
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;
}
}
///
/// 拆分:在原位的邻位再生成一件。
///
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;
}
}
///
/// 整理:按占格面积降序重排(大件先塞)—— 这是所有背包 UI 的玩家直觉。
/// 排完必须整包重推:客户端的渲染序号与服务端的 CellX/CellY 已经不同了。
///
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;
}
}
/// 找第一个能放下的空位,生成一件新物品(GM 发物品用)。
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;
}
}
///
/// 容器 → 背包 / 仓库(67257572)。
///
/// ⛑ 参数是客户端发的那一串:container, fromX, fromY, destBag, toX, toY, isRotated。
/// 第一版把第 4 个字段当成 "源 x",导致 destBag 读到的是 fromX(垃圾值)⇒ 任何搬运都失败
/// —— 这就是捐赠端实机看到的「容器里的东西搬不到背包 / 双击像复制一份」(§14.8 / §14.10)。
///
/// toX / toY 是客户端自己算好的落点。放得下就用它;放不下退回 first-fit ——
/// 客户端也会自己校验一遍,两边不一致的表现是「拖过去弹回」,所以服务端宽容一点没坏处。
///
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;
}
}
///
/// 容器 → 轮盘 / 战备槽(67688819):container, fromX, fromY, slotBag, slotIndex。
///
/// 槽位组的约定(客户端 AskExtractionShooterShiftSlotItemToContainer(bag, itemInfo.CellX, …) 为证):
/// itemInfo.CellX 就是槽位下标,所以这里 CellX = slotIndex、CellY = 0、不旋转。
///
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;
}
}
///
/// 背包 → 容器(67512840):srcBag, fromX, fromY, container, toX, toY, isRotated。
/// ⛑ 这条是 bag 在前 —— 和 刚好是镜像。
///
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;
}
}
///
/// 轮盘 / 战备槽 → 容器(67054596):srcBag, fromSlotIndex, container, toX, toY, isRotated。
/// 源那一侧的坐标只有一个槽位下标(CellX = 槽位下标、CellY = 0,见
/// 的说明)—— 没有单独的 y。
///
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);
///
/// 容器内挪位 / 原地旋转(67007990):container, fromX, fromY, toX, toY, isRotated。
///
/// 做法和背包挪位一致:先摘下来重建占位图,再按新位置试放,放不下就放回原位
/// —— 直接改坐标而不重算占位是「两件物品叠在一起」的经典来源。
///
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;
}
}
/// 容器内删除(67825663):container, x, y。
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;
}
}
/// 往容器里放一件:优先用客户端给的格子,放不下退回 first-fit。
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;
}
/// 往包里放一件:优先用客户端给的格子,放不下退回 first-fit。
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 会和客户端的落点不一致。留着只是等着被误调用。
///
/// 「全部入仓」:背包 / 安全箱里所有东西挪进一号仓库,塞不下的原地留住。
///
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;
}
}
///
/// 扩容。只有 Type=1(给 BelongBagId 加一行)会影响格数;
/// Type=2 是解锁一个新的仓库包,交给调用方去 之外补一条 UnlockedBags。
/// 返回受影响的目标包 id,0 = 这条扩容不存在。
///
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);
}
}
/// 背包容量的 wire 参数:[x, y] 两个 i32(不是「每格写一个 1」)。
internal static List CapacityOf(ExtractionShooterBagState bag)
=> [bag.Meta.CapacityX, bag.Meta.CapacityY];
// ================================================================== 武器轮盘(第二轮)
///
/// 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 会直接打空返回。
///
internal const int WheelHomeOffset4229938 = 2;
///
/// 轮盘里能放武器的格数(CellX 0..N-1)。
/// WheelWeaponCfg(99952004)SlotCapacity{initial:8, max:8} —— 8 个槽位里有 1 个是私有武器槽
/// (客户端 GetCellIndexBySlotIndex 把它扣掉了)⇒ 真正能放轮盘武器的是 7 格。
///
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);
}
}
/// 轮盘里的一件武器:格子 + 线上物品 id + SceneitemConfig 模板 + 账号军械实例 id。
internal readonly record struct WheelWeapon(
uint CellX, uint WireItemId, uint SceneItemTemplateId, ulong InstanceId);
///
/// 轮盘 bag 里当前所有「能实体化成武器」的物品,按 CellX 升序。
///
/// 牛奶 / 药剂这类也允许放进轮盘,但它们的 SceneitemConfig 不是军械武器
/// (FightSkillType = 0 ⇒ AccountInstanceId 返回 0)⇒ 会被过滤掉,
/// 客户端那边仍然正常显示为轮盘物品,只是不参与 Tab 切枪。
///
internal static IReadOnlyList WheelWeapons(ExtractionShooterSessionState owner)
{
lock (owner.SyncRoot)
{
if (!owner.Bags.TryGetValue(ExtractionShooterCatalog4229938.WheelBagId, out var bag)
|| bag.Items.Count == 0)
return [];
var result = new List(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;
}
}
/// 轮盘 bag 里那一格上的物品(null = 空槽)。
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);
}
}
///
/// 一件轮盘武器 → WeaponDetail。实例 id 复用账号军械库那一份(见 注释里的坑 2)。
///
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;
}
/// 轮盘里的全部武器实体化详情,按 CurrWeaponSlots 下标排好(下标 = CellX + 2)。
internal static Dictionary WheelWeaponDetails(
ExtractionShooterSessionState owner, uint spiritTemplateId)
{
var result = new Dictionary();
foreach (var weapon in WheelWeapons(owner))
{
if (BuildWheelWeaponDetail(weapon, spiritTemplateId) is { } detail)
result[(int)weapon.CellX + WheelHomeOffset4229938] = detail;
}
return result;
}
///
/// SyncSpiritWeaponDetail(68255545) 的负载 —— 一次给出整张实体化武器表。
///
/// 表长固定为 2 + :
/// · 下标 0 = 拳头。必须给且非 null —— 客户端 AskSwitchWeaponToFistWeapon 会先判
/// weapons[FistWeaponIndex] == nil 然后静默返回,空着就等于「切不回拳头」。
/// · 下标 1 = 私有武器。
/// · 下标 2..N+1 = 轮盘 CellX 0..N-1。空槽写 null。
/// 长度固定而不是按实际件数裁剪:Tab / 滚轮切枪只按 CurrWeaponSlots 的位置走,
/// 位置一抖轮盘顺序就跟着抖。
///
///
/// 每件非空武器在序列化前的最后一道加工。弹药 / 耐久 / 流派活在 WorldEntryState(Handler 层),
/// 本类不该知道,所以由调用方回调盖上去。
///
internal static SceneMethods.SpiritWeaponDetail BuildSpiritWeaponDetailBody(
ExtractionShooterSessionState owner,
uint spiritTemplateId,
ulong unitId,
ulong currentWeaponInstanceId,
SceneMethods.WeaponDetail? fistWeapon,
SceneMethods.WeaponDetail? privateWeapon,
Action? prepare = null)
{
var slots = new List(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;
}
///
/// SyncSpiritAddWeaponAction(68055102) 的负载 —— 单件增量。
/// SlotIndex 是 0 基下标,轮盘 CellX c 对应 c + 2(0/1 是拳头和私有武器,不该被增量覆盖)。
///
internal static byte[] BuildAddWheelWeaponPayload(
uint spiritTemplateId, ulong unitId, WheelWeapon weapon,
Action? 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,
});
}
///
/// SyncSpiritRemoveWeaponAction(68663624) 的负载 —— 单件增量移除。
/// Key 是 WeaponUid(InstanceId),Reason = Remove(5) —— 只卸下,不改语义为「丢到地上」。
///
internal static byte[] BuildRemoveWheelWeaponPayload(uint spiritTemplateId, ulong unitId, ulong instanceId)
=> UxSerializer.Serialize(CombatCodec.SpiritRemoveWeapon(spiritTemplateId, unitId, instanceId));
///
/// 轮盘物品要不要带 WeaponData 的运行时值。
///
/// 面板改这个比改配置省事(不用改 json、不用重启),GM 面板那条 checkbox 走的就是这里的 setter。
/// 没动过时 null ⇒ 回落到 world.content.extraction.wheelWeaponData,重启就恢复配置值。
/// 关掉它专门用于回退验证:枪的 tooltip 会退化成普通物品描述,能一眼看出 tooltip 那条链路
/// 到底走的是 ItemInfo.WeaponData 还是虚构出来的东西。
///
private static bool? s_wheelWeaponDataOverride;
internal static bool WheelWeaponDataEnabled
{
get => s_wheelWeaponDataOverride
?? PrivateServerConfigStore.Current.World.Content.Extraction.WheelWeaponData;
set => s_wheelWeaponDataOverride = value;
}
///
/// 轮盘物品要带的 ItemInfo.WeaponData(可为 null)。只对 ReadInfoType == 1 的类型填
/// (实测就是武器与医疗道具),且必须能解析出军械实例 id。
///
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);
}
}