using Ananta.SDK.Logging; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Auto = Ananta.Server.RpcTypes.Client4229938.Auto; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// Shared economy plumbing for build 4229938 mall purchases and gacha draws: currency/wallet mapping, /// backpack grants, the pushes the client expects, and the snapshot that lets money and items survive /// a re-login. Retail keeps all of this in its account database; here the session state is the single /// authority and mirrors it to one JSON file per player. /// internal sealed partial class GameRouter { // Wallet-borne currencies (CommonItemManager.lua SpecialItemId): 36920001 RewardGold (gold), // 36920002 RewardMoney (money), 36920003 RewardBindingGold (binding gold). Anything else a price // asks for is a normal backpack item and is paid out of the granted stack. private const uint CurrencyRewardGold4229938 = 36920001u; private const uint CurrencyRewardMoney4229938 = 36920002u; private const uint CurrencyRewardBindingGold4229938 = 36920003u; /// A pending economy mutation: what to take away, what to add, how the wallet moves. private sealed class EconomyChanges4229938 { internal Dictionary Consumed { get; } = []; internal Dictionary Granted { get; } = []; internal double MoneyDelta { get; set; } internal double GoldDelta { get; set; } internal double BindingGoldDelta { get; set; } } /// Loads the saved character economy into the session state at login. private static void RestorePlayerProgress4229938(WorldEntryState state, ServerLogger log) { lock (state.SyncRoot) { if (state.ProgressRestored) return; } PlayerProgressStore.LoadInto(state.Progress, log); lock (state.SyncRoot) { state.ProgressRestored = true; state.WalletMoney = state.Progress.Money; state.WalletGold = state.Progress.Gold; state.WalletBindingGold = state.Progress.BindingGold; state.GmBackpackItems.Clear(); foreach (var item in state.Progress.Items) { // Reuse the stored instance id so a re-login does not invalidate stacks the client // already knows, and reserve it so later grants cannot collide with it. var uniqueId = item.UniqueId; if (uniqueId == 0) uniqueId = NextGmItemUniqueId4229938(); else ReserveGmItemUniqueId4229938(uniqueId); state.GmBackpackItems[item.TemplateId] = RuntimePayloadFactory.GmPackItem4229938( item.TemplateId, item.Count, item.IsBind, uniqueId); } // Worn phone-skin suits. RuntimePayloadFactory reads PhoneSkinBySpirit when it builds // the login payload, so the restore has to run before MinimalPlayerInfo4229938. // GameRouter.World.cs already calls this in that order. state.PhoneSkinBySpirit.Clear(); foreach (var (spiritId, skin) in state.Progress.PhoneSkins) { state.PhoneSkinBySpirit[spiritId] = new Auto.SpiritMobileSkinInfo { Wallpaper = skin.Wallpaper, Decoration = skin.Decoration, Pendant = skin.Pendant, }; } state.FashionBySpirit.Clear(); foreach (var (spiritId, fashion) in state.Progress.Fashions) { // An empty id list would publish "wearing nothing", so keep the authored default. if (fashion.FashionIds.Count == 0) continue; state.FashionBySpirit[spiritId] = fashion; } state.Colorings.Clear(); foreach (var (fashionId, coloring) in state.Progress.Colorings) { if (fashionId == 0 || coloring is null) continue; state.Colorings[fashionId] = coloring; } // Livehouse music-game clears + the "intro already played" latch (2026-10-04). // Without this bridge the save would come back into state.Progress but never reach // WorldEntryState, and Save() reads WorldEntryState ⇒ the scores would look permanent // within one session and vanish on the next login. state.LivehouseRecords.Clear(); foreach (var (musicId, record) in state.Progress.LivehouseRecords) { if (musicId == 0 || record is null) continue; state.LivehouseRecords[musicId] = record; } state.LivehouseFirstPlayDone = state.Progress.LivehouseFirstPlayDone; state.WeaponSkins.Clear(); foreach (var (spiritId, skins) in state.Progress.WeaponSkins) { if (spiritId == 0 || skins is null || skins.SkinBySceneItem.Count == 0) continue; state.WeaponSkins[spiritId] = skins; } // Gameplay talent trees (天赋). Trees the player never touched stay absent and are // seeded from talentPointSeed by RuntimePayloadFactory when the login payload is built. state.GameplayTalents.Clear(); foreach (var (gameplayId, saved) in state.Progress.GameplayTalents) { if (gameplayId == 0 || saved is null) continue; var tree = new GameplayTalentState4229938 { Point = saved.Point, Exp = saved.Exp, Level = saved.Level, }; foreach (var (talentId, layer) in saved.Nodes) { if (talentId == 0 || layer == 0) continue; tree.Nodes[talentId] = layer; } state.GameplayTalents[gameplayId] = tree; } RestoreCombatLoadout4229938(state, log); } } /// /// Replays the saved weapon / fight-style choices. Every entry is re-validated against the /// catalog on purpose: a save written against an older config (or hand-edited) must degrade to /// the authored default instead of restoring a combination the client refuses to animate. /// private static void RestoreCombatLoadout4229938(WorldEntryState state, ServerLogger log) { var weapons = 0; var spiritStyles = 0; var weaponStyles = 0; var wheels = 0; // Personal wheels first. The equipped weapon has to be one of the instances this wheel // carries, otherwise CombatCodec.SpiritWeaponSnapshot rewrites CurrentWeaponUid back to // slot 0 and the player ends up holding the authored default no matter what we restore. state.WeaponSlotsBySpirit.Clear(); foreach (var (spiritId, saved) in state.Progress.WeaponSlotsBySpirit) { if (spiritId == 0 || saved.Count == 0) continue; // A save can name a spirit the catalog no longer carries (character removed from the // roster, row hand-edited). Skipping that one row keeps the rest of the profile intact; // throwing here aborts LoginGame and leaves the client unable to log in at all. if (CombatCatalogRepository.TryLoadout(spiritId) is not { } loadout) continue; var wheel = new List(loadout.Slots.Count); for (var index = 0; index < loadout.Slots.Count; index++) { // Honour empty slots (a deposit is a legitimate choice) but fall back to the // authored occupant when the saved instance no longer resolves in the catalog. var instanceId = index < saved.Count ? saved[index] : 0UL; if (instanceId != 0 && CombatCatalogRepository.Weapon(spiritId, instanceId) is null) instanceId = loadout.Slots[index]?.InstanceId ?? 0UL; wheel.Add(instanceId); } state.WeaponSlotsBySpirit[spiritId] = wheel; wheels++; } state.LastWeaponBySpirit.Clear(); foreach (var (spiritId, instanceId) in state.Progress.LastWeaponBySpirit) { // Accept a weapon the catalog knows either under this character or account-wide. if (CombatCatalogRepository.Weapon(spiritId, instanceId) is null && CombatCatalogRepository.Weapon(instanceId) is null) continue; // ⛑ 战斗目录里没有的角色(被 content.roster.excludedTemplateIds 排除 / 已下架,例如 // 15021049)必须在这里跳过:上面建轮盘时已经 continue 掉了它,于是它的 wheel 不存在, // 下面必然走 TryAdoptWeaponIntoWheel4229938 → CombatCodec.Loadout(spiritId) 抛 // KeyNotFoundException,整个 LoginGame 被打挂。 // 实测 2026-10-01 21:59:客户端表现为「连接服务器失败,请重新选择服务器」。 if (CombatCatalogRepository.TryLoadout(spiritId) is null) continue; // Saves written before the wheel was persisted can still name a weapon that only // exists inside that session's edited wheel. Put it back into the first free // editable slot so an existing profile heals itself instead of silently reverting. if (!state.WeaponSlotsBySpirit.TryGetValue(spiritId, out var wheel) || !wheel.Contains(instanceId)) TryAdoptWeaponIntoWheel4229938(state, spiritId, instanceId); state.LastWeaponBySpirit[spiritId] = instanceId; weapons++; } state.SpiritStyleOverrides.Clear(); foreach (var (key, styleId) in state.Progress.SpiritStyleOverrides) { // Save-file key was flattened to "spiritId:typeId" because tuple keys cannot be // serialized. Anything that does not round-trip is dropped. var separator = key.IndexOf(':'); if (separator <= 0 || !uint.TryParse(key.AsSpan(0, separator), out var spiritId) || !uint.TryParse(key.AsSpan(separator + 1), out var typeId)) continue; var style = CombatCatalogRepository.Style(styleId); if (style is null || !CombatCatalogRepository.StyleMatchesType(styleId, typeId)) continue; if (style.SpiritIds.Count > 0 && !style.SpiritIds.Contains(spiritId)) continue; state.SpiritStyleOverrides[(spiritId, typeId)] = styleId; spiritStyles++; } state.WeaponStyleOverrides.Clear(); foreach (var (instanceId, styleId) in state.Progress.WeaponStyleOverrides) { var weapon = CombatCatalogRepository.Weapon(instanceId); var style = CombatCatalogRepository.Style(styleId); if (weapon is null || style is null || !CombatCatalogRepository.IsStyleCompatible(weapon, style)) continue; state.WeaponStyleOverrides[instanceId] = styleId; weaponStyles++; } if (weapons > 0 || spiritStyles > 0 || weaponStyles > 0 || wheels > 0) log.Info($"[LOADOUT] restored weapons={weapons} spiritStyles={spiritStyles} weaponStyles={weaponStyles} wheels={wheels}"); } private static void TryAdoptWeaponIntoWheel4229938(WorldEntryState state, uint spiritId, ulong instanceId) { if (!state.WeaponSlotsBySpirit.TryGetValue(spiritId, out var wheel)) { // 目录里没有这个角色 ⇒ 无从「收养」(连槽位都没有)。这里抛异常会把 LoginGame 整条链打挂, // 所以改成放弃这一行存档,保住其余档案。 if (CombatCatalogRepository.TryLoadout(spiritId) is not { } authored) return; wheel = authored.Slots.Select(x => x?.InstanceId ?? 0UL).ToList(); } // Slot 0 is the character's fixed base weapon, so only positions 1..15 are adoptable. var free = -1; for (var index = 1; index < wheel.Count; index++) { if (wheel[index] == 0) { free = index; break; } } if (free < 0) return; wheel[free] = instanceId; state.WeaponSlotsBySpirit[spiritId] = wheel; } private static ulong NextGmItemUniqueId4229938() => (ulong)IdCounterStore.Next(GmItemSeqCounter, GmItemSeqFloor); private static void ReserveGmItemUniqueId4229938(ulong uniqueId) { if (uniqueId > long.MaxValue) return; // 水位只升不降,所以这里不需要 CAS 循环 —— IdCounterStore.Reserve 本身就是单调的。 IdCounterStore.Reserve(GmItemSeqCounter, (long)uniqueId); } /// /// Charges a price in units. Wallet currencies debit the wallet, /// everything else debits the granted stack. A shortfall is clamped and logged instead of failing /// the command: this private server always lets the player through. /// private static void ChargeCurrency4229938( WorldEntryState state, EconomyChanges4229938 changes, uint currencyItemId, double amount, ServerLogger log, string context) { if (currencyItemId == 0 || amount <= 0) return; switch (currencyItemId) { case CurrencyRewardMoney4229938: changes.MoneyDelta -= amount; return; case CurrencyRewardGold4229938: changes.GoldDelta -= amount; return; case CurrencyRewardBindingGold4229938: changes.BindingGoldDelta -= amount; return; } uint available; lock (state.SyncRoot) available = state.GmBackpackItems.TryGetValue(currencyItemId, out var stack) ? stack.Count : 0u; var taken = Math.Min(amount, available); if (taken < amount) log.Warn($"[ECONOMY] {context}: missing {currencyItemId} to pay {amount} with (have {available}), granting the remainder for free"); if (taken <= 0) return; var count = (uint)Math.Min(taken, uint.MaxValue); changes.Consumed[currencyItemId] = changes.Consumed.TryGetValue(currencyItemId, out var previous) ? SaturatingAdd4229938(previous, count) : count; } /// Rolls one drop row times into . private static void GrantDrop4229938( EconomyChanges4229938 changes, EconomyConfigRepository.DropRow drop, uint rolls, string context, ServerLogger log) { if (rolls == 0) return; if (drop.Money != 0) changes.MoneyDelta += drop.Money * rolls; if (drop.BindingGold != 0) changes.BindingGoldDelta += drop.BindingGold * rolls; foreach (var (id, count) in drop.Guaranteed) AddGrant4229938(changes, id, SaturatingMultiply4229938(count, rolls), isBind: false); for (var roll = 0u; roll < rolls; roll++) { if (drop.Ranged.Count > 0) { var (id, min, max) = drop.Ranged[Random.Shared.Next(drop.Ranged.Count)]; AddGrant4229938(changes, id, RollCount4229938(min, max), isBind: false); } if (drop.Weighted.Count > 0) { var index = PickWeightedIndex4229938(drop.Weighted.Count, i => drop.Weighted[i].Weight); if (index >= 0) AddGrant4229938(changes, drop.Weighted[index].Id, 1, isBind: false); } } log.Info( $"[ECONOMY] {context}: drop={drop.Id} rolls={rolls} money={drop.Money * rolls} " + $"bindingGold={drop.BindingGold * rolls} guaranteed={drop.Guaranteed.Count} " + $"ranged={drop.Ranged.Count} weighted={drop.Weighted.Count}"); } private static void AddGrant4229938(EconomyChanges4229938 changes, uint templateId, uint count, bool isBind) { if (templateId == 0 || count == 0) return; if (changes.Granted.TryGetValue(templateId, out var existing)) { changes.Granted[templateId] = existing with { Count = SaturatingAdd4229938(existing.Count, count), IsBind = existing.IsBind || isBind, }; return; } changes.Granted[templateId] = new ItemGrant4229938(templateId, count, isBind); } /// /// Applies a pending mutation to the live session state, pushes the resulting wallet/backpack /// deltas, and snapshots the character. Returns a short summary for the caller's log line. /// Per-RPC 入口(conn)额外转交给下面的 session 版本;后台任务用 session 版本。 /// private static Task ApplyEconomy4229938Async( Connection conn, WorldEntryState state, EconomyChanges4229938 changes, string context) => ApplyEconomy4229938Async(conn.Session, state, changes, context, CancellationToken.None); /// /// 后台循环入口(通缉结算等长生命周期调用方,2026-09-23 新增)。 /// 是 per-RPC 包装、构造是 internal、且它持有那一次 RPC 的 token, /// 后台任务既拿不到也不能用,所以单独留一个 TcpSession 重载;Connection 版委托到这里, /// 两个入口的行为(含 RuntimeLogs 解码日志)完全一致。 /// private static async Task ApplyEconomy4229938Async( TcpSession session, WorldEntryState state, EconomyChanges4229938 changes, string context, CancellationToken token) { async Task Push(uint methodId, T payload) { await session.NotifyAsync(methodId, UxSerializer.Serialize(payload), token); RuntimeLogs.Decoded(session.Log.Scope, "S2C", methodId, typeof(T), payload); } var adds = new List(); var updates = new List(); var deletes = new List(); double money; double gold; double bindingGold; lock (state.SyncRoot) { // One pass per touched template so a currency that is both spent and re-granted (a voucher // bought with vouchers) ends up with a single, correct stack instead of add/delete churn. var templates = new HashSet(changes.Consumed.Keys); foreach (var templateId in changes.Granted.Keys) templates.Add(templateId); foreach (var templateId in templates) { changes.Consumed.TryGetValue(templateId, out var consumed); changes.Granted.TryGetValue(templateId, out var grant); if (state.GmBackpackItems.TryGetValue(templateId, out var stack)) { var remaining = stack.Count > consumed ? stack.Count - consumed : 0u; var final = SaturatingAdd4229938(remaining, grant.Count); if (final == 0) { state.GmBackpackItems.Remove(templateId); deletes.Add(stack); continue; } stack.Count = final; stack.IsBind = stack.IsBind || grant.IsBind; updates.Add(stack); continue; } if (grant.Count == 0) continue; var item = RuntimePayloadFactory.GmPackItem4229938( grant.TemplateId, grant.Count, grant.IsBind, NextGmItemUniqueId4229938()); state.GmBackpackItems[templateId] = item; adds.Add(item); } money = state.WalletMoney + changes.MoneyDelta; gold = state.WalletGold + changes.GoldDelta; bindingGold = state.WalletBindingGold + changes.BindingGoldDelta; if (money < 0 || gold < 0 || bindingGold < 0) { session.Log.Warn( $"[ECONOMY] {context}: wallet went negative " + $"(money={money} gold={gold} bindingGold={bindingGold}), clamped to zero"); money = Math.Max(money, 0); gold = Math.Max(gold, 0); bindingGold = Math.Max(bindingGold, 0); } state.WalletMoney = money; state.WalletGold = gold; state.WalletBindingGold = bindingGold; } if (changes.MoneyDelta != 0 || changes.GoldDelta != 0 || changes.BindingGoldDelta != 0) { await Push(MethodId.SyncMoney, new GameMethods.SyncMoney4229938 { money = money, gold = gold, bindingGold = bindingGold, }); if (changes.MoneyDelta > 0) { await Push(MethodId.SyncMoneyAdd, new GameMethods.SyncMoneyAdd4229938 { value = changes.MoneyDelta, reason = 0, silence = false, }); } } if (adds.Count > 0 || updates.Count > 0 || deletes.Count > 0) { await Push(MethodId.SyncBackpackItemChanged, new GameMethods.SyncBackpackItemChanged4229938 { addItemList = adds, updateItemList = updates, deleteItemList = deletes, }); } PlayerProgressStore.Save(state, session.Log); return $"wallet=({money},{gold},{bindingGold}) add={adds.Count} update={updates.Count} delete={deletes.Count}"; } /// /// GM 面板「发放全货币」:把三种钱包货币各自**补到** 。 /// 之所以是「补到」而不是「累加」:重复点击不会无限叠加到一个最终溢出的数字, /// 已经达到或超过目标值的币种保持不变(delta=0,不推 pack)。 /// 返回 ApplyEconomy 的摘要串,供调用方写日志。 /// internal static Task GrantAllCurrencies4229938Async( TcpSession session, WorldEntryState state, double amount, CancellationToken token) { double moneyDelta; double goldDelta; double bindingDelta; lock (state.SyncRoot) { moneyDelta = Math.Max(0, amount - state.WalletMoney); goldDelta = Math.Max(0, amount - state.WalletGold); bindingDelta = Math.Max(0, amount - state.WalletBindingGold); } var changes = new EconomyChanges4229938 { MoneyDelta = moneyDelta, GoldDelta = goldDelta, BindingGoldDelta = bindingDelta, }; if (moneyDelta == 0 && goldDelta == 0 && bindingDelta == 0) return Task.FromResult("all currencies already at or above target, nothing pushed"); return ApplyEconomy4229938Async(session, state, changes, "grant-all-currency", token); } internal static uint SaturatingAdd4229938(uint left, uint right) => left > uint.MaxValue - right ? uint.MaxValue : left + right; private static uint SaturatingMultiply4229938(uint value, uint factor) => factor == 0 ? 0u : (value > uint.MaxValue / factor ? uint.MaxValue : value * factor); private static uint RollCount4229938(uint min, uint max) { if (max < min) (min, max) = (max, min); return max == min ? min : (uint)Random.Shared.NextInt64(min, (long)max + 1); } /// Weighted index in [0, count); -1 when every weight is zero. private static int PickWeightedIndex4229938(int count, Func weightAt) { var total = 0d; for (var i = 0; i < count; i++) { var weight = weightAt(i); if (weight > 0) total += weight; } if (total <= 0) return -1; var roll = Random.Shared.NextDouble() * total; for (var i = 0; i < count; i++) { var weight = weightAt(i); if (weight <= 0) continue; roll -= weight; if (roll <= 0) return i; } return count - 1; } /// /// AskMallBuyCommodity(commodityId, buyCount, isAutoExchange) and AskDrawGacha(poolId, drawCount, /// isAutoExchange) share the same wire layout: two uint32 ids followed by one bool. /// private readonly record struct EconomyRequest4229938(uint Id, uint Count, bool Flag); private static EconomyRequest4229938 ParseEconomyRequest4229938(byte[] body) => new( body.Length >= 4 ? BitConverter.ToUInt32(body, 0) : 0u, body.Length >= 8 ? BitConverter.ToUInt32(body, 4) : 0u, body.Length > 8 && body[8] != 0); internal static async Task<(bool Ok, string Message)> PanelGiveItemAsync( TcpSession session, uint templateId, uint count, bool isBind) { var state = GetStateIfExists(session); if (state is null) return (false, "no live game session (is the client in the world?)"); if (templateId == 0) return (false, "missing templateId"); if (!TryResolveGmItem4229938(templateId, out var name)) return (false, $"Item Id {templateId} not found in ConsumableConfig{DescribeItemNamespace4229938(templateId)}"); if (count == 0) count = 1; var changes = new EconomyChanges4229938(); changes.Granted[templateId] = new ItemGrant4229938(templateId, count, isBind); var summary = await ApplyEconomy4229938Async( session, state, changes, $"panel give-item {templateId}x{count}", CancellationToken.None); uint total; lock (state.SyncRoot) total = state.GmBackpackItems.TryGetValue(templateId, out var stack) ? stack.Count : 0u; session.Log.Info($"[PANEL] give-item id={templateId} name={name} count={count} bind={isBind} total={total} {summary}"); return (true, $"Granted {name} (Id={templateId}) x{count}, backpack now has {total}; {summary}"); } private static bool TryResolveGmItem4229938(uint templateId, out string name) { name = string.Empty; if (!EconomyConfigRepository.HasConsumableItems) return true; if (!EconomyConfigRepository.TryConsumableItem(templateId, out var item)) return false; name = item.Name; return true; } private static string DescribeItemNamespace4229938(uint templateId) => templateId switch { >= 36000000u and < 37000000u => " (36xxxxxx Item ID exists in range but not in rows)", >= 98000000u and < 99000000u => " (Weapon ID, please equip via Armory Wheel)", >= 81000000u and < 82000000u => " (Vehicle ID)", >= 11190000u and < 11200000u => " (Fashion Suit ID)", >= 11100000u and < 11190000u => " (Fashion Piece ID)", _ => " (Unknown Item ID range)", }; }