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