feat: Implement missions 1-5 (combat fluidity, urban crowd locomotion, smart traffic signals, dynamic gacha pity, universal item spawner & armory)
This commit is contained in:
1 parent
5e333d7678
commit
ce1f471eab
27 files changed
+321970
-135
No files matched your search
@@ -27,10 +27,12 @@ internal sealed partial class GameRouter
|
||||
return;
|
||||
}
|
||||
|
||||
state.SkillByInstanceId[req.SkillInstanceId] = req.SkillId;
|
||||
|
||||
var style = ActiveStyle(state);
|
||||
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
|
||||
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
|
||||
if (weapon.IsReloadSkill(req.SkillId))
|
||||
if (weapon.IsReloadSkill(req.SkillId) || CombatCatalogRepository.SkillCastTag(req.SkillId) == 19u)
|
||||
await ReloadWeapon4229938(ctx, state, weapon);
|
||||
if (!weapon.AllowsSkill(style, req.SkillId))
|
||||
{
|
||||
@@ -406,25 +408,68 @@ internal sealed partial class GameRouter
|
||||
?? throw new InvalidDataException($"Missing runtime view for spirit {spiritId}, weapon {instanceId}."))
|
||||
.ToArray();
|
||||
|
||||
Task OnSkillUseWeaponDurability(RpcContext ctx, int skillInstanceId, int triggerIndex)
|
||||
async Task OnSkillUseWeaponDurability(RpcContext ctx, int skillInstanceId, int triggerIndex)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (!state.Ready)
|
||||
return Task.CompletedTask;
|
||||
return;
|
||||
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
|
||||
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
|
||||
if (!weapon.IsShootWeapon || weapon.MagazineAmmo == 0)
|
||||
return Task.CompletedTask;
|
||||
return;
|
||||
|
||||
uint skillId = 0;
|
||||
if (state.SkillByInstanceId.TryGetValue(skillInstanceId, out var mappedSkillId))
|
||||
skillId = mappedSkillId;
|
||||
else if (skillInstanceId > 0)
|
||||
skillId = (uint)skillInstanceId;
|
||||
else if (state.ActiveSkillId != 0)
|
||||
skillId = state.ActiveSkillId;
|
||||
|
||||
// If this durability event belongs to a reload skill, it represents the reload trigger in the animation,
|
||||
// not a bullet shot. Never decrement ammunition for a reload event!
|
||||
if (weapon.IsReloadSkill(skillId) || CombatCatalogRepository.SkillCastTag(skillId) == 19u)
|
||||
{
|
||||
ctx.Session.Log.Info($"[AMMO] reload-trigger detected token={skillInstanceId} skill={skillId} weapon={weapon.TemplateId}/{weapon.InstanceId}");
|
||||
await ReloadWeapon4229938(ctx, state, weapon);
|
||||
return;
|
||||
}
|
||||
|
||||
var current = EnsureMagazine4229938(state, weapon);
|
||||
if (current > 0)
|
||||
var infiniteAmmo = InfiniteAmmoEnabled4229938() && weapon.MagazineAmmo > 0;
|
||||
if (infiniteAmmo)
|
||||
{
|
||||
// Infinite ammo: restate the magazine at full capacity so the client never reaches
|
||||
// the dry state (the one that stops firing and waits for an incomplete reload).
|
||||
current = weapon.MagazineAmmo;
|
||||
state.WeaponMagazineAmmo[weapon.InstanceId] = current;
|
||||
}
|
||||
else if (current > 0)
|
||||
{
|
||||
state.WeaponMagazineAmmo[weapon.InstanceId] = --current;
|
||||
}
|
||||
|
||||
// Separate-bullet firearms consume only the loaded magazine; their reserve is an ordinary
|
||||
// backpack item. Other firearms use Durability as total remaining ammunition, so decrement
|
||||
// total and magazine together. Infinite (-1) values stay infinite.
|
||||
var durability = CurrentDurability4229938(state, weapon, current);
|
||||
if (!weapon.SeparateBullets && durability > 0)
|
||||
if (infiniteAmmo)
|
||||
{
|
||||
// Infinite ammo: freeze the total pool for non-separate guns as well.
|
||||
if (!weapon.SeparateBullets)
|
||||
{
|
||||
if (durability < current)
|
||||
{
|
||||
durability = current;
|
||||
state.WeaponDurabilityAmmo[weapon.InstanceId] = durability;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
durability = current;
|
||||
}
|
||||
}
|
||||
else if (!weapon.SeparateBullets && durability > 0)
|
||||
{
|
||||
durability--;
|
||||
state.WeaponDurabilityAmmo[weapon.InstanceId] = durability;
|
||||
@@ -439,7 +484,7 @@ internal sealed partial class GameRouter
|
||||
? EnsureReserve4229938(state, bulletId)
|
||||
: NonSeparateReserve4229938(durability, current);
|
||||
ctx.Session.Log.Info($"[AMMO] shot weapon={weapon.TemplateId}/{weapon.InstanceId} token={skillInstanceId} trigger={triggerIndex} mag={current}/{weapon.MagazineAmmo} reserve={reserve} bullet={bulletId} total={durability}");
|
||||
return ctx.NotifyAsync(MethodId.SyncSpiritWeaponDurabilityChangedAction,
|
||||
await ctx.NotifyAsync(MethodId.SyncSpiritWeaponDurabilityChangedAction,
|
||||
CombatCodec.WeaponDurabilityChanged(state.ActiveSpiritTemplateId, state.ActiveSpiritUnitId, weapon, durability, current, bulletId));
|
||||
}
|
||||
|
||||
@@ -602,5 +647,26 @@ internal sealed partial class GameRouter
|
||||
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
|
||||
return ResolveWeaponStyle(state, weapon);
|
||||
}
|
||||
|
||||
private static long _infiniteAmmoCheckTicks4229938;
|
||||
private static bool _infiniteAmmoCached4229938 = true;
|
||||
|
||||
internal static bool InfiniteAmmoEnabled4229938()
|
||||
{
|
||||
var now = System.Environment.TickCount64;
|
||||
if (now - _infiniteAmmoCheckTicks4229938 < 2000)
|
||||
return _infiniteAmmoCached4229938;
|
||||
_infiniteAmmoCheckTicks4229938 = now;
|
||||
try
|
||||
{
|
||||
_infiniteAmmoCached4229938 = !System.IO.File.Exists(
|
||||
System.IO.Path.Combine(System.AppContext.BaseDirectory, "infinite-ammo.disabled"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
_infiniteAmmoCached4229938 = true;
|
||||
}
|
||||
return _infiniteAmmoCached4229938;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
using Ananta.SDK.Logging;
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
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;
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
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
|
||||
{
|
||||
internal Dictionary<uint, uint> Consumed { get; } = [];
|
||||
internal Dictionary<uint, ItemGrant4229938> Granted { get; } = [];
|
||||
internal double MoneyDelta { get; set; }
|
||||
internal double GoldDelta { get; set; }
|
||||
internal double BindingGoldDelta { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Loads the saved character economy into the session state at login.</summary>
|
||||
internal 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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong NextGmItemUniqueId4229938()
|
||||
=> (ulong)Interlocked.Increment(ref _gmItemSeq);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Charges a price in currency units. Wallet currencies debit the wallet,
|
||||
/// everything else debits the granted stack.
|
||||
/// </summary>
|
||||
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 remainder");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Rolls one drop row into changes.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
private static Task<string> ApplyEconomy4229938Async(
|
||||
Connection conn,
|
||||
WorldEntryState state,
|
||||
EconomyChanges4229938 changes,
|
||||
string context)
|
||||
=> ApplyEconomy4229938Async(conn.Session, conn.Log, state, changes, context);
|
||||
|
||||
private static async Task<string> ApplyEconomy4229938Async(
|
||||
TcpSession session,
|
||||
ServerLogger log,
|
||||
WorldEntryState state,
|
||||
EconomyChanges4229938 changes,
|
||||
string context)
|
||||
{
|
||||
var adds = new List<Auto.PlayerPackItem>();
|
||||
var updates = new List<Auto.PlayerPackItem>();
|
||||
var deletes = new List<Auto.PlayerPackItem>();
|
||||
double money;
|
||||
double gold;
|
||||
double bindingGold;
|
||||
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
var templates = new HashSet<uint>(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)
|
||||
{
|
||||
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 NotifySession4229938Async(session, MethodId.SyncMoney, new GameMethods.SyncMoney4229938
|
||||
{
|
||||
money = money,
|
||||
gold = gold,
|
||||
bindingGold = bindingGold,
|
||||
});
|
||||
|
||||
if (changes.MoneyDelta > 0)
|
||||
{
|
||||
await NotifySession4229938Async(session, MethodId.SyncMoneyAdd, new GameMethods.SyncMoneyAdd4229938
|
||||
{
|
||||
value = changes.MoneyDelta,
|
||||
reason = 0,
|
||||
silence = false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (adds.Count > 0 || updates.Count > 0 || deletes.Count > 0)
|
||||
{
|
||||
await NotifySession4229938Async(session, MethodId.SyncBackpackItemChanged, new GameMethods.SyncBackpackItemChanged4229938
|
||||
{
|
||||
addItemList = adds,
|
||||
updateItemList = updates,
|
||||
deleteItemList = deletes,
|
||||
});
|
||||
}
|
||||
|
||||
PlayerProgressStore.Save(state, 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.
|
||||
/// </summary>
|
||||
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, 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;
|
||||
|
||||
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}");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static int PickWeightedIndex4229938(int count, Func<int, double> 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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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)",
|
||||
};
|
||||
}
|
||||
@@ -1,97 +1,220 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938.Auto;
|
||||
using Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Gacha draws for build 4229938 (GachaManager.DoDrawGacha -> AskDrawGacha(poolId, drawCount, isAutoExchange)).
|
||||
///
|
||||
/// Odds follow GachaPoolTierRuleConfig: RuleType 1 pools ramp the grand-prize chance from
|
||||
/// BaseProbability by ProbabilityIncrement per draw past RampStartDraw and hand out a guaranteed
|
||||
/// grand prize at PityThreshold; these pools also pick the prize row itself by relative weight within
|
||||
/// the "grand" or "ordinary" group. RuleType 0 pools have no pity ramp and simply pick by weight.
|
||||
/// Duplicate grand prizes are converted to the row's duplicateReturn drop, which is also how the
|
||||
/// client labels the result (IsConverted).
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
private const int GachaProbabilityScale4229938 = 100000;
|
||||
|
||||
[Handler(MethodId.AskDrawGacha, HandlerPacketKind.Invoke)]
|
||||
private async Task AskDrawGacha(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<AskDrawGachaArgs>();
|
||||
uint poolId = args.gachaPoolId == 0 ? 1001u : args.gachaPoolId;
|
||||
uint count = args.drawCount == 0 ? 1u : args.drawCount;
|
||||
var state = GetWorldState(msg.Context);
|
||||
var request = ParseEconomyRequest4229938(msg.Body);
|
||||
var drawCount = Math.Clamp(request.Count == 0 ? 1u : request.Count, 1u, 10u);
|
||||
|
||||
conn.Session.Log.Info($"[GACHA] AskDrawGacha: poolId={poolId} count={count} autoEx={args.isAutoExchange}");
|
||||
|
||||
var fiveStarSpirits = new uint[] { 15020992, 15020991, 15020997, 15021024, 15021025 };
|
||||
var fourStarSpirits = new uint[] { 15020989, 15020990, 15021016, 15021017, 15021020, 15021021, 15021022, 15021023 };
|
||||
var topWeapons = new uint[] { 98003001, 98003023, 98003131, 98003184, 98003185 };
|
||||
|
||||
var sync = new SyncGachaDrawInfo
|
||||
if (!EconomyConfigRepository.TryPool(request.Id, out var pool))
|
||||
{
|
||||
isGrandPrizeWithAllFillers = false,
|
||||
drawDetails = new List<GachaDrawDetail>()
|
||||
};
|
||||
conn.Log.Warn($"[GACHA] unknown pool {request.Id} (body={Convert.ToHexString(msg.Body)})");
|
||||
await ReplyDrawGacha4229938Async(conn, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
var records = new List<GachaDrawRecord>();
|
||||
var rng = Random.Shared;
|
||||
|
||||
for (uint i = 0; i < count; i++)
|
||||
var contents = EconomyConfigRepository.PoolContents(pool.Id);
|
||||
if (contents.Count == 0)
|
||||
{
|
||||
bool isGuaranteedFiveStar = (count >= 10 && i == count - 1) || (rng.Next(100) < 15);
|
||||
bool isFourStar = !isGuaranteedFiveStar && (rng.Next(100) < 45);
|
||||
conn.Log.Warn($"[GACHA] pool {pool.Id} has no contents, nothing can be drawn");
|
||||
await ReplyDrawGacha4229938Async(conn, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
uint prizeId;
|
||||
if (isGuaranteedFiveStar)
|
||||
EconomyConfigRepository.GachaTierRule? tierRule = null;
|
||||
if (pool.GrandTierRuleId != 0 && EconomyConfigRepository.TryTierRule(pool.GrandTierRuleId, out var foundRule))
|
||||
tierRule = foundRule;
|
||||
|
||||
var changes = new EconomyChanges4229938();
|
||||
ChargeCurrency4229938(
|
||||
state, changes, pool.MoneyId, pool.CostFor(drawCount), conn.Log, $"gacha {pool.Id}");
|
||||
|
||||
var details = new List<GameMethods.GachaDrawDetail4229938>((int)drawCount);
|
||||
var grandCount = 0;
|
||||
var skipped = 0;
|
||||
|
||||
for (var draw = 0u; draw < drawCount; draw++)
|
||||
{
|
||||
uint drawsSinceGrand;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
prizeId = fiveStarSpirits[rng.Next(fiveStarSpirits.Length)];
|
||||
state.Progress.GachaDrawsSinceGrand.TryGetValue(pool.Id, out var previous);
|
||||
drawsSinceGrand = previous + 1;
|
||||
}
|
||||
else if (isFourStar)
|
||||
|
||||
var isGrand = RollGrandPrize4229938(tierRule, drawsSinceGrand);
|
||||
var content = PickGachaContent4229938(contents, isGrand)
|
||||
?? PickGachaContent4229938(contents, grandOnly: null);
|
||||
|
||||
if (content is null)
|
||||
{
|
||||
prizeId = fourStarSpirits[rng.Next(fourStarSpirits.Length)];
|
||||
skipped++;
|
||||
lock (state.SyncRoot)
|
||||
state.Progress.GachaDrawsSinceGrand[pool.Id] = drawsSinceGrand;
|
||||
continue;
|
||||
}
|
||||
|
||||
isGrand = content.IsGrandPrize;
|
||||
lock (state.SyncRoot)
|
||||
state.Progress.GachaDrawsSinceGrand[pool.Id] = isGrand ? 0u : drawsSinceGrand;
|
||||
|
||||
if (isGrand)
|
||||
grandCount++;
|
||||
|
||||
var converted = false;
|
||||
var isNew = false;
|
||||
var dropId = content.DropId;
|
||||
var rolls = 1u;
|
||||
|
||||
if (TryPrizeTemplateId4229938(content.DropId, out var prizeTemplateId))
|
||||
{
|
||||
var owned = IsItemOwned4229938(state, prizeTemplateId);
|
||||
isNew = !owned;
|
||||
|
||||
if (owned && content.DuplicateReturnDropId != 0 && content.DuplicateReturnCount != 0)
|
||||
{
|
||||
converted = true;
|
||||
dropId = content.DuplicateReturnDropId;
|
||||
rolls = content.DuplicateReturnCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (dropId != 0 && EconomyConfigRepository.TryDrop(dropId, out var drop) && !drop.IsEmpty)
|
||||
GrantDrop4229938(changes, drop, rolls, $"gacha {pool.Id} content {content.Id}", conn.Log);
|
||||
else
|
||||
{
|
||||
prizeId = topWeapons[rng.Next(topWeapons.Length)];
|
||||
}
|
||||
conn.Log.Warn($"[GACHA] content {content.Id} grants nothing (drop={dropId})");
|
||||
|
||||
sync.drawDetails.Add(new GachaDrawDetail
|
||||
details.Add(new GameMethods.GachaDrawDetail4229938
|
||||
{
|
||||
PoolContentId = prizeId,
|
||||
IsGrandPrize = isGuaranteedFiveStar,
|
||||
IsConverted = false,
|
||||
IsNew = true
|
||||
});
|
||||
|
||||
records.Add(new GachaDrawRecord
|
||||
{
|
||||
GachaId = poolId,
|
||||
PoolContentId = prizeId,
|
||||
DropId = prizeId,
|
||||
DrawTimeUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
||||
PoolContentId = content.Id,
|
||||
IsGrandPrize = isGrand,
|
||||
IsConverted = converted,
|
||||
IsNew = isNew,
|
||||
});
|
||||
}
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncGachaDrawInfo, sync);
|
||||
var summary = await ApplyEconomy4229938Async(conn, state, changes, $"gacha {pool.Id}");
|
||||
|
||||
var gachaInfos = new PlayerGachaInfos();
|
||||
var poolInfo = new PlayerGachaPoolInfo
|
||||
if (details.Count > 0)
|
||||
{
|
||||
DrawCount = count,
|
||||
DrawRecords = records
|
||||
};
|
||||
gachaInfos.PoolInfos[poolId] = poolInfo;
|
||||
gachaInfos.GroupInfos[poolId] = new PlayerGachaGroupInfo
|
||||
{
|
||||
TotalDrawCount = count
|
||||
};
|
||||
gachaInfos.PityInfos[poolId] = new PlayerGachaPityInfo
|
||||
{
|
||||
TotalDrawCount = count,
|
||||
DrawsSinceLastReset = count % 80
|
||||
};
|
||||
await conn.NotifyAsync(MethodId.IGameToClient_SyncGachaDrawInfo, new GameMethods.SyncGachaDrawInfo4229938
|
||||
{
|
||||
drawDetails = details,
|
||||
isGrandPrizeWithAllFillers = false,
|
||||
});
|
||||
}
|
||||
|
||||
await conn.ReturnAsync(msg, gachaInfos);
|
||||
conn.Log.Info(
|
||||
$"[GACHA] pool={pool.Id} system={pool.MoneyId} count={drawCount} cost={pool.CostFor(drawCount)} " +
|
||||
$"grand={grandCount} skipped={skipped} rule={tierRule?.Id ?? 0} ruleType={tierRule?.RuleType ?? 0} " +
|
||||
$"-> {summary}");
|
||||
|
||||
await ReplyDrawGacha4229938Async(conn, msg);
|
||||
}
|
||||
|
||||
private static bool RollGrandPrize4229938(EconomyConfigRepository.GachaTierRule? rule, uint drawsSinceGrand)
|
||||
{
|
||||
if (rule is null)
|
||||
return false;
|
||||
|
||||
if (rule.PityThreshold > 0 && drawsSinceGrand >= rule.PityThreshold)
|
||||
return true;
|
||||
|
||||
if (rule.RuleType != 1)
|
||||
return false;
|
||||
|
||||
var probability = (double)rule.BaseProbability;
|
||||
if (rule.RampStartDraw > 0 && drawsSinceGrand > rule.RampStartDraw)
|
||||
probability += (double)(drawsSinceGrand - rule.RampStartDraw) * rule.ProbabilityIncrement;
|
||||
|
||||
if (probability <= 0)
|
||||
return false;
|
||||
if (probability >= GachaProbabilityScale4229938)
|
||||
return true;
|
||||
|
||||
return Random.Shared.Next(GachaProbabilityScale4229938) < probability;
|
||||
}
|
||||
|
||||
private static EconomyConfigRepository.GachaContent? PickGachaContent4229938(
|
||||
IReadOnlyList<EconomyConfigRepository.GachaContent> contents,
|
||||
bool? grandOnly)
|
||||
{
|
||||
var candidates = new List<EconomyConfigRepository.GachaContent>();
|
||||
foreach (var content in contents)
|
||||
{
|
||||
if (content.Weight <= 0)
|
||||
continue;
|
||||
if (grandOnly is { } wanted && content.IsGrandPrize != wanted)
|
||||
continue;
|
||||
|
||||
candidates.Add(content);
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return null;
|
||||
|
||||
var index = PickWeightedIndex4229938(candidates.Count, i => candidates[i].Weight);
|
||||
return index < 0 ? null : candidates[index];
|
||||
}
|
||||
|
||||
private static bool TryPrizeTemplateId4229938(uint dropId, out uint templateId)
|
||||
{
|
||||
templateId = 0;
|
||||
if (dropId == 0 || !EconomyConfigRepository.TryDrop(dropId, out var drop))
|
||||
return false;
|
||||
|
||||
if (drop.Guaranteed.Count > 0)
|
||||
templateId = drop.Guaranteed[0].Id;
|
||||
else if (drop.Ranged.Count > 0)
|
||||
templateId = drop.Ranged[0].Id;
|
||||
else if (drop.Weighted.Count > 0)
|
||||
templateId = drop.Weighted[0].Id;
|
||||
|
||||
return templateId != 0;
|
||||
}
|
||||
|
||||
private static bool IsItemOwned4229938(WorldEntryState state, uint templateId)
|
||||
{
|
||||
lock (state.SyncRoot)
|
||||
return state.GmBackpackItems.TryGetValue(templateId, out var stack) && stack.Count > 0;
|
||||
}
|
||||
|
||||
private static Task ReplyDrawGacha4229938Async(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (DefaultReturnCatalog4229938.TryGet(msg.MethodId, out var body, out var shape))
|
||||
{
|
||||
conn.Log.Info($"[GACHA] reply typed-default {shape} {body.Length}b");
|
||||
return msg.Context.ReturnAsync(body);
|
||||
}
|
||||
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskClaimGachaMilestone, HandlerPacketKind.Invoke)]
|
||||
private Task AskClaimGachaMilestone(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<AskClaimGachaMilestoneArgs>();
|
||||
var args = msg.GetArgs<GameMethods.AskClaimGachaMilestoneArgs>();
|
||||
conn.Session.Log.Info($"[GACHA] AskClaimGachaMilestone: groupId={args.groupId} count={args.milestoneCount}");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.Configuration;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Debug-panel armory operations: install any armory weapon into any spirit editable wheel
|
||||
/// slot (1..15) and republish the proven weapon-detail / fight-style / switch shapes.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
internal static async Task<(bool Ok, string Message)> PanelEquipWeaponAsync(
|
||||
TcpSession session, uint? spiritIdOption, int slotIndex, uint? templateIdOption, ulong? instanceIdOption)
|
||||
{
|
||||
var state = GetStateIfExists(session);
|
||||
if (state is null)
|
||||
return (false, "no live game session (is the client in the world?)");
|
||||
|
||||
var spiritId = spiritIdOption is uint requested && requested != 0
|
||||
? requested
|
||||
: state.ActiveSpiritTemplateId;
|
||||
if (!ClientConfigRepository.Characters().Any(character => character.TemplateId == spiritId))
|
||||
return (false, $"spirit {spiritId} is not in the roster");
|
||||
if (!IsEditableWeaponSlot(slotIndex))
|
||||
return (false, "slotIndex must be 1..15 (slot 0 is the authored fists slot)");
|
||||
|
||||
ulong instanceId;
|
||||
if (instanceIdOption is ulong requestedInstance && requestedInstance != 0)
|
||||
{
|
||||
instanceId = requestedInstance;
|
||||
}
|
||||
else if (templateIdOption is uint templateId && templateId != 0)
|
||||
{
|
||||
var match = CombatCatalogRepository.AccountWeapons.FirstOrDefault(weapon => weapon.TemplateId == templateId);
|
||||
if (match is null)
|
||||
return (false, $"template {templateId} is not an armory weapon (see GET /api/catalog/weapons)");
|
||||
instanceId = match.InstanceId;
|
||||
}
|
||||
else
|
||||
{
|
||||
return (false, "provide templateId or instanceId");
|
||||
}
|
||||
|
||||
var target = CombatCatalogRepository.Weapon(spiritId, instanceId);
|
||||
if (target is null)
|
||||
return (false, $"weapon instance {instanceId} has no runtime view for spirit {spiritId}");
|
||||
|
||||
if (state.ArmoryWeaponsAnnounced.Add(instanceId))
|
||||
{
|
||||
await session.NotifyAsync(MethodId.SyncArmoryAddWeapon,
|
||||
UxSerializer.Serialize(RuntimePayloadFactory.WeaponData(target)), CancellationToken.None);
|
||||
session.Log.Info($"[ARMORY] announce-to-client weapon={target.TemplateId}/{instanceId}");
|
||||
}
|
||||
|
||||
var slots = WeaponSlotIds(state, spiritId);
|
||||
var existingIndex = slots.IndexOf(instanceId);
|
||||
if (existingIndex >= 0 && existingIndex != slotIndex)
|
||||
(slots[existingIndex], slots[slotIndex]) = (slots[slotIndex], slots[existingIndex]);
|
||||
else
|
||||
slots[slotIndex] = instanceId;
|
||||
|
||||
await PublishPanelWeaponSlotsAsync(session, state, spiritId, $"panel-equip:{instanceId}@{slotIndex}");
|
||||
session.Log.Info($"[ARMORY] panel equip spirit={spiritId} slot={slotIndex} weapon={target.TemplateId}/{instanceId} name={target.Name}");
|
||||
return (true, $"spirit {spiritId} slot {slotIndex} = {target.Name} ({target.TemplateId})");
|
||||
}
|
||||
|
||||
private static async Task PublishPanelWeaponSlotsAsync(TcpSession session, WorldEntryState state, uint spiritId, string operation)
|
||||
{
|
||||
var slots = WeaponSlotIds(state, spiritId);
|
||||
var isActive = spiritId == state.ActiveSpiritTemplateId;
|
||||
var currentWeaponId = isActive
|
||||
? state.ActiveWeaponInstanceId
|
||||
: state.LastWeaponBySpirit.GetValueOrDefault(spiritId);
|
||||
if (currentWeaponId == 0 || !slots.Contains(currentWeaponId))
|
||||
currentWeaponId = slots.FirstOrDefault(id => id != 0);
|
||||
if (currentWeaponId == 0)
|
||||
currentWeaponId = CombatCodec.Loadout(spiritId).DefaultWeapon.InstanceId;
|
||||
|
||||
var unitId = isActive
|
||||
? state.ActiveSpiritUnitId
|
||||
: ClientConfigRepository.Characters().First(x => x.TemplateId == spiritId).UnitId;
|
||||
var currentWeapon = CombatCatalogRepository.Weapon(spiritId, currentWeaponId)
|
||||
?? throw new InvalidDataException($"Missing runtime view for spirit {spiritId}, weapon {currentWeaponId}.");
|
||||
var currentChanged = isActive && state.ActiveWeaponInstanceId != currentWeaponId;
|
||||
var style = ResolveWeaponStyle(state, currentWeapon);
|
||||
|
||||
state.LastWeaponBySpirit[spiritId] = currentWeaponId;
|
||||
if (isActive)
|
||||
{
|
||||
state.ActiveWeaponInstanceId = currentWeaponId;
|
||||
state.ActiveFightStyleId = style.Id;
|
||||
state.ActiveSkillId = 0;
|
||||
state.RestoreResourcesAfterActiveSkill = false;
|
||||
state.ActiveClientSkillInstanceId = 0;
|
||||
state.ActiveSkillStartedTicks = 0;
|
||||
}
|
||||
|
||||
var snapshot = CombatCodec.SpiritWeaponSnapshot(unitId, spiritId, currentWeaponId, WeaponSlotDefinitions(state, spiritId));
|
||||
foreach (var detail in snapshot.WeaponSlots)
|
||||
{
|
||||
if (detail is null)
|
||||
continue;
|
||||
if (CombatCatalogRepository.Weapon(spiritId, detail.InstanceId) is not { } definition)
|
||||
continue;
|
||||
detail.FightStyleId = ResolveWeaponStyle(state, definition).Id;
|
||||
detail.MagazineAmmo = EnsureMagazine4229938(state, definition);
|
||||
detail.Durability = CurrentDurability4229938(state, definition, detail.MagazineAmmo);
|
||||
detail.BulletDatas.BulletId = EnsureBullet4229938(state, definition);
|
||||
}
|
||||
await session.NotifyAsync(MethodId.SyncSpiritWeaponDetail, UxSerializer.Serialize(snapshot), CancellationToken.None);
|
||||
|
||||
foreach (var detail in snapshot.WeaponSlots.Where(x => x is not null).Select(x => x!))
|
||||
{
|
||||
await session.NotifyAsync(MethodId.SyncWeaponFightStyleChange, UxSerializer.Serialize(
|
||||
new GameMethods.SyncWeaponFightStyleChange
|
||||
{
|
||||
weaponInstanceId = detail.InstanceId,
|
||||
fightStyleId = detail.FightStyleId,
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
await session.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon, UxSerializer.Serialize(
|
||||
CombatCodec.SpiritLastUsedWeapon(spiritId, currentWeaponId)), CancellationToken.None);
|
||||
|
||||
if (!currentChanged)
|
||||
return;
|
||||
|
||||
await session.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction, UxSerializer.Serialize(
|
||||
CombatCodec.SpiritSwitchWeapon(unitId, currentWeaponId)), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncPlayerAllSkillChargeData, UxSerializer.Serialize(
|
||||
CombatCodec.AllSkillCharges(unitId, currentWeapon, style)), CancellationToken.None);
|
||||
foreach (var (resourceId, maximum) in CombatCodec.AllResourceMaximums)
|
||||
{
|
||||
await session.NotifyAsync(MethodId.SyncFightResource, UxSerializer.Serialize(
|
||||
CombatCodec.FightResource(unitId, resourceId, maximum)), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncFightResourceFreeState, UxSerializer.Serialize(
|
||||
CombatCodec.FightResourceFreeState(unitId, resourceId, true)), CancellationToken.None);
|
||||
}
|
||||
await session.NotifyAsync(MethodId.SyncChangeCommonSkill, UxSerializer.Serialize(CombatCodec.CommonBinding(unitId, style)), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncChangeHeavyAttack, UxSerializer.Serialize(CombatCodec.HeavyAttackBinding(unitId, style)), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncChangeDodgeSkill, UxSerializer.Serialize(CombatCodec.DodgeBinding(unitId, style)), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncChangeControlSkill, UxSerializer.Serialize(CombatCodec.ControlBinding(unitId, style)), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncChangeActiveSkill, UxSerializer.Serialize(CombatCodec.ActiveBinding(unitId, currentWeapon, style)), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncChangeUniqueSkill, UxSerializer.Serialize(CombatCodec.UniqueBinding(unitId, currentWeapon, style)), CancellationToken.None);
|
||||
session.Log.Info($"[ARMORY] panel {operation} spirit={spiritId} active={isActive} current={currentWeaponId} style={style.Id}");
|
||||
}
|
||||
}
|
||||
@@ -73,4 +73,19 @@ internal sealed class TrafficAndCrowdDriver : ITrafficVehicleDriver, IUrbanCrowd
|
||||
var result = await GameRouter.SpawnStaticNpcAtAsync(session, npcFormworkId, poiActionId, x, y, z, facing);
|
||||
return (result.Ok, result.Ids);
|
||||
}
|
||||
|
||||
public async Task DespawnCrowdPedestrianAsync(TcpSession session, ulong entityId)
|
||||
{
|
||||
await session.NotifyAsync(MethodId.SyncLogicAgentLeave,
|
||||
UxSerializer.Serialize(WorldCodec.LogicAgentLeave(entityId)), CancellationToken.None);
|
||||
session.Log.Info($"[CROWD] despawn pedestrian entity={entityId}");
|
||||
}
|
||||
|
||||
public async Task SendPedestrianMoveAsync(
|
||||
TcpSession session, ulong entityId, float x, float y, float z, float facing, byte moveId)
|
||||
{
|
||||
var packet = WorldCodec.PositionAndFacing(
|
||||
entityId, new Vec3(x, y, z), facing, WorldCodec.SetPositionType.Gm, continueMove: true, moveId: moveId);
|
||||
await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing, UxSerializer.Serialize(packet), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user