Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.Profiles.cs
T

206 lines
11 KiB
C#

using Ananta.SDK.Rpc;
using Ananta.Server.Handlers;
using Ananta.Server.Gameplay;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.ClientData.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.Game;
/// <summary>Runtime player/combat hydration shared by world entry and character switching.</summary>
internal sealed partial class GameRouter
{
async Task PublishSkillBindings(
RpcContext ctx,
ulong unitId,
CombatWeaponDefinition weapon,
CombatStyleDefinition style)
{
if (weapon.SpiritTemplateId == 15020992) // Taffy (塔菲)
{
// Taffy signature skills:
// 51935209: 塔菲普攻1 (Hammer strikes)
// 51935213: 塔菲重击 (Heavy slam)
// 51935207: 通用闪避 (Dodge)
// 51935230: 塔菲主动技_上车A (Taffy Monowheel Mount!)
// 51935224: 塔菲大招 (超次元神速连打)
await ctx.NotifyAsync(MethodId.SyncChangeCommonSkill, WorldCodec.SkillBinding(unitId, 51935209));
await ctx.NotifyAsync(MethodId.SyncChangeHeavyAttack, WorldCodec.SkillBinding(unitId, 51935213));
await ctx.NotifyAsync(MethodId.SyncChangeDodgeSkill, WorldCodec.SkillBinding(unitId, 51935207));
await ctx.NotifyAsync(MethodId.SyncChangeControlSkill, CombatCodec.ControlBinding(unitId, style));
await ctx.NotifyAsync(MethodId.SyncChangeActiveSkill, WorldCodec.SkillBinding(unitId, 51935230));
await ctx.NotifyAsync(MethodId.SyncChangeUniqueSkill, WorldCodec.SkillBinding(unitId, 51935224));
ctx.Session.Log.Info($"[COMBAT] bound signature Taffy Monowheel skill profile for unit={unitId}");
return;
}
await ctx.NotifyAsync(MethodId.SyncChangeCommonSkill, CombatCodec.CommonBinding(unitId, style));
await ctx.NotifyAsync(MethodId.SyncChangeHeavyAttack, CombatCodec.HeavyAttackBinding(unitId, style));
await ctx.NotifyAsync(MethodId.SyncChangeDodgeSkill, CombatCodec.DodgeBinding(unitId, style));
await ctx.NotifyAsync(MethodId.SyncChangeControlSkill, CombatCodec.ControlBinding(unitId, style));
await ctx.NotifyAsync(MethodId.SyncChangeActiveSkill, CombatCodec.ActiveBinding(unitId, weapon, style));
await ctx.NotifyAsync(MethodId.SyncChangeUniqueSkill, CombatCodec.UniqueBinding(unitId, weapon, style));
}
async Task PublishCombatResources(RpcContext ctx, ulong unitId)
{
foreach (var (resourceId, maximum) in CombatCodec.AllResourceMaximums)
{
await ctx.NotifyAsync(MethodId.SyncFightResource,
CombatCodec.FightResource(unitId, resourceId, maximum));
await ctx.NotifyAsync(MethodId.SyncFightResourceFreeState,
CombatCodec.FightResourceFreeState(unitId, resourceId, false));
}
}
private static IEnumerable<KeyValuePair<uint, uint>> SpiritFightStyleOverrides(
WorldEntryState state,
uint templateId)
=> state.SpiritStyleOverrides
.Where(x => x.Key.SpiritId == templateId)
.Select(x => new KeyValuePair<uint, uint>(x.Key.FightStyleTypeId, x.Value));
private static uint PublishedWeaponFightStyleId(WorldEntryState state, CombatWeaponDefinition weapon)
{
// The armory can store category-wide and per-instance choices separately, but the live
// combat runtime also caches FightStyleId by weapon instance. Publishing the EFFECTIVE
// style keeps FightStyleManager.GetUnitFightStyleByTemplateAndWeapon, ActionType and the
// native SkillJumpGraph on the same martial art. Category changes refresh this cache.
return ResolveWeaponStyle(state, weapon).Id;
}
async Task PublishMinimalActorPresentation4229938(
RpcContext ctx,
ulong unitId,
uint templateId,
string reason)
{
var state = GetWorldState(ctx);
var weapon = CombatCodec.DefaultWeapon(templateId);
var style = ResolveWeaponStyle(state, weapon);
state.ActiveWeaponInstanceId = weapon.InstanceId;
state.ActiveFightStyleId = style.Id;
state.LastWeaponBySpirit[templateId] = weapon.InstanceId;
// Actor presentation is part of the ownership contract, not the legacy combat server.
// The client already received the small referenced armory in SyncPlayerInfo; these packets
// only bind the current model to its authored fashion/wheel/current weapon.
var wearFashions = state.CustomWornFashionsBySpirit.TryGetValue(templateId, out var customWear)
? new GameMethods.SyncSetSpiritFashions
{
spiritId = templateId,
source = 0,
spiritWearFashionsInfo = customWear,
}
: RuntimePayloadFactory.CharacterFashions(templateId);
await ctx.NotifyAsync(MethodId.SyncSetSpiritFashions, wearFashions);
await PublishWeaponSnapshot(ctx, unitId, templateId, weapon.InstanceId);
await ctx.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon,
CombatCodec.SpiritLastUsedWeapon(templateId, weapon.InstanceId));
await ctx.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction,
CombatCodec.SpiritSwitchWeapon(unitId, weapon.InstanceId));
await PublishSelectedWeaponProfile(ctx, unitId, templateId, weapon, style);
ctx.Session.Log.Info($"[ACTOR-MIN] presentation unit={unitId} template={templateId} weapon={weapon.TemplateId}/{weapon.InstanceId} style={style.Id} reason={reason} combatModules=true skills=true");
}
async Task PublishWeaponSnapshot(
RpcContext ctx,
ulong unitId,
uint templateId,
ulong currentWeaponInstanceId)
{
// Armory and character wheels are separate client stores. Publish the shared account armory
// once, refresh this character's mutable 16-slot view, then select the current instance.
// Sending only SyncSpiritSwitchWeaponAction leaves CurrWeaponSlots nil and produces
// "Not Find Current Weapon" in WeaponManager.lua.
var state = GetWorldState(ctx);
if (!state.AccountArmoryPublished)
{
// Minimal V4.1 puts only the weapon instances referenced by owned spirit wheels into
// SyncPlayerInfo.InfoSpirit.InfoArmory.Weapons. PlayerInfoSpiritData feeds that compact subset
// directly to gWeaponManager, so no incremental account-armory replay is needed here.
state.AccountArmoryPublished = true;
var actorArmoryCount = ClientConfigRepository.Characters()
.SelectMany(character => CombatCodec.Loadout(character.TemplateId).Slots)
.Where(weapon => weapon is not null && weapon.InstanceId != 0)
.Select(weapon => weapon!.InstanceId)
.Distinct()
.Count();
ctx.Session.Log.Info($"[ARMORY-MIN] retained login actor-reference subset count={actorArmoryCount} fullAccount={CombatCatalogRepository.AccountWeapons.Count} incrementalAddReplay=false");
}
var snapshot = CombatCodec.SpiritWeaponSnapshot(
unitId,
templateId,
currentWeaponInstanceId,
WeaponSlotDefinitions(state, templateId));
foreach (var weapon in snapshot.WeaponSlots)
{
if (weapon is null)
continue;
if (CombatCodec.Weapon(templateId, weapon.InstanceId) is { } definition)
{
weapon.FightStyleId = PublishedWeaponFightStyleId(state, definition);
weapon.MagazineAmmo = EnsureMagazine4229938(state, definition);
weapon.Durability = CurrentDurability4229938(state, definition, weapon.MagazineAmmo);
weapon.BulletDatas.BulletId = EnsureBullet4229938(state, definition);
if (definition.SeparateBullets && weapon.BulletDatas.BulletId != 0)
_ = EnsureReserve4229938(state, weapon.BulletDatas.BulletId);
}
}
await ctx.NotifyAsync(MethodId.SyncSpiritWeaponDetail, snapshot);
// FightStyleId is also cached in the account armory object. Reconcile each equipped weapon
// when a character becomes current so a style that was valid for the previous model cannot
// leak into this model's attack ActionSet.
foreach (var detail in snapshot.WeaponSlots.Where(x => x is not null).Select(x => x!))
{
await ctx.NotifyAsync(MethodId.SyncWeaponFightStyleChange,
new GameMethods.SyncWeaponFightStyleChange
{
weaponInstanceId = detail.InstanceId,
fightStyleId = detail.FightStyleId
});
}
}
async Task PublishSafeRuntimeBuffSnapshot4229938(RpcContext ctx, ulong unitId, uint templateId, string phase)
{
var state = GetWorldState(ctx);
var buffs = WebTraversal.CapabilityBuffIds(templateId);
var firstInstanceId = state.NextBuffInstanceId;
await ctx.NotifyAsync(MethodId.SyncUnitBuffList,
WorldCodec.UnitBuffList(unitId, buffs, firstInstanceId));
state.NextBuffInstanceId += (uint)buffs.Count;
// Do NOT bulk-activate the extracted buff catalogue. It contains 4k+ records for
// bosses, NPCs, timelines, skin replacement, ControlList and ActionGroup switches. The client
// applies those as executable behavior, not as an "unlocked buff catalogue". In the failing
// capture this forced actionGroup=33 on main_A104001 and later crashed ReplaceSkinAction.End.
ctx.Session.Log.Info($"[BUFFS-MIN] {phase} unit={unitId} template={templateId} count={buffs.Count} firstInstance={firstInstanceId} bulk=false safeWebOnly=true legacyCatalogLoaded=false");
}
async Task PublishInitialCapabilityBuffs(RpcContext ctx)
{
var state = GetWorldState(ctx);
if (state.InitialCapabilityBuffsPublished)
return;
state.InitialCapabilityBuffsPublished = true;
// Keep the opening/loading barrier on the proven small traversal capability set.
// Never activate the entire BuffConfig as one player snapshot: it contains NPC/timeline actions.
var targetUnitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
var firstInstanceId = state.NextBuffInstanceId;
await ctx.NotifyAsync(MethodId.SyncUnitBuffList,
WorldCodec.UnitBuffList(targetUnitId, WebTraversal.InitialCapabilityBuffIds, firstInstanceId));
state.NextBuffInstanceId += (uint)WebTraversal.InitialCapabilityBuffIds.Count;
ctx.Session.Log.Info($"[CAPABILITY] initial unit={targetUnitId} count={WebTraversal.InitialCapabilityBuffIds.Count} loadingSafe=true allBuffsDeferred=true");
}
}