Initial commit: Ananta CBT 4229938 private server with all fixes
This commit is contained in:
commit
c84135c357
286 files changed
+2214315
No files matched your search
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Ananta.SDK\Ananta.SDK.csproj" />
|
||||
<ProjectReference Include="..\Ananta.Core\Ananta.Core.csproj" />
|
||||
<ProjectReference Include="..\Ananta.Gameplay\Ananta.Gameplay.csproj" />
|
||||
<ProjectReference Include="..\Ananta.RpcTypes\Ananta.RpcTypes.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
[assembly: InternalsVisibleTo("Ananta.App")]
|
||||
@@ -0,0 +1,605 @@
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.Handlers;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>Authoritative weapon, fight-style and skill lifecycle for the current local unit.</summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
async Task OnClientUseSkill(RpcContext ctx, SceneMethods.SkillUseData req)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
await ctx.ReturnEmptyOkAsync();
|
||||
|
||||
if (!state.CombatProfilePublished)
|
||||
{
|
||||
ctx.Session.Log.Warn("[COMBAT] skill request arrived before initial combat profile");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.Releaser != 0 && req.Releaser != state.ActiveSpiritUnitId)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[COMBAT] reject releaser={req.Releaser} active={state.ActiveSpiritUnitId} skill={req.SkillId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var style = ActiveStyle(state);
|
||||
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
|
||||
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
|
||||
if (weapon.IsReloadSkill(req.SkillId))
|
||||
await ReloadWeapon4229938(ctx, state, weapon);
|
||||
if (!weapon.AllowsSkill(style, req.SkillId))
|
||||
{
|
||||
ctx.Session.Log.Warn($"[COMBAT] reject unknown-skill style={style.Id} weapon={state.ActiveWeaponInstanceId} skill={req.SkillId} inst={req.SkillInstanceId}");
|
||||
return;
|
||||
}
|
||||
|
||||
state.CombatUseCount++;
|
||||
state.RestoreResourcesAfterActiveSkill |=
|
||||
req.SkillId == weapon.ActiveSkill(style) ||
|
||||
req.SkillId == weapon.UniqueSkill(style) ||
|
||||
weapon.Cooldown(style, req.SkillId, 0f) > 0f ||
|
||||
weapon.MaximumCharges(style, req.SkillId) > 0;
|
||||
state.ActiveSkillId = req.SkillId;
|
||||
state.ActiveClientSkillInstanceId = req.SkillInstanceId;
|
||||
state.ActiveSkillStartedTicks = Environment.TickCount64;
|
||||
|
||||
if (state.CombatUseCount <= 20 || state.CombatUseCount % 50 == 0)
|
||||
ctx.Session.Log.Info($"[COMBAT] use unit={state.ActiveSpiritUnitId} weapon={state.ActiveWeaponInstanceId} style={style.Id} skill={req.SkillId} inst={req.SkillInstanceId} target={req.TargetId} nativeAction=true count={state.CombatUseCount}");
|
||||
}
|
||||
|
||||
async Task OnReportSkillEnd(RpcContext ctx, SceneMethods.ReportSkillEnd report)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
state.CombatEndCount++;
|
||||
var elapsed = state.ActiveSkillStartedTicks > 0
|
||||
? Math.Max(0, Environment.TickCount64 - state.ActiveSkillStartedTicks)
|
||||
: -1;
|
||||
|
||||
if (state.CombatEndCount <= 20 || state.CombatEndCount % 50 == 0)
|
||||
ctx.Session.Log.Info($"[COMBAT] end unit={report.unitId} token={report.skillId} next={report.newSkillId} break={report.isBreak} elapsedMs={elapsed} count={state.CombatEndCount}");
|
||||
|
||||
var style = ActiveStyle(state);
|
||||
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
|
||||
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
|
||||
if (report.newSkillId != 0 && weapon.AllowsSkill(style, report.newSkillId))
|
||||
{
|
||||
// isBreak describes how the previous native action ended, not whether the authored chain
|
||||
// ended. The client reports isBreak=true + a non-zero newSkillId for ordinary rapid-click
|
||||
// combo transitions. Keep every known next skill alive and never inject cooldown/resource
|
||||
// snapshots between click, hold and release stages.
|
||||
state.ActiveSkillId = report.newSkillId;
|
||||
state.ActiveClientSkillInstanceId = 0;
|
||||
state.ActiveSkillStartedTicks = Environment.TickCount64;
|
||||
return;
|
||||
}
|
||||
|
||||
var restoreResources = state.RestoreResourcesAfterActiveSkill;
|
||||
state.ActiveSkillId = 0;
|
||||
state.RestoreResourcesAfterActiveSkill = false;
|
||||
state.ActiveClientSkillInstanceId = 0;
|
||||
state.ActiveSkillStartedTicks = 0;
|
||||
|
||||
// Ordinary attacks must finish without any server-side skill-state rewrite. Re-publishing the
|
||||
// whole charge/resource snapshot after each common attack can make the client save a new action
|
||||
// while the previous trigger graph is still iterating, which restarts the first animation on
|
||||
// every click. Only cooldown/charge abilities and ultimates need sandbox restoration.
|
||||
if (!restoreResources)
|
||||
return;
|
||||
|
||||
// The private server runs an unrestricted combat sandbox: restore charges and ultimate energy
|
||||
// only after the complete authored ability chain has ended, never between combo/hold stages.
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerAllSkillChargeData,
|
||||
CombatCodec.AllSkillCharges(state.ActiveSpiritUnitId, weapon, style));
|
||||
await ctx.NotifyAsync(MethodId.SyncFightResource,
|
||||
CombatCodec.FightResource(state.ActiveSpiritUnitId, CombatCodec.UltimateResourceId, CombatCodec.UltimateResourceMax));
|
||||
await ctx.NotifyAsync(MethodId.SyncFightResourceFreeState,
|
||||
CombatCodec.FightResourceFreeState(state.ActiveSpiritUnitId, CombatCodec.UltimateResourceId, true));
|
||||
}
|
||||
|
||||
Task OnSkillHit(RpcContext ctx, SceneMethods.SkillHitData hit)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (hit.ReleaserId != 0 && hit.ReleaserId != state.ActiveSpiritUnitId)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[HIT] reject releaser={hit.ReleaserId} active={state.ActiveSpiritUnitId} skill={hit.SkillId}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var style = ActiveStyle(state);
|
||||
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
|
||||
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
|
||||
if (!weapon.AllowsSkill(style, hit.SkillId))
|
||||
{
|
||||
ctx.Session.Log.Warn($"[HIT] reject unknown-skill style={style.Id} skill={hit.SkillId} target={hit.HitTarget}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
state.CombatHitCount++;
|
||||
if (state.CombatHitCount <= 20 || state.CombatHitCount % 100 == 0)
|
||||
ctx.Session.Log.Info($"[HIT] accepted unit={state.ActiveSpiritUnitId} skill={hit.SkillId} target={hit.HitTarget} stage={hit.Stage} trigger={hit.TriggerIndex}/{hit.TriggerInstanceId} material={hit.HitMaterial} reflected={hit.IsReflected} count={state.CombatHitCount}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task OnSwitchWeapon(RpcContext ctx, int index)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (!state.Ready || state.PendingSwitchTemplateId != 0)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[WEAPON] ignored index={index} ready={state.Ready} pendingSpirit={state.PendingSwitchTemplateId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var slotWeapons = WeaponSlotDefinitions(state, state.ActiveSpiritTemplateId);
|
||||
var weapon = index >= 0 && index < slotWeapons.Count ? slotWeapons[index] : null;
|
||||
if (weapon is null)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[WEAPON] invalid index={index} template={state.ActiveSpiritTemplateId} slots={slotWeapons.Count}");
|
||||
return;
|
||||
}
|
||||
|
||||
var style = ResolveWeaponStyle(state, weapon);
|
||||
if (state.ActiveSkillId != 0)
|
||||
await ctx.NotifyAsync(MethodId.SyncBreakSkill, CombatCodec.BreakSkill(state.ActiveSpiritUnitId));
|
||||
|
||||
state.ActiveSkillId = 0;
|
||||
state.RestoreResourcesAfterActiveSkill = false;
|
||||
state.ActiveClientSkillInstanceId = 0;
|
||||
state.ActiveSkillStartedTicks = 0;
|
||||
state.ActiveWeaponInstanceId = weapon.InstanceId;
|
||||
state.ActiveFightStyleId = style.Id;
|
||||
state.LastWeaponBySpirit[state.ActiveSpiritTemplateId] = weapon.InstanceId;
|
||||
|
||||
// Re-send the authoritative slot snapshot as a cheap self-heal if the client rebuilt its
|
||||
// WeaponManager during a scene/UI transition.
|
||||
await PublishWeaponSnapshot(ctx, state.ActiveSpiritUnitId, state.ActiveSpiritTemplateId, weapon.InstanceId);
|
||||
await ctx.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction,
|
||||
CombatCodec.SpiritSwitchWeapon(state.ActiveSpiritUnitId, weapon.InstanceId));
|
||||
await PublishSelectedWeaponProfile(ctx, state.ActiveSpiritUnitId, state.ActiveSpiritTemplateId, weapon, style);
|
||||
ctx.Session.Log.Info($"[WEAPON] switched template={state.ActiveSpiritTemplateId} unit={state.ActiveSpiritUnitId} slot={index} page={index / 8 + 1} weapon={weapon.TemplateId}/{weapon.InstanceId} name={weapon.Name} style={style.FightSkillTypeId}:{style.Id} skills={style.CommonSkill},{style.HeavyCommonSkill},{weapon.ActiveSkill(style)},{weapon.UniqueSkill(style)}");
|
||||
}
|
||||
|
||||
async Task OnSwitchFightStyle(RpcContext ctx, uint spiritId, uint fightStyleTypeId, uint fightStyleId)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
var style = CombatCatalogRepository.Style(fightStyleId);
|
||||
if (style is null || !CombatCatalogRepository.StyleMatchesType(fightStyleId, fightStyleTypeId) ||
|
||||
(style.SpiritIds.Count > 0 && !style.SpiritIds.Contains(spiritId)))
|
||||
{
|
||||
ctx.Session.Log.Warn($"[STYLE] rejected spirit={spiritId} type={fightStyleTypeId} style={fightStyleId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (spiritId == state.ActiveSpiritTemplateId &&
|
||||
CombatCodec.Weapon(spiritId, state.ActiveWeaponInstanceId) is { } activeWeapon &&
|
||||
(activeWeapon.WeaponFightSkillTypeId == fightStyleTypeId || activeWeapon.Style.FightSkillTypeId == fightStyleTypeId) &&
|
||||
!CombatCatalogRepository.IsStyleCompatible(activeWeapon, style))
|
||||
{
|
||||
var safeStyle = ResolveWeaponStyle(state, activeWeapon);
|
||||
await ctx.NotifyAsync(MethodId.SyncSpiritFightStyleChangeAction,
|
||||
CombatCodec.FightStyleAction(spiritId, SpiritFightStyleOverrides(state, spiritId)));
|
||||
ctx.Session.Log.Warn($"[STYLE] rejected+restored model-incompatible spirit={spiritId} weapon={activeWeapon.TemplateId}/{activeWeapon.InstanceId} requested={fightStyleId} restored={safeStyle.Id} actionType={style.ActionType} baseActionType={activeWeapon.Style.ActionType}");
|
||||
return;
|
||||
}
|
||||
|
||||
// The RPC carries the category being replaced. Keep exactly that key; duplicating the
|
||||
// style under FightSkillConfig.FightSkillType can overwrite an unrelated special category.
|
||||
state.SpiritStyleOverrides[(spiritId, fightStyleTypeId)] = fightStyleId;
|
||||
await ctx.NotifyAsync(MethodId.SyncSpiritFightStyleChangeAction,
|
||||
CombatCodec.FightStyleAction(spiritId, SpiritFightStyleOverrides(state, spiritId)));
|
||||
|
||||
if (spiritId == state.ActiveSpiritTemplateId)
|
||||
{
|
||||
// FightStyleManager also keeps a weapon-instance style cache. Refresh the equipped
|
||||
// instances in this category so the explicit live FightStyleId follows Replace All.
|
||||
foreach (var instanceId in WeaponSlotIds(state, spiritId).Where(x => x != 0).Distinct())
|
||||
{
|
||||
if (CombatCodec.Weapon(spiritId, instanceId) is not { } slotWeapon)
|
||||
continue;
|
||||
if (slotWeapon.WeaponFightSkillTypeId != fightStyleTypeId &&
|
||||
slotWeapon.Style.FightSkillTypeId != fightStyleTypeId)
|
||||
continue;
|
||||
var slotStyle = ResolveWeaponStyle(state, slotWeapon);
|
||||
await ctx.NotifyAsync(MethodId.SyncWeaponFightStyleChange,
|
||||
new GameMethods.SyncWeaponFightStyleChange
|
||||
{
|
||||
weaponInstanceId = instanceId,
|
||||
fightStyleId = slotStyle.Id
|
||||
});
|
||||
}
|
||||
|
||||
var weapon = CombatCodec.Weapon(spiritId, state.ActiveWeaponInstanceId);
|
||||
if (weapon is not null &&
|
||||
(weapon.WeaponFightSkillTypeId == fightStyleTypeId || weapon.Style.FightSkillTypeId == fightStyleTypeId))
|
||||
{
|
||||
if (state.ActiveSkillId != 0)
|
||||
await ctx.NotifyAsync(MethodId.SyncBreakSkill, CombatCodec.BreakSkill(state.ActiveSpiritUnitId));
|
||||
state.ActiveSkillId = 0;
|
||||
state.RestoreResourcesAfterActiveSkill = false;
|
||||
state.ActiveClientSkillInstanceId = 0;
|
||||
state.ActiveSkillStartedTicks = 0;
|
||||
var effectiveStyle = ResolveWeaponStyle(state, weapon);
|
||||
state.ActiveFightStyleId = effectiveStyle.Id;
|
||||
await PublishSelectedWeaponProfile(ctx, state.ActiveSpiritUnitId, spiritId, weapon, effectiveStyle);
|
||||
}
|
||||
}
|
||||
ctx.Session.Log.Info($"[STYLE] switched spirit={spiritId} type={fightStyleTypeId} style={fightStyleId} name={style.Name}");
|
||||
}
|
||||
|
||||
async Task OnSetWeaponFightStyle(RpcContext ctx, ulong weaponInstanceId, uint fightStyleId)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
var weapon = CombatCatalogRepository.Weapon(state.ActiveSpiritTemplateId, weaponInstanceId)
|
||||
?? CombatCatalogRepository.Weapon(weaponInstanceId);
|
||||
var style = CombatCatalogRepository.Style(fightStyleId);
|
||||
if (weapon is null || style is null || !CombatCatalogRepository.IsStyleCompatible(weapon, style))
|
||||
{
|
||||
if (weapon is not null)
|
||||
{
|
||||
var safeStyle = ResolveWeaponStyle(state, weapon);
|
||||
await ctx.NotifyAsync(MethodId.SyncWeaponFightStyleChange,
|
||||
new GameMethods.SyncWeaponFightStyleChange
|
||||
{
|
||||
weaponInstanceId = weaponInstanceId,
|
||||
fightStyleId = safeStyle.Id
|
||||
});
|
||||
if (weaponInstanceId == state.ActiveWeaponInstanceId)
|
||||
await PublishSelectedWeaponProfile(ctx, state.ActiveSpiritUnitId, state.ActiveSpiritTemplateId, weapon, safeStyle);
|
||||
ctx.Session.Log.Warn($"[STYLE] rejected+restored weapon={weapon.TemplateId}/{weaponInstanceId} requested={fightStyleId} restored={safeStyle.Id} requestedAction={style?.ActionType ?? 0} baseAction={weapon.Style.ActionType}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.Session.Log.Warn($"[STYLE] rejected unknown weapon={weaponInstanceId} style={fightStyleId}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
state.WeaponStyleOverrides[weaponInstanceId] = fightStyleId;
|
||||
await ctx.NotifyAsync(MethodId.SyncWeaponFightStyleChange,
|
||||
new GameMethods.SyncWeaponFightStyleChange
|
||||
{
|
||||
weaponInstanceId = weaponInstanceId,
|
||||
fightStyleId = fightStyleId
|
||||
});
|
||||
|
||||
if (weaponInstanceId == state.ActiveWeaponInstanceId)
|
||||
{
|
||||
if (state.ActiveSkillId != 0)
|
||||
await ctx.NotifyAsync(MethodId.SyncBreakSkill, CombatCodec.BreakSkill(state.ActiveSpiritUnitId));
|
||||
state.ActiveSkillId = 0;
|
||||
state.RestoreResourcesAfterActiveSkill = false;
|
||||
state.ActiveClientSkillInstanceId = 0;
|
||||
state.ActiveSkillStartedTicks = 0;
|
||||
state.ActiveFightStyleId = style.Id;
|
||||
await PublishSelectedWeaponProfile(ctx, state.ActiveSpiritUnitId, state.ActiveSpiritTemplateId, weapon, style);
|
||||
}
|
||||
ctx.Session.Log.Info($"[STYLE] weapon-style weapon={weaponInstanceId} style={fightStyleId} name={style.Name}");
|
||||
}
|
||||
|
||||
async Task OnLoadWeaponToSlot(RpcContext ctx, uint spiritId, ulong weaponInstanceId, int slotIndex)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
var slots = WeaponSlotIds(state, spiritId);
|
||||
if (!IsEditableWeaponSlot(slotIndex) ||
|
||||
CombatCatalogRepository.Weapon(weaponInstanceId) is null ||
|
||||
CombatCatalogRepository.Weapon(spiritId, weaponInstanceId) is null)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[ARMORY] load rejected spirit={spiritId} weapon={weaponInstanceId} slot={slotIndex}");
|
||||
return;
|
||||
}
|
||||
|
||||
var existingIndex = slots.IndexOf(weaponInstanceId);
|
||||
if (existingIndex >= 0 && existingIndex != slotIndex)
|
||||
(slots[existingIndex], slots[slotIndex]) = (slots[slotIndex], slots[existingIndex]);
|
||||
else
|
||||
slots[slotIndex] = weaponInstanceId;
|
||||
|
||||
await PublishWeaponSlotsAfterMutation(ctx, spiritId, $"load:{weaponInstanceId}@{slotIndex}");
|
||||
}
|
||||
|
||||
async Task OnDepositSpiritWeapon(RpcContext ctx, uint spiritId, int slotIndex)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
var slots = WeaponSlotIds(state, spiritId);
|
||||
if (!IsEditableWeaponSlot(slotIndex))
|
||||
{
|
||||
ctx.Session.Log.Warn($"[ARMORY] deposit rejected spirit={spiritId} slot={slotIndex}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty wheel positions are represented by a normal complex-object null marker. Keep the
|
||||
// shared armory intact while removing the weapon only from this character's personal wheel.
|
||||
slots[slotIndex] = 0;
|
||||
await PublishWeaponSlotsAfterMutation(ctx, spiritId, $"deposit:{slotIndex}");
|
||||
}
|
||||
|
||||
async Task OnExchangeWeaponSlot(
|
||||
RpcContext ctx,
|
||||
uint fromSpirit,
|
||||
int fromIndex,
|
||||
uint toSpirit,
|
||||
int toIndex)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
var fromSlots = WeaponSlotIds(state, fromSpirit);
|
||||
var toSlots = WeaponSlotIds(state, toSpirit);
|
||||
if (!IsEditableWeaponSlot(fromIndex) || !IsEditableWeaponSlot(toIndex))
|
||||
{
|
||||
ctx.Session.Log.Warn($"[ARMORY] exchange rejected from={fromSpirit}:{fromIndex} to={toSpirit}:{toIndex}");
|
||||
return;
|
||||
}
|
||||
|
||||
(fromSlots[fromIndex], toSlots[toIndex]) = (toSlots[toIndex], fromSlots[fromIndex]);
|
||||
await PublishWeaponSlotsAfterMutation(ctx, fromSpirit, $"exchange:{fromIndex}->{toSpirit}:{toIndex}");
|
||||
if (toSpirit != fromSpirit)
|
||||
await PublishWeaponSlotsAfterMutation(ctx, toSpirit, $"exchange:{toIndex}<-{fromSpirit}:{fromIndex}");
|
||||
}
|
||||
|
||||
private async Task PublishWeaponSlotsAfterMutation(RpcContext ctx, uint spiritId, string operation)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
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(x => x != 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;
|
||||
|
||||
if (currentChanged && state.ActiveSkillId != 0)
|
||||
await ctx.NotifyAsync(MethodId.SyncBreakSkill, CombatCodec.BreakSkill(state.ActiveSpiritUnitId));
|
||||
|
||||
state.LastWeaponBySpirit[spiritId] = currentWeaponId;
|
||||
if (isActive)
|
||||
{
|
||||
state.ActiveSkillId = 0;
|
||||
state.RestoreResourcesAfterActiveSkill = false;
|
||||
state.ActiveClientSkillInstanceId = 0;
|
||||
state.ActiveSkillStartedTicks = 0;
|
||||
state.ActiveWeaponInstanceId = currentWeaponId;
|
||||
state.ActiveFightStyleId = ResolveWeaponStyle(state, currentWeapon).Id;
|
||||
}
|
||||
|
||||
await PublishWeaponSnapshot(ctx, unitId, spiritId, currentWeaponId);
|
||||
await ctx.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon,
|
||||
CombatCodec.SpiritLastUsedWeapon(spiritId, currentWeaponId));
|
||||
if (currentChanged)
|
||||
{
|
||||
var style = ResolveWeaponStyle(state, currentWeapon);
|
||||
await ctx.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction,
|
||||
CombatCodec.SpiritSwitchWeapon(unitId, currentWeaponId));
|
||||
await PublishSelectedWeaponProfile(ctx, unitId, spiritId, currentWeapon, style);
|
||||
}
|
||||
|
||||
ctx.Session.Log.Info($"[ARMORY] slots committed operation={operation} spirit={spiritId} active={isActive} current={currentWeaponId} unique={slots.Where(x => x != 0).Distinct().Count()} slots={slots.Count}");
|
||||
}
|
||||
|
||||
private static bool IsEditableWeaponSlot(int index) => index >= 0 && index < 16;
|
||||
|
||||
private static List<ulong> WeaponSlotIds(WorldEntryState state, uint spiritId)
|
||||
{
|
||||
if (state.WeaponSlotsBySpirit.TryGetValue(spiritId, out var slots))
|
||||
return slots;
|
||||
var loadout = CombatCodec.Loadout(spiritId);
|
||||
slots = loadout.Slots.Select(x => x?.InstanceId ?? 0UL).ToList();
|
||||
state.WeaponSlotsBySpirit.Add(spiritId, slots);
|
||||
return slots;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CombatWeaponDefinition?> WeaponSlotDefinitions(WorldEntryState state, uint spiritId)
|
||||
=> WeaponSlotIds(state, spiritId)
|
||||
.Select(instanceId => instanceId == 0 ? null :
|
||||
CombatCatalogRepository.Weapon(spiritId, instanceId)
|
||||
?? throw new InvalidDataException($"Missing runtime view for spirit {spiritId}, weapon {instanceId}."))
|
||||
.ToArray();
|
||||
|
||||
Task OnSkillUseWeaponDurability(RpcContext ctx, int skillInstanceId, int triggerIndex)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (!state.Ready)
|
||||
return Task.CompletedTask;
|
||||
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
|
||||
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
|
||||
if (!weapon.IsShootWeapon || weapon.MagazineAmmo == 0)
|
||||
return Task.CompletedTask;
|
||||
|
||||
var current = EnsureMagazine4229938(state, weapon);
|
||||
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)
|
||||
{
|
||||
durability--;
|
||||
state.WeaponDurabilityAmmo[weapon.InstanceId] = durability;
|
||||
}
|
||||
else if (weapon.SeparateBullets)
|
||||
{
|
||||
durability = current;
|
||||
}
|
||||
|
||||
var bulletId = EnsureBullet4229938(state, weapon);
|
||||
var reserve = weapon.SeparateBullets && bulletId != 0
|
||||
? 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,
|
||||
CombatCodec.WeaponDurabilityChanged(state.ActiveSpiritTemplateId, state.ActiveSpiritUnitId, weapon, durability, current, bulletId));
|
||||
}
|
||||
|
||||
async Task OnWeaponEquipBullets(RpcContext ctx, ulong weaponInstanceId, uint bulletId)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
var weapon = CombatCatalogRepository.Weapon(state.ActiveSpiritTemplateId, weaponInstanceId)
|
||||
?? CombatCatalogRepository.Weapon(weaponInstanceId);
|
||||
if (weapon is null || !weapon.SeparateBullets || bulletId == 0 || !weapon.BulletIds.Contains(bulletId))
|
||||
{
|
||||
ctx.Session.Log.Warn($"[AMMO] equip rejected weapon={weaponInstanceId} bullet={bulletId}");
|
||||
return;
|
||||
}
|
||||
state.WeaponBulletByInstance[weaponInstanceId] = bulletId;
|
||||
var mag = EnsureMagazine4229938(state, weapon);
|
||||
var reserve = EnsureReserve4229938(state, bulletId);
|
||||
await ctx.NotifyAsync(MethodId.SyncSpiritWeaponDurabilityChangedAction,
|
||||
CombatCodec.WeaponDurabilityChanged(state.ActiveSpiritTemplateId, state.ActiveSpiritUnitId, weapon, mag, mag, bulletId));
|
||||
ctx.Session.Log.Info($"[AMMO] bullet-selected weapon={weapon.TemplateId}/{weapon.InstanceId} bullet={bulletId} mag={mag}/{weapon.MagazineAmmo} reserve={reserve}");
|
||||
}
|
||||
|
||||
private async Task ReloadWeapon4229938(RpcContext ctx, WorldEntryState state, CombatWeaponDefinition weapon)
|
||||
{
|
||||
if (!weapon.IsShootWeapon || weapon.MagazineAmmo <= 0)
|
||||
return;
|
||||
var mag = EnsureMagazine4229938(state, weapon);
|
||||
var capacity = Math.Max(0, weapon.MagazineAmmo);
|
||||
if (mag < 0 || mag >= capacity)
|
||||
return;
|
||||
var bulletId = EnsureBullet4229938(state, weapon);
|
||||
|
||||
if (weapon.SeparateBullets && bulletId != 0)
|
||||
{
|
||||
var reserve = EnsureReserve4229938(state, bulletId);
|
||||
var need = (uint)Math.Max(0, capacity - mag);
|
||||
var loaded = Math.Min(need, reserve);
|
||||
if (loaded == 0)
|
||||
return;
|
||||
mag += (int)loaded;
|
||||
reserve -= loaded;
|
||||
state.WeaponMagazineAmmo[weapon.InstanceId] = mag;
|
||||
state.BackpackItemCounts[bulletId] = reserve;
|
||||
await ctx.NotifyAsync(MethodId.SyncBackpackItemChanged,
|
||||
new GameMethods.SyncBackpackItemChanged4229938
|
||||
{
|
||||
updateItemList = [RuntimePayloadFactory.AmmoPackItem(bulletId, reserve)]
|
||||
});
|
||||
await ctx.NotifyAsync(MethodId.SyncSpiritWeaponDurabilityChangedAction,
|
||||
CombatCodec.WeaponDurabilityChanged(state.ActiveSpiritTemplateId, state.ActiveSpiritUnitId, weapon, mag, mag, bulletId));
|
||||
ctx.Session.Log.Info($"[AMMO] reload weapon={weapon.TemplateId}/{weapon.InstanceId} loaded={loaded} mag={mag}/{capacity} reserve={reserve} bullet={bulletId}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-separate firearms use Durability as total remaining ammunition. Reload moves rounds
|
||||
// from reserve into the magazine without increasing total ammo. A total of -1 is infinite.
|
||||
var durability = EnsureDurability4229938(state, weapon);
|
||||
var reserveNonSeparate = NonSeparateReserve4229938(durability, mag);
|
||||
var needed = Math.Max(0, capacity - mag);
|
||||
var loadedNonSeparate = durability < 0
|
||||
? needed
|
||||
: Math.Min(needed, (int)reserveNonSeparate);
|
||||
if (loadedNonSeparate <= 0)
|
||||
return;
|
||||
mag += loadedNonSeparate;
|
||||
state.WeaponMagazineAmmo[weapon.InstanceId] = mag;
|
||||
await ctx.NotifyAsync(MethodId.SyncSpiritWeaponDurabilityChangedAction,
|
||||
CombatCodec.WeaponDurabilityChanged(state.ActiveSpiritTemplateId, state.ActiveSpiritUnitId, weapon, durability, mag, bulletId));
|
||||
ctx.Session.Log.Info($"[AMMO] reload weapon={weapon.TemplateId}/{weapon.InstanceId} loaded={loadedNonSeparate} mag={mag}/{capacity} reserve={NonSeparateReserve4229938(durability, mag)} bullet=inline total={durability}");
|
||||
}
|
||||
|
||||
private static int EnsureMagazine4229938(WorldEntryState state, CombatWeaponDefinition weapon)
|
||||
{
|
||||
if (!state.WeaponMagazineAmmo.TryGetValue(weapon.InstanceId, out var value))
|
||||
{
|
||||
value = weapon.InitialMagazineAmmo;
|
||||
state.WeaponMagazineAmmo[weapon.InstanceId] = value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static int EnsureDurability4229938(WorldEntryState state, CombatWeaponDefinition weapon)
|
||||
{
|
||||
if (!weapon.IsShootWeapon)
|
||||
return weapon.Durability;
|
||||
if (weapon.SeparateBullets)
|
||||
return EnsureMagazine4229938(state, weapon);
|
||||
if (!state.WeaponDurabilityAmmo.TryGetValue(weapon.InstanceId, out var value))
|
||||
{
|
||||
value = weapon.Durability;
|
||||
state.WeaponDurabilityAmmo[weapon.InstanceId] = value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static int CurrentDurability4229938(WorldEntryState state, CombatWeaponDefinition weapon, int magazineAmmo)
|
||||
=> weapon.IsShootWeapon && weapon.SeparateBullets
|
||||
? magazineAmmo
|
||||
: EnsureDurability4229938(state, weapon);
|
||||
|
||||
private static uint NonSeparateReserve4229938(int durability, int magazineAmmo)
|
||||
=> durability < 0
|
||||
? uint.MaxValue
|
||||
: (uint)Math.Max(0, durability - Math.Max(0, magazineAmmo));
|
||||
|
||||
private static uint EnsureBullet4229938(WorldEntryState state, CombatWeaponDefinition weapon)
|
||||
{
|
||||
if (!state.WeaponBulletByInstance.TryGetValue(weapon.InstanceId, out var bulletId))
|
||||
{
|
||||
bulletId = weapon.BulletId;
|
||||
state.WeaponBulletByInstance[weapon.InstanceId] = bulletId;
|
||||
}
|
||||
return bulletId;
|
||||
}
|
||||
|
||||
private static uint EnsureReserve4229938(WorldEntryState state, uint bulletId)
|
||||
{
|
||||
if (bulletId == 0)
|
||||
return 0;
|
||||
if (!state.BackpackItemCounts.TryGetValue(bulletId, out var count))
|
||||
{
|
||||
count = RuntimePayloadFactory.InitialAmmoReserve4229938;
|
||||
state.BackpackItemCounts[bulletId] = count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async Task PublishSelectedWeaponProfile(
|
||||
RpcContext ctx,
|
||||
ulong unitId,
|
||||
uint templateId,
|
||||
CombatWeaponDefinition weapon,
|
||||
CombatStyleDefinition style)
|
||||
{
|
||||
// Switching/individually styling one weapon must never overwrite the character-wide
|
||||
// FightStyleInfo map. OnSwitchFightStyle publishes that map explicitly.
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerAllSkillChargeData,
|
||||
CombatCodec.AllSkillCharges(unitId, weapon, style));
|
||||
await PublishCombatResources(ctx, unitId);
|
||||
await PublishSkillBindings(ctx, unitId, weapon, style);
|
||||
await ctx.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon,
|
||||
CombatCodec.SpiritLastUsedWeapon(templateId, weapon.InstanceId));
|
||||
}
|
||||
|
||||
private static CombatStyleDefinition ResolveWeaponStyle(WorldEntryState state, CombatWeaponDefinition weapon)
|
||||
{
|
||||
if (state.WeaponStyleOverrides.TryGetValue(weapon.InstanceId, out var weaponStyleId) &&
|
||||
CombatCatalogRepository.Style(weaponStyleId) is { } weaponStyle &&
|
||||
CombatCatalogRepository.IsStyleCompatible(weapon, weaponStyle))
|
||||
return weaponStyle;
|
||||
if (state.SpiritStyleOverrides.TryGetValue((weapon.SpiritTemplateId, weapon.WeaponFightSkillTypeId), out var spiritStyleId) &&
|
||||
CombatCatalogRepository.Style(spiritStyleId) is { } spiritStyle &&
|
||||
CombatCatalogRepository.IsStyleCompatible(weapon, spiritStyle))
|
||||
return spiritStyle;
|
||||
return weapon.Style;
|
||||
}
|
||||
|
||||
private static CombatStyleDefinition ActiveStyle(WorldEntryState state)
|
||||
{
|
||||
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
|
||||
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
|
||||
return ResolveWeaponStyle(state, weapon);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Build-4229938 RPC-surface compatibility. Semantic handlers still win. Any other Invoke uses
|
||||
/// the exact generated return reader shape from the matching client Lua so callbacks receive a
|
||||
/// valid default value (empty list/dict, zero primitive, empty string, or non-null empty object)
|
||||
/// instead of a zero-byte body.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
private Task DefaultUnknownInvoke4229938(RpcContext ctx)
|
||||
{
|
||||
if (DefaultReturnCatalog4229938.TryGet(ctx.MethodId, out var body, out var shape))
|
||||
{
|
||||
ctx.Session.Log.Warn($"[RPC4229938] unimplemented {Ananta.SDK.Logging.RpcMethodNames.Display(ctx.MethodId)} -> typed-default {shape} {body.Length}b");
|
||||
return ctx.ReturnAsync(body);
|
||||
}
|
||||
|
||||
ctx.Session.Log.Warn($"[RPC4229938] no generated return reader for {Ananta.SDK.Logging.RpcMethodNames.Display(ctx.MethodId)} -> empty OK");
|
||||
return ctx.ReturnEmptyOkAsync();
|
||||
}
|
||||
|
||||
private Task DefaultUnknownNotify4229938(RpcContext ctx)
|
||||
{
|
||||
// Notifies have no callback body. Accepting an unimplemented generated 4229938 notify is
|
||||
// safer than treating it as a protocol error; stateful methods get semantic handlers as
|
||||
// they are implemented, while telemetry/client-local signals remain harmless no-ops.
|
||||
ctx.Session.Log.Warn($"[RPC4229938] unimplemented notify {Ananta.SDK.Logging.RpcMethodNames.Display(ctx.MethodId)} -> accepted");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// High-frequency/UI RPCs seen in stock 4229938 are made explicit so they no longer appear as
|
||||
// unknown traffic. The server does not emulate production economy/social services here; it
|
||||
// returns the exact neutral value expected by the generated client callback.
|
||||
[Handler(MethodId.AskTradeGetMarketList, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskTradeGetHistoryPage, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskPlayerRankingSummary, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskPopularityPhoneFirstOpened, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskPopularityUIOpened, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.SyncOpenInspireHub, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskQueryInspireHubAllGamePlayRecommendData, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskQueryInspireHubAllGamePlayRankData, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskLinkInfos, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.GetLastMode, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskSwitchLinkMode, HandlerPacketKind.Invoke)]
|
||||
private Task KnownNeutralInvoke4229938(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (DefaultReturnCatalog4229938.TryGet(msg.MethodId, out var body, out var shape))
|
||||
{
|
||||
conn.Log.Info($"[RPC4229938] neutral {Ananta.SDK.Logging.RpcMethodNames.Display(msg.MethodId)} -> {shape} {body.Length}b");
|
||||
return msg.Context.ReturnAsync(body);
|
||||
}
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.OnParkourStateChange, HandlerPacketKind.Invoke)]
|
||||
private Task OnParkourStateChange4229938(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>Only RPC endpoints used by the maintained 4229938 private-server feature set.</summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
[Handler(MethodId.LoginGame, HandlerPacketKind.Notify)]
|
||||
private Task LoginGame(Connection conn, UxRpcMessage msg)
|
||||
=> SendInitialGameState(msg.Context);
|
||||
|
||||
[Handler(MethodId.RequestGameSceneData, HandlerPacketKind.Notify)]
|
||||
private Task RequestGameSceneData(Connection conn, UxRpcMessage msg)
|
||||
=> SendEnterSceneIfNeeded(msg.Context);
|
||||
|
||||
[Handler(MethodId.GetServerTimeGame)]
|
||||
private async Task GetServerTime(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.IsInvoke)
|
||||
{
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
return;
|
||||
}
|
||||
var args = msg.GetArgs<GameMethods.GetServerTime>();
|
||||
await conn.NotifyAsync(MethodId.SendServerTimeGame, LoginCodec.ServerTime(args.clientUnixTime));
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskAllSpiritPanelData, HandlerPacketKind.Invoke)]
|
||||
private Task AskAllSpiritPanelData(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnAsync(msg, RuntimePayloadFactory.MinimalAllSpiritPanelData4229938());
|
||||
|
||||
[Handler(MethodId.GetSpriteToken, HandlerPacketKind.Invoke)]
|
||||
private Task GetSpriteToken4229938(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnAsync(msg, new GameMethods.SpriteToken4229938
|
||||
{
|
||||
Token = string.Empty,
|
||||
ExpireTimeStamp = 0
|
||||
});
|
||||
|
||||
[Handler(MethodId.CheckPSOPermissions, HandlerPacketKind.Invoke)]
|
||||
private Task CheckPsoPermissions(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnAsync(msg, false);
|
||||
|
||||
[Handler(MethodId.AskSetGamePause, HandlerPacketKind.Invoke)]
|
||||
private async Task AskSetGamePause(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskSetGamePause>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await conn.NotifyAsync(MethodId.SyncGamePause, new SceneMethods.SyncGamePause { pause = args.value });
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskLoadSceneCompleted, HandlerPacketKind.Notify)]
|
||||
private Task AskLoadSceneCompleted(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskLoadSceneCompleted>();
|
||||
return OnLoadSceneCompleted(msg.Context, args.sceneId, args.sessionId);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskLoadGameResCompleted, HandlerPacketKind.Notify)]
|
||||
private Task AskLoadGameResCompleted(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskLoadGameResCompleted>();
|
||||
return OnLoadGameResourcesCompleted(msg.Context, args.sceneId);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskLoadingFinished, HandlerPacketKind.Invoke)]
|
||||
private async Task AskLoadingFinished(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskLoadingFinished>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await OnLoadingFinished(msg.Context, args.sceneId, args.sessionId);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskLoadedInSameScene, HandlerPacketKind.Notify)]
|
||||
private Task AskLoadedInSameScene(Connection conn, UxRpcMessage msg)
|
||||
=> OnLoadedInSameScene(msg.Context);
|
||||
|
||||
[Handler(MethodId.AskTeleport, HandlerPacketKind.Invoke)]
|
||||
private async Task AskTeleport(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskTeleportArgs>();
|
||||
if (conn.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState state)
|
||||
{
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
state.PendingTeleportPosition = new Vec3(args.option.position.X, args.option.position.Y, args.option.position.Z);
|
||||
state.PendingTeleportFacing = args.option.facing;
|
||||
state.PendingTeleportId = args.option.teleportId;
|
||||
state.LastReportedPlayerPosition = state.PendingTeleportPosition;
|
||||
state.LastReportedPlayerRotation = new Vec3(0f, args.option.facing, 0f);
|
||||
state.HasLastReportedPlayerTransform = true;
|
||||
}
|
||||
}
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
var sync = new SceneMethods.SyncPreTeleportOption { option = args.option };
|
||||
await conn.NotifyAsync(MethodId.SyncPreTeleportOption, sync);
|
||||
}
|
||||
|
||||
[Handler(MethodId.ReportPreTeleportFinish, HandlerPacketKind.Invoke)]
|
||||
private async Task ReportPreTeleportFinish(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
if (conn.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState state)
|
||||
{
|
||||
Vec3 targetPos;
|
||||
float targetFacing;
|
||||
ulong teleportId;
|
||||
ulong unitId;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
targetPos = state.PendingTeleportPosition;
|
||||
targetFacing = state.PendingTeleportFacing;
|
||||
teleportId = state.PendingTeleportId;
|
||||
unitId = state.ActiveSpiritUnitId;
|
||||
}
|
||||
|
||||
var sync = new SceneMethods.SyncTeleport
|
||||
{
|
||||
option = new SceneMethods.TeleportOption
|
||||
{
|
||||
teleportId = teleportId,
|
||||
Position = new SceneMethods.UxVector3(targetPos.X, targetPos.Y, targetPos.Z),
|
||||
Facing = targetFacing,
|
||||
IsSwitchScene = false,
|
||||
WaitTaskResource = false,
|
||||
MapEntranceId = 0
|
||||
}
|
||||
};
|
||||
await conn.NotifyAsync(MethodId.SyncTeleport, sync);
|
||||
|
||||
if (unitId != 0)
|
||||
{
|
||||
await conn.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
|
||||
WorldCodec.PositionAndFacing(unitId, targetPos, targetFacing));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Handler(MethodId.ReportPostTeleportFinish, HandlerPacketKind.Notify)]
|
||||
private Task ReportPostTeleportFinish(Connection conn, UxRpcMessage msg)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
[Handler(MethodId.AskPlayerChangePositionByScenePortal, HandlerPacketKind.Invoke)]
|
||||
private Task AskPlayerChangePositionByScenePortal(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
[Handler(MethodId.AskPublicSwitchToPublicScene, HandlerPacketKind.Invoke)]
|
||||
private async Task AskPublicSwitchToPublicScene(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskPublicSwitchToPublicSceneArgs>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
uint raidId = args.raidid;
|
||||
ulong instanceId = raidId == 23300999 ? 20001223UL : 20001222UL;
|
||||
uint universeId = 76000888;
|
||||
Vec3 pos = raidId == 23300999 ? new Vec3(-4719.8f, 168.5f, -2844.7f) : new Vec3(2588.66f, 269.0f, -637.28f);
|
||||
|
||||
await SwitchSceneDirectAsync(conn.Session, raidId, instanceId, universeId, pos, 0f, isAirport: true);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskGadgetDoorTransfer, HandlerPacketKind.Notify)]
|
||||
private Task AskGadgetDoorTransfer(Connection conn, UxRpcMessage msg)
|
||||
=> HandleAirportOrTeleportAsync(conn.Session);
|
||||
|
||||
[Handler(MethodId.AskSwitchSpirit)]
|
||||
private async Task AskSwitchSpirit(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskSwitchSpirit>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await OnSwitchSpirit(msg.Context, args.spiritId);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSwitchWeapon, HandlerPacketKind.Notify)]
|
||||
private Task AskSwitchWeapon(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskSwitchWeapon>();
|
||||
return OnSwitchWeapon(msg.Context, args.index);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSwitchFightStyle, HandlerPacketKind.Invoke)]
|
||||
private async Task AskSwitchFightStyle(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskSwitchFightStyle>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await OnSwitchFightStyle(msg.Context, args.spiritId, args.fightStyleTypeId, args.fightStyleId);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSetWeaponFightStyle, HandlerPacketKind.Invoke)]
|
||||
private async Task AskSetWeaponFightStyle(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskSetWeaponFightStyle>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await OnSetWeaponFightStyle(msg.Context, args.weaponInstanceId, args.fightStyleId);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskLoadWeaponToSlot, HandlerPacketKind.Invoke)]
|
||||
private async Task AskLoadWeaponToSlot(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskLoadWeaponToSlot>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await OnLoadWeaponToSlot(msg.Context, args.spiritId, args.weaponId, args.slotIndex);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskDepositSpiritWeapon, HandlerPacketKind.Invoke)]
|
||||
private async Task AskDepositSpiritWeapon(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskDepositSpiritWeapon>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await OnDepositSpiritWeapon(msg.Context, args.spiritId, args.slotIndex);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskExchangeWeaponSlot, HandlerPacketKind.Invoke)]
|
||||
private async Task AskExchangeWeaponSlot(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskExchangeWeaponSlot>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await OnExchangeWeaponSlot(msg.Context, args.fromSpirit, args.fromIndex, args.toSpirit, args.toIndex);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSetWeaponSkins)]
|
||||
private Task AskSetWeaponSkins(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
[Handler(MethodId.AskSyncWeaponSkinToSpirits)]
|
||||
private Task AskSyncWeaponSkinToSpirits(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
[Handler(MethodId.AskAddClientBuff)]
|
||||
private async Task AskAddClientBuff(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskAddClientBuff>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await OnClientBuffAdd(msg.Context, args.unitId, args.buffId);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskRemoveClientBuff)]
|
||||
private async Task AskRemoveClientBuff(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskRemoveClientBuff>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await OnClientBuffRemove(msg.Context, args.unitId, args.buffId);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskEnterFeiSuoCrouch, HandlerPacketKind.Notify)]
|
||||
private Task AskEnterFeiSuoCrouch(Connection conn, UxRpcMessage msg)
|
||||
=> OnEnterFeiSuoCrouch(msg.Context);
|
||||
|
||||
[Handler(MethodId.AskFeiSuoSuccess, HandlerPacketKind.Invoke)]
|
||||
private async Task AskFeiSuoSuccess(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskFeiSuoSuccess>();
|
||||
await OnFeiSuoSuccess(msg.Context, args.feiSuoId);
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskLeaveFeiSuoCrouch, HandlerPacketKind.Notify)]
|
||||
private Task AskLeaveFeiSuoCrouch(Connection conn, UxRpcMessage msg)
|
||||
=> OnLeaveFeiSuoCrouch(msg.Context);
|
||||
|
||||
[Handler(MethodId.AskClientUseCommonSkill, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskUseSkill, HandlerPacketKind.Invoke)]
|
||||
private Task AskUseSkill(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskUseSkill>();
|
||||
return OnClientUseSkill(msg.Context, args.data);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSkillUseWeaponDurability, HandlerPacketKind.Notify)]
|
||||
private Task AskSkillUseWeaponDurability(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskSkillUseWeaponDurability>();
|
||||
return OnSkillUseWeaponDurability(msg.Context, args.skillid, args.triggerindex);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskWeaponEquipBullets, HandlerPacketKind.Invoke)]
|
||||
private async Task AskWeaponEquipBullets(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskWeaponEquipBullets>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await OnWeaponEquipBullets(msg.Context, args.weaponinstanceid, args.bulletid);
|
||||
}
|
||||
|
||||
[Handler(MethodId.ReportSkillEnd, HandlerPacketKind.Notify)]
|
||||
private Task ReportSkillEnd(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.ReportSkillEnd>();
|
||||
return OnReportSkillEnd(msg.Context, args);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskMultipleSkillHit2, HandlerPacketKind.Notify)]
|
||||
private Task AskMultipleSkillHit2(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskMultipleSkillHit2>();
|
||||
return OnSkillHit(msg.Context, args.skillHitData);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskInterruptSkillExecute, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskInterruptSkillExecuteStiff, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskSkillExecuteEnd, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskSkillExecute, HandlerPacketKind.Invoke)]
|
||||
private static Task AcceptCombatInvoke(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
[Handler(MethodId.AskSkillDestructibleCreate, HandlerPacketKind.Invoke)]
|
||||
private static Task AskSkillDestructibleCreate(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnAsync(msg, 0UL);
|
||||
|
||||
[Handler(MethodId.AskReportLogicAgentSyncData)]
|
||||
[Handler(MethodId.AskUnitMoveActionSimple, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.AskUnitMoveActionSimpleWithGround, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.AskUnitMoveActionWithGround, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.AskUnitMoveAction, HandlerPacketKind.Notify)]
|
||||
private async Task MovementReport(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
IEnumerable<SceneMethods.LogicAgentSyncData>? samples = msg.MethodId switch
|
||||
{
|
||||
MethodId.AskReportLogicAgentSyncData => msg.GetArgs<SceneMethods.AskReportLogicAgentSyncData>().list,
|
||||
MethodId.AskUnitMoveActionSimple => msg.GetArgs<SceneMethods.AskUnitMoveActionSimple>().actions.Select(x =>
|
||||
new SceneMethods.LogicAgentSyncData { AgentId = x.UnitId, Position = x.Pos, Rotation = x.Rot }),
|
||||
MethodId.AskUnitMoveActionSimpleWithGround => msg.GetArgs<SceneMethods.AskUnitMoveActionSimpleWithGround>().actions.Select(x =>
|
||||
new SceneMethods.LogicAgentSyncData { AgentId = x.UnitId, Position = x.Pos, Rotation = x.Rot }),
|
||||
MethodId.AskUnitMoveAction => msg.GetArgs<SceneMethods.AskUnitMoveAction>().actions.Select(x =>
|
||||
new SceneMethods.LogicAgentSyncData { AgentId = x.UnitId, Position = x.Pos, Rotation = x.Rot }),
|
||||
MethodId.AskUnitMoveActionWithGround => msg.GetArgs<SceneMethods.AskUnitMoveActionWithGround>().actions.Select(x =>
|
||||
new SceneMethods.LogicAgentSyncData { AgentId = x.UnitId, Position = x.Pos, Rotation = x.Rot }),
|
||||
_ => null
|
||||
};
|
||||
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await OnMovementReport(msg.Context, samples);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSetSpiritFashionsWithSource, HandlerPacketKind.Invoke)]
|
||||
private Task AskSetSpiritFashionsWithSource(Connection conn, UxRpcMessage msg)
|
||||
=> HandleAskSetSpiritFashionsWithSourceAsync(conn, msg);
|
||||
|
||||
[Handler(MethodId.AskSetSpiritFashions, HandlerPacketKind.Invoke)]
|
||||
private Task AskSetSpiritFashions(Connection conn, UxRpcMessage msg)
|
||||
=> HandleAskSetSpiritFashionsAsync(conn, msg);
|
||||
|
||||
[Handler(MethodId.AskModifySpiritWearFashionsOnlyWearWithSource, HandlerPacketKind.Invoke)]
|
||||
private Task AskModifySpiritWearFashionsOnlyWearWithSource(Connection conn, UxRpcMessage msg)
|
||||
=> HandleAskModifySpiritWearFashionsOnlyWearWithSourceAsync(conn, msg);
|
||||
|
||||
[Handler(MethodId.AskModifySpiritWearFashionsOnlyWear, HandlerPacketKind.Invoke)]
|
||||
private Task AskModifySpiritWearFashionsOnlyWear(Connection conn, UxRpcMessage msg)
|
||||
=> HandleAskModifySpiritWearFashionsOnlyWearAsync(conn, msg);
|
||||
|
||||
[Handler(MethodId.AskModifySpiritWearFashionsWithSource, HandlerPacketKind.Invoke)]
|
||||
private Task AskModifySpiritWearFashionsWithSource(Connection conn, UxRpcMessage msg)
|
||||
=> HandleAskModifySpiritWearFashionsWithSourceAsync(conn, msg);
|
||||
|
||||
[Handler(MethodId.AskModifySpiritWearFashionEditInfosWithSource, HandlerPacketKind.Invoke)]
|
||||
private Task AskModifySpiritWearFashionEditInfosWithSource(Connection conn, UxRpcMessage msg)
|
||||
=> HandleAskModifySpiritWearFashionEditInfosWithSourceAsync(conn, msg);
|
||||
|
||||
[Handler(MethodId.AskModifySpiritWearFashionEditInfos, HandlerPacketKind.Invoke)]
|
||||
private Task AskModifySpiritWearFashionEditInfos(Connection conn, UxRpcMessage msg)
|
||||
=> HandleAskModifySpiritWearFashionEditInfosAsync(conn, msg);
|
||||
|
||||
[Handler(MethodId.AskSetSpiritWearFashionHiddenPartsWithSource, HandlerPacketKind.Invoke)]
|
||||
private Task AskSetSpiritWearFashionHiddenPartsWithSource(Connection conn, UxRpcMessage msg)
|
||||
=> HandleAskSetSpiritWearFashionHiddenPartsWithSourceAsync(conn, msg);
|
||||
|
||||
[Handler(MethodId.AskSetSpiritWearFashionHiddenParts, HandlerPacketKind.Invoke)]
|
||||
private Task AskSetSpiritWearFashionHiddenParts(Connection conn, UxRpcMessage msg)
|
||||
=> HandleAskSetSpiritWearFashionHiddenPartsAsync(conn, msg);
|
||||
|
||||
[Handler(MethodId.AskOpenOrCloseFashionPanel, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskUnlockFashionColoringSlot, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskUnlockFashionSuitSlot, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskApplyFashionColoringSchemeInf, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskSetFashionColoringSchemeInfos, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskFavoriteFashions, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskFavoriteFashionSuits, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskReadFashions, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskReadFashionSuits, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskSetSpiritFashionVariantPreference, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskTradeRecycleFashion, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskClearSwitchInFashionCache, HandlerPacketKind.Invoke)]
|
||||
private static Task AcceptFashionInvoke(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
[Handler(MethodId.AskRecommendGetTopWearFashionTag, HandlerPacketKind.Invoke)]
|
||||
private static Task AskRecommendGetTopWearFashionTag(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnAsync(msg, 0u);
|
||||
|
||||
[Handler(MethodId.AskActivityCancelRedPoint, HandlerPacketKind.Invoke)]
|
||||
private static Task AskActivityCancelRedPoint(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
// Story & Quest Task Handlers
|
||||
[Handler(MethodId.AskAcceptTask, HandlerPacketKind.Invoke)]
|
||||
private static Task AskAcceptTask(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[TASK] AskAcceptTask accepted");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskAcceptAndSetCurrentTask, HandlerPacketKind.Invoke)]
|
||||
private static Task AskAcceptAndSetCurrentTask(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[TASK] AskAcceptAndSetCurrentTask accepted");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSubmitTask, HandlerPacketKind.Invoke)]
|
||||
private static Task AskSubmitTask(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[TASK] AskSubmitTask accepted");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Wardrobe & Fashion dressing engine for client 4229938.
|
||||
/// Handles dressing, customizing, and equipping fashion items and persists worn state per spirit.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
private async Task HandleAskSetSpiritFashionsWithSourceAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskSetSpiritFashionsWithSource>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritOrInstanceId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
state.CustomWornFashionsBySpirit[spiritId] = args.spiritWearFashionsInfo;
|
||||
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
// Notify client of updated worn fashion
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = args.source,
|
||||
spiritWearFashionsInfo = args.spiritWearFashionsInfo,
|
||||
});
|
||||
|
||||
conn.Session.Log.Info($"[FASHION] AskSetSpiritFashionsWithSource: spirit={spiritId} source={args.source} items={args.spiritWearFashionsInfo.WearFashionInfoList.Count}");
|
||||
}
|
||||
|
||||
private async Task HandleAskSetSpiritFashionsAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskSetSpiritFashions>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
state.CustomWornFashionsBySpirit[spiritId] = args.spiritWearFashionsInfo;
|
||||
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = 0,
|
||||
spiritWearFashionsInfo = args.spiritWearFashionsInfo,
|
||||
});
|
||||
|
||||
conn.Session.Log.Info($"[FASHION] AskSetSpiritFashions: spirit={spiritId} items={args.spiritWearFashionsInfo.WearFashionInfoList.Count}");
|
||||
}
|
||||
|
||||
private async Task HandleAskModifySpiritWearFashionsOnlyWearWithSourceAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionsOnlyWearWithSource>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritOrInstanceId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
ApplyFashionWearDiff(currentWear, args.unwearFashionIdList, args.wearFashionInfoList);
|
||||
|
||||
var response = new GameMethods.ModifySpiritWearFashionsOnlyWearResponse
|
||||
{
|
||||
unwearFashionIdList = args.unwearFashionIdList,
|
||||
wearFashionInfoList = args.wearFashionInfoList,
|
||||
};
|
||||
await conn.ReturnAsync(msg, response);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = args.source,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
|
||||
conn.Session.Log.Info($"[FASHION] ModifySpiritWearFashionsOnlyWearWithSource: spirit={spiritId} unwear={args.unwearFashionIdList.Count} wear={args.wearFashionInfoList.Count}");
|
||||
}
|
||||
|
||||
private async Task HandleAskModifySpiritWearFashionsOnlyWearAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionsOnlyWear>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
ApplyFashionWearDiff(currentWear, args.unwearFashionIdList, args.wearFashionInfoList);
|
||||
|
||||
var response = new GameMethods.ModifySpiritWearFashionsOnlyWearResponse
|
||||
{
|
||||
unwearFashionIdList = args.unwearFashionIdList,
|
||||
wearFashionInfoList = args.wearFashionInfoList,
|
||||
};
|
||||
await conn.ReturnAsync(msg, response);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = 0,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
|
||||
conn.Session.Log.Info($"[FASHION] ModifySpiritWearFashionsOnlyWear: spirit={spiritId} unwear={args.unwearFashionIdList.Count} wear={args.wearFashionInfoList.Count}");
|
||||
}
|
||||
|
||||
private async Task HandleAskModifySpiritWearFashionsWithSourceAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionsWithSource>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritOrInstanceId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
ApplyFashionWearDiff(currentWear, args.unwearFashionIdList, args.wearFashionInfoList);
|
||||
ApplyFashionEditDiff(currentWear, args.uneditWearFashionIdList, args.editWearFashionEditInfoList);
|
||||
|
||||
var result = new GameMethods.ModifySpiritWearFashionResult
|
||||
{
|
||||
R0 = args.unwearFashionIdList,
|
||||
R1 = args.wearFashionInfoList,
|
||||
R2 = args.uneditWearFashionIdList,
|
||||
R3 = args.editWearFashionEditInfoList,
|
||||
};
|
||||
await conn.ReturnAsync(msg, result);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = args.source,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
|
||||
conn.Session.Log.Info($"[FASHION] ModifySpiritWearFashionsWithSource: spirit={spiritId}");
|
||||
}
|
||||
|
||||
private async Task HandleAskModifySpiritWearFashionEditInfosWithSourceAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionEditInfosWithSource>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritOrInstanceId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
ApplyFashionEditDiff(currentWear, args.uneditWearFashionIdList, args.editWearFashionEditInfoList);
|
||||
|
||||
var response = new GameMethods.ModifySpiritWearFashionEditInfosResponse
|
||||
{
|
||||
uneditWearFashionIdList = args.uneditWearFashionIdList,
|
||||
editWearFashionEditInfoList = args.editWearFashionEditInfoList,
|
||||
};
|
||||
await conn.ReturnAsync(msg, response);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = args.source,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleAskModifySpiritWearFashionEditInfosAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionEditInfos>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
ApplyFashionEditDiff(currentWear, args.uneditWearFashionIdList, args.editWearFashionEditInfoList);
|
||||
|
||||
var response = new GameMethods.ModifySpiritWearFashionEditInfosResponse
|
||||
{
|
||||
uneditWearFashionIdList = args.uneditWearFashionIdList,
|
||||
editWearFashionEditInfoList = args.editWearFashionEditInfoList,
|
||||
};
|
||||
await conn.ReturnAsync(msg, response);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = 0,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleAskSetSpiritWearFashionHiddenPartsWithSourceAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskSetSpiritWearFashionHiddenPartsWithSource>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritOrInstanceId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
currentWear.HiddenParts = args.hiddenParts;
|
||||
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = args.source,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleAskSetSpiritWearFashionHiddenPartsAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskSetSpiritWearFashionHiddenParts>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
currentWear.HiddenParts = args.hiddenParts;
|
||||
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = 0,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
}
|
||||
|
||||
private static GameMethods.SpiritWearFashionsInfo GetOrCreateSpiritWear(WorldEntryState state, uint spiritId)
|
||||
{
|
||||
if (!state.CustomWornFashionsBySpirit.TryGetValue(spiritId, out var currentWear))
|
||||
{
|
||||
var defaultIds = ClientConfigRepository.DefaultFashionIds(spiritId);
|
||||
currentWear = RuntimePayloadFactory.WearFashionsForGameMethods(defaultIds);
|
||||
state.CustomWornFashionsBySpirit[spiritId] = currentWear;
|
||||
}
|
||||
return currentWear;
|
||||
}
|
||||
|
||||
private static void ApplyFashionWearDiff(
|
||||
GameMethods.SpiritWearFashionsInfo wear,
|
||||
List<uint> unwearIds,
|
||||
List<GameMethods.WearFashionInfo> newWear)
|
||||
{
|
||||
if (unwearIds.Count > 0)
|
||||
{
|
||||
var unwearSet = unwearIds.ToHashSet();
|
||||
wear.WearFashionInfoList.RemoveAll(x => unwearSet.Contains(x.FashionId));
|
||||
}
|
||||
|
||||
foreach (var item in newWear)
|
||||
{
|
||||
if (!wear.WearFashionInfoList.Any(x => x.FashionId == item.FashionId))
|
||||
{
|
||||
wear.WearFashionInfoList.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyFashionEditDiff(
|
||||
GameMethods.SpiritWearFashionsInfo wear,
|
||||
List<uint> uneditIds,
|
||||
List<GameMethods.WearFashionEditInfo> newEdits)
|
||||
{
|
||||
wear.WearFashionEditInfoList ??= [];
|
||||
|
||||
if (uneditIds.Count > 0)
|
||||
{
|
||||
var uneditSet = uneditIds.ToHashSet();
|
||||
wear.WearFashionEditInfoList.RemoveAll(x => uneditSet.Contains(x.FashionId));
|
||||
}
|
||||
|
||||
foreach (var item in newEdits)
|
||||
{
|
||||
var existingIndex = wear.WearFashionEditInfoList.FindIndex(x => x.FashionId == item.FashionId);
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
wear.WearFashionEditInfoList[existingIndex] = item;
|
||||
}
|
||||
else
|
||||
{
|
||||
wear.WearFashionEditInfoList.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938.Auto;
|
||||
using Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
[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;
|
||||
|
||||
conn.Session.Log.Info($"[GACHA] AskDrawGacha: poolId={poolId} count={count} autoEx={args.isAutoExchange}");
|
||||
|
||||
var sync = new SyncGachaDrawInfo
|
||||
{
|
||||
isGrandPrizeWithAllFillers = false,
|
||||
drawDetails = new List<GachaDrawDetail>()
|
||||
};
|
||||
|
||||
for (uint i = 0; i < count; i++)
|
||||
{
|
||||
sync.drawDetails.Add(new GachaDrawDetail
|
||||
{
|
||||
PoolContentId = poolId,
|
||||
IsGrandPrize = (i == count - 1),
|
||||
IsConverted = false,
|
||||
IsNew = true
|
||||
});
|
||||
}
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncGachaDrawInfo, sync);
|
||||
|
||||
var gachaInfos = new PlayerGachaInfos();
|
||||
gachaInfos.PoolInfos[poolId] = new PlayerGachaPoolInfo
|
||||
{
|
||||
DrawCount = count
|
||||
};
|
||||
gachaInfos.GroupInfos[poolId] = new PlayerGachaGroupInfo
|
||||
{
|
||||
TotalDrawCount = count
|
||||
};
|
||||
gachaInfos.PityInfos[poolId] = new PlayerGachaPityInfo
|
||||
{
|
||||
TotalDrawCount = count,
|
||||
DrawsSinceLastReset = count % 80
|
||||
};
|
||||
|
||||
await conn.ReturnAsync(msg, gachaInfos);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskClaimGachaMilestone, HandlerPacketKind.Invoke)]
|
||||
private Task AskClaimGachaMilestone(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<AskClaimGachaMilestoneArgs>();
|
||||
conn.Session.Log.Info($"[GACHA] AskClaimGachaMilestone: groupId={args.groupId} count={args.milestoneCount}");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskChaosMasterGacha, HandlerPacketKind.Invoke)]
|
||||
private Task AskChaosMasterGacha(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Session.Log.Info("[GACHA] AskChaosMasterGacha invoke");
|
||||
return conn.ReturnAsync(msg, new List<uint>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Private-server GM surface (build 4229938): the in-game GM console (ALT+F1) sends these
|
||||
/// C2S invokes. Strategy is return-real-ids-first: the client spawns GM entities locally
|
||||
/// (GM invoke call sites ignore the return), the server records the ids so the debug
|
||||
/// panel can manage them. If a command proves to wait for a server broadcast, the push
|
||||
/// (SyncLogicVehicleEnter etc.) gets added for that command.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
private static long _gmEntitySeq = 320000000000L;
|
||||
private static long _gmEnemySeq = 330000000000L;
|
||||
|
||||
[Handler(MethodId.GmSpawnVehicle, HandlerPacketKind.Invoke)]
|
||||
private Task GmSpawnVehicle(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.GmSpawnVehicle>();
|
||||
var entityId = (ulong)Interlocked.Increment(ref _gmEntitySeq);
|
||||
RegisterSummoned(entityId, args.TemplateId, args.Position.X, args.Position.Y, args.Position.Z, args.Facing);
|
||||
conn.Log.Info($"[GM] spawn vehicle cfg={args.TemplateId} suit={args.SuitId} own={args.LoadOwnVehicle} spoon='{args.SpoonName}' at=({args.Position.X:F1},{args.Position.Y:F1},{args.Position.Z:F1}) -> entity={entityId}");
|
||||
return conn.ReturnAsync(msg, entityId);
|
||||
}
|
||||
|
||||
[Handler(MethodId.GmAddEnemyWithPosition, HandlerPacketKind.Invoke)]
|
||||
private Task GmAddEnemyWithPosition(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.GmAddEnemyWithPosition>();
|
||||
var entityId = (ulong)Interlocked.Increment(ref _gmEnemySeq);
|
||||
conn.Log.Info($"[GM] add enemy id={args.EnemyId} camp={args.Camp} nav={args.NavTagType} at=({args.Position.X:F1},{args.Position.Y:F1},{args.Position.Z:F1}) -> entity={entityId}");
|
||||
return conn.ReturnAsync(msg, entityId);
|
||||
}
|
||||
|
||||
[Handler(MethodId.GmAddEnemy, HandlerPacketKind.Invoke)]
|
||||
private Task GmAddEnemy(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.GmAddEnemy>();
|
||||
var entityId = (ulong)Interlocked.Increment(ref _gmEnemySeq);
|
||||
conn.Log.Info($"[GM] add enemy id={args.EnemyId} camp={args.Camp} tree='{args.TreeName}' -> entity={entityId}");
|
||||
return conn.ReturnAsync(msg, entityId);
|
||||
}
|
||||
|
||||
[Handler(MethodId.GmAddEnemyByPlayer, HandlerPacketKind.Invoke)]
|
||||
private Task GmAddEnemyByPlayer(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.GmAddEnemyByPlayer>();
|
||||
var entityId = (ulong)Interlocked.Increment(ref _gmEnemySeq);
|
||||
conn.Log.Info($"[GM] add enemy by player id={args.EnemyId} camp={args.Camp} -> entity={entityId}");
|
||||
return conn.ReturnAsync(msg, entityId);
|
||||
}
|
||||
|
||||
[Handler(MethodId.GmTeleportXYZ, HandlerPacketKind.Invoke)]
|
||||
private Task GmTeleportXYZ(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.GmTeleportXYZ>();
|
||||
conn.Log.Info($"[GM] teleport to=({args.X:F1},{args.Y:F1},{args.Z:F1}) facing={args.Facing:F1} (client-side, accepted)");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.DavinciCode, HandlerPacketKind.Notify)]
|
||||
private static void DavinciCode(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[SECURITY] DavinciCode notify absorbed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>Client movement reports and live transform tracking.</summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
async Task OnMovementReport(RpcContext ctx, IEnumerable<SceneMethods.LogicAgentSyncData>? samples)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
|
||||
// All build-4229938 movement RPCs are projected into the same transform shape by the endpoint.
|
||||
// Walk the whole batch so the newest action wins instead of keeping the oldest position.
|
||||
if (samples is not null)
|
||||
{
|
||||
foreach (var item in samples)
|
||||
{
|
||||
var isActive = item.AgentId == state.ActiveSpiritUnitId;
|
||||
var isPendingTarget = state.PendingSwitchUnitId != 0 && item.AgentId == state.PendingSwitchUnitId;
|
||||
var isPendingOld = state.PendingSwitchOldUnitId != 0 && item.AgentId == state.PendingSwitchOldUnitId;
|
||||
if (!isActive && !isPendingTarget && !isPendingOld)
|
||||
continue;
|
||||
|
||||
var p = item.Position;
|
||||
var r = item.Rotation;
|
||||
if (float.IsFinite(p.X) && float.IsFinite(p.Y) && float.IsFinite(p.Z) &&
|
||||
float.IsFinite(r.X) && float.IsFinite(r.Y) && float.IsFinite(r.Z))
|
||||
{
|
||||
state.LastReportedPlayerPosition = new Vec3(p.X, p.Y, p.Z);
|
||||
state.LastReportedPlayerRotation = new Vec3(r.X, r.Y, r.Z);
|
||||
state.HasLastReportedPlayerTransform = true;
|
||||
state.RecordPositionCheckpoint(state.LastReportedPlayerPosition, state.LastReportedPlayerRotation.Y);
|
||||
|
||||
// V5.7 safe-entry mode: movement is a pure client->server transform report.
|
||||
// Do not use the first movement edge to reintroduce the same AOI/DynamicGo/social
|
||||
// notification burst that was removed from AskLoadingFinished. The V3 4229938
|
||||
// stable capture remained playable with no post-load server world-content push.
|
||||
// World-content streaming will be re-enabled incrementally only after the crash
|
||||
// boundary is proven stable.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.FirstMovementSeen)
|
||||
return;
|
||||
|
||||
// Do not consume the first-movement edge before AskLoadingFinished has committed Ready.
|
||||
// Build 4229938 can report a transform while the loading overlay is still closing; a later
|
||||
// real gameplay movement must still be able to trigger the pause-only stability release.
|
||||
if (!state.Ready)
|
||||
{
|
||||
ctx.Session.Log.Warn("[MOVE] movement arrived before world ready; pause-only stability release remains armed");
|
||||
return;
|
||||
}
|
||||
|
||||
state.FirstMovementSeen = true;
|
||||
ctx.Session.Log.Info($"[MOVE] client-agent-traffic rpc={ctx.MethodId} bytes={ctx.Body.Length} liveTransform={state.HasLastReportedPlayerTransform} logicProjection={state.WorldEntryLogicProjectionPublishedGeneration} currentMetadata={state.WorldEntryCurrentMetadataPublishedGeneration}");
|
||||
|
||||
// The supplied v7 guide keeps actor creation/presentation client-owned on initial entry.
|
||||
// PlayerInfo + the same-ID projection already contain the build-local roster/fashion data,
|
||||
// so do NOT replay SyncSetSpiritFashions/weapon snapshots for the initial actor. The only
|
||||
// server feature intentionally layered on after GameplayReady is the requested safe web kit.
|
||||
var unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
|
||||
var templateId = state.ActiveSpiritTemplateId != 0 ? state.ActiveSpiritTemplateId : Profile.InitialSpiritTemplateId;
|
||||
|
||||
if (!state.AllBuildBuffsPublished)
|
||||
{
|
||||
await PublishSafeRuntimeBuffSnapshot4229938(ctx, unitId, templateId, "first-gameplay-movement");
|
||||
state.AllBuildBuffsPublished = true;
|
||||
}
|
||||
|
||||
// Same edge: populate the phone/garage vehicle list automatically.
|
||||
await PublishGarageAsync(ctx);
|
||||
|
||||
await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false));
|
||||
state.MovementCapabilityPublished = true;
|
||||
state.FreeRoamReleased = true;
|
||||
ctx.Session.Log.Info($"[GAMEPLAY-V7] first-movement post-load-buffs unit={unitId} template={templateId} actorPresentation=client-owned safeBuffs=true pause=false catalogBulk=false");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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)
|
||||
{
|
||||
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, true));
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
ctx.Session.Log.Info($"[ACTOR-MIN] presentation unit={unitId} template={templateId} weapon={weapon.TemplateId}/{weapon.InstanceId} style={style.Id} reason={reason} combatModules=false skills=false");
|
||||
}
|
||||
|
||||
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 firstInstanceId = state.NextBuffInstanceId;
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitBuffList,
|
||||
WorldCodec.UnitBuffList(Profile.InitialUnitId, WebTraversal.InitialCapabilityBuffIds, firstInstanceId));
|
||||
state.NextBuffInstanceId += (uint)WebTraversal.InitialCapabilityBuffIds.Count;
|
||||
ctx.Session.Log.Info($"[CAPABILITY] initial unit={Profile.InitialUnitId} count={WebTraversal.InitialCapabilityBuffIds.Count} loadingSafe=true allBuffsDeferred=true");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.Handlers;
|
||||
using Ananta.Server.Gameplay;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>Generic character switching. No protagonist-specific switch path lives here.</summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
async Task OnSwitchSpirit(RpcContext ctx, uint requestedSpiritId)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (!state.Ready)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[SWITCH-MIN] ignored before world ready spirit={requestedSpiritId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.PendingSwitchTemplateId != 0)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[SWITCH-MIN] ignored while pending target={state.PendingSwitchTemplateId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var target = ClientConfigRepository.Characters().FirstOrDefault(x => x.TemplateId == requestedSpiritId);
|
||||
if (target is null)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[SWITCH-MIN] unknown requested spirit={requestedSpiritId}");
|
||||
return;
|
||||
}
|
||||
if (target.TemplateId == state.ActiveSpiritTemplateId)
|
||||
return;
|
||||
|
||||
var hasLiveTransform = state.HasLastReportedPlayerTransform;
|
||||
var position = hasLiveTransform
|
||||
? state.LastReportedPlayerPosition
|
||||
: state.WorldEntryCreateHeroPosition;
|
||||
var facing = hasLiveTransform && float.IsFinite(state.LastReportedPlayerRotation.Y)
|
||||
? state.LastReportedPlayerRotation.Y
|
||||
: state.WorldEntryCreateHeroFacing;
|
||||
|
||||
// Freeze the old actor's last authoritative transform before CurrentSpirit changes ownership.
|
||||
// Delayed movement packets from the old unit are ignored after the handoff, so every target
|
||||
// starts exactly where the player stood when the switch was requested.
|
||||
state.LastReportedPlayerPosition = position;
|
||||
state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f);
|
||||
state.HasLastReportedPlayerTransform = true;
|
||||
|
||||
// Minimal mode never starts SyncPreSwitchSpirit/SyncSwitchSpiritConfigId Timeline flows.
|
||||
// It performs only the identity/unit handoff needed to keep the requested character controllable.
|
||||
await SwitchSpiritDirectSameScene4229938(ctx, state, target.TemplateId, target.UnitId, position, facing);
|
||||
}
|
||||
|
||||
|
||||
async Task SwitchSpiritDirectSameScene4229938(
|
||||
RpcContext ctx, WorldEntryState state, uint templateId, ulong unitId, Vec3 switchPosition, float switchFacing)
|
||||
{
|
||||
var oldUnitId = state.ActiveSpiritUnitId;
|
||||
state.PendingSwitchTemplateId = templateId;
|
||||
state.PendingSwitchUnitId = unitId;
|
||||
state.PendingSwitchOldUnitId = oldUnitId;
|
||||
state.PendingSwitchShowId = 0;
|
||||
state.PendingSwitchPosition = switchPosition;
|
||||
state.PendingSwitchFacing = switchFacing;
|
||||
state.PendingSwitchControlTransferred = false;
|
||||
state.PendingSwitchLandingStarted = false;
|
||||
|
||||
// Materialize exactly one target character. No authored switch Timeline, weapons, combat graph,
|
||||
// fashion inventory replay, vehicle cleanup or old-build gameplay hydration is published.
|
||||
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(unitId));
|
||||
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent, WorldCodec.ManagedLogicAgent(unitId, Profile.PlayerPid, 0));
|
||||
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
|
||||
RuntimePayloadFactory.CharacterUnitProjection(templateId, switchPosition, switchFacing));
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
|
||||
WorldCodec.PositionAndFacing(unitId, switchPosition, switchFacing));
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerCurrentSpirit,
|
||||
WorldCodec.CurrentSpirit(Profile.PlayerPid, templateId, unitId, isAgentSwitch: true));
|
||||
// CurrentSpirit can rebuild the controlled actor on the client. Re-assert the frozen transform
|
||||
// after ownership transfer so the new character cannot fall back to its template/default spawn.
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
|
||||
WorldCodec.PositionAndFacing(unitId, switchPosition, switchFacing));
|
||||
|
||||
state.LastReportedPlayerPosition = switchPosition;
|
||||
state.LastReportedPlayerRotation = new Vec3(0f, switchFacing, 0f);
|
||||
state.HasLastReportedPlayerTransform = true;
|
||||
state.ActiveSpiritTemplateId = templateId;
|
||||
state.ActiveSpiritUnitId = unitId;
|
||||
state.PendingSwitchControlTransferred = true;
|
||||
state.ActiveSkillId = 0;
|
||||
state.RestoreResourcesAfterActiveSkill = false;
|
||||
state.ActiveClientSkillInstanceId = 0;
|
||||
state.ActiveSkillStartedTicks = 0;
|
||||
|
||||
// Direct switch still needs a complete actor presentation even though combat gameplay is
|
||||
// disabled. Bind the stock fashion/wheel/current weapon so the target does not become a
|
||||
// half-initialized BaseUnit after CurrentSpirit changes ownership.
|
||||
await PublishMinimalActorPresentation4229938(ctx, unitId, templateId, "direct-switch");
|
||||
|
||||
if (state.ActiveFeiSuoBuffInstanceId != 0)
|
||||
{
|
||||
var transientUnitId = state.ActiveFeiSuoBuffUnitId != 0 ? state.ActiveFeiSuoBuffUnitId : oldUnitId;
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
|
||||
WorldCodec.UnitRemoveBuff(transientUnitId, state.ActiveFeiSuoBuffInstanceId));
|
||||
state.ActiveFeiSuoBuffInstanceId = 0;
|
||||
state.ActiveFeiSuoBuffUnitId = 0;
|
||||
}
|
||||
await ClearClientWebLifecycleBuffs(ctx, oldUnitId, "minimal-direct-switch");
|
||||
await PublishSafeRuntimeBuffSnapshot4229938(ctx, unitId, templateId, "minimal-direct-switch");
|
||||
state.AllBuildBuffsPublished = true;
|
||||
|
||||
if (oldUnitId != 0 && oldUnitId != unitId)
|
||||
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(oldUnitId));
|
||||
await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false));
|
||||
|
||||
state.SwitchCount++;
|
||||
state.LastSwitchShowId = 0;
|
||||
state.PendingSwitchTemplateId = 0;
|
||||
state.PendingSwitchUnitId = 0;
|
||||
state.PendingSwitchOldUnitId = 0;
|
||||
state.PendingSwitchShowId = 0;
|
||||
state.PendingSwitchFacing = state.WorldEntryCreateHeroFacing;
|
||||
state.PendingSwitchControlTransferred = false;
|
||||
state.PendingSwitchLandingStarted = false;
|
||||
|
||||
ctx.Session.Log.Info($"[SWITCH-MIN] direct count={state.SwitchCount} oldUnit={oldUnitId} target={templateId}/{unitId} authoredTimeline=false combatModules=false actorPresentation=true vehicles=false buffs=safe-web-only pos=({switchPosition.X:0.##},{switchPosition.Y:0.##},{switchPosition.Z:0.##}) facing={switchFacing:0.##}");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Time + weather. The client runs its clock/atmosphere locally; these handlers make
|
||||
/// the in-game time UI work (accept + remember) and the debug panel can force it:
|
||||
/// time via SyncPlayerCurrentTime (proto-verified shape). Stored per session,
|
||||
/// re-pushed after every load.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
internal static WorldEntryState? GetStateIfExists(TcpSession session)
|
||||
=> session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing
|
||||
? existing
|
||||
: null;
|
||||
|
||||
private static void SetSessionTime(WorldEntryState state, uint hour, uint minute, bool fix, uint transition)
|
||||
{
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
state.TimeHour = Math.Min(hour, 23);
|
||||
state.TimeMinute = Math.Min(minute, 59);
|
||||
state.TimeFixed = fix;
|
||||
state.TimeTransitionSeconds = transition;
|
||||
state.HasExplicitTime = true;
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task PushSessionTimeAsync(TcpSession session)
|
||||
{
|
||||
var state = GetStateIfExists(session);
|
||||
if (state is null)
|
||||
return;
|
||||
uint hour, minute, transition;
|
||||
bool fix;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (!state.HasExplicitTime)
|
||||
return;
|
||||
hour = state.TimeHour;
|
||||
minute = state.TimeMinute;
|
||||
fix = state.TimeFixed;
|
||||
transition = state.TimeTransitionSeconds;
|
||||
}
|
||||
await session.NotifyAsync(MethodId.SyncPlayerCurrentTime, UxSerializer.Serialize(
|
||||
new GameMethods.SyncPlayerCurrentTime4229938
|
||||
{
|
||||
realTime = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
raidDaySeconds = hour * 3600 + minute * 60,
|
||||
fix = fix,
|
||||
isPause = false,
|
||||
transitionSecond = transition,
|
||||
reason = 0, // RaidTimeAndWeatherChangeReason.Gm
|
||||
nodeId = 0,
|
||||
}), CancellationToken.None);
|
||||
session.Log.Info($"[TIME] push {hour:D2}:{minute:D2} fix={fix} transition={transition}s");
|
||||
}
|
||||
|
||||
internal static async Task PushSessionWeatherAsync(TcpSession session)
|
||||
{
|
||||
var state = GetStateIfExists(session);
|
||||
if (state is null)
|
||||
return;
|
||||
uint weatherId, transition;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (!state.HasExplicitWeather)
|
||||
return;
|
||||
weatherId = state.WeatherId;
|
||||
transition = state.WeatherTransitionSeconds;
|
||||
}
|
||||
await session.NotifyAsync(MethodId.SyncPlayerWeather, UxSerializer.Serialize(
|
||||
new GameMethods.SyncPlayerWeather4229938
|
||||
{
|
||||
weatherTypeId = weatherId,
|
||||
nextWeatherTypeId = weatherId,
|
||||
transitionSecond = transition,
|
||||
}), CancellationToken.None);
|
||||
session.Log.Info($"[WEATHER] push id={weatherId} transition={transition}s");
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskPassingTime)]
|
||||
private async Task AskPassingTime(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.TryGetArgs<GameMethods.PassingTimeArgs4229938>(out var args) && args is not null)
|
||||
{
|
||||
SetSessionTime(GetWorldState(msg.Context), args.hour, args.minute, true, 5);
|
||||
conn.Log.Info($"[TIME] AskPassingTime {args.hour:D2}:{args.minute:D2}");
|
||||
await PushSessionTimeAsync(msg.Context.Session);
|
||||
}
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.GmPassingTime)]
|
||||
private async Task GmPassingTime(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.TryGetArgs<GameMethods.PassingTimeArgs4229938>(out var args) && args is not null)
|
||||
{
|
||||
SetSessionTime(GetWorldState(msg.Context), args.hour, args.minute, true, 5);
|
||||
conn.Log.Info($"[TIME] GmPassingTime {args.hour:D2}:{args.minute:D2}");
|
||||
await PushSessionTimeAsync(msg.Context.Session);
|
||||
}
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.GmSetTime)]
|
||||
private async Task GmSetTime(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.TryGetArgs<GameMethods.GmSetTimeArgs4229938>(out var args) && args is not null)
|
||||
{
|
||||
SetSessionTime(GetWorldState(msg.Context), args.hour, args.minute, true, args.transition);
|
||||
conn.Log.Info($"[TIME] GmSetTime {args.hour:D2}:{args.minute:D2} transition={args.transition}s");
|
||||
await PushSessionTimeAsync(msg.Context.Session);
|
||||
}
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.GmFixRaidTime)]
|
||||
private async Task GmFixRaidTime(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.TryGetArgs<GameMethods.GmFixRaidTimeArgs4229938>(out var args) && args is not null)
|
||||
{
|
||||
var state = GetWorldState(msg.Context);
|
||||
if (args.clear)
|
||||
{
|
||||
lock (state.SyncRoot)
|
||||
state.HasExplicitTime = false;
|
||||
conn.Log.Info("[TIME] GmFixRaidTime clear");
|
||||
}
|
||||
else
|
||||
{
|
||||
SetSessionTime(state, args.hour, args.minute, true, 5);
|
||||
conn.Log.Info($"[TIME] GmFixRaidTime {args.hour:D2}:{args.minute:D2}");
|
||||
await PushSessionTimeAsync(msg.Context.Session);
|
||||
}
|
||||
}
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskTimePanelInfo)]
|
||||
private async Task AskTimePanelInfo(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var state = GetWorldState(msg.Context);
|
||||
GameMethods.TimePanelInfo4229938 response;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
response = new GameMethods.TimePanelInfo4229938
|
||||
{
|
||||
PersonalTimeSettings = new List<GameMethods.PersonalTimeSetting4229938>(state.PersonalTimeSettings)
|
||||
};
|
||||
}
|
||||
conn.Log.Info($"[TIME] AskTimePanelInfo: returning {response.PersonalTimeSettings.Count} personal time slots");
|
||||
await conn.ReturnAsync(msg, response);
|
||||
}
|
||||
|
||||
[Handler(MethodId.ChangePersonalTimeSetting)]
|
||||
private async Task ChangePersonalTimeSetting(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.TryGetArgs<GameMethods.ChangePersonalTimeSettingArgs4229938>(out var args) && args is not null)
|
||||
{
|
||||
var state = GetWorldState(msg.Context);
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (args.info is null)
|
||||
{
|
||||
if (args.index >= 0 && args.index < state.PersonalTimeSettings.Count)
|
||||
{
|
||||
state.PersonalTimeSettings.RemoveAt(args.index);
|
||||
conn.Log.Info($"[TIME] personal delete slot={args.index}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (args.index >= 0 && args.index < state.PersonalTimeSettings.Count)
|
||||
{
|
||||
state.PersonalTimeSettings[args.index] = args.info;
|
||||
conn.Log.Info($"[TIME] personal update slot={args.index} label={args.info.Label} {args.info.Hour:D2}:{args.info.Minute:D2}");
|
||||
}
|
||||
else
|
||||
{
|
||||
state.PersonalTimeSettings.Add(args.info);
|
||||
conn.Log.Info($"[TIME] personal append slot={args.index} label={args.info.Label} {args.info.Hour:D2}:{args.info.Minute:D2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AddPersonalTimeSetting)]
|
||||
private async Task AddPersonalTimeSetting(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.TryGetArgs<GameMethods.AddPersonalTimeSettingArgs4229938>(out var args) && args?.info is not null)
|
||||
{
|
||||
var state = GetWorldState(msg.Context);
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
state.PersonalTimeSettings.Add(args.info);
|
||||
}
|
||||
conn.Log.Info($"[TIME] personal add label={args.info.Label} {args.info.Hour:D2}:{args.info.Minute:D2}");
|
||||
}
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSetMobileSkinPart)]
|
||||
private async Task AskSetMobileSkinPart(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.TryGetArgs<GameMethods.AskSetMobileSkinPartArgs4229938>(out var args) && args is not null)
|
||||
{
|
||||
var state = GetWorldState(msg.Context);
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
state.MobileWallpaperId = args.wallpaper;
|
||||
state.MobileDecorationId = args.decoration;
|
||||
state.MobilePendantId = args.pendant;
|
||||
}
|
||||
conn.Log.Info($"[PHONE] AskSetMobileSkinPart wallpaper={args.wallpaper} decoration={args.decoration} pendant={args.pendant}");
|
||||
}
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskResetMobileSkinPart)]
|
||||
private async Task AskResetMobileSkinPart(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var state = GetWorldState(msg.Context);
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
state.MobileWallpaperId = 0;
|
||||
state.MobileDecorationId = 0;
|
||||
state.MobilePendantId = 0;
|
||||
}
|
||||
conn.Log.Info("[PHONE] AskResetMobileSkinPart reset to defaults");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskInstallMobileApp)]
|
||||
private async Task AskInstallMobileApp(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.TryGetArgs<GameMethods.AskInstallMobileAppArgs4229938>(out var args) && args is not null)
|
||||
conn.Log.Info($"[PHONE] AskInstallMobileApp appId={args.appId}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskCancelInteractionActionRedPoint)]
|
||||
private async Task AskCancelInteractionActionRedPoint(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.TryGetArgs<GameMethods.AskCancelInteractionActionRedPointArgs4229938>(out var args) && args is not null)
|
||||
conn.Log.Info($"[ACTION] AskCancelInteractionActionRedPoint actionId={args.actionId}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.GmSetWeather)]
|
||||
private async Task GmSetWeather(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.TryGetArgs<GameMethods.GmSetWeatherArgs4229938>(out var args) && args is not null)
|
||||
{
|
||||
var state = GetWorldState(msg.Context);
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
state.WeatherId = args.weatherId;
|
||||
state.WeatherTransitionSeconds = 15;
|
||||
state.HasExplicitWeather = true;
|
||||
}
|
||||
conn.Log.Info($"[WEATHER] GmSetWeather id={args.weatherId}");
|
||||
await PushSessionWeatherAsync(msg.Context.Session);
|
||||
}
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.GmSetWeatherParam)]
|
||||
private async Task GmSetWeatherParam(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.TryGetArgs<GameMethods.GmSetWeatherParamArgs4229938>(out var args) && args is not null)
|
||||
conn.Log.Info($"[WEATHER] GmSetWeatherParam id={args.weatherParamId}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.Handlers;
|
||||
using Ananta.Server.Gameplay;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>Protagonist traversal/web buff handling. Buff ids come from private-server.json.</summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
static bool IsActiveProtagonist(WorldEntryState state, ulong unitId = 0)
|
||||
=> WebTraversal.IsProtagonist(state.ActiveSpiritTemplateId)
|
||||
&& (unitId == 0 || unitId == state.ActiveSpiritUnitId);
|
||||
|
||||
async Task EnsureFeiSuoActionBuff(RpcContext ctx)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (!IsActiveProtagonist(state))
|
||||
return;
|
||||
if (state.ActiveFeiSuoBuffInstanceId != 0)
|
||||
return;
|
||||
|
||||
var instanceId = state.NextBuffInstanceId++;
|
||||
state.ActiveFeiSuoBuffInstanceId = instanceId;
|
||||
state.ActiveFeiSuoBuffUnitId = state.ActiveSpiritUnitId;
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff,
|
||||
WorldCodec.UnitAddBuff(state.ActiveSpiritUnitId, WebTraversal.FeiSuoBuff, instanceId));
|
||||
}
|
||||
|
||||
async Task OnEnterFeiSuoCrouch(RpcContext ctx)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (!IsActiveProtagonist(state))
|
||||
{
|
||||
ctx.Session.Log.Info($"[WEB] feisuo-enter ignored template={state.ActiveSpiritTemplateId} protagonist=false");
|
||||
return;
|
||||
}
|
||||
await PublishInitialCapabilityBuffs(ctx);
|
||||
await EnsureFeiSuoActionBuff(ctx);
|
||||
ctx.Session.Log.Info($"[WEB] feisuo-enter ack=true transient={WebTraversal.FeiSuoBuff}");
|
||||
}
|
||||
|
||||
async Task OnFeiSuoSuccess(RpcContext ctx, int feiSuoId)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (!IsActiveProtagonist(state))
|
||||
{
|
||||
ctx.Session.Log.Info($"[WEB] feisuo-success ignored template={state.ActiveSpiritTemplateId} protagonist=false");
|
||||
return;
|
||||
}
|
||||
await EnsureFeiSuoActionBuff(ctx);
|
||||
ctx.Session.Log.Info($"[WEB] feisuo-success id={feiSuoId} transient={WebTraversal.FeiSuoBuff}");
|
||||
}
|
||||
|
||||
async Task OnLeaveFeiSuoCrouch(RpcContext ctx)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (state.ActiveFeiSuoBuffInstanceId == 0)
|
||||
return;
|
||||
|
||||
var instanceId = state.ActiveFeiSuoBuffInstanceId;
|
||||
var unitId = state.ActiveFeiSuoBuffUnitId != 0
|
||||
? state.ActiveFeiSuoBuffUnitId
|
||||
: state.ActiveSpiritUnitId;
|
||||
state.ActiveFeiSuoBuffInstanceId = 0;
|
||||
state.ActiveFeiSuoBuffUnitId = 0;
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
|
||||
WorldCodec.UnitRemoveBuff(unitId, instanceId));
|
||||
ctx.Session.Log.Info($"[WEB] feisuo-leave transient-removed instance={instanceId}");
|
||||
}
|
||||
|
||||
async Task OnClientBuffAdd(RpcContext ctx, ulong unitId, uint buffId)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (WebTraversal.IsWebBuff(buffId) && !IsActiveProtagonist(state, unitId))
|
||||
{
|
||||
ctx.Session.Log.Info($"[WEB] client-buff-add denied unit={unitId} template={state.ActiveSpiritTemplateId} buff={buffId} protagonist=false");
|
||||
return;
|
||||
}
|
||||
// SwingBuff/WallRushBuff are action states, not unlocks. The 4229938 client requests them
|
||||
// when an action starts and removes them when it ends. Track one exact instance per unit/buff
|
||||
// so repeated client requests refresh the state instead of stacking permanent super-armor.
|
||||
if (WebTraversal.IsClientLifecycleBuff(buffId))
|
||||
{
|
||||
var key = (unitId, buffId);
|
||||
if (state.ActiveClientWebBuffInstances.Remove(key, out var previousInstanceId))
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff, WorldCodec.UnitRemoveBuff(unitId, previousInstanceId));
|
||||
|
||||
var lifecycleInstanceId = state.NextBuffInstanceId++;
|
||||
state.ActiveClientWebBuffInstances[key] = lifecycleInstanceId;
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff,
|
||||
WorldCodec.UnitAddBuff(unitId, buffId, lifecycleInstanceId));
|
||||
ctx.Session.Log.Info($"[WEB] lifecycle-add unit={unitId} buff={buffId} instance={lifecycleInstanceId} refreshed={previousInstanceId != 0}");
|
||||
return;
|
||||
}
|
||||
|
||||
var instanceId = state.NextBuffInstanceId++;
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff, WorldCodec.UnitAddBuff(unitId, buffId, instanceId));
|
||||
if (buffId == WebTraversal.FeiSuoBuff)
|
||||
{
|
||||
state.ActiveFeiSuoBuffInstanceId = instanceId;
|
||||
state.ActiveFeiSuoBuffUnitId = unitId;
|
||||
}
|
||||
|
||||
if (buffId == WebTraversal.FeiSuoBuff || WebTraversal.IsProtected(buffId))
|
||||
ctx.Session.Log.Info($"[WEB] client-buff-add unit={unitId} buff={buffId} instance={instanceId}");
|
||||
}
|
||||
|
||||
async Task OnClientBuffRemove(RpcContext ctx, ulong unitId, uint buffId)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (WebTraversal.IsWebBuff(buffId) && !IsActiveProtagonist(state, unitId))
|
||||
return;
|
||||
if (WebTraversal.IsClientLifecycleBuff(buffId))
|
||||
{
|
||||
var key = (unitId, buffId);
|
||||
if (state.ActiveClientWebBuffInstances.Remove(key, out var lifecycleInstanceId))
|
||||
{
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
|
||||
WorldCodec.UnitRemoveBuff(unitId, lifecycleInstanceId));
|
||||
ctx.Session.Log.Info($"[WEB] lifecycle-remove unit={unitId} buff={buffId} instance={lifecycleInstanceId}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (buffId == WebTraversal.FeiSuoBuff)
|
||||
{
|
||||
if (state.ActiveFeiSuoBuffInstanceId != 0)
|
||||
{
|
||||
var instanceId = state.ActiveFeiSuoBuffInstanceId;
|
||||
var transientUnitId = state.ActiveFeiSuoBuffUnitId != 0
|
||||
? state.ActiveFeiSuoBuffUnitId
|
||||
: unitId;
|
||||
state.ActiveFeiSuoBuffInstanceId = 0;
|
||||
state.ActiveFeiSuoBuffUnitId = 0;
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
|
||||
WorldCodec.UnitRemoveBuff(transientUnitId, instanceId));
|
||||
ctx.Session.Log.Info($"[WEB] client-buff-remove transient={buffId} instance={instanceId}");
|
||||
}
|
||||
|
||||
// Client 4229938 reports grapple teardown by buff *id*, not by instance.
|
||||
// Locally that teardown can consume the configured resident grapple gate as well as the short-lived
|
||||
// action copy. This is especially visible on the female protagonist: the first grapple
|
||||
// succeeds, then no second client-buff-add is emitted. Re-arm one untracked resident gate
|
||||
// after teardown for either protagonist. It is deliberately NOT stored as
|
||||
// ActiveFeiSuoBuffInstanceId; that slot is reserved for the next action copy only.
|
||||
await Task.Delay(WebTraversal.GrappleRearmDelayMs);
|
||||
if (IsActiveProtagonist(state, unitId))
|
||||
{
|
||||
var residentInstanceId = state.NextBuffInstanceId++;
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff,
|
||||
WorldCodec.UnitAddBuff(unitId, WebTraversal.FeiSuoBuff, residentInstanceId));
|
||||
ctx.Session.Log.Info($"[WEB] persistent-gate-reassert unit={unitId} buff={WebTraversal.FeiSuoBuff} instance={residentInstanceId}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!WebTraversal.IsProtected(buffId))
|
||||
return;
|
||||
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff, WorldCodec.UnitAddBuff(unitId, buffId, state.NextBuffInstanceId++));
|
||||
ctx.Session.Log.Info($"[WEB] protected-buff-reassert unit={unitId} buff={buffId}");
|
||||
}
|
||||
async Task ClearClientWebLifecycleBuffs(RpcContext ctx, ulong unitId, string phase)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
var active = state.ActiveClientWebBuffInstances
|
||||
.Where(x => x.Key.UnitId == unitId)
|
||||
.ToArray();
|
||||
foreach (var item in active)
|
||||
{
|
||||
state.ActiveClientWebBuffInstances.Remove(item.Key);
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
|
||||
WorldCodec.UnitRemoveBuff(unitId, item.Value));
|
||||
}
|
||||
if (active.Length > 0)
|
||||
ctx.Session.Log.Info($"[WEB] lifecycle-clear phase={phase} unit={unitId} count={active.Length}");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.Configuration;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// S011 vehicle boarding story channel (build 4229938). Ported from the proven
|
||||
/// Server_V2 flow: the client only offers the F-enter prompt and drives the
|
||||
/// enter/exit animation phases once the S011 root node exists; without the root
|
||||
/// bootstrap, pressing F near a spawned vehicle sends nothing.
|
||||
/// Flow: root bootstrap (once per session) → client S011PlayerNetEnterVehicleRequest →
|
||||
/// enter child + controller + status 2 → phase 2 (status 3) → phase 3 (status 4,
|
||||
/// seated) → exit request → exit child + status 5 → phase 1 (status 0, done).
|
||||
/// Vehicle records live in the shared static SummonedVehicles registry (extended
|
||||
/// with seat state); the boarding transaction itself is per-session.
|
||||
/// Lock order everywhere: story state, then SummonedVehiclesSync.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
private const string VehicleStoryKey = "client4229938-vehicle-story";
|
||||
private const uint VehicleStoryRootNid = 1;
|
||||
private const uint VehicleStoryEnterNid = 2;
|
||||
private const uint VehicleStoryExitNid = 3;
|
||||
|
||||
internal sealed class VehicleStoryState
|
||||
{
|
||||
internal object SyncRoot { get; } = new();
|
||||
internal bool RootSent;
|
||||
internal bool SeenAnyRpc;
|
||||
internal long HighestConfirmedRpcId = -1;
|
||||
internal SortedSet<long> PendingRpcIds { get; } = new();
|
||||
internal HashSet<long> CommittedRpcIds { get; } = new();
|
||||
internal ulong EnterVehicleId;
|
||||
internal byte EnterSeat;
|
||||
internal bool ExitInProgress;
|
||||
internal ulong CurrentVehicleId;
|
||||
internal int CurrentSeat = -1;
|
||||
internal ulong BoardingUnitId;
|
||||
}
|
||||
|
||||
internal static VehicleStoryState GetVehicleStoryState(TcpSession session)
|
||||
{
|
||||
if (session.Items.TryGetValue(VehicleStoryKey, out var raw) && raw is VehicleStoryState existing)
|
||||
return existing;
|
||||
var created = new VehicleStoryState();
|
||||
session.Items[VehicleStoryKey] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
internal static WorldEntryState? GetVehicleWorldState(TcpSession session)
|
||||
=> session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing
|
||||
? existing
|
||||
: null;
|
||||
|
||||
internal static (ulong VehicleId, int Seat) VehicleSeatSnapshot(TcpSession session)
|
||||
{
|
||||
var story = GetVehicleStoryState(session);
|
||||
lock (story.SyncRoot)
|
||||
return (story.CurrentVehicleId, story.CurrentSeat);
|
||||
}
|
||||
|
||||
internal static async Task EnsureVehicleStoryRoot(RpcContext ctx)
|
||||
{
|
||||
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
|
||||
return;
|
||||
var story = GetVehicleStoryState(ctx.Session);
|
||||
long confirm;
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
if (story.RootSent)
|
||||
return;
|
||||
story.RootSent = true;
|
||||
story.BoardingUnitId = GetVehicleWorldState(ctx.Session)?.ActiveSpiritUnitId
|
||||
?? Profile.InitialUnitId;
|
||||
confirm = story.SeenAnyRpc ? Math.Max(0, story.HighestConfirmedRpcId) : -1;
|
||||
}
|
||||
await ctx.NotifyAsync(MethodId.SyncStoryCoreServerInfo,
|
||||
StoryS011Codec4229938.BuildRootBootstrap(VehicleStoryRootNid, confirm));
|
||||
ctx.Session.Log.Info($"[VEHICLE-STORY] S011 root ready nid={VehicleStoryRootNid} confirm={confirm}");
|
||||
}
|
||||
|
||||
internal static void ResetVehicleStoryForEntity(TcpSession session, ulong entityId)
|
||||
{
|
||||
var story = GetVehicleStoryState(session);
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
if (story.CurrentVehicleId == entityId)
|
||||
{
|
||||
story.CurrentVehicleId = 0;
|
||||
story.CurrentSeat = -1;
|
||||
}
|
||||
if (story.EnterVehicleId == entityId)
|
||||
story.EnterVehicleId = 0;
|
||||
story.ExitInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fresh scene load (login / airport travel): the client builds a new story
|
||||
/// instance and drops all server-made nodes, so the S011 root must be sent
|
||||
/// again by the post-load finalization. Called from OnLoadingFinished for each
|
||||
/// accepted generation, next to the garage/aether one-shot resets. The early
|
||||
/// root pushed on the pre-load story hello (if any) is harmless — the client
|
||||
/// discards it with the old story instance.
|
||||
/// </summary>
|
||||
internal static void ResetVehicleStory(TcpSession session)
|
||||
{
|
||||
var story = GetVehicleStoryState(session);
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
story.RootSent = false;
|
||||
story.SeenAnyRpc = false;
|
||||
story.HighestConfirmedRpcId = -1;
|
||||
story.PendingRpcIds.Clear();
|
||||
story.CommittedRpcIds.Clear();
|
||||
story.EnterVehicleId = 0;
|
||||
story.EnterSeat = 0;
|
||||
story.ExitInProgress = false;
|
||||
story.CurrentVehicleId = 0;
|
||||
story.CurrentSeat = -1;
|
||||
}
|
||||
}
|
||||
|
||||
[Handler(MethodId.SyncStoryCoreClientInfo, HandlerPacketKind.Notify)]
|
||||
private async Task SyncStoryCoreClientInfo(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
await EnsureVehicleStoryRoot(msg.Context);
|
||||
var story = GetVehicleStoryState(msg.Context.Session);
|
||||
lock (story.SyncRoot)
|
||||
story.BoardingUnitId = GetVehicleWorldState(msg.Context.Session)?.ActiveSpiritUnitId
|
||||
?? Profile.InitialUnitId;
|
||||
|
||||
var commands = StoryVehicleCodec4229938.Parse(msg.Body, out var full);
|
||||
if (!full)
|
||||
conn.Log.Warn($"[VEHICLE-STORY] partial parse commands={commands.Count} bytes={msg.Body.Length} hex={BitConverter.ToString(msg.Body)}");
|
||||
foreach (var summary in commands)
|
||||
{
|
||||
// Non-heartbeat story traffic is rare; log it so new flows are visible.
|
||||
if (summary.Name != "Rpc3" || summary.RpcId < 0)
|
||||
conn.Log.Info($"[VEHICLE-STORY] cmd mark={summary.TypeMark} nid={summary.Nid} name={summary.Name} rpc={summary.RpcId} payload={summary.PayloadKind} vehicle={summary.PayloadVehicleId} seats=[{(summary.PayloadSeatIndices is null ? "" : string.Join(",", summary.PayloadSeatIndices))}] bool={summary.PayloadBool} int={summary.PayloadInt}");
|
||||
}
|
||||
|
||||
foreach (var cmd in commands)
|
||||
{
|
||||
var duplicateCommitted = false;
|
||||
long duplicateConfirm = -1;
|
||||
if (cmd.HasRpcId && cmd.RpcId >= 0)
|
||||
{
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
story.SeenAnyRpc = true;
|
||||
if (cmd.RpcId <= story.HighestConfirmedRpcId)
|
||||
{
|
||||
duplicateCommitted = true;
|
||||
duplicateConfirm = story.HighestConfirmedRpcId;
|
||||
}
|
||||
else
|
||||
{
|
||||
story.PendingRpcIds.Add(cmd.RpcId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicateCommitted)
|
||||
{
|
||||
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildConfirmRpc(duplicateConfirm));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cmd.TypeMark is 3 or 6)
|
||||
{
|
||||
await ConfirmVehicleStoryRpc(conn, story, cmd.HasRpcId ? cmd.RpcId : null);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cmd.Name == "S011PlayerNetEnterVehicleRequest" && cmd.PayloadKind == "S011EnterVehicleRequest")
|
||||
await BeginVehicleEnter(conn, story, cmd);
|
||||
else if (cmd.Name == "S011PlayerNetExitVehicleRequest" && cmd.PayloadKind == "Bool")
|
||||
await BeginVehicleExit(conn, story, cmd.PayloadBool ?? true);
|
||||
else if (cmd.Nid == VehicleStoryEnterNid && cmd.Name == "MsgrChangePhaseRequest" && cmd.PayloadKind is "Byte" or "Int")
|
||||
await AdvanceVehicleEnter(conn, story, (int)(cmd.PayloadInt ?? 0));
|
||||
else if (cmd.Nid == VehicleStoryExitNid && cmd.Name == "MsgrChangePhaseRequest" && cmd.PayloadKind is "Byte" or "Int")
|
||||
await AdvanceVehicleExit(conn, story, (int)(cmd.PayloadInt ?? 0));
|
||||
|
||||
await ConfirmVehicleStoryRpc(conn, story, cmd.HasRpcId ? cmd.RpcId : null);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BeginVehicleEnter(Connection conn, VehicleStoryState story, StoryClientCommand4229938 cmd)
|
||||
{
|
||||
var vehicleId = cmd.PayloadVehicleId ?? 0;
|
||||
var seat = cmd.PayloadSeatIndices is { Count: > 0 } ? cmd.PayloadSeatIndices[0] : (byte)0;
|
||||
var pid = Profile.PlayerPid;
|
||||
ulong unitId;
|
||||
var ok = false;
|
||||
var lazyRegistered = false;
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
unitId = story.BoardingUnitId != 0 ? story.BoardingUnitId : Profile.InitialUnitId;
|
||||
lock (SummonedVehiclesSync)
|
||||
{
|
||||
// GM-console cars are spawned client-locally with client-side ids the
|
||||
// server has never seen: adopt them on first enter attempt instead of
|
||||
// leaving the client stuck with a hidden prompt. Seat layout is grown
|
||||
// from the requested index; real counts arrive via SyncSpawnVehicle.
|
||||
if (vehicleId != 0 && !SummonedVehicles.ContainsKey(vehicleId))
|
||||
{
|
||||
SummonedVehicles[vehicleId] = new SummonedVehicle
|
||||
{
|
||||
SeatCount = (int)Math.Min(16, Math.Max(2, (int)seat + 1)),
|
||||
Interactable = true,
|
||||
};
|
||||
lazyRegistered = true;
|
||||
}
|
||||
if (vehicleId != 0 && story.CurrentVehicleId == 0
|
||||
&& story.EnterVehicleId == 0 && !story.ExitInProgress
|
||||
&& SummonedVehicles.TryGetValue(vehicleId, out var vehicle) && vehicle.Interactable
|
||||
&& seat < vehicle.SeatCount
|
||||
&& !vehicle.SeatReservations.ContainsKey(seat) && !vehicle.SeatOccupants.ContainsKey(seat))
|
||||
{
|
||||
vehicle.SeatReservations[seat] = pid;
|
||||
story.EnterVehicleId = vehicleId;
|
||||
story.EnterSeat = seat;
|
||||
vehicle.ControllerPid = pid;
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
conn.Log.Warn($"[VEHICLE-STORY] enter rejected vehicle={vehicleId} seat={seat}");
|
||||
return;
|
||||
}
|
||||
|
||||
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildEnterChildBootstrap(VehicleStoryEnterNid, vehicleId, seat));
|
||||
await conn.NotifyAsync(MethodId.SyncChangeVehicleController, new SceneMethods.SyncChangeVehicleController
|
||||
{ VehicleInstanceId = vehicleId, ControllerPid = pid });
|
||||
await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 2);
|
||||
conn.Log.Info($"[VEHICLE-STORY] enter begin vehicle={vehicleId} seat={seat} unit={unitId} status=2 lazy={lazyRegistered}");
|
||||
}
|
||||
|
||||
private async Task AdvanceVehicleEnter(Connection conn, VehicleStoryState story, int phase)
|
||||
{
|
||||
ulong vehicleId;
|
||||
byte seat;
|
||||
ulong unitId;
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
vehicleId = story.EnterVehicleId;
|
||||
seat = story.EnterSeat;
|
||||
unitId = story.BoardingUnitId != 0 ? story.BoardingUnitId : Profile.InitialUnitId;
|
||||
}
|
||||
if (vehicleId == 0)
|
||||
return;
|
||||
switch (phase)
|
||||
{
|
||||
case 0:
|
||||
case 1:
|
||||
return;
|
||||
case 2:
|
||||
{
|
||||
var committed = false;
|
||||
lock (SummonedVehiclesSync)
|
||||
{
|
||||
if (SummonedVehicles.TryGetValue(vehicleId, out var vehicle)
|
||||
&& vehicle.SeatReservations.TryGetValue(seat, out var owner) && owner == Profile.PlayerPid
|
||||
&& !vehicle.SeatOccupants.ContainsKey(seat))
|
||||
{
|
||||
vehicle.SeatReservations.Remove(seat);
|
||||
vehicle.SeatOccupants[seat] = Profile.PlayerPid;
|
||||
committed = true;
|
||||
}
|
||||
}
|
||||
if (!committed)
|
||||
{
|
||||
conn.Log.Warn($"[VEHICLE-STORY] enter phase=2 commit failed vehicle={vehicleId} seat={seat}");
|
||||
return;
|
||||
}
|
||||
await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 3);
|
||||
conn.Log.Info($"[VEHICLE-STORY] enter phase=2 vehicle={vehicleId} seat={seat} status=3");
|
||||
return;
|
||||
}
|
||||
case 3:
|
||||
await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 4);
|
||||
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryEnterNid));
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
story.CurrentVehicleId = vehicleId;
|
||||
story.CurrentSeat = seat;
|
||||
story.EnterVehicleId = 0;
|
||||
lock (SummonedVehiclesSync)
|
||||
{
|
||||
if (SummonedVehicles.TryGetValue(vehicleId, out var v))
|
||||
v.ControllerPid = Profile.PlayerPid;
|
||||
}
|
||||
}
|
||||
conn.Log.Info($"[VEHICLE-STORY] enter complete vehicle={vehicleId} seat={seat} status=4");
|
||||
return;
|
||||
case 4:
|
||||
await SendVehicleBoardingStatus(conn, unitId, vehicleId, 0, 0);
|
||||
await conn.NotifyAsync(MethodId.SyncChangeVehicleController, new SceneMethods.SyncChangeVehicleController
|
||||
{ VehicleInstanceId = vehicleId, ControllerPid = 0 });
|
||||
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryEnterNid));
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
story.EnterVehicleId = 0;
|
||||
lock (SummonedVehiclesSync)
|
||||
{
|
||||
if (SummonedVehicles.TryGetValue(vehicleId, out var v))
|
||||
{
|
||||
if (v.SeatReservations.TryGetValue(seat, out var reservedBy) && reservedBy == Profile.PlayerPid)
|
||||
v.SeatReservations.Remove(seat);
|
||||
if (v.SeatOccupants.TryGetValue(seat, out var occupiedBy) && occupiedBy == Profile.PlayerPid)
|
||||
v.SeatOccupants.Remove(seat);
|
||||
v.ControllerPid = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.Log.Info($"[VEHICLE-STORY] enter cancelled vehicle={vehicleId}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BeginVehicleExit(Connection conn, VehicleStoryState story, bool stopBeforeLeave)
|
||||
{
|
||||
ulong vehicleId;
|
||||
byte seat;
|
||||
ulong unitId;
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
vehicleId = story.CurrentVehicleId;
|
||||
seat = (byte)Math.Max(0, story.CurrentSeat);
|
||||
unitId = story.BoardingUnitId != 0 ? story.BoardingUnitId : Profile.InitialUnitId;
|
||||
if (vehicleId == 0 || story.ExitInProgress)
|
||||
return;
|
||||
story.ExitInProgress = true;
|
||||
}
|
||||
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildExitChildBootstrap(VehicleStoryExitNid, vehicleId, seat, stopBeforeLeave));
|
||||
await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 5);
|
||||
conn.Log.Info($"[VEHICLE-STORY] exit begin vehicle={vehicleId} seat={seat} status=5");
|
||||
}
|
||||
|
||||
private async Task AdvanceVehicleExit(Connection conn, VehicleStoryState story, int phase)
|
||||
{
|
||||
ulong vehicleId;
|
||||
byte seat;
|
||||
ulong unitId;
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
vehicleId = story.CurrentVehicleId;
|
||||
seat = (byte)Math.Max(0, story.CurrentSeat);
|
||||
unitId = story.BoardingUnitId != 0 ? story.BoardingUnitId : Profile.InitialUnitId;
|
||||
}
|
||||
if (vehicleId == 0)
|
||||
return;
|
||||
if (phase == 0)
|
||||
return;
|
||||
if (phase == 1)
|
||||
{
|
||||
await SendVehicleBoardingStatus(conn, unitId, vehicleId, 0, 0);
|
||||
await conn.NotifyAsync(MethodId.SyncChangeVehicleController, new SceneMethods.SyncChangeVehicleController
|
||||
{ VehicleInstanceId = vehicleId, ControllerPid = 0 });
|
||||
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryExitNid));
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
story.CurrentVehicleId = 0;
|
||||
story.CurrentSeat = -1;
|
||||
story.ExitInProgress = false;
|
||||
lock (SummonedVehiclesSync)
|
||||
{
|
||||
if (SummonedVehicles.TryGetValue(vehicleId, out var v))
|
||||
{
|
||||
if (v.SeatOccupants.TryGetValue(seat, out var occupiedBy) && occupiedBy == Profile.PlayerPid)
|
||||
v.SeatOccupants.Remove(seat);
|
||||
if (v.SeatReservations.TryGetValue(seat, out var reservedBy) && reservedBy == Profile.PlayerPid)
|
||||
v.SeatReservations.Remove(seat);
|
||||
v.ControllerPid = 0;
|
||||
v.Interactable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.Log.Info($"[VEHICLE-STORY] exit complete vehicle={vehicleId} status=0");
|
||||
}
|
||||
else if (phase == 2)
|
||||
{
|
||||
await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 4);
|
||||
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryExitNid));
|
||||
lock (story.SyncRoot)
|
||||
story.ExitInProgress = false;
|
||||
conn.Log.Info($"[VEHICLE-STORY] exit cancelled vehicle={vehicleId} status=4");
|
||||
}
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskClaimVehicleSeat, HandlerPacketKind.Invoke)]
|
||||
private async Task AskClaimVehicleSeatStory(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (!msg.TryGetArgs<SceneMethods.AskClaimVehicleSeatArgs>(out var args) || args is null)
|
||||
{
|
||||
conn.Log.Warn($"[VEHICLE] AskClaimVehicleSeat undecodable bytes={msg.Body.Length}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
return;
|
||||
}
|
||||
var story = GetVehicleStoryState(msg.Context.Session);
|
||||
byte selected = 0;
|
||||
var valid = false;
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
lock (SummonedVehiclesSync)
|
||||
{
|
||||
if (story.EnterVehicleId == args.VehicleEntityId
|
||||
&& SummonedVehicles.TryGetValue(args.VehicleEntityId, out var vehicle))
|
||||
{
|
||||
foreach (var seat in args.SeatIndices)
|
||||
{
|
||||
var reservedByOther = vehicle.SeatReservations.TryGetValue(seat, out var reservedBy) && reservedBy != Profile.PlayerPid;
|
||||
var occupiedByOther = vehicle.SeatOccupants.TryGetValue(seat, out var occupiedBy) && occupiedBy != Profile.PlayerPid;
|
||||
if (seat < vehicle.SeatCount && !reservedByOther && !occupiedByOther)
|
||||
{
|
||||
selected = seat;
|
||||
valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (valid)
|
||||
{
|
||||
var oldSeat = story.EnterSeat;
|
||||
if (vehicle.SeatReservations.TryGetValue(oldSeat, out var oldOwner) && oldOwner == Profile.PlayerPid)
|
||||
vehicle.SeatReservations.Remove(oldSeat);
|
||||
vehicle.SeatReservations[selected] = Profile.PlayerPid;
|
||||
story.EnterSeat = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!valid)
|
||||
{
|
||||
await conn.ReturnEmptyAsync(msg, 1);
|
||||
return;
|
||||
}
|
||||
await conn.ReturnAsync(msg, selected);
|
||||
conn.Log.Info($"[VEHICLE-STORY] seat claim vehicle={args.VehicleEntityId} seat={selected}");
|
||||
}
|
||||
|
||||
private Task SendVehicleBoardingStatus(Connection conn, ulong unitId, ulong vehicleId, byte seat, byte status)
|
||||
=> conn.NotifyAsync(MethodId.SyncUnitVehicleStatus, new SceneMethods.NewClientBoardingInfo
|
||||
{
|
||||
EntityId = unitId,
|
||||
Status = status,
|
||||
VehicleUId = vehicleId,
|
||||
SeatIndex = seat,
|
||||
ExtInfo = null,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Server-forced enter (debug panel path): seats the player without any client
|
||||
/// F-press. Mirrors the enter-complete leg — move-to-seat, controller, boarding
|
||||
/// status seated, start/state-change/finish drive-state notifies — plus an S011
|
||||
/// enter child so the story side agrees, deleted right after like a phase-3 commit.
|
||||
/// </summary>
|
||||
internal static async Task<(bool Ok, string Message)> ForceEnterVehicleAsync(TcpSession session, ulong entityId = 0)
|
||||
{
|
||||
var story = GetVehicleStoryState(session);
|
||||
var unitId = GetVehicleWorldState(session)?.ActiveSpiritUnitId ?? Profile.InitialUnitId;
|
||||
var pid = Profile.PlayerPid;
|
||||
ulong vehicleId = entityId != 0 ? entityId : LastSummonedEntity();
|
||||
const byte seat = 0;
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
lock (SummonedVehiclesSync)
|
||||
{
|
||||
if (vehicleId == 0 || !SummonedVehicles.TryGetValue(vehicleId, out var vehicle))
|
||||
return (false, "no spawned vehicle (spawn one first)");
|
||||
if (story.CurrentVehicleId != 0)
|
||||
return (false, $"already in vehicle {story.CurrentVehicleId} (exit first)");
|
||||
if (!vehicle.Interactable || vehicle.SeatOccupants.ContainsKey(seat))
|
||||
return (false, "vehicle is not enterable right now");
|
||||
vehicle.SeatReservations.Remove(seat);
|
||||
vehicle.SeatOccupants[seat] = pid;
|
||||
vehicle.ControllerPid = pid;
|
||||
story.CurrentVehicleId = vehicleId;
|
||||
story.CurrentSeat = seat;
|
||||
story.EnterVehicleId = 0;
|
||||
}
|
||||
}
|
||||
var drive = new SceneMethods.PlayerVehicleDriveStateInfo
|
||||
{
|
||||
Pid = pid, EnterOrLeave = true, VehicleEntityId = vehicleId, SeatIndex = seat,
|
||||
IfForce = true, OpenDoorTypeId = 0, OpenDoorActionSpeed = 0, OpenDoorActionClipLength = 0,
|
||||
};
|
||||
try
|
||||
{
|
||||
var ct = CancellationToken.None;
|
||||
await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncChangeVehicleInteractable { VehicleInstanceId = vehicleId, Interactable = false }), ct);
|
||||
await session.NotifyAsync(MethodId.SyncPlayerMoveToDriveSeat, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncPlayerMoveToDriveSeat { Pid = pid, VehicleEntityId = vehicleId }), ct);
|
||||
await session.NotifyAsync(MethodId.SyncUnitVehicleStatus, UxSerializer.Serialize(
|
||||
new SceneMethods.NewClientBoardingInfo { EntityId = unitId, Status = 4, VehicleUId = vehicleId, SeatIndex = seat, ExtInfo = null }), ct);
|
||||
await session.NotifyAsync(MethodId.SyncPlayerStartEnterOrExitVehicl, UxSerializer.Serialize(drive), ct);
|
||||
await session.NotifyAsync(MethodId.SyncPlayerVehicleStateChange, UxSerializer.Serialize(drive), ct);
|
||||
await session.NotifyAsync(MethodId.SyncChangeVehicleController, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncChangeVehicleController { VehicleInstanceId = vehicleId, ControllerPid = pid }), ct);
|
||||
await session.NotifyAsync(MethodId.SyncPlayerFinishEnterOrExitVehic, UxSerializer.Serialize(drive), ct);
|
||||
await SendStoryAsync(session, StoryS011Codec4229938.BuildEnterChildBootstrap(VehicleStoryEnterNid, vehicleId, seat));
|
||||
await SendStoryAsync(session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryEnterNid));
|
||||
session.Log.Info($"[VEHICLE-STORY] force enter vehicle={vehicleId} seat={seat} unit={unitId} status=4");
|
||||
return (true, $"seated in vehicle {vehicleId} (seat 0) — drive with normal keys, exit with F or the Exit button");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"enter failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Server-forced exit (debug panel path).</summary>
|
||||
internal static async Task<(bool Ok, string Message)> ForceExitVehicleAsync(TcpSession session)
|
||||
{
|
||||
var story = GetVehicleStoryState(session);
|
||||
var unitId = GetVehicleWorldState(session)?.ActiveSpiritUnitId ?? Profile.InitialUnitId;
|
||||
ulong vehicleId;
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
vehicleId = story.CurrentVehicleId;
|
||||
if (vehicleId == 0)
|
||||
return (false, "not in a vehicle");
|
||||
}
|
||||
try
|
||||
{
|
||||
var ct = CancellationToken.None;
|
||||
await session.NotifyAsync(MethodId.SyncPlayerExitVehicle, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncPlayerExitVehicle { VehicleEntityId = vehicleId, Force = true, StopBeforeLeave = true }), ct);
|
||||
await session.NotifyAsync(MethodId.SyncUnitVehicleStatus, UxSerializer.Serialize(
|
||||
new SceneMethods.NewClientBoardingInfo { EntityId = unitId, Status = 0, VehicleUId = vehicleId, SeatIndex = 0, ExtInfo = null }), ct);
|
||||
await session.NotifyAsync(MethodId.SyncChangeVehicleController, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncChangeVehicleController { VehicleInstanceId = vehicleId, ControllerPid = 0 }), ct);
|
||||
await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncChangeVehicleInteractable { VehicleInstanceId = vehicleId, Interactable = true }), ct);
|
||||
await SendStoryAsync(session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryEnterNid));
|
||||
await SendStoryAsync(session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryExitNid));
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
story.CurrentVehicleId = 0;
|
||||
story.CurrentSeat = -1;
|
||||
story.EnterVehicleId = 0;
|
||||
story.ExitInProgress = false;
|
||||
lock (SummonedVehiclesSync)
|
||||
{
|
||||
if (SummonedVehicles.TryGetValue(vehicleId, out var v))
|
||||
{
|
||||
v.SeatOccupants.Clear();
|
||||
v.SeatReservations.Clear();
|
||||
v.ControllerPid = 0;
|
||||
v.Interactable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
session.Log.Info($"[VEHICLE-STORY] force exit vehicle={vehicleId}");
|
||||
return (true, $"exited vehicle {vehicleId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"exit failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConfirmVehicleStoryRpc(Connection conn, VehicleStoryState story, long? commitRpcId)
|
||||
{
|
||||
long highest;
|
||||
lock (story.SyncRoot)
|
||||
{
|
||||
if (commitRpcId is long id && id >= 0)
|
||||
story.CommittedRpcIds.Add(id);
|
||||
highest = story.HighestConfirmedRpcId;
|
||||
while (story.PendingRpcIds.Count > 0)
|
||||
{
|
||||
var next = highest + 1;
|
||||
if (next >= 0 && story.CommittedRpcIds.Contains(next))
|
||||
{
|
||||
highest = next;
|
||||
story.PendingRpcIds.Remove(next);
|
||||
story.CommittedRpcIds.Remove(next);
|
||||
}
|
||||
else break;
|
||||
}
|
||||
if (highest <= story.HighestConfirmedRpcId)
|
||||
return;
|
||||
story.HighestConfirmedRpcId = highest;
|
||||
}
|
||||
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildConfirmRpc(highest));
|
||||
}
|
||||
|
||||
private static Task SendStoryAsync(TcpSession session, byte[] payload)
|
||||
=> session.NotifyAsync(MethodId.SyncStoryCoreServerInfo, payload, CancellationToken.None);
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
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 Ananta.Server.RpcTypes.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Private-server vehicle surface (build 4229938): summon + owned fleet + drive-loop accepts.
|
||||
/// Spawn uses the proven 3-notify sequence (SyncLogicVehicleEnter + SyncSpawnVehicle +
|
||||
/// SyncChangeVehicleInteractable) with 5.5m-right/+0.15m offsets; driving itself is
|
||||
/// simulated client-side.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
private static long _vehicleEntitySeq = 300000000000L;
|
||||
private static long _vehicleTaskSeq;
|
||||
private static readonly Dictionary<ulong, SummonedVehicle> SummonedVehicles = new();
|
||||
private static readonly object SummonedVehiclesSync = new();
|
||||
|
||||
internal sealed class SummonedVehicle
|
||||
{
|
||||
internal uint ConfigId;
|
||||
internal float X;
|
||||
internal float Y;
|
||||
internal float Z;
|
||||
internal float Yaw;
|
||||
internal bool HasFix;
|
||||
// Boarding state (S011 story flow): seat layout + occupancy.
|
||||
internal int SeatCount = 2;
|
||||
internal bool Interactable = true;
|
||||
internal ulong ControllerPid;
|
||||
internal readonly Dictionary<byte, ulong> SeatReservations = new();
|
||||
internal readonly Dictionary<byte, ulong> SeatOccupants = new();
|
||||
}
|
||||
|
||||
private static ulong _lastSummonedEntity;
|
||||
|
||||
internal static ulong LastSummonedEntity()
|
||||
{
|
||||
lock (SummonedVehiclesSync)
|
||||
return _lastSummonedEntity;
|
||||
}
|
||||
|
||||
internal static bool TryGetSummoned(ulong entityId, out SummonedVehicle? vehicle)
|
||||
{
|
||||
lock (SummonedVehiclesSync)
|
||||
return SummonedVehicles.TryGetValue(entityId, out vehicle);
|
||||
}
|
||||
|
||||
internal static List<(ulong EntityId, uint ConfigId, float X, float Y, float Z, float Yaw, bool HasFix)> SummonedSnapshot()
|
||||
{
|
||||
lock (SummonedVehiclesSync)
|
||||
return SummonedVehicles
|
||||
.Select(kv => (kv.Key, kv.Value.ConfigId, kv.Value.X, kv.Value.Y, kv.Value.Z, kv.Value.Yaw, kv.Value.HasFix))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
internal static void RegisterSummoned(ulong entityId, uint configId)
|
||||
{
|
||||
lock (SummonedVehiclesSync)
|
||||
{
|
||||
if (!SummonedVehicles.TryGetValue(entityId, out var existing))
|
||||
SummonedVehicles[entityId] = existing = new SummonedVehicle { ConfigId = configId };
|
||||
else
|
||||
existing.ConfigId = configId;
|
||||
existing.SeatCount = LookupSeatCount(configId);
|
||||
existing.Interactable = true;
|
||||
_lastSummonedEntity = entityId;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void RegisterSummoned(ulong entityId, uint configId, float x, float y, float z, float yaw)
|
||||
{
|
||||
lock (SummonedVehiclesSync)
|
||||
{
|
||||
SummonedVehicles[entityId] = new SummonedVehicle
|
||||
{
|
||||
ConfigId = configId,
|
||||
X = x,
|
||||
Y = y,
|
||||
Z = z,
|
||||
Yaw = yaw,
|
||||
HasFix = float.IsFinite(x) && float.IsFinite(y) && float.IsFinite(z),
|
||||
SeatCount = LookupSeatCount(configId),
|
||||
Interactable = true,
|
||||
};
|
||||
_lastSummonedEntity = entityId;
|
||||
}
|
||||
}
|
||||
|
||||
private static int LookupSeatCount(uint configId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (VehicleCatalog4229938.TryGet(configId, out var config)
|
||||
&& config.SeatCount is >= 1 and <= 16)
|
||||
return config.SeatCount;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
internal static void ForgetSummoned(ulong entityId)
|
||||
{
|
||||
lock (SummonedVehiclesSync)
|
||||
SummonedVehicles.Remove(entityId);
|
||||
}
|
||||
|
||||
internal static void TrackVehicleMove(ulong entityId, float x, float y, float z, float yaw)
|
||||
{
|
||||
if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z) || !float.IsFinite(yaw))
|
||||
return;
|
||||
lock (SummonedVehiclesSync)
|
||||
{
|
||||
// Only track server-summoned entities; traffic/AI vehicles stay untracked
|
||||
// so the registry cannot grow unbounded.
|
||||
if (SummonedVehicles.TryGetValue(entityId, out var existing))
|
||||
{
|
||||
existing.X = x;
|
||||
existing.Y = y;
|
||||
existing.Z = z;
|
||||
existing.Yaw = yaw;
|
||||
existing.HasFix = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSummonVehicle, HandlerPacketKind.Invoke)]
|
||||
private async Task AskSummonVehicle(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
|
||||
{
|
||||
await conn.ReturnEmptyAsync(msg, 1);
|
||||
return;
|
||||
}
|
||||
if (!msg.TryGetArgs<SceneMethods.AskSummonVehicle>(out var args) || args is null
|
||||
|| msg.Body.Length != 20 || args.VehicleConfigId == 0
|
||||
|| !float.IsFinite(args.Position.X) || !float.IsFinite(args.Position.Y) || !float.IsFinite(args.Position.Z)
|
||||
|| !float.IsFinite(args.FacingDirection))
|
||||
{
|
||||
conn.Log.Warn($"[VEHICLE] AskSummonVehicle bad args bytes={msg.Body.Length}");
|
||||
await conn.ReturnEmptyAsync(msg, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await SpawnDirectAsync(
|
||||
msg.Context.Session,
|
||||
args.VehicleConfigId,
|
||||
new Vec3(args.Position.X, args.Position.Y, args.Position.Z),
|
||||
args.FacingDirection,
|
||||
rightOffset: 5.5f,
|
||||
sourceType: 2,
|
||||
reason: "client-summon");
|
||||
if (!result.Ok)
|
||||
{
|
||||
conn.Log.Warn($"[VEHICLE] summon rejected: {result.Message}");
|
||||
await conn.ReturnEmptyAsync(msg, 2);
|
||||
return;
|
||||
}
|
||||
await conn.ReturnAsync(msg, new SceneMethods.SummonVehicleResult
|
||||
{
|
||||
VehicleEntityId = result.EntityId,
|
||||
TaskToken = result.Token,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct scene spawn shared by client summon and the debug panel: SyncLogicVehicleEnter
|
||||
/// + SyncSpawnVehicle (interactable) + SyncChangeVehicleInteractable. Offsets proven by V2:
|
||||
/// rightOffset meters to the right of the facing, +0.15m up.
|
||||
/// </summary>
|
||||
internal static async Task<(bool Ok, string Message, ulong EntityId, ulong Token, Vec3 SpawnPos)> SpawnDirectAsync(
|
||||
TcpSession session, uint configId, Vec3 nearPos, float facing, float rightOffset, byte sourceType, string reason)
|
||||
{
|
||||
if (configId == 0)
|
||||
return (false, "vehicle 0 does not exist", 0, 0, default);
|
||||
|
||||
// Unknown / model-less ids are allowed with a warning (newer client data may know
|
||||
// more vehicles than our dump); the client validates against its own configs.
|
||||
var seatCount = 2;
|
||||
if (VehicleCatalog4229938.TryGet(configId, out var config))
|
||||
{
|
||||
if (config.SeatCount is >= 1 and <= 16)
|
||||
seatCount = config.SeatCount;
|
||||
if (string.IsNullOrWhiteSpace(config.Model))
|
||||
session.Log.Warn($"[VEHICLE] config {configId} has no prefab model in dump, trying anyway");
|
||||
}
|
||||
else
|
||||
{
|
||||
session.Log.Warn($"[VEHICLE] config {configId} not in VehicleConfig dump, trying with {seatCount} seats");
|
||||
}
|
||||
|
||||
var yaw = facing * (MathF.PI / 180f);
|
||||
var spawn = new Vec3(
|
||||
nearPos.X + MathF.Cos(yaw) * rightOffset,
|
||||
nearPos.Y + 0.15f,
|
||||
nearPos.Z - MathF.Sin(yaw) * rightOffset);
|
||||
var entityId = (ulong)Interlocked.Increment(ref _vehicleEntitySeq);
|
||||
var token = (ulong)Interlocked.Increment(ref _vehicleTaskSeq);
|
||||
RegisterSummoned(entityId, configId, spawn.X, spawn.Y, spawn.Z, facing);
|
||||
|
||||
var euler = new SceneMethods.UxVector3(0f, facing, 0f);
|
||||
var pos = new SceneMethods.UxVector3(spawn.X, spawn.Y, spawn.Z);
|
||||
await session.NotifyAsync(MethodId.SyncLogicVehicleEnter, UxSerializer.Serialize(new SceneMethods.SyncLogicVehicleEnter
|
||||
{
|
||||
EntityId = entityId,
|
||||
VehicleConfigId = configId,
|
||||
CreateSourceType = sourceType,
|
||||
Parts = [],
|
||||
SuitId = 0,
|
||||
LicensePlate = string.Empty,
|
||||
Interactable = true,
|
||||
MoveToken = 0,
|
||||
Position = pos,
|
||||
EulerAngles = euler,
|
||||
VehicleSpoonName = null,
|
||||
}), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncSpawnVehicle, UxSerializer.Serialize(new SceneMethods.VehicleClientInfo
|
||||
{
|
||||
ControllerPid = 0,
|
||||
CreateSourceType = sourceType,
|
||||
EntityId = entityId,
|
||||
VehicleConfigId = configId,
|
||||
Parts = [],
|
||||
SuitId = 0,
|
||||
Position = pos,
|
||||
Facing = facing,
|
||||
EulerAngles = euler,
|
||||
Velocity = 0,
|
||||
IsStatic = false,
|
||||
DeformStatus = 0,
|
||||
SeatInfos = Enumerable.Range(0, seatCount).Select(i => new SceneMethods.RaidVehicleSeatInfo
|
||||
{
|
||||
EntityId = 0,
|
||||
SeatIndex = (byte)i,
|
||||
SeatState = 0,
|
||||
DestroyRelated = false,
|
||||
}).ToList(),
|
||||
SpoonId = 0,
|
||||
IsDynamicGo = false,
|
||||
VehicleEnemyId = 0,
|
||||
DisableNavigation = false,
|
||||
Interactable = true,
|
||||
MoveToken = 0,
|
||||
LicensePlate = string.Empty,
|
||||
}), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncChangeVehicleInteractable { VehicleInstanceId = entityId, Interactable = true }),
|
||||
CancellationToken.None);
|
||||
session.Log.Info($"[VEHICLE] DIRECT_SPAWN reason={reason} config={configId} entity={entityId} token={token} seats={seatCount} spawn=({spawn.X:F1},{spawn.Y:F1},{spawn.Z:F1})");
|
||||
return (true, "spawned", entityId, token, spawn);
|
||||
}
|
||||
|
||||
internal static async Task DestroyDirectAsync(TcpSession session, ulong entityId, string reason)
|
||||
{
|
||||
ForgetSummoned(entityId);
|
||||
ResetVehicleStoryForEntity(session, entityId);
|
||||
await session.NotifyAsync(MethodId.SyncDestroyVehicle, UxSerializer.Serialize(
|
||||
new SceneMethods.DestroyVehicle { VehicleEntityId = entityId, VehicleDestroyType = 0, Distance = 0, DynamicGoId = 0 }),
|
||||
CancellationToken.None);
|
||||
session.Log.Info($"[VEHICLE] destroy entity={entityId} reason={reason}");
|
||||
}
|
||||
|
||||
internal static SceneMethods.AskGetUnlockedVehiclesResult BuildUnlockedResult()
|
||||
{
|
||||
var fleet = PrivateServerConfigStore.Current.Gameplay.Vehicles.FleetIds;
|
||||
return new SceneMethods.AskGetUnlockedVehiclesResult
|
||||
{
|
||||
Vehicles = fleet.Select(id => new SceneMethods.PlayerVehicleClientDetail
|
||||
{
|
||||
Id = id,
|
||||
Parts = [],
|
||||
SuitId = 0,
|
||||
IsPersistent = true,
|
||||
}).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push the owned fleet right after world entry finalization (first gameplay movement),
|
||||
/// so the phone/garage UI is populated without a manual resync. Once per entry.
|
||||
/// </summary>
|
||||
internal static async Task PublishGarageAsync(RpcContext ctx)
|
||||
{
|
||||
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
|
||||
return;
|
||||
if (!(ctx.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState world))
|
||||
return;
|
||||
bool first;
|
||||
lock (world.SyncRoot)
|
||||
{
|
||||
first = !world.GaragePublished;
|
||||
if (first)
|
||||
world.GaragePublished = true;
|
||||
}
|
||||
if (!first)
|
||||
return;
|
||||
var result = BuildUnlockedResult();
|
||||
await ctx.NotifyAsync(MethodId.SyncAllUnlockedVehicles, result);
|
||||
ctx.Session.Log.Info($"[VEHICLE] garage auto-published count={result.Vehicles.Count}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push Aether vehicle-AI init once per world entry (mirrors V2): RaidId +
|
||||
/// zone-graph handle + empty lists. The client DriveManager gates vehicle
|
||||
/// materialization on this init; without it spawns stay invisible.
|
||||
/// </summary>
|
||||
internal static async Task PublishAetherInitAsync(RpcContext ctx)
|
||||
{
|
||||
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
|
||||
return;
|
||||
if (!(ctx.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState world))
|
||||
return;
|
||||
bool first;
|
||||
uint raidId;
|
||||
lock (world.SyncRoot)
|
||||
{
|
||||
first = !world.AetherVehicleInitSent;
|
||||
if (first)
|
||||
world.AetherVehicleInitSent = true;
|
||||
raidId = world.ActiveRaidId;
|
||||
}
|
||||
if (!first)
|
||||
return;
|
||||
var settings = PrivateServerConfigStore.Current.Gameplay.Vehicles;
|
||||
var hasZoneGraph = raidId == PrivateServerConfigStore.Current.World.RaidId;
|
||||
await ctx.NotifyAsync(MethodId.SyncAetherAIInitDatas, new SceneMethods.AetherAIInitData
|
||||
{
|
||||
RaidId = raidId,
|
||||
HasZoneGraph = hasZoneGraph,
|
||||
ZoneStorageDataHandle = hasZoneGraph ? settings.ZoneStorageDataHandle : 0,
|
||||
Intersections = [],
|
||||
Vehicles = [],
|
||||
StaticVehicles = [],
|
||||
});
|
||||
ctx.Session.Log.Info($"[VEHICLE] aether-init raid={raidId} zoneGraph={hasZoneGraph} lists=empty");
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskGetUnlockedVehicles, HandlerPacketKind.Invoke)]
|
||||
private Task AskGetUnlockedVehicles(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var result = BuildUnlockedResult();
|
||||
conn.Log.Info($"[VEHICLE] unlocked list -> {result.Vehicles.Count} vehicles");
|
||||
return conn.ReturnAsync(msg, result);
|
||||
}
|
||||
|
||||
// Client-driven enter/exit + movement reports. The client owns the simulation;
|
||||
// the server accepts them so the drive loop never stalls waiting for a reply.
|
||||
// AskVehicleMove additionally feeds the debug panel's live coordinates.
|
||||
[Handler(MethodId.AskVehicleMove, HandlerPacketKind.Notify)]
|
||||
private Task AskVehicleMove(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = msg.GetArgs<SceneMethods.RaidVehicleSyncData>();
|
||||
TrackVehicleMove(data.Id, data.Position.X, data.Position.Y, data.Position.Z, data.FacingDirection);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
conn.Log.Warn($"[VEHICLE] move parse failed, accepted anyway: {ex.Message}");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskPlayerStartEnterOrExitVehicle, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.AskPlayerFinishEnterOrExitVehicle, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.AskVehicleStartMove, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.AskVehicleStopMove, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.AskVehicleHorn, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.AskEnterVehicleIndoor, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.AskExitVehicleIndoor, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.ReportDrivingVehicle, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.AskKillVehicle, HandlerPacketKind.Notify)]
|
||||
private Task VehicleNotifyAccept(Connection conn, UxRpcMessage msg) => Task.CompletedTask;
|
||||
|
||||
// Drive-loop invokes with void returns (combat/contact/horn-nitro/state signals).
|
||||
[Handler(MethodId.VehicleDriveStateChange, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskChangeGoVehicleDriveState, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskVehicleDeadEnd, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskVehicleNitro, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskVehicleStuck, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskVehicleHit, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskVehicleHitEnd, HandlerPacketKind.Invoke)]
|
||||
private Task VehicleInvokeAccept(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
// Autonomous Driving & Vehicle Pathfinder Handlers
|
||||
[Handler(MethodId.AskVehicleStartAutonomousDriving, HandlerPacketKind.Invoke)]
|
||||
private async Task AskVehicleStartAutonomousDriving(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var vehicleEntityId = LastSummonedEntity();
|
||||
conn.Log.Info($"[AUTODRIVE] start requested vehicleEntity={vehicleEntityId}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
var syncVehicle = new SceneMethods.SyncVehicleAutonomousDrivingState
|
||||
{
|
||||
VehicleEntityId = vehicleEntityId,
|
||||
IsStart = true
|
||||
};
|
||||
await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncVehicleAutonomousDrivingState, syncVehicle);
|
||||
|
||||
var syncPlayer = new SceneMethods.SyncPlayerAutonomousDrivingState
|
||||
{
|
||||
IsInOverrideMode = false,
|
||||
IsAutoDrivingBlocked = false,
|
||||
IsImmersiveModeBlocked = false
|
||||
};
|
||||
await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncPlayerAutonomousDrivingState, syncPlayer);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskVehicleStopAutonomousDriving, HandlerPacketKind.Invoke)]
|
||||
private async Task AskVehicleStopAutonomousDriving(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var vehicleEntityId = LastSummonedEntity();
|
||||
conn.Log.Info($"[AUTODRIVE] stop requested vehicleEntity={vehicleEntityId}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
var syncVehicle = new SceneMethods.SyncVehicleAutonomousDrivingState
|
||||
{
|
||||
VehicleEntityId = vehicleEntityId,
|
||||
IsStart = false
|
||||
};
|
||||
await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncVehicleAutonomousDrivingState, syncVehicle);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskVehicleChangeAutonomousDrivingTarget, HandlerPacketKind.Invoke)]
|
||||
private Task AskVehicleChangeAutonomousDrivingTarget(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[AUTODRIVE] target position changed");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskVehicleCancelAutonomousDrivingTarget, HandlerPacketKind.Invoke)]
|
||||
private Task AskVehicleCancelAutonomousDrivingTarget(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[AUTODRIVE] target position cancelled");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskVehicleNavigationPathPoints, HandlerPacketKind.Invoke)]
|
||||
private Task AskVehicleNavigationPathPoints(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
uint navReqId = 0;
|
||||
SceneMethods.UxVector3 targetPos = new(0, 0, 0);
|
||||
try
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskVehicleNavigationPathPointsArgs>();
|
||||
navReqId = args.NavReqId;
|
||||
targetPos = args.TargetPosition;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
conn.Log.Warn($"[NAV] parse path points failed: {ex.Message}");
|
||||
}
|
||||
|
||||
var state = conn.Session is not null ? GetStateIfExists(conn.Session) : null;
|
||||
var playerPos = state?.LastReportedPlayerPosition ?? Profile.WorldSpawn;
|
||||
var startUx = new SceneMethods.UxVector3(playerPos.X, playerPos.Y, playerPos.Z);
|
||||
|
||||
// Generate intermediate waypoints between player and target so GPS renders along roads
|
||||
var points = GenerateNavWaypoints(startUx, targetPos);
|
||||
|
||||
var result = new SceneMethods.VehicleNavigationPathPointsResult
|
||||
{
|
||||
NavReqId = navReqId,
|
||||
Points = points,
|
||||
CenterPoints = points
|
||||
};
|
||||
conn.Log.Info($"[NAV] path points req={navReqId} pts={points.Count} from=({playerPos.X:F1},{playerPos.Y:F1},{playerPos.Z:F1}) to=({targetPos.X:F1},{targetPos.Y:F1},{targetPos.Z:F1})");
|
||||
return conn.ReturnAsync(msg, result);
|
||||
}
|
||||
|
||||
private static List<SceneMethods.UxVector3> GenerateNavWaypoints(SceneMethods.UxVector3 start, SceneMethods.UxVector3 end)
|
||||
{
|
||||
var list = new List<SceneMethods.UxVector3> { start };
|
||||
float dx = end.X - start.X;
|
||||
float dy = end.Y - start.Y;
|
||||
float dz = end.Z - start.Z;
|
||||
float dist = MathF.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
|
||||
// Generate smooth path segments for vehicle road line
|
||||
if (dist > 25f)
|
||||
{
|
||||
int steps = Math.Clamp((int)(dist / 20f), 2, 12);
|
||||
for (int i = 1; i < steps; i++)
|
||||
{
|
||||
float t = (float)i / steps;
|
||||
list.Add(new SceneMethods.UxVector3(start.X + dx * t, start.Y + dy * t, start.Z + dz * t));
|
||||
}
|
||||
}
|
||||
list.Add(end);
|
||||
return list;
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskVehicleNavigationPathLength, HandlerPacketKind.Invoke)]
|
||||
private Task AskVehicleNavigationPathLength(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var state = conn.Session is not null ? GetStateIfExists(conn.Session) : null;
|
||||
var playerPos = state?.LastReportedPlayerPosition ?? Profile.WorldSpawn;
|
||||
return conn.ReturnAsync(msg, 150.0f);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskVehicleNavigationPathLengthList, HandlerPacketKind.Invoke)]
|
||||
private Task AskVehicleNavigationPathLengthList(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
return conn.ReturnAsync(msg, new List<float> { 150.0f });
|
||||
}
|
||||
|
||||
// Metro / Subway System Handlers
|
||||
[Handler(MethodId.AskGetAllMetroInfos, HandlerPacketKind.Invoke)]
|
||||
private Task AskGetAllMetroInfos(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[METRO] GetAllMetroInfos requested -> returning active metro train lines");
|
||||
// Active trains for rail lines 1, 2, 3, 4 across the city
|
||||
var list = new List<MetroClientInfo>
|
||||
{
|
||||
new() { Id = 1001, LineId = 1, ElapsedTime = 12.0f, IsFinalTrain = false },
|
||||
new() { Id = 1002, LineId = 2, ElapsedTime = 25.0f, IsFinalTrain = false },
|
||||
new() { Id = 1003, LineId = 3, ElapsedTime = 38.0f, IsFinalTrain = false },
|
||||
new() { Id = 1004, LineId = 4, ElapsedTime = 5.0f, IsFinalTrain = false }
|
||||
};
|
||||
return conn.ReturnAsync(msg, list);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskMetroGadgetIds, HandlerPacketKind.Invoke)]
|
||||
private Task AskMetroGadgetIds(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
return conn.ReturnAsync(msg, new List<MetroCarriageGadgetInfos>());
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskPlayerOnMetro, HandlerPacketKind.Invoke)]
|
||||
private Task AskPlayerOnMetro(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[METRO] player entered/boarded metro train");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskOnMetroEnterStation, HandlerPacketKind.Notify)]
|
||||
private Task AskOnMetroEnterStation(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[METRO] player entered station");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskOnMetroExitStation, HandlerPacketKind.Notify)]
|
||||
private Task AskOnMetroExitStation(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[METRO] player exited station");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskPlayerLeaveMetro, HandlerPacketKind.Invoke)]
|
||||
private Task AskPlayerLeaveMetro(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[METRO] player leave metro requested");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
// Taffy Moto / Monowheel Handlers
|
||||
[Handler(MethodId.AskTaffyMotoEnterRush, HandlerPacketKind.Invoke)]
|
||||
private Task AskTaffyMotoEnterRush(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[TAFFY_MOTO] AskTaffyMotoEnterRush");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskTaffyMotoLeaveRush, HandlerPacketKind.Invoke)]
|
||||
private Task AskTaffyMotoLeaveRush(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[TAFFY_MOTO] AskTaffyMotoLeaveRush");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.OnTafeiMotorColliding, HandlerPacketKind.Invoke)]
|
||||
private Task OnTafeiMotorColliding(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[TAFFY_MOTO] OnTafeiMotorColliding");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskGetOffMotor, HandlerPacketKind.Invoke)]
|
||||
private Task AskGetOffMotor(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[TAFFY_MOTO] AskGetOffMotor");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.Handlers;
|
||||
using Ananta.Server.Gameplay;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>World entry and client-driven scene loading barriers.</summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
WorldEntryState GetWorldState(RpcContext ctx)
|
||||
{
|
||||
if (ctx.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing)
|
||||
return existing;
|
||||
|
||||
var created = new WorldEntryState();
|
||||
created.SeedHistoryFromGlobal();
|
||||
ctx.Session.Items[WorldStateKey] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
void ArmWorldEntry4229938(WorldEntryState state)
|
||||
{
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
var next = state.WorldEntryControlGeneration + 1;
|
||||
if (next <= 0)
|
||||
next = 1;
|
||||
|
||||
state.WorldEntryControlGeneration = next;
|
||||
state.WorldEntryControlPending = true;
|
||||
state.WorldEntryControlFinalized = false;
|
||||
state.WorldEntryControlUnit = Profile.InitialUnitId;
|
||||
state.WorldEntryControlTemplate = Profile.InitialSpiritTemplateId;
|
||||
state.WorldEntryLoadingCompletedGeneration = 0;
|
||||
state.WorldEntryOpeningEndedGeneration = 0;
|
||||
state.WorldEntryControlFinalizingGeneration = 0;
|
||||
state.WorldEntryLogicProjectionPublishedGeneration = 0;
|
||||
state.WorldEntryCurrentMetadataPublishedGeneration = 0;
|
||||
state.WorldEntrySceneId4229938 = 0;
|
||||
state.WorldEntrySessionId4229938 = 0;
|
||||
state.WorldEntryCreateHeroPosition = Profile.WorldSpawn;
|
||||
state.WorldEntryCreateHeroFacing = Profile.WorldFacing;
|
||||
state.LastReportedPlayerPosition = Profile.WorldSpawn;
|
||||
state.LastReportedPlayerRotation = new Vec3(0f, Profile.WorldFacing, 0f);
|
||||
state.HasLastReportedPlayerTransform = false;
|
||||
state.LastSwitchShowId = 0;
|
||||
state.AllBuildBuffsPublished = false;
|
||||
state.GaragePublished = false;
|
||||
state.AetherVehicleInitSent = false;
|
||||
state.InitialActorPresentationPublished = false;
|
||||
state.ActiveSpiritUnitId = Profile.InitialUnitId;
|
||||
state.ActiveSpiritTemplateId = Profile.InitialSpiritTemplateId;
|
||||
state.WorldEntryIsAirportTravel = false;
|
||||
}
|
||||
}
|
||||
|
||||
async Task SendInitialGameState(RpcContext ctx)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (state.InitialStateSent)
|
||||
{
|
||||
ctx.Session.Log.Info("[WORLD-MIN] LoginGame repeat ignored");
|
||||
return;
|
||||
}
|
||||
state.InitialStateSent = true;
|
||||
|
||||
await ctx.NotifyAsync(MethodId.SendServerTimeGame, LoginCodec.ServerTime());
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerInfo, RuntimePayloadFactory.MinimalPlayerInfo4229938());
|
||||
await SendEnterSceneIfNeeded(ctx);
|
||||
}
|
||||
|
||||
|
||||
async Task SendEnterSceneIfNeeded(RpcContext ctx)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
if (state.EnterSceneSent)
|
||||
return;
|
||||
|
||||
state.EnterSceneSent = true;
|
||||
ArmWorldEntry4229938(state);
|
||||
state.WorldEntrySwitchShowId = 0;
|
||||
|
||||
var rollback = WorldEntryState.TryGetGlobalCheckpointSecondsAgo(10.0);
|
||||
Vec3 spawnPos;
|
||||
float spawnFacing;
|
||||
if (rollback is not null && (DateTime.UtcNow - rollback.Timestamp).TotalSeconds < 60)
|
||||
{
|
||||
spawnPos = rollback.Position;
|
||||
spawnFacing = rollback.Facing;
|
||||
ctx.Session.Log.Info($"[RECONNECT-ROLLBACK] Reconnecting player restored to 10s prior position ({spawnPos.X:F1},{spawnPos.Y:F1},{spawnPos.Z:F1}) facing={spawnFacing:F1}");
|
||||
}
|
||||
else
|
||||
{
|
||||
spawnPos = Profile.WorldSpawn;
|
||||
spawnFacing = Profile.WorldFacing;
|
||||
}
|
||||
|
||||
state.WorldEntryCreateHeroPosition = spawnPos;
|
||||
state.WorldEntryCreateHeroFacing = spawnFacing;
|
||||
|
||||
// Minimal mode intentionally does not ask the client to play an authored opening Timeline.
|
||||
// Scene assets and native quest/story systems remain entirely client-owned.
|
||||
await ctx.NotifyAsync(MethodId.SyncEnterScene, RuntimePayloadFactory.EnterScene(
|
||||
Profile.RaidId,
|
||||
Profile.SceneInstanceId,
|
||||
Profile.UniverseId,
|
||||
Profile.InitialUnitId,
|
||||
Profile.InitialSpiritTemplateId,
|
||||
spawnPos,
|
||||
spawnFacing,
|
||||
switchShowId: 0,
|
||||
isSwitchSpiritShow: false));
|
||||
|
||||
ctx.Session.Log.Info($"[WORLD-MIN] enter generation={state.WorldEntryControlGeneration} raid={Profile.RaidId} instance={Profile.SceneInstanceId} universe={Profile.UniverseId} unit={Profile.InitialUnitId} template={Profile.InitialSpiritTemplateId} pos=({spawnPos.X:0.##},{spawnPos.Y:0.##},{spawnPos.Z:0.##}) opening=false switchShow=0");
|
||||
}
|
||||
|
||||
|
||||
async Task<bool> CommitWorldEntryCreateHeroData4229938(RpcContext ctx, int generation)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
ulong unitId;
|
||||
uint templateId;
|
||||
ulong playerPid;
|
||||
Vec3 createHeroPosition;
|
||||
float createHeroFacing;
|
||||
string? reject = null;
|
||||
|
||||
// Port the proven v7 preflight literally: validate the complete canonical identity and
|
||||
// CreateHero transform BEFORE claiming/sending the first authority packet. A failure after
|
||||
// LogicAgentEnter would leave an orphan authority entity, so this block is fail-closed.
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (!state.WorldEntryControlPending || state.WorldEntryControlFinalized)
|
||||
reject = "transaction-not-pending";
|
||||
else if (generation <= 0 || generation != state.WorldEntryControlGeneration)
|
||||
reject = "generation-mismatch";
|
||||
else if (Profile.PlayerPid == 0 || state.WorldEntryControlUnit == 0 || state.WorldEntryControlTemplate == 0)
|
||||
reject = "zero-identity";
|
||||
else if (state.ActiveSpiritUnitId != state.WorldEntryControlUnit
|
||||
|| state.ActiveSpiritTemplateId != state.WorldEntryControlTemplate)
|
||||
reject = "active-identity-mismatch";
|
||||
else if (state.WorldEntryControlUnit != Profile.InitialUnitId
|
||||
|| state.WorldEntryControlTemplate != Profile.InitialSpiritTemplateId)
|
||||
reject = "profile-identity-mismatch";
|
||||
else if (!ClientConfigRepository.Characters().Any(x =>
|
||||
x.UnitId == state.WorldEntryControlUnit &&
|
||||
x.TemplateId == state.WorldEntryControlTemplate))
|
||||
reject = "canonical-roster-row-missing";
|
||||
else if (!float.IsFinite(state.WorldEntryCreateHeroPosition.X)
|
||||
|| !float.IsFinite(state.WorldEntryCreateHeroPosition.Y)
|
||||
|| !float.IsFinite(state.WorldEntryCreateHeroPosition.Z)
|
||||
|| !float.IsFinite(state.WorldEntryCreateHeroFacing))
|
||||
reject = "non-finite-createhero-transform";
|
||||
else if (state.WorldEntryLogicProjectionPublishedGeneration == generation
|
||||
&& state.WorldEntryCurrentMetadataPublishedGeneration == generation)
|
||||
return true;
|
||||
else if (state.WorldEntryLogicProjectionPublishedGeneration != 0
|
||||
|| state.WorldEntryCurrentMetadataPublishedGeneration != 0)
|
||||
reject = "projection-already-claimed";
|
||||
|
||||
unitId = state.WorldEntryControlUnit;
|
||||
templateId = state.WorldEntryControlTemplate;
|
||||
playerPid = Profile.PlayerPid;
|
||||
createHeroPosition = state.WorldEntryCreateHeroPosition;
|
||||
createHeroFacing = state.WorldEntryCreateHeroFacing;
|
||||
|
||||
if (reject is null)
|
||||
{
|
||||
// Both generation edges are claimed before any send. Partial failure remains
|
||||
// fail-closed; a duplicate callback must never replay half of the transaction.
|
||||
state.WorldEntryLogicProjectionPublishedGeneration = -generation;
|
||||
state.WorldEntryCurrentMetadataPublishedGeneration = -generation;
|
||||
}
|
||||
}
|
||||
|
||||
if (reject is not null)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[WORLD-V7] commit rejected generation={generation} reason={reject}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact proven order from the supplied v7 guide. No sleep, no presentation hydration,
|
||||
// no buffs and no weapon/fashion deltas may be interleaved into this quartet.
|
||||
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(unitId));
|
||||
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent, WorldCodec.ManagedLogicAgent(unitId, playerPid, 0));
|
||||
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
|
||||
RuntimePayloadFactory.ExistingUnitProjection(unitId, templateId, createHeroPosition, createHeroFacing));
|
||||
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.WorldEntryLogicProjectionPublishedGeneration == -generation)
|
||||
state.WorldEntryLogicProjectionPublishedGeneration = generation;
|
||||
}
|
||||
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerCurrentSpirit,
|
||||
WorldCodec.CurrentSpirit(playerPid, templateId, unitId, isAgentSwitch: false));
|
||||
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.WorldEntryCurrentMetadataPublishedGeneration == -generation)
|
||||
state.WorldEntryCurrentMetadataPublishedGeneration = generation;
|
||||
state.CurrentSpiritStateSent = true;
|
||||
state.ControlPublished = true;
|
||||
}
|
||||
|
||||
ctx.Session.Log.Info($"[HANDOFF-V7] generation={generation} exact=LogicAgentEnter->ManagedLogicAgent->sameID-AOI->CurrentSpirit unit={unitId} template={templateId} presentation=false buffs=false duplicateActor=false");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
async Task OnLoadSceneCompleted(RpcContext ctx, ulong sceneId, ulong sessionId)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
int generation = 0;
|
||||
string? reject = null;
|
||||
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (sceneId == 0 || sessionId == 0)
|
||||
reject = "invalid-scene-session";
|
||||
else if (!state.WorldEntryControlPending || state.WorldEntryControlFinalized)
|
||||
reject = "transaction-not-pending";
|
||||
else if (state.WorldEntrySceneId4229938 == 0 && state.WorldEntrySessionId4229938 == 0)
|
||||
{
|
||||
state.WorldEntrySceneId4229938 = sceneId;
|
||||
state.WorldEntrySessionId4229938 = sessionId;
|
||||
}
|
||||
else if (state.WorldEntrySceneId4229938 != sceneId || state.WorldEntrySessionId4229938 != sessionId)
|
||||
reject = "correlation-mismatch";
|
||||
|
||||
if (reject is null)
|
||||
{
|
||||
generation = state.WorldEntryControlGeneration;
|
||||
state.SceneLoadAckSeen = true;
|
||||
state.SceneId = sceneId;
|
||||
}
|
||||
}
|
||||
|
||||
if (reject is not null)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[WORLD] AskLoadSceneCompleted rejected scene={sceneId} session={sessionId} reason={reject}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await CommitWorldEntryCreateHeroData4229938(ctx, generation))
|
||||
return;
|
||||
|
||||
// The confirmed handoff transaction ends with CurrentSpirit. Do not interleave roster/combat/state
|
||||
// hydration here; the client must reach AskLoadingFinished with the post-load quartet intact.
|
||||
ctx.Session.Log.Info($"[WORLD] load-edge committed generation={generation} scene={sceneId} session={sessionId} logicProjection={state.WorldEntryLogicProjectionPublishedGeneration} currentMetadata={state.WorldEntryCurrentMetadataPublishedGeneration} sceneComplete=false hydration=false");
|
||||
}
|
||||
|
||||
Task OnLoadGameResourcesCompleted(RpcContext ctx, ulong sceneId)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
state.GameResourcesReadySeen = true;
|
||||
if (state.SceneId == 0 && sceneId != 0)
|
||||
state.SceneId = sceneId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task OnLoadingFinished(RpcContext ctx, ulong sceneId, ulong sessionId)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
int generation = 0;
|
||||
string? reject = null;
|
||||
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (sceneId == 0 || sessionId == 0)
|
||||
reject = "invalid-scene-session";
|
||||
else if (!state.WorldEntryControlPending || state.WorldEntryControlFinalized)
|
||||
reject = state.WorldEntryControlFinalized ? "already-finalized" : "transaction-not-pending";
|
||||
else if (sceneId != state.WorldEntrySceneId4229938 || sessionId != state.WorldEntrySessionId4229938)
|
||||
reject = "correlation-mismatch";
|
||||
else
|
||||
{
|
||||
generation = state.WorldEntryControlGeneration;
|
||||
if (state.WorldEntryLogicProjectionPublishedGeneration != generation
|
||||
|| state.WorldEntryCurrentMetadataPublishedGeneration != generation)
|
||||
reject = "v7-handoff-incomplete";
|
||||
else if (state.WorldEntryLoadingCompletedGeneration == generation)
|
||||
return;
|
||||
else if (state.WorldEntryLoadingCompletedGeneration != 0)
|
||||
reject = "loading-edge-already-claimed";
|
||||
else if (state.WorldEntryControlFinalizingGeneration != 0)
|
||||
reject = "finalizer-already-claimed";
|
||||
else
|
||||
{
|
||||
state.WorldEntryLoadingCompletedGeneration = -generation;
|
||||
state.WorldEntryControlFinalizingGeneration = -generation;
|
||||
state.SceneId = sceneId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reject is not null)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[WORLD-V7] AskLoadingFinished rejected scene={sceneId} session={sessionId} generation={generation} reason={reject}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Supplied guide invariant: SyncSceneLoadCompleted is the FIRST and ONLY S2C gameplay edge
|
||||
// of AskLoadingFinished. Do not put buffs/weapon/fashion/current before it. All optional
|
||||
// runtime hydration is deferred to the first real gameplay movement after Ready=true.
|
||||
await ctx.NotifyAsync(MethodId.SyncSceneLoadCompleted, WorldCodec.SceneLoadCompleted(sceneId));
|
||||
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
state.SceneCompletionSent = true;
|
||||
if (state.WorldEntryLoadingCompletedGeneration == -generation)
|
||||
state.WorldEntryLoadingCompletedGeneration = generation;
|
||||
state.LivePlayerProfilePublished = false;
|
||||
state.CombatProfilePublished = false;
|
||||
state.AccountArmoryPublished = true; // compact referenced subset already arrived in SyncPlayerInfo
|
||||
state.FreeRoamReleased = false;
|
||||
state.MovementCapabilityPublished = false;
|
||||
state.AllBuildBuffsPublished = false;
|
||||
state.GaragePublished = false;
|
||||
state.AetherVehicleInitSent = false;
|
||||
ResetVehicleStory(ctx.Session);
|
||||
state.InitialActorPresentationPublished = false;
|
||||
state.InitialCapabilityBuffsPublished = false;
|
||||
state.WorldEntryControlPending = false;
|
||||
state.WorldEntryControlFinalized = true;
|
||||
if (state.WorldEntryControlFinalizingGeneration == -generation)
|
||||
state.WorldEntryControlFinalizingGeneration = generation;
|
||||
state.Ready = true;
|
||||
state.WorldEntryIsAirportTravel = false;
|
||||
}
|
||||
|
||||
// Normal post-load finalization, deliberately after the terminal scene-ready edge.
|
||||
await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false));
|
||||
await PublishGarageAsync(ctx);
|
||||
await PublishAetherInitAsync(ctx);
|
||||
await EnsureVehicleStoryRoot(ctx);
|
||||
await PushSessionTimeAsync(ctx.Session);
|
||||
await PushSessionWeatherAsync(ctx.Session);
|
||||
|
||||
var activitiesSync = new GameMethods.SyncAllActivities
|
||||
{
|
||||
activities =
|
||||
[
|
||||
new GameMethods.AwardActivityItem
|
||||
{
|
||||
BaseActivityInfo = new GameMethods.BaseActivityInfo { CfgId = 2684, StartTime = 0, EndTime = 2000000000 },
|
||||
ActivityData = new GameMethods.ActivityData { CfgId = 2684, ShowRedPoint = true, IsOutOfDate = false }
|
||||
},
|
||||
new GameMethods.AwardActivityItem
|
||||
{
|
||||
BaseActivityInfo = new GameMethods.BaseActivityInfo { CfgId = 2685, StartTime = 0, EndTime = 2000000000 },
|
||||
ActivityData = new GameMethods.ActivityData { CfgId = 2685, ShowRedPoint = true, IsOutOfDate = false }
|
||||
},
|
||||
new GameMethods.AwardActivityItem
|
||||
{
|
||||
BaseActivityInfo = new GameMethods.BaseActivityInfo { CfgId = 2686, StartTime = 0, EndTime = 2000000000 },
|
||||
ActivityData = new GameMethods.ActivityData { CfgId = 2686, ShowRedPoint = true, IsOutOfDate = false }
|
||||
}
|
||||
]
|
||||
};
|
||||
await ctx.NotifyAsync(MethodId.SyncAllActivities, activitiesSync);
|
||||
|
||||
// Unlock all scene fog of war on the client
|
||||
await ctx.NotifyAsync(MethodId.SyncSceneFogMapAllUnlock, new SceneMethods.SyncSceneFogMapAllUnlockInfo
|
||||
{
|
||||
SceneId = state.ActiveRaidId,
|
||||
Unlocked = true
|
||||
});
|
||||
|
||||
// Initialize story task tracking
|
||||
await ctx.NotifyAsync(MethodId.SyncCurrentTask, new SceneMethods.SyncCurrentTaskInfo
|
||||
{
|
||||
Type = 1,
|
||||
TaskId = 10001,
|
||||
EventId = 0,
|
||||
FirstTime = true,
|
||||
Reason = 0
|
||||
});
|
||||
|
||||
ctx.Session.Log.Info($"[WORLD-V7] ready generation={generation} scene={sceneId} session={sessionId} exactGuide=true sceneComplete=first actorPresentation=client-owned buffs=deferred-first-movement noStory=false noAOI=true");
|
||||
}
|
||||
|
||||
|
||||
async Task OnLoadedInSameScene(RpcContext ctx)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
bool firstPresentationEdge;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
firstPresentationEdge = !state.SameSceneAckSeen;
|
||||
state.SameSceneAckSeen = true;
|
||||
if (firstPresentationEdge && Profile.WorldEntryOpeningEnabled && state.WorldEntryControlGeneration > 0)
|
||||
state.WorldEntryOpeningEndedGeneration = state.WorldEntryControlGeneration;
|
||||
}
|
||||
|
||||
// 4229938 emits AskLoadedInSameScene after every authored same-scene character switch, not
|
||||
// only once after login. SyncPlayerLoadRate is therefore an acknowledgement for EACH edge.
|
||||
// Keep the opening-generation bookkeeping one-shot, but never suppress the per-switch ack.
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerLoadRate, WorldCodec.PlayerLoadRate());
|
||||
ctx.Session.Log.Info($"[SAME-SCENE] load-rate ack=true first={firstPresentationEdge} generation={state.WorldEntryControlGeneration} pendingSwitch={state.PendingSwitchTemplateId}/{state.PendingSwitchUnitId} controlMutation=false");
|
||||
}
|
||||
|
||||
internal static async Task<(bool Ok, string Message)> SwitchSceneDirectAsync(
|
||||
TcpSession session,
|
||||
uint raidId,
|
||||
ulong instanceId,
|
||||
uint universeId,
|
||||
Vec3 position,
|
||||
float facing,
|
||||
bool isAirport = false)
|
||||
{
|
||||
if (!session.Items.TryGetValue(WorldStateKey, out var raw) || raw is not WorldEntryState state)
|
||||
return (false, "no active world state for session");
|
||||
|
||||
int generation;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
var next = state.WorldEntryControlGeneration + 1;
|
||||
if (next <= 0) next = 1;
|
||||
generation = next;
|
||||
|
||||
state.WorldEntryControlGeneration = next;
|
||||
state.WorldEntryControlPending = true;
|
||||
state.WorldEntryControlFinalized = false;
|
||||
state.WorldEntryControlUnit = Profile.InitialUnitId;
|
||||
state.WorldEntryControlTemplate = Profile.InitialSpiritTemplateId;
|
||||
state.WorldEntryLoadingCompletedGeneration = 0;
|
||||
state.WorldEntryOpeningEndedGeneration = 0;
|
||||
state.WorldEntryControlFinalizingGeneration = 0;
|
||||
state.WorldEntryLogicProjectionPublishedGeneration = 0;
|
||||
state.WorldEntryCurrentMetadataPublishedGeneration = 0;
|
||||
state.WorldEntrySceneId4229938 = 0;
|
||||
state.WorldEntrySessionId4229938 = 0;
|
||||
state.WorldEntryCreateHeroPosition = position;
|
||||
state.WorldEntryCreateHeroFacing = facing;
|
||||
state.LastReportedPlayerPosition = position;
|
||||
state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f);
|
||||
state.HasLastReportedPlayerTransform = false;
|
||||
state.LastSwitchShowId = 0;
|
||||
state.AllBuildBuffsPublished = false;
|
||||
state.GaragePublished = false;
|
||||
state.AetherVehicleInitSent = false;
|
||||
state.InitialActorPresentationPublished = false;
|
||||
state.ActiveSpiritUnitId = Profile.InitialUnitId;
|
||||
state.ActiveSpiritTemplateId = Profile.InitialSpiritTemplateId;
|
||||
state.WorldEntryIsAirportTravel = isAirport;
|
||||
state.Ready = false;
|
||||
|
||||
state.ActiveRaidId = raidId;
|
||||
state.ActiveInstanceId = instanceId;
|
||||
state.ActiveUniverseId = universeId;
|
||||
}
|
||||
|
||||
var payload = RuntimePayloadFactory.EnterScene(
|
||||
raidId,
|
||||
instanceId,
|
||||
universeId,
|
||||
Profile.InitialUnitId,
|
||||
Profile.InitialSpiritTemplateId,
|
||||
position,
|
||||
facing,
|
||||
switchShowId: 0,
|
||||
isSwitchSpiritShow: false);
|
||||
|
||||
var bytes = UxSerializer.Serialize(payload);
|
||||
await session.NotifyAsync(MethodId.SyncEnterScene, bytes, CancellationToken.None);
|
||||
session.Log.Info($"[WORLD] scene-switch generation={generation} raid={raidId} instance={instanceId} universe={universeId} pos=({position.X:0.##},{position.Y:0.##},{position.Z:0.##}) facing={facing:0.##} airport={isAirport}");
|
||||
return (true, $"switched to raid={raidId} instance={instanceId} universe={universeId}");
|
||||
}
|
||||
|
||||
internal static async Task HandleAirportOrTeleportAsync(TcpSession session)
|
||||
{
|
||||
if (!session.Items.TryGetValue(WorldStateKey, out var raw) || raw is not WorldEntryState state)
|
||||
return;
|
||||
|
||||
uint currentRaid;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
currentRaid = state.ActiveRaidId;
|
||||
}
|
||||
|
||||
if (currentRaid == 23300888)
|
||||
{
|
||||
session.Log.Info("[AIRPORT] travel: Main City (23300888) -> Longqi Village (23300999)");
|
||||
await SwitchSceneDirectAsync(
|
||||
session,
|
||||
raidId: 23300999,
|
||||
instanceId: 20001223,
|
||||
universeId: 76000888,
|
||||
position: new Vec3(-4719.8f, 168.5f, -2844.7f),
|
||||
facing: 0f,
|
||||
isAirport: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
session.Log.Info($"[AIRPORT] travel: raid {currentRaid} -> Main City (23300888)");
|
||||
await SwitchSceneDirectAsync(
|
||||
session,
|
||||
raidId: 23300888,
|
||||
instanceId: 20001222,
|
||||
universeId: 76000888,
|
||||
position: new Vec3(-4468.3f, -6.15f, -2597.5f),
|
||||
facing: 85.5f,
|
||||
isAirport: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.Handlers.LoginGate;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Build 4229938 private-server surface: world entry, direct character switching,
|
||||
/// combat, traversal/buffs, vehicles and movement. Everything else falls back to typed-neutral RPC replies.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
internal const string WorldStateKey = "client4229938-world";
|
||||
private readonly GateSessionHub? _gateSessions;
|
||||
|
||||
internal GameRouter(GateSessionHub? gateSessions = null) => _gateSessions = gateSessions;
|
||||
|
||||
private static readonly HashSet<uint> Enabled4229938MethodIds = new()
|
||||
{
|
||||
// Login / world-entry barriers.
|
||||
MethodId.LoginGame,
|
||||
MethodId.RequestGameSceneData,
|
||||
MethodId.GetServerTimeGame,
|
||||
MethodId.GetSpriteToken,
|
||||
MethodId.CheckPSOPermissions,
|
||||
MethodId.AskSetGamePause,
|
||||
MethodId.AskLoadSceneCompleted,
|
||||
MethodId.AskLoadGameResCompleted,
|
||||
MethodId.AskLoadingFinished,
|
||||
MethodId.AskLoadedInSameScene,
|
||||
|
||||
// Roster + direct character switching.
|
||||
MethodId.AskAllSpiritPanelData,
|
||||
MethodId.AskSwitchSpirit,
|
||||
|
||||
// Combat / weapon sandbox.
|
||||
MethodId.AskSwitchWeapon,
|
||||
MethodId.AskSwitchFightStyle,
|
||||
MethodId.AskSetWeaponFightStyle,
|
||||
MethodId.AskLoadWeaponToSlot,
|
||||
MethodId.AskDepositSpiritWeapon,
|
||||
MethodId.AskExchangeWeaponSlot,
|
||||
MethodId.AskClientUseCommonSkill,
|
||||
MethodId.AskUseSkill,
|
||||
MethodId.AskSkillUseWeaponDurability,
|
||||
MethodId.AskWeaponEquipBullets,
|
||||
MethodId.AskSetWeaponSkins,
|
||||
MethodId.AskSyncWeaponSkinToSpirits,
|
||||
MethodId.ReportSkillEnd,
|
||||
MethodId.AskMultipleSkillHit2,
|
||||
MethodId.AskInterruptSkillExecute,
|
||||
MethodId.AskInterruptSkillExecuteStiff,
|
||||
MethodId.AskSkillExecuteEnd,
|
||||
MethodId.AskSkillExecute,
|
||||
MethodId.AskSkillDestructibleCreate,
|
||||
|
||||
// Client-decided traversal/web buffs.
|
||||
MethodId.AskAddClientBuff,
|
||||
MethodId.AskRemoveClientBuff,
|
||||
MethodId.AskEnterFeiSuoCrouch,
|
||||
MethodId.AskFeiSuoSuccess,
|
||||
MethodId.AskLeaveFeiSuoCrouch,
|
||||
|
||||
// Live transform tracking.
|
||||
MethodId.AskReportLogicAgentSyncData,
|
||||
MethodId.AskUnitMoveActionSimple,
|
||||
MethodId.AskUnitMoveActionSimpleWithGround,
|
||||
MethodId.AskUnitMoveActionWithGround,
|
||||
MethodId.AskUnitMoveAction,
|
||||
|
||||
// Vehicles: summon + owned fleet + client-driven drive loop + S011 boarding story.
|
||||
MethodId.AskSummonVehicle,
|
||||
MethodId.AskGetUnlockedVehicles,
|
||||
MethodId.SyncStoryCoreClientInfo,
|
||||
MethodId.AskClaimVehicleSeat,
|
||||
MethodId.AskPlayerStartEnterOrExitVehicle,
|
||||
MethodId.AskPlayerFinishEnterOrExitVehicle,
|
||||
MethodId.AskVehicleMove,
|
||||
MethodId.AskVehicleStartMove,
|
||||
MethodId.AskVehicleStopMove,
|
||||
MethodId.AskVehicleHorn,
|
||||
MethodId.AskEnterVehicleIndoor,
|
||||
MethodId.AskExitVehicleIndoor,
|
||||
MethodId.ReportDrivingVehicle,
|
||||
MethodId.AskKillVehicle,
|
||||
MethodId.VehicleDriveStateChange,
|
||||
MethodId.AskChangeGoVehicleDriveState,
|
||||
MethodId.AskVehicleDeadEnd,
|
||||
MethodId.AskVehicleNitro,
|
||||
MethodId.AskVehicleStuck,
|
||||
MethodId.AskVehicleHit,
|
||||
MethodId.AskVehicleHitEnd,
|
||||
|
||||
// GM console (ALT+F1): C2S invokes, server records real ids.
|
||||
MethodId.GmSpawnVehicle,
|
||||
MethodId.GmAddEnemyWithPosition,
|
||||
MethodId.GmAddEnemy,
|
||||
MethodId.GmAddEnemyByPlayer,
|
||||
MethodId.GmTeleportXYZ,
|
||||
MethodId.DavinciCode,
|
||||
|
||||
// Time of day: client-driven UI (accept + remember) + debug-panel slider push.
|
||||
MethodId.AskPassingTime,
|
||||
MethodId.GmPassingTime,
|
||||
MethodId.GmSetTime,
|
||||
MethodId.GmFixRaidTime,
|
||||
MethodId.ChangePersonalTimeSetting,
|
||||
MethodId.AddPersonalTimeSetting,
|
||||
MethodId.GmSetWeather,
|
||||
MethodId.GmSetWeatherParam,
|
||||
|
||||
// Fast travel, teleport, and scene transfers.
|
||||
MethodId.AskTeleport,
|
||||
MethodId.ReportPreTeleportFinish,
|
||||
MethodId.ReportPostTeleportFinish,
|
||||
MethodId.AskGadgetDoorTransfer,
|
||||
MethodId.AskPlayerChangePositionByScenePortal,
|
||||
MethodId.AskPublicSwitchToPublicScene,
|
||||
|
||||
// Gacha system.
|
||||
MethodId.AskDrawGacha,
|
||||
MethodId.AskClaimGachaMilestone,
|
||||
MethodId.AskChaosMasterGacha,
|
||||
|
||||
// Activities.
|
||||
MethodId.AskActivityCancelRedPoint,
|
||||
|
||||
// Wardrobe & Fashion dressing.
|
||||
MethodId.AskSetSpiritFashionsWithSource,
|
||||
MethodId.AskSetSpiritFashions,
|
||||
MethodId.AskModifySpiritWearFashionsOnlyWearWithSource,
|
||||
MethodId.AskModifySpiritWearFashionsOnlyWear,
|
||||
MethodId.AskModifySpiritWearFashionsWithSource,
|
||||
MethodId.AskModifySpiritWearFashionEditInfosWithSource,
|
||||
MethodId.AskModifySpiritWearFashionEditInfos,
|
||||
MethodId.AskSetSpiritWearFashionHiddenPartsWithSource,
|
||||
MethodId.AskSetSpiritWearFashionHiddenParts,
|
||||
MethodId.AskOpenOrCloseFashionPanel,
|
||||
MethodId.AskUnlockFashionColoringSlot,
|
||||
MethodId.AskUnlockFashionSuitSlot,
|
||||
MethodId.AskApplyFashionColoringSchemeInf,
|
||||
MethodId.AskSetFashionColoringSchemeInfos,
|
||||
MethodId.AskFavoriteFashions,
|
||||
MethodId.AskFavoriteFashionSuits,
|
||||
MethodId.AskReadFashions,
|
||||
MethodId.AskReadFashionSuits,
|
||||
MethodId.AskSetSpiritFashionVariantPreference,
|
||||
MethodId.AskTradeRecycleFashion,
|
||||
MethodId.AskClearSwitchInFashionCache,
|
||||
MethodId.AskRecommendGetTopWearFashionTag,
|
||||
|
||||
// Autonomous driving & Vehicle pathfinder.
|
||||
MethodId.AskVehicleStartAutonomousDriving,
|
||||
MethodId.AskVehicleStopAutonomousDriving,
|
||||
MethodId.AskVehicleChangeAutonomousDrivingTarget,
|
||||
MethodId.AskVehicleCancelAutonomousDrivingTarget,
|
||||
MethodId.AskVehicleNavigationPathPoints,
|
||||
MethodId.AskVehicleNavigationPathLength,
|
||||
MethodId.AskVehicleNavigationPathLengthList,
|
||||
|
||||
// Metro / Subway system.
|
||||
MethodId.AskGetAllMetroInfos,
|
||||
MethodId.AskMetroGadgetIds,
|
||||
MethodId.AskPlayerOnMetro,
|
||||
MethodId.AskOnMetroEnterStation,
|
||||
MethodId.AskOnMetroExitStation,
|
||||
MethodId.AskPlayerLeaveMetro,
|
||||
|
||||
// Story & Quests.
|
||||
MethodId.AskAcceptTask,
|
||||
MethodId.AskAcceptAndSetCurrentTask,
|
||||
MethodId.AskSubmitTask,
|
||||
|
||||
// Time App, Mobile Phone Skins & Interaction Actions.
|
||||
MethodId.AskTimePanelInfo,
|
||||
MethodId.AskSetMobileSkinPart,
|
||||
MethodId.AskResetMobileSkinPart,
|
||||
MethodId.AskInstallMobileApp,
|
||||
MethodId.AskCancelInteractionActionRedPoint,
|
||||
|
||||
// Taffy Moto / Monowheel traversal.
|
||||
MethodId.AskTaffyMotoEnterRush,
|
||||
MethodId.AskTaffyMotoLeaveRush,
|
||||
MethodId.OnTafeiMotorColliding,
|
||||
MethodId.AskGetOffMotor,
|
||||
};
|
||||
|
||||
internal RpcRouter Build()
|
||||
{
|
||||
var router = new RpcRouter("game");
|
||||
MethodId.RegisterKnownNames(router);
|
||||
_ = AttributedHandlerRegistry.RegisterSelected(router, this, Enabled4229938MethodIds);
|
||||
router.OnUnknownInvoke(DefaultUnknownInvoke4229938);
|
||||
router.OnUnknownNotify(DefaultUnknownNotify4229938);
|
||||
return router;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Ananta.SDK.Network;
|
||||
|
||||
namespace Ananta.Server.Handlers.LoginGate;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the authenticated Gate socket. IMasterToClient is routed to ServerMark.Gate
|
||||
/// in client build 4229938, so server-pushed master RPCs must use this session, not Game.
|
||||
/// </summary>
|
||||
internal sealed class GateSessionHub
|
||||
{
|
||||
private TcpSession? _current;
|
||||
private long _lastSeenTicks;
|
||||
|
||||
internal TcpSession? Current => Volatile.Read(ref _current);
|
||||
internal DateTimeOffset? LastSeenUtc
|
||||
{
|
||||
get
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _lastSeenTicks);
|
||||
return ticks == 0 ? null : new DateTimeOffset(ticks, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
internal void Touch(TcpSession session)
|
||||
{
|
||||
Volatile.Write(ref _current, session);
|
||||
Interlocked.Exchange(ref _lastSeenTicks, DateTimeOffset.UtcNow.Ticks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.Configuration;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
|
||||
namespace Ananta.Server.Handlers.LoginGate;
|
||||
|
||||
/// <summary>
|
||||
/// Login/gate endpoints for build 4229938. Method mapping is visible directly on each handler.
|
||||
/// Business values still come from private-server.json.
|
||||
/// </summary>
|
||||
internal sealed class LoginGateRouter
|
||||
{
|
||||
private readonly GateSessionHub? _gateSessions;
|
||||
private static PrivateServerConfig Config => PrivateServerConfigStore.Current;
|
||||
|
||||
internal LoginGateRouter(GateSessionHub? gateSessions = null)
|
||||
=> _gateSessions = gateSessions;
|
||||
|
||||
internal RpcRouter Build()
|
||||
{
|
||||
var router = new RpcRouter("login-gate");
|
||||
MethodId.RegisterKnownNames(router);
|
||||
AttributedHandlerRegistry.Register(router, this);
|
||||
return router;
|
||||
}
|
||||
|
||||
[Handler(MethodId.CheckVersion, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.HasOnlinePlayer, HandlerPacketKind.Invoke)]
|
||||
private Task EmptySuccess(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
[Handler(MethodId.AskNewHotFixPatchLogin, HandlerPacketKind.Invoke)]
|
||||
private Task AskNewHotFixPatchLogin(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnEmptyAsync(msg, Config.Client.NoMoreHotfixPatchError);
|
||||
|
||||
[Handler(MethodId.RequestPatchesCheckDataFromLogin, HandlerPacketKind.Invoke)]
|
||||
private Task RequestPatchesCheckDataFromLogin(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnAsync(msg, LoginCodec.PatchCheck());
|
||||
|
||||
[Handler(MethodId.RequestPatchesFromLogin, HandlerPacketKind.Invoke)]
|
||||
private Task RequestPatchesFromLogin(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnAsync(msg, new List<uint>());
|
||||
|
||||
[Handler(MethodId.AskUniSdkShareToken_Login, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskUniSdkShareToken_Gate, HandlerPacketKind.Invoke)]
|
||||
private Task AskUniSdkShareToken(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnAsync(msg, Config.Player.ShareToken);
|
||||
|
||||
[Handler(MethodId.RequestFpPassToken, HandlerPacketKind.Invoke)]
|
||||
private Task RequestFpPassToken(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnAsync(msg, Config.Player.FpPassToken);
|
||||
|
||||
[Handler(MethodId.CheckAccount, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.CheckAccountPassBy, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.CheckAccountOpenId, HandlerPacketKind.Invoke)]
|
||||
private Task CheckAccount(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnAsync(msg, LoginCodec.CheckAccount());
|
||||
|
||||
[Handler(MethodId.TryLogin, HandlerPacketKind.Invoke)]
|
||||
private async Task TryLogin(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await conn.NotifyAsync(MethodId.SyncRoleList, Profile.PlayerPid);
|
||||
}
|
||||
|
||||
[Handler(MethodId.RequestCreateRoleEx, HandlerPacketKind.Invoke)]
|
||||
private async Task RequestCreateRoleEx(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
await conn.ReturnAsync(msg, Profile.PlayerPid);
|
||||
await conn.NotifyAsync(MethodId.SyncRoleList, Profile.PlayerPid);
|
||||
}
|
||||
|
||||
[Handler(MethodId.RequestEnterGame, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.DebugRequestEnterGame, HandlerPacketKind.Invoke)]
|
||||
private Task RequestEnterGame(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnAsync(msg, LoginCodec.EnterGame(Config.Network.AdvertisedHost, Config.Network.LoginPorts[0]));
|
||||
|
||||
[Handler(MethodId.AskCloseConnectionToGate, HandlerPacketKind.Invoke)]
|
||||
private Task GateAskCloseConnection(Connection conn, UxRpcMessage msg) => conn.ReturnAsync(msg, 0u);
|
||||
|
||||
[Handler(MethodId.GetServerTime)]
|
||||
private async Task GateGetServerTime(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.IsInvoke)
|
||||
{
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
var args = msg.GetArgs<GameMethods.GetServerTime>();
|
||||
await conn.NotifyAsync(MethodId.SendServerTime, LoginCodec.ServerTime(args.clientUnixTime));
|
||||
}
|
||||
|
||||
[Handler(MethodId.GetFriendApplicationListToMe, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.GetAllChatGroupLatestMessage, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.GetSimplePlayerInfoByPidList, HandlerPacketKind.Invoke)]
|
||||
private Task GateEmptyList(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnAsync(msg, new List<uint>());
|
||||
|
||||
[Handler(MethodId.QuerySkey, HandlerPacketKind.Invoke)]
|
||||
private Task QuerySkey(Connection conn, UxRpcMessage msg) => conn.ReturnAsync(msg, Config.Player.Skey);
|
||||
|
||||
[Handler(MethodId.ReportNetworkQuality, HandlerPacketKind.Notify)]
|
||||
private static void ReportNetworkQuality4229938(Connection conn, UxRpcMessage msg) { }
|
||||
|
||||
[Handler(MethodId.DavinciCode, HandlerPacketKind.Notify)]
|
||||
private static void DavinciCode(Connection conn, UxRpcMessage msg) { }
|
||||
|
||||
[Handler(MethodId.Login, HandlerPacketKind.Invoke)]
|
||||
private async Task GateLogin(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
// Build 4229938 routes IMasterToClient to ServerMark.Gate. Capture the
|
||||
// authenticated Gate socket here; login and gate can share the same TCP port.
|
||||
_gateSessions?.Touch(conn.Session);
|
||||
conn.Log.Info("[GATE] authenticated session registered for IMasterToClient S2C");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await conn.NotifyAsync(MethodId.SendServerTime, LoginCodec.ServerTime());
|
||||
await conn.NotifyAsync(MethodId.SyncPlayerGameServerInfo,
|
||||
LoginCodec.GameServerInfo(Config.Network.AdvertisedHost, Config.Network.GamePort));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user