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

311 lines
14 KiB
C#
Raw 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.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)");
}
}