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