feat: Upgrade server with HyNB v2.4 modular architecture, minigames, admin GM web panel, companion summons, and car radio
This commit is contained in:
1 parent
74e2963a12
commit
3e5adf1dc5
1571 files changed
+336468
-6348
No files matched your search
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -12,19 +13,18 @@ namespace Ananta.Server.Handlers.Game;
|
||||
/// <summary>
|
||||
/// 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 PlayerProgressStore mirrors it to one JSON file per player.
|
||||
/// a re-login. Retail keeps all of this in its account database; here the session state is the single
|
||||
/// authority and <see cref="PlayerProgressStore"/> mirrors it to one JSON file per player.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
private static long _gmItemSeq = 920000000000L;
|
||||
|
||||
internal readonly record struct ItemGrant4229938(uint TemplateId, uint Count, bool IsBind);
|
||||
|
||||
/// <summary>A pending economy mutation: what to take away, what to add, how the wallet moves.</summary>
|
||||
private sealed class EconomyChanges4229938
|
||||
{
|
||||
@@ -36,7 +36,7 @@ internal sealed partial class GameRouter
|
||||
}
|
||||
|
||||
/// <summary>Loads the saved character economy into the session state at login.</summary>
|
||||
internal static void RestorePlayerProgress4229938(WorldEntryState state, ServerLogger log)
|
||||
private static void RestorePlayerProgress4229938(WorldEntryState state, ServerLogger log)
|
||||
{
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
@@ -56,15 +56,8 @@ internal sealed partial class GameRouter
|
||||
state.GmBackpackItems.Clear();
|
||||
foreach (var item in state.Progress.Items)
|
||||
{
|
||||
if (EconomyConfigRepository.HasConsumableItems &&
|
||||
!EconomyConfigRepository.TryConsumableItem(item.TemplateId, out _))
|
||||
{
|
||||
log.Warn(
|
||||
$"[GM] dropping saved backpack item templateId={item.TemplateId} count={item.Count}: " +
|
||||
"not in ConsumableConfig for this build");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -74,31 +67,243 @@ internal sealed partial class GameRouter
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ulong>(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)Interlocked.Increment(ref _gmItemSeq);
|
||||
=> (ulong)IdCounterStore.Next(GmItemSeqCounter, GmItemSeqFloor);
|
||||
|
||||
private static void ReserveGmItemUniqueId4229938(ulong uniqueId)
|
||||
{
|
||||
if (uniqueId > long.MaxValue)
|
||||
return;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var current = Interlocked.Read(ref _gmItemSeq);
|
||||
if (uniqueId <= (ulong)current)
|
||||
return;
|
||||
|
||||
if (Interlocked.CompareExchange(ref _gmItemSeq, (long)uniqueId, current) == current)
|
||||
return;
|
||||
}
|
||||
// 水位只升不降,所以这里不需要 CAS 循环 —— IdCounterStore.Reserve 本身就是单调的。
|
||||
IdCounterStore.Reserve(GmItemSeqCounter, (long)uniqueId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Charges a price in currency units. Wallet currencies debit the wallet,
|
||||
/// everything else debits the granted stack.
|
||||
/// Charges a price in <paramref name="currencyItemId"/> 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.
|
||||
/// </summary>
|
||||
private static void ChargeCurrency4229938(
|
||||
WorldEntryState state,
|
||||
@@ -130,7 +335,7 @@ internal sealed partial class GameRouter
|
||||
|
||||
var taken = Math.Min(amount, available);
|
||||
if (taken < amount)
|
||||
log.Warn($"[ECONOMY] {context}: missing {currencyItemId} to pay {amount} with (have {available}), granting remainder");
|
||||
log.Warn($"[ECONOMY] {context}: missing {currencyItemId} to pay {amount} with (have {available}), granting the remainder for free");
|
||||
|
||||
if (taken <= 0)
|
||||
return;
|
||||
@@ -141,7 +346,7 @@ internal sealed partial class GameRouter
|
||||
: count;
|
||||
}
|
||||
|
||||
/// <summary>Rolls one drop row into changes.</summary>
|
||||
/// <summary>Rolls one drop row <paramref name="rolls"/> times into <paramref name="changes"/>.</summary>
|
||||
private static void GrantDrop4229938(
|
||||
EconomyChanges4229938 changes,
|
||||
EconomyConfigRepository.DropRow drop,
|
||||
@@ -200,20 +405,37 @@ internal sealed partial class GameRouter
|
||||
changes.Granted[templateId] = new ItemGrant4229938(templateId, count, isBind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 入口(<c>conn</c>)额外转交给下面的 session 版本;后台任务用 session 版本。
|
||||
/// </summary>
|
||||
private static Task<string> ApplyEconomy4229938Async(
|
||||
Connection conn,
|
||||
WorldEntryState state,
|
||||
EconomyChanges4229938 changes,
|
||||
string context)
|
||||
=> ApplyEconomy4229938Async(conn.Session, conn.Log, state, changes, context);
|
||||
=> ApplyEconomy4229938Async(conn.Session, state, changes, context, CancellationToken.None);
|
||||
|
||||
/// <summary>
|
||||
/// 后台循环入口(通缉结算等长生命周期调用方,2026-09-23 新增)。
|
||||
/// <see cref="Connection"/> 是 per-RPC 包装、构造是 internal、且它持有那一次 RPC 的 token,
|
||||
/// 后台任务既拿不到也不能用,所以单独留一个 TcpSession 重载;Connection 版委托到这里,
|
||||
/// 两个入口的行为(含 RuntimeLogs 解码日志)完全一致。
|
||||
/// </summary>
|
||||
private static async Task<string> ApplyEconomy4229938Async(
|
||||
TcpSession session,
|
||||
ServerLogger log,
|
||||
WorldEntryState state,
|
||||
EconomyChanges4229938 changes,
|
||||
string context)
|
||||
string context,
|
||||
CancellationToken token)
|
||||
{
|
||||
async Task Push<T>(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<Auto.PlayerPackItem>();
|
||||
var updates = new List<Auto.PlayerPackItem>();
|
||||
var deletes = new List<Auto.PlayerPackItem>();
|
||||
@@ -223,6 +445,8 @@ internal sealed partial class GameRouter
|
||||
|
||||
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<uint>(changes.Consumed.Keys);
|
||||
foreach (var templateId in changes.Granted.Keys)
|
||||
templates.Add(templateId);
|
||||
@@ -265,7 +489,7 @@ internal sealed partial class GameRouter
|
||||
|
||||
if (money < 0 || gold < 0 || bindingGold < 0)
|
||||
{
|
||||
log.Warn(
|
||||
session.Log.Warn(
|
||||
$"[ECONOMY] {context}: wallet went negative " +
|
||||
$"(money={money} gold={gold} bindingGold={bindingGold}), clamped to zero");
|
||||
money = Math.Max(money, 0);
|
||||
@@ -280,7 +504,7 @@ internal sealed partial class GameRouter
|
||||
|
||||
if (changes.MoneyDelta != 0 || changes.GoldDelta != 0 || changes.BindingGoldDelta != 0)
|
||||
{
|
||||
await NotifySession4229938Async(session, MethodId.SyncMoney, new GameMethods.SyncMoney4229938
|
||||
await Push(MethodId.SyncMoney, new GameMethods.SyncMoney4229938
|
||||
{
|
||||
money = money,
|
||||
gold = gold,
|
||||
@@ -289,7 +513,7 @@ internal sealed partial class GameRouter
|
||||
|
||||
if (changes.MoneyDelta > 0)
|
||||
{
|
||||
await NotifySession4229938Async(session, MethodId.SyncMoneyAdd, new GameMethods.SyncMoneyAdd4229938
|
||||
await Push(MethodId.SyncMoneyAdd, new GameMethods.SyncMoneyAdd4229938
|
||||
{
|
||||
value = changes.MoneyDelta,
|
||||
reason = 0,
|
||||
@@ -300,7 +524,7 @@ internal sealed partial class GameRouter
|
||||
|
||||
if (adds.Count > 0 || updates.Count > 0 || deletes.Count > 0)
|
||||
{
|
||||
await NotifySession4229938Async(session, MethodId.SyncBackpackItemChanged, new GameMethods.SyncBackpackItemChanged4229938
|
||||
await Push(MethodId.SyncBackpackItemChanged, new GameMethods.SyncBackpackItemChanged4229938
|
||||
{
|
||||
addItemList = adds,
|
||||
updateItemList = updates,
|
||||
@@ -308,48 +532,44 @@ internal sealed partial class GameRouter
|
||||
});
|
||||
}
|
||||
|
||||
PlayerProgressStore.Save(state, log);
|
||||
PlayerProgressStore.Save(state, session.Log);
|
||||
return $"wallet=({money},{gold},{bindingGold}) add={adds.Count} update={updates.Count} delete={deletes.Count}";
|
||||
}
|
||||
|
||||
private static async Task NotifySession4229938Async<T>(TcpSession session, uint methodId, T body)
|
||||
{
|
||||
var bytes = UxSerializer.Serialize(body);
|
||||
RuntimeLogs.Decoded(session.Log.Scope, "S2C", methodId, typeof(T), body);
|
||||
await session.NotifyAsync(methodId, bytes, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debug panel: hands out a backpack stack through the mall pipeline, syncing SyncBackpackItemChanged.
|
||||
/// GM 面板「发放全货币」:把三种钱包货币各自**补到** <paramref name="amount"/>。
|
||||
/// 之所以是「补到」而不是「累加」:重复点击不会无限叠加到一个最终溢出的数字,
|
||||
/// 已经达到或超过目标值的币种保持不变(delta=0,不推 pack)。
|
||||
/// 返回 ApplyEconomy 的摘要串,供调用方写日志。
|
||||
/// </summary>
|
||||
internal static async Task<(bool Ok, string Message)> PanelGiveItemAsync(
|
||||
internal static Task<string> GrantAllCurrencies4229938Async(
|
||||
TcpSession session,
|
||||
uint templateId,
|
||||
uint count,
|
||||
bool isBind)
|
||||
WorldEntryState state,
|
||||
double amount,
|
||||
CancellationToken token)
|
||||
{
|
||||
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)}");
|
||||
double moneyDelta;
|
||||
double goldDelta;
|
||||
double bindingDelta;
|
||||
|
||||
if (count == 0)
|
||||
count = 1;
|
||||
|
||||
var changes = new EconomyChanges4229938();
|
||||
changes.Granted[templateId] = new ItemGrant4229938(templateId, count, isBind);
|
||||
var summary = await ApplyEconomy4229938Async(
|
||||
session, session.Log, state, changes, $"panel give-item {templateId}x{count}");
|
||||
|
||||
uint total;
|
||||
lock (state.SyncRoot)
|
||||
total = state.GmBackpackItems.TryGetValue(templateId, out var stack) ? stack.Count : 0u;
|
||||
{
|
||||
moneyDelta = Math.Max(0, amount - state.WalletMoney);
|
||||
goldDelta = Math.Max(0, amount - state.WalletGold);
|
||||
bindingDelta = Math.Max(0, amount - state.WalletBindingGold);
|
||||
}
|
||||
|
||||
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}");
|
||||
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)
|
||||
@@ -366,6 +586,7 @@ internal sealed partial class GameRouter
|
||||
return max == min ? min : (uint)Random.Shared.NextInt64(min, (long)max + 1);
|
||||
}
|
||||
|
||||
/// <summary>Weighted index in [0, count); -1 when every weight is zero.</summary>
|
||||
private static int PickWeightedIndex4229938(int count, Func<int, double> weightAt)
|
||||
{
|
||||
var total = 0d;
|
||||
@@ -394,6 +615,10 @@ internal sealed partial class GameRouter
|
||||
return count - 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AskMallBuyCommodity(commodityId, buyCount, isAutoExchange) and AskDrawGacha(poolId, drawCount,
|
||||
/// isAutoExchange) share the same wire layout: two uint32 ids followed by one bool.
|
||||
/// </summary>
|
||||
private readonly record struct EconomyRequest4229938(uint Id, uint Count, bool Flag);
|
||||
|
||||
private static EconomyRequest4229938 ParseEconomyRequest4229938(byte[] body)
|
||||
@@ -402,6 +627,36 @@ internal sealed partial class GameRouter
|
||||
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;
|
||||
@@ -424,4 +679,5 @@ internal sealed partial class GameRouter
|
||||
>= 11100000u and < 11190000u => " (Fashion Piece ID)",
|
||||
_ => " (Unknown Item ID range)",
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user