This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.ExtractionAdmin.cs
T

310 lines
14 KiB
C#
Raw Normal View History

using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 搜打撤的 GM / 面板面 —— 手动进出图、出场结算、状态输出、轮盘内容列举。
///
/// 玩法逻辑本身在 <c>GameRouter.Extraction.cs</c>(背包/容器/搜索)与
/// <c>GameRouter.ExtractionWeapons.cs</c>(轮盘实体化);这里只放「人要看 / 人要点」的那一层。
///
/// ⛑ 三条排障常识(面板帮不上忙的那些):
/// 1. <b>改了没生效</b> → 先比对源码 mtime 与 <c>logs/console-latest.log</c> 里的服务端启动时间,
/// 确认这一版真的被跑过(同一天里踩过两次:跑的是改之前的 dll)。
/// 2. <b>[Handler] ≠ 已注册</b> → 还要进 <c>GameRouter.cs</c> 的 <c>Enabled4229938MethodIds</c>,
/// 漏了的判据是日志那行 <c>unimplemented … typed-default void 0b</c>。
/// 3. <b>某一片 UI 整体空</b> → 客户端日志搜 <c>attempt to perform arithmetic</c> /
/// <c>BindingWidgetShow failing</c>,一个 nil 算术会崩掉整棵 store 树,不是只坏一个面板。
/// </summary>
internal sealed partial class GameRouter
{
// ------------------------------------------------------ runtime-overridable switches
// 面板上的勾选项改内存里这四份,不必改 json、不必重启;重启之后回落到配置文件的值。
private static bool? s_extractionFakeMatchOverride;
private static uint? s_extractionPreferredRaidOverride;
/// <summary>「伪造匹配」开关 —— 见 <c>GameRouter.ExtractionMatch.cs</c> 顶部说明。</summary>
internal static bool ExtractionFakeMatch
{
get => s_extractionFakeMatchOverride ?? ExtractionCfg().FakeMatch;
set
{
s_extractionFakeMatchOverride = value;
Console.WriteLine($"[EXTRACT] fake match = {value}");
}
}
/// <summary>伪造匹配要去的 raid。</summary>
internal static uint ExtractionPreferredRaid
{
get => s_extractionPreferredRaidOverride ?? ExtractionCfg().PreferredRaid;
set
{
s_extractionPreferredRaidOverride = value;
Console.WriteLine($"[EXTRACT] preferred raid = {value}");
}
}
// ------------------------------------------------------------------ enter / exit
/// <summary>
/// 把人切进一张搜打撤图(面板「进图」按钮与伪造匹配共用这一条)。
///
/// 为什么不能直接改 <c>state.ActiveRaidId</c>:只有 <c>SwitchSceneAsync</c> 会重新起一次
/// 进图事务(推新的 world-entry generation、重推 SyncEnterScene、重建角色控制),
/// 手动改字段的结果是「服务端认为进图了、客户端还站在老图上」。
/// 进图之后的搜打撤专属包(<c>SyncPlayerEnterExtractionShooter</c> 空体 + 各包容量)由
/// <c>GameRouter.World.cs</c> 在事务收尾时调 <c>PushExtractionEnterAsync</c> 补,
/// 和真实客户端自己走 EnterRaid 那条路完全同路径 —— 这里不再重复推一次。
/// </summary>
internal static async Task<(bool Ok, string Message)> EnterExtractionRaidAsync(
TcpSession session, WorldEntryState state, uint raidId)
{
var cfg = ExtractionCfg();
if (!cfg.Enabled)
return (false, "搜打撤整套已关(world.content.extraction.enabled=false)");
if (!IsExtractionRaid(raidId))
return (false, $"raid {raidId} 不在搜打撤名单里(world.content.extraction.raidIds)");
if (!GameAdminBridge.TryGetScenePreset(raidId, out var preset) || preset is null)
return (false, $"raid {raidId} 没有收录进场点位 —— 先在「场景切换」里确认这条 preset 存在");
var owner = EnsureExtractionSeeded(session);
await SwitchSceneAsync(session, preset.RaidId, preset.InstanceId, preset.UniverseId,
preset.X, preset.Y, preset.Z, preset.Facing, "extraction-enter");
session.Log.Info($"{ExtractionTag} enter raid={preset.RaidId} scene={preset.InstanceId} " +
$"@ ({preset.X}, {preset.Y}, {preset.Z}) bags={owner.Bags.Count}");
return (true, $"已切进 raid {preset.RaidId}(scene {preset.InstanceId}," +
$"落点 {preset.X}/{preset.Y}/{preset.Z})");
}
/// <summary>
/// 出场结算 <c>SyncRaidSettlement</c>(68995324):退图之前推,让客户端走完整的结算流程。
///
/// ⛑ 线格式见 <see cref="SceneMethods.SyncRaidSettlementArgs4229938"/> 的注释 ——
/// <b>零战斗数据恒 32 字节</b>,这是唯一能肉眼核对的东西;字节数一变,说明 complex 被写成 Inline 了。
/// 我们不填战斗数据(<c>SpiritBattleDatas</c> 恒空):既没抓到过样本,
/// 也不值得为一段 UI 动画去碰 <c>Auto.Reader[1334]</c> 那套未知布局。
/// </summary>
internal static async Task PushExtractionSettlementAsync(TcpSession session, bool isWin)
{
var owner = ExtractionStateOf(session);
uint enterRaidTime;
lock (owner.SyncRoot)
enterRaidTime = owner.EnterRaidSeconds;
var state = GetStateIfExists(session);
ulong instanceId = 0;
if (state is not null)
lock (state.SyncRoot)
instanceId = state.ActiveInstanceId;
var now = NowSeconds();
await session.NotifyAsync(MethodId.SyncRaidSettlement,
UxSerializer.Serialize(new SceneMethods.SyncRaidSettlementArgs4229938
{
InstanceId = instanceId,
IsWin = isWin,
Settlement = new SceneMethods.ExtractionShooterSettlementData
{
EnterRaidTime = enterRaidTime,
BattleTime = enterRaidTime == 0 ? 0d : Math.Max(0, now - enterRaidTime),
ElementEffectCount = 0,
},
CloseTime = now,
}), CancellationToken.None);
session.Log.Info($"{ExtractionTag} settlement pushed instance={instanceId} win={isWin} " +
$"battleseconds={(enterRaidTime == 0 ? 0 : Math.Max(0, now - enterRaidTime))}");
}
// ------------------------------------------------------------------ panel state
/// <summary>
/// <c>GET /api/extraction/status</c> 的返回体。
///
/// 字段命名用小写驼峰 ⇒ 与前端 JS 一一对应;这里的形状改动时记得同步
/// <c>AdminWebServer.cs</c> 里 <c>exStatus()</c> 那段,它是唯一消费者。
/// </summary>
internal static object ExtractionStateForPanel()
{
var cfg = ExtractionCfg();
var target = GameAdminBridge.RequirePlayerSession();
ExtractionShooterSessionState owner;
WorldEntryState? state = null;
uint activeRaid = 0;
if (target is null)
{
owner = new ExtractionShooterSessionState();
}
else
{
owner = ExtractionStateOf(target.Value.Session);
state = target.Value.State;
lock (state.SyncRoot)
activeRaid = state.ActiveRaidId;
}
var inRaid = IsExtractionRaid(activeRaid);
var content = ExtractionWheelContent(owner);
return new
{
enabled = cfg.Enabled,
raidIds = cfg.RaidIds.Select(id => id.ToString()).ToArray(),
activeRaid = activeRaid.ToString(),
inExtractionRaid = inRaid,
fakeMatch = ExtractionFakeMatch,
preferredRaid = ExtractionPreferredRaid.ToString(),
exitToMainCity = cfg.ExitToMainCity,
exitSettlement = cfg.ExitSettlement,
catalogError = ExtractionShooterCatalog4229938.LoadError,
online = target is not null,
bags = BagRows(owner),
containers = owner.Containers.Count,
tokens = owner.TokenCount,
wheel = new
{
mode = ExtractionWheelSync.ToString(),
weaponData = ExtractionShooterRuntime4229938.WheelWeaponDataEnabled,
bagId = ExtractionShooterCatalog4229938.WheelBagId,
slotCells = ExtractionShooterRuntime4229938.WheelSlotCells4229938,
homeOffset = ExtractionShooterRuntime4229938.WheelHomeOffset4229938,
weapons = content.Select(w => new
{
index = w.Index,
cellX = w.CellX,
itemId = w.WireItemId,
templateId = w.SceneItemTemplateId,
instanceId = w.InstanceId.ToString(),
}).ToArray<object>(),
inert = ExtractionWheelInertItems(owner)
.Select(x => new { cellX = x.CellX, itemId = x.WireItemId }).ToArray<object>(),
},
};
}
/// <summary>每个包一行:id + 当前格数 + 物品数(面板用来确认「到底有没有东西」)。</summary>
private static object[] BagRows(ExtractionShooterSessionState owner)
{
var rows = new List<object>();
lock (owner.SyncRoot)
{
foreach (var bag in owner.Bags.Values)
{
rows.Add(new
{
bagId = bag.BagId,
size = $"{bag.Meta.CapacityX}x{bag.Meta.CapacityY}",
items = bag.Items.Count,
slotGroup = bag.IsSlotGroup,
});
}
}
return rows.ToArray();
}
/// <summary>
/// 轮盘里会被实体化的那些武器(<c>index = CellX + 2</c>)。
/// </summary>
internal static IReadOnlyList<(int Index, uint CellX, uint WireItemId, uint SceneItemTemplateId, ulong InstanceId)>
ExtractionWheelContent(ExtractionShooterSessionState owner)
=> ExtractionShooterRuntime4229938.WheelWeapons(owner)
.Select(w => ((int)(w.CellX + ExtractionShooterRuntime4229938.WheelHomeOffset4229938),
w.CellX, w.WireItemId, w.SceneItemTemplateId, w.InstanceId))
.ToArray();
/// <summary>
/// 轮盘上但不参与 Tab 切枪的物品(牛奶 / 药剂这类 —— 有格子、没有军械实例)。
/// </summary>
internal static IReadOnlyList<(uint CellX, uint WireItemId)> ExtractionWheelInertItems(
ExtractionShooterSessionState owner)
{
var armed = ExtractionShooterRuntime4229938.WheelWeapons(owner)
.Select(w => w.CellX).ToHashSet();
var result = new List<(uint, uint)>();
lock (owner.SyncRoot)
{
if (!owner.Bags.TryGetValue(ExtractionShooterCatalog4229938.WheelBagId, out var bag))
return result;
foreach (var item in bag.Items)
{
var cellX = (uint)item.CellX;
if (armed.Contains(cellX))
continue;
result.Add((cellX, ExtractionShooterCatalog4229938.WireIdFor(item.ConfigId)));
}
}
return result;
}
/// <summary>
/// 面板「重推登录帧」:<c>SyncPlayerInfo</c>(64682159) 再发一次。
///
/// 登录帧里的 <c>InfoMinor.PlayerExtractionShooterInfo</c> 是<b>唯一</b>把包柜塞给客户端的通道
/// (<see cref="ExtractionShooterRuntime4229938.FillLoginInfo"/>),后期再改那些 bag 状态
/// 客户端不会自己来问 ⇒ 排障时想"把整套包结构重来一遍",按这个按钮比重新登录快得多。
/// </summary>
internal static async Task<(bool Ok, string Message)> RepublishExtractionLoginFrameAsync()
{
var target = GameAdminBridge.RequirePlayerSession();
if (target is null)
return (false, "还没有玩家会话(先用游戏客户端登录)");
var (session, state) = target.Value;
await session.NotifyAsync(MethodId.SyncPlayerInfo,
UxSerializer.Serialize(RuntimePayloadFactory.MinimalPlayerInfo4229938(state)), CancellationToken.None);
return (true, "已重推 SyncPlayerInfo(含 PlayerExtractionShooterInfo)");
}
/// <summary>
/// 面板「任意 notify 探针」:把一条<b>裸包</b>按指定 methodId 直接推给客户端。
///
/// 用途:搜打撤还剩的最大空白是<b>撤离点</b> <c>SyncExtractionShooterEvacuationRooms</c> 这类
/// 「不知道客户端到底认什么形状」的包。靠猜契约再编译再进游戏太慢,用这个探针可以直接
/// 往客户端打任意 methodId + 任意 hex,从 <c>Player.log</c> 的
/// <c>deserialize failed</c> / <c>attempt to perform arithmetic</c> 反推它要什么(硬规则 15)。
///
/// ⛑️ 纯调试通道,不写任何状态、不进白名单(S2C 方向本就不需要)。
/// ⛑️ hex 为空 = 推空体,用来试探「客户端有没有这一条的 impl」(空体最不容易崩)。
/// 移植自捐赠端 v1.7(<c>GameRouter.PushExtractionNotifyAsync</c>)。
/// </summary>
internal static async Task<(bool Ok, string Message)> PushExtractionNotifyAsync(uint methodId, string? hex)
{
var target = GameAdminBridge.RequirePlayerSession();
if (target is null)
return (false, "还没有玩家会话(先用游戏客户端登录)");
var payload = Array.Empty<byte>();
if (!string.IsNullOrWhiteSpace(hex))
{
try
{
payload = Convert.FromHexString(hex.Trim());
}
catch (Exception ex)
{
return (false, $"hex 解析失败:{ex.Message}");
}
}
var session = target.Value.Session;
await session.NotifyAsync(methodId, payload, CancellationToken.None);
Console.WriteLine($"[EXTRACT] notify probe methodId={methodId} bytes={payload.Length}");
return (true, $"已推 {methodId}({payload.Length}b)");
}
}