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;
///
/// 搜打撤的 GM / 面板面 —— 手动进出图、出场结算、状态输出、轮盘内容列举。
///
/// 玩法逻辑本身在 GameRouter.Extraction.cs(背包/容器/搜索)与
/// GameRouter.ExtractionWeapons.cs(轮盘实体化);这里只放「人要看 / 人要点」的那一层。
///
/// ⛑ 三条排障常识(面板帮不上忙的那些):
/// 1. 改了没生效 → 先比对源码 mtime 与 logs/console-latest.log 里的服务端启动时间,
/// 确认这一版真的被跑过(同一天里踩过两次:跑的是改之前的 dll)。
/// 2. [Handler] ≠ 已注册 → 还要进 GameRouter.cs 的 Enabled4229938MethodIds,
/// 漏了的判据是日志那行 unimplemented … typed-default void 0b。
/// 3. 某一片 UI 整体空 → 客户端日志搜 attempt to perform arithmetic /
/// BindingWidgetShow failing,一个 nil 算术会崩掉整棵 store 树,不是只坏一个面板。
///
internal sealed partial class GameRouter
{
// ------------------------------------------------------ runtime-overridable switches
// 面板上的勾选项改内存里这四份,不必改 json、不必重启;重启之后回落到配置文件的值。
private static bool? s_extractionFakeMatchOverride;
private static uint? s_extractionPreferredRaidOverride;
/// 「伪造匹配」开关 —— 见 GameRouter.ExtractionMatch.cs 顶部说明。
internal static bool ExtractionFakeMatch
{
get => s_extractionFakeMatchOverride ?? ExtractionCfg().FakeMatch;
set
{
s_extractionFakeMatchOverride = value;
Console.WriteLine($"[EXTRACT] fake match = {value}");
}
}
/// 伪造匹配要去的 raid。
internal static uint ExtractionPreferredRaid
{
get => s_extractionPreferredRaidOverride ?? ExtractionCfg().PreferredRaid;
set
{
s_extractionPreferredRaidOverride = value;
Console.WriteLine($"[EXTRACT] preferred raid = {value}");
}
}
// ------------------------------------------------------------------ enter / exit
///
/// 把人切进一张搜打撤图(面板「进图」按钮与伪造匹配共用这一条)。
///
/// 为什么不能直接改 state.ActiveRaidId:只有 SwitchSceneAsync 会重新起一次
/// 进图事务(推新的 world-entry generation、重推 SyncEnterScene、重建角色控制),
/// 手动改字段的结果是「服务端认为进图了、客户端还站在老图上」。
/// 进图之后的搜打撤专属包(SyncPlayerEnterExtractionShooter 空体 + 各包容量)由
/// GameRouter.World.cs 在事务收尾时调 PushExtractionEnterAsync 补,
/// 和真实客户端自己走 EnterRaid 那条路完全同路径 —— 这里不再重复推一次。
///
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})");
}
///
/// 出场结算 SyncRaidSettlement(68995324):退图之前推,让客户端走完整的结算流程。
///
/// ⛑ 线格式见 的注释 ——
/// 零战斗数据恒 32 字节,这是唯一能肉眼核对的东西;字节数一变,说明 complex 被写成 Inline 了。
/// 我们不填战斗数据(SpiritBattleDatas 恒空):既没抓到过样本,
/// 也不值得为一段 UI 动画去碰 Auto.Reader[1334] 那套未知布局。
///
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
///
/// GET /api/extraction/status 的返回体。
///
/// 字段命名用小写驼峰 ⇒ 与前端 JS 一一对应;这里的形状改动时记得同步
/// AdminWebServer.cs 里 exStatus() 那段,它是唯一消费者。
///
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