Files

792 lines
42 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Ananta.SDK.Rpc;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
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)
{
// 机械羊自己放的原生技能(MeikaShip 只列了传送 51984701):
// 只登记生命周期,不记账、不扣玩家资源、不伪造伤害。
if (await RecordMechanikaSkill4229938(ctx.Session, req)) return;
ctx.Session.Log.Warn($"[COMBAT] reject releaser={req.Releaser} active={state.ActiveSpiritUnitId} skill={req.SkillId}");
return;
}
state.SkillByInstanceId[req.SkillInstanceId] = req.SkillId;
var style = ActiveStyle(state);
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
if (weapon.IsReloadSkill(req.SkillId) || CombatCatalogRepository.SkillCastTag(req.SkillId) == 19u)
await ReloadWeapon4229938(ctx, state, weapon);
// Auxiliary skills authored on the character row itself (FightSpiritConfig.TempSkill) belong
// to no weapon; the client sends them through this same AskUseSkill (that is how Seymour's
// spider/drone summon buttons reach us), so accept whatever the client config authors.
var authoredSkill = AllowsAuthoredCharacterSkill(state.ActiveSpiritTemplateId, req.SkillId);
// ★★ 梅卡妮卡的"羊技能族"(51937721 根 / 51937722 召唤 / 51934941 念力砸击)。
// 这三个 id <b>不在任何 FightSkillConfig 行里</b>(2026-09-27 全表 grep 确认)
// ⇒ weapon.AllowsSkill 一定 false ⇒ 必须在那条校验<b>之前</b>放行,否则客户端按
// SkillReplace 替换后发来的 id 会被 "reject unknown-skill" 吃掉,羊永远召不出来。
var sheepSkill = AllowsMechanikaSheepSkill4229938(state.ActiveSpiritTemplateId, req.SkillId);
if (!weapon.AllowsSkill(style, req.SkillId) && !authoredSkill && !sheepSkill)
{
ctx.Session.Log.Warn($"[COMBAT] reject unknown-skill style={style.Id} weapon={state.ActiveWeaponInstanceId} skill={req.SkillId} inst={req.SkillInstanceId}");
return;
}
if (authoredSkill)
ctx.Session.Log.Info($"[COMBAT] accept temp-skill template={state.ActiveSpiritTemplateId} skill={req.SkillId} inst={req.SkillInstanceId}");
// 黑客 APP 的蜘蛛机器人 / 送货无人机按钮、以及巡卫官执勤终端的机械狗(51938184 /
// 51938186)都是从这条路进来的(GameRouter.HackerSummon.cs 的文件注释里有抓包原文)。
// 服务端此前只记账不动作,点上按钮自然什么都不会出现。
// 召唤成功后它的记账对技能本身没有意义(它会接管操控,不再由玩家本体出招),
// 所以这里直接 return,不落 CombatUseCount / ActiveSkillId。
if (await TryHandleControllableSummonSkill(ctx, req))
return;
// ★ 梅卡妮卡「梅卡定制召唤羊」(51937722)。这是<b>原生直通</b>那条:
// 客户端 SkillJumpConfig 97011851 判出「10 米内没羊」后,会把这次 E 换成这个技能 id 发过来。
// ⛑ 服务端再判一次同样的 10 米(见 SummonMechanikaSheep4229938),两边一致才生成。
if (req.SkillId == MechanikaSummonSkill4229938)
{
var summon = await SummonMechanikaSheep4229938(ctx.Session);
ctx.Session.Log.Info($"[MECHANIKA] summon-request ok={summon.Ok}: {summon.Message}");
return;
}
// ★ 51937721「梅卡定制念力」(根技能)与 51939201(当前 E 实际走的壳子)。
// ⛑ 2026-09-27 17:2x:<b>10 米判定已按用户要求删除</b> —— 这两个 id 一旦到达就直接放一批羊,
// 不再有"范围内已有羊 ⇒ 这次走念力"的分支。
if (req.SkillId == MechanikaMindRootSkill4229938 ||
req.SkillId == MechanikaSheepCastProxySkill4229938)
{
var root = await SummonMechanikaSheep4229938(ctx.Session);
ctx.Session.Log.Info($"[MECHANIKA] cast skill={req.SkillId} ok={root.Ok}: {root.Message}");
return;
}
// ★ 51934941「通用念力砸击」= 原来 10 米内有羊时这次 E 的落点(判据已删,这条基本不会再触发)。
// 纯客户端演出(砸可破坏物),服务端既不生成羊、也不记账(这个技能不在沙箱里)。
if (req.SkillId == MechanikaMindStrikeSkill4229938)
{
ctx.Session.Log.Info($"[MECHANIKA] mind-strike skill={req.SkillId} — 范围内已有羊,这次 E 走念力砸击(不生成)");
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;
// 白靖时缓的第二条触发(捐赠端 7k-v0.5 的做法):挂到当前流派 ActiveSkill(E)上。
// ⛔ 默认<b>关闭</b> —— 2026-09-27 用户拍板走原生驾驶 HUD。
// 实机(14:2x)显示驾驶 HUD 上拿不到那个按钮 ⇒ 开关
// <c>gameplay.baiJingTimeBindActiveSkill</c> 留在这里,置 true 即可用上,不用重编译。
if (PrivateServerConfigStore.Current.Gameplay.BaiJingTimeBindActiveSkill
&& req.SkillId != 0 && req.SkillId == weapon.ActiveSkill(style))
await TryStartBaiJingTime4229938(ctx.Session, state.ActiveSpiritUnitId, "e-active-skill");
// ⛔ 白靖的时缓<b>不挂 E</b>(2026-09-27 用户拍板;挂 E 是捐赠端 7k-v0.5 自己加的)。
// 原生入口是<b>驾驶 HUD 上的职业技能按钮</b>,判据只有一条:
// DriveControlDriverStore.lua:12442 BTN_VALID.PROFESSION_SKILL = self:ShowProfessionSkillButton()
// DriveControlDriverStore.lua:17690 return PlayerUnit and gBuffUtils.HasBuff(Pid, BuffConfig.TimeScaleBadgeBuff)
// 按下 ⇒ BattleMgr.lua:580 ClickProfessionSkill() ⇒ AskAddClientBuff(pid, <b>52959496</b>)(lua 硬编码)
// ⇒ 服务端 TryStartBaiJingTime4229938(GameRouter.Traversal.cs)⇒ SyncSandevistanStart。
// ⛑ 服务端唯一的前置就是把门闸 buff 52959495 挂到玩家单位上(已由 CharacterCapabilities 下发)。
// 那条 <c>BattleMgr.lua:414 CheckShowJobSpecialBtn</c> 是<b>步行</b> HUD 的另一条路,
// 它的调用点(CoreHudCharacterControlStore.lua:286 / :3161)是无参调用 ⇒ show 恒 nil,
// 与骑乘这条无关,不要拿它当「原生不通」的判据。
// ★ 梅卡妮卡 E 兜底。原生链路要求客户端自己把这次 E 换成 51937722(跳转图 97011851);
// 万一那条替换没生效(流派 / 跳转图没跑到梅卡妮卡那一段),这里补一次。
// ⛑ 它走的是与直通那条<b>完全相同的 10 米判据</b>:范围内有羊 ⇒ 什么都不做,
// 这一下 E 就纯粹是念力攻击 —— 不会像捐赠端 v0.5 那样每按一次 E 就再堆羊。
if (req.SkillId != 0 && req.SkillId == weapon.ActiveSkill(style)
&& state.ActiveSpiritTemplateId == MechanikaSpirit4229938)
{
var fallback = await SummonMechanikaSheep4229938(ctx.Session);
ctx.Session.Log.Info($"[MECHANIKA] summon-fallback e-active-skill={req.SkillId} ok={fallback.Ok}: {fallback.Message}");
}
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);
// 机械羊自己的技能收尾:不能落到玩家的连招记账上(否则会补冷却 / 补大招能量)。
if (report.unitId != 0 && report.unitId != state.ActiveSpiritUnitId)
{
RecordMechanikaSkillEnd4229938(ctx.Session, report);
return;
}
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) ||
AllowsAuthoredCharacterSkill(state.ActiveSpiritTemplateId, 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));
}
async Task OnSkillHit(RpcContext ctx, SceneMethods.SkillHitData hit)
{
var state = GetWorldState(ctx);
// Client-spawned enemy intake, deliberately FIRST. The enemy the player fights is
// created client-side and is unknown to this server, so its damage has to be
// settled here; doing it before the weapon-sandbox validation below means a local
// enemy stays defeatable even when the skill is not in the sandbox's allow-list.
// See GameRouter.LocalEnemyCombat.cs for the ledger and the reaction packets.
if (hit.HitTarget != 0)
await TrackLocalEnemyHitAsync(ctx, hit);
if (hit.ReleaserId != 0 && hit.ReleaserId != state.ActiveSpiritUnitId)
{
ctx.Session.Log.Warn($"[HIT] reject releaser={hit.ReleaserId} active={state.ActiveSpiritUnitId} skill={hit.SkillId}");
return;
}
var style = ActiveStyle(state);
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
if (!weapon.AllowsSkill(style, hit.SkillId) &&
!AllowsAuthoredCharacterSkill(state.ActiveSpiritTemplateId, hit.SkillId))
{
ctx.Session.Log.Warn($"[HIT] reject unknown-skill style={style.Id} skill={hit.SkillId} target={hit.HitTarget}");
return;
}
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}");
}
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);
// 搜打撤里 Tab / 滚轮切的是「轮盘枪」,不是角色的城镇 16 槽配装:
// 下标 0/1 仍然是拳头 / 私有武器,≥ 2 落到轮盘 bag 的 CellX = index - 2。
// 客户端侧没有任何 bag ↔ 下标的换算(轮盘界面点击是空的),位置全部由服务端下发的
// CurrWeaponSlots 决定,所以这里按同一口径还原即可。
var inRaid = IsExtractionRaid(state.ActiveRaidId);
var weapon = inRaid
? ExtractionSwitchWeaponAt(ctx.Session, state, index)
: (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} inRaid={inRaid}");
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;
// 轮盘枪是「本局带进来的」,不能写进角色的城镇配装记忆:出图后 PublishCombatProfile 会按
// LastWeaponBySpirit 复原手上这把,记成轮盘枪就会让角色在主城里拿着一把不在 16 槽表里的枪。
if (!inRaid)
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)}");
PlayerProgressStore.Save(state, ctx.Session.Log);
}
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}");
PlayerProgressStore.Save(state, ctx.Session.Log);
}
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}");
PlayerProgressStore.Save(state, ctx.Session.Log);
}
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;
// ⛑ ControlCharacter 而不是 Characters().First():科斯机甲不在花名册里,
// 非当前角色分支下这里会直接抛 "Sequence contains no matching element"(同 04:39 那个坑,
// 只是崩在切人链的下一环)。
var rosterEntry = ClientConfigRepository.ControlCharacter(spiritId);
var unitId = isActive
? state.ActiveSpiritUnitId
: rosterEntry?.UnitId
?? throw new InvalidDataException($"Unknown spirit {spiritId} has no roster row.");
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}");
// Every wheel edit (load / deposit / exchange) is now part of the persisted snapshot.
// Without this the wheel returns to the authored loadout on the next login and the
// equipped weapon stops being selectable.
PlayerProgressStore.Save(state, ctx.Session.Log);
}
private static bool IsEditableWeaponSlot(int index) => index >= 1 && 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);
var infiniteAmmo = InfiniteAmmoEnabled4229938() && weapon.MagazineAmmo > 0;
if (infiniteAmmo)
{
// Infinite ammo: restate the magazine at full capacity so the client never reaches
// the dry state (the one that stops firing and waits for an incomplete reload).
current = weapon.MagazineAmmo;
state.WeaponMagazineAmmo[weapon.InstanceId] = current;
}
else if (current > 0)
{
state.WeaponMagazineAmmo[weapon.InstanceId] = --current;
}
// Separate-bullet firearms consume only the loaded magazine; their reserve is an ordinary
// backpack item. Other firearms use Durability as total remaining ammunition, so decrement
// total and magazine together. Infinite (-1) values stay infinite.
var durability = CurrentDurability4229938(state, weapon, current);
if (infiniteAmmo)
{
// Infinite ammo: freeze the total pool for non-separate guns as well.
if (!weapon.SeparateBullets)
{
if (durability < current)
{
durability = current;
state.WeaponDurabilityAmmo[weapon.InstanceId] = durability;
}
}
else
{
durability = current;
}
}
else if (!weapon.SeparateBullets && durability > 0)
{
durability--;
state.WeaponDurabilityAmmo[weapon.InstanceId] = durability;
}
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} infinite={infiniteAmmo}");
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}");
}
// ===== Infinite ammo (ammo_v2) =============================================
// Stock 4229938 stops firing when the synced magazine reaches 0 and then waits for a reload
// sequence this private server never completes. Restating the magazine at full capacity on
// every shot keeps ammo effectively infinite. Opt out by creating an empty file named
// "infinite-ammo.disabled" next to Ananta.App.exe, then restart the server.
private static long _infiniteAmmoCheckTicks4229938;
private static bool _infiniteAmmoCached4229938 = true;
internal static bool InfiniteAmmoEnabled4229938()
{
var now = System.Environment.TickCount64;
if (now - _infiniteAmmoCheckTicks4229938 < 2000)
return _infiniteAmmoCached4229938;
_infiniteAmmoCheckTicks4229938 = now;
try
{
_infiniteAmmoCached4229938 = !System.IO.File.Exists(
System.IO.Path.Combine(System.AppContext.BaseDirectory, "infinite-ammo.disabled"));
}
catch
{
_infiniteAmmoCached4229938 = true;
}
return _infiniteAmmoCached4229938;
}
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);
}
/// <summary>
/// True when <paramref name="skillId"/> is authored straight on the character row
/// (<c>FightSpiritConfig.TempSkill</c>) instead of on a weapon. Those skills never appear in a
/// weapon's skill list, so the weapon sandbox alone would reject them even though the client
/// legitimately asks for them. Read from the client config so the allow-list tracks whatever the
/// build authors — do not hard-code ids here.
/// </summary>
private static bool AllowsAuthoredCharacterSkill(uint templateId, uint skillId)
=> skillId != 0 && ClientConfigRepository.TempSkills(templateId).Contains(skillId);
}