Files

791 lines
42 KiB
C#
Raw Permalink Normal View History

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);
}