712 lines
32 KiB
C#
712 lines
32 KiB
C#
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Rpc;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using Ananta.Server.RpcTypes.Client4229938;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// Seymour 大停电 (Blackout) — 2026-09-23 新增,改编自旧版 donor 文档 Blackout_IMPLEMENTATION_RU.md。
|
||
///
|
||
/// <b>核心原则(donor 原文的关键一句):服务端 запускает нативный граф клиента,不自己做灭灯。</b>
|
||
/// 我们要做的只有三件事:把控制器 Gadget 放进世界、回答它的 deadline、把 0.3 秒的干扰 Creation
|
||
/// 推出去。剩下的灭灯、广告牌换肤、俄罗斯方块 UI、到点收拾,全在客户端自己的图里(GraphId 36003094)。
|
||
///
|
||
/// 与 donor 实现的两处故意差异:
|
||
/// 1. <b>不下发 Lua</b>。donor 用 SyncDoToLuaMaster(45437825) 走 GATE 通道调
|
||
/// DoHackPressClickAction + ReleaseContextEvent 去"替玩家按那个按钮"。本服务器从未实现
|
||
/// GATE Lua 下发(全仓只有 MethodId 常量),为一个 arrests 尚未验证的功能先搭一整条
|
||
/// Lua 通道不划算。所以这里改成:<b>刷出控制器后什么都不做,等客户端自己触发</b>。
|
||
/// 如果 Gadgets_Hacker_Tetris 自带 HackInteract 交互点,玩家走近就能按,链路天然成立。
|
||
/// 这条假设是否成立,就是本次要实测的东西 —— 见 <see cref="StartBlackoutAsync"/> 的日志判据。
|
||
/// 2. <b>电池不无脑补满</b>。donor 把 6/6 当成 dev 简化。但 HackerHackTypeConfig.Id=44 的
|
||
/// HackCost=4,真实服每次大停电要吃掉 4 点。这里保留还原 PRIMA 的口子,但默认仍在进入能力
|
||
/// 快照时满 6/6(既有行为),避免回归。希望能:把 `HackBatteryRecoverySpeed=15` 接进来做真经济。
|
||
///
|
||
/// 三个易混淆的对象(donor §2,务必分清):
|
||
/// · Gadgets_Hacker_Tetris —— 大停电控制器,本文件要的就是它。
|
||
/// · Gadgets_Hacker_BigScreen —— 单独可骇的屏幕/目标,跟这条链无关。
|
||
/// · Creation 56860922 —— 只是 0.3 秒干扰脉冲,<b>不是</b>停电本体。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
// ---- 常量:全部来自 build 4229938 自己的素材,不是 donor 的旧值 ----
|
||
|
||
/// <summary>控制器 pathId。uid 1166913245,与 GraphId 36003094 一并来自
|
||
/// readable_world_dump_4229938_final/05_SCENEITEMS_GADGETS/server_compatible/Gadget.csv
|
||
/// 的第 2936 行;这份 PathMap 里 GraphId 36003094 唯一。</summary>
|
||
internal const int BlackoutControllerPathId = 1166913245;
|
||
|
||
/// <summary>触发大停电的任务图。TaskCatalog.json: Id=60002222 Name="骇入触发大停电"
|
||
/// RelatedRaid=23300888。中文名与 donor 的 task 完全一致。</summary>
|
||
internal const uint BlackoutStartTaskId = 60002222;
|
||
|
||
/// <summary>donor 给的结束任务。TaskCatalog 里是 "!!未命名任务!!"(官方占位),照抄无妨。</summary>
|
||
internal const uint BlackoutEndTaskId = 60002224;
|
||
|
||
/// <summary>SyncPlayerLoadTaskSpoonDebug 的 eventId。</summary>
|
||
internal const uint BlackoutTaskEventId = 34;
|
||
|
||
/// <summary>干扰脉冲。CreationConfig.json: 56860922 "【黑客】大停电-范围干扰" Time=0.3。</summary>
|
||
internal const uint BlackoutCreationId = 56860922;
|
||
|
||
/// <summary>HackerConfig.static.json: HackerTetris_PlayTime = 60。</summary>
|
||
internal const int BlackoutPlaySeconds = 60;
|
||
|
||
/// <summary>
|
||
/// iScale 的封装值。⛑️ 与 <see cref="GameRouter.SpawnGadgetAsync"/> 里普通物件用的 100
|
||
/// <b>不是一回事</b>:donor 明确指出这里是 packed fixed-point(104960100 = 0x6419064,
|
||
/// 高 12 位是整数部分 100),普通放置物件一直用 100 且经banner 实测无异常,而 task-gadget
|
||
/// 这条路径只有 donor 的实机值作证据。留成命名常量便于一条中发现缩了就切。
|
||
/// </summary>
|
||
internal const int BlackoutPackScaleDonor = 104_960_100;
|
||
|
||
/// <summary>运行时 instanceId 段。唯一性即可,donor 用 4.72e12 附近;刻意避开
|
||
/// WorldEntryState.NextSpawnedGadgetId 的 9.5e12 段,免得两个账本串味。</summary>
|
||
private const ulong BlackoutGadgetIdBase = 4_720_000_000_000UL;
|
||
|
||
/// <summary>
|
||
/// ⛑️⛑️ 号段高水位必须落盘(hard rule 104)。原来是 `private static int` + Interlocked,
|
||
/// 服务端一重启就从 4_720_000_000_001 重新发号 —— 而客户端可能仍记着上一轮那个 id
|
||
/// (同一个 gadget 还在场景里 / 交互表还没清)⇒ 同一个 instanceId 被下发两遍,
|
||
/// 客户端把它当成"已存在"直接忽略 ⇒ **控制器刷出来了但骇入点不出现**,
|
||
/// 症状完全不像重号,正是「有时候不起作用」的来源之一。
|
||
/// </summary>
|
||
private const string BlackoutGadgetCounter = "blackout-controller";
|
||
|
||
/// <summary>donor §7:已有未过期的轮次时拒绝再启。</summary>
|
||
private const int BlackoutErrAlreadyRunning = 65401064;
|
||
|
||
/// <summary>donor §7:没有正在进行的轮次。</summary>
|
||
private const int BlackoutErrNotRunning = 65401066;
|
||
|
||
private const string BlackoutStateKey = "client4229938-blackout";
|
||
|
||
/// <summary>下一个控制器 / creation 的 instanceId(落盘,见 <see cref="BlackoutGadgetCounter"/>)。</summary>
|
||
private static ulong NextBlackoutId()
|
||
=> BlackoutGadgetIdBase + unchecked((ulong)IdCounterStore.Next(BlackoutGadgetCounter, 1));
|
||
|
||
/// <summary>Per-session 大停电状态。donor §5 要求按 session + 场景代存,不能做成进程级全局。</summary>
|
||
private sealed class BlackoutRuntimeState
|
||
{
|
||
public readonly object SyncRoot = new();
|
||
public readonly SemaphoreSlim Gate = new(1, 1);
|
||
|
||
public ulong RuntimeInstanceId;
|
||
public bool TaskGraphSent;
|
||
public bool Running;
|
||
public DateTime EndsUtc;
|
||
public bool CreationSent;
|
||
|
||
/// <summary>本轮控制器的落点 —— 补发(re-arm)时要用同一组坐标。</summary>
|
||
public float AtX;
|
||
public float AtY;
|
||
public float AtZ;
|
||
|
||
public TaskCompletionSource<bool>? GadgetLoaded;
|
||
public TaskCompletionSource<bool>? Started;
|
||
public TaskCompletionSource<uint>? Finished;
|
||
}
|
||
|
||
/// <summary>Read-only snapshot for the admin panel — no internal types cross the boundary.</summary>
|
||
internal static (ulong Id, bool Running, DateTime EndsUtc) ProbeBlackout(TcpSession session)
|
||
{
|
||
var rt = GetBlackoutState(session);
|
||
lock (rt.SyncRoot)
|
||
return (rt.RuntimeInstanceId, rt.Running, rt.EndsUtc);
|
||
}
|
||
|
||
private static BlackoutRuntimeState GetBlackoutState(TcpSession session)
|
||
{
|
||
if (session.Items.TryGetValue(BlackoutStateKey, out var raw) && raw is BlackoutRuntimeState existing)
|
||
return existing;
|
||
var created = new BlackoutRuntimeState();
|
||
session.Items[BlackoutStateKey] = created;
|
||
return created;
|
||
}
|
||
|
||
/// <summary>Invalidates everything donor §5 lists: running flag, instance id, all waiters.
|
||
/// Call on scene switch and on re-entry — a stale waiter from the previous scene would let one
|
||
/// round's late packet release a different round.</summary>
|
||
internal static void ResetBlackoutState(TcpSession session)
|
||
{
|
||
var rt = GetBlackoutState(session);
|
||
lock (rt.SyncRoot)
|
||
{
|
||
rt.RuntimeInstanceId = 0;
|
||
rt.Running = false;
|
||
rt.CreationSent = false;
|
||
rt.EndsUtc = default;
|
||
rt.TaskGraphSent = false;
|
||
CompletePending(rt.GadgetLoaded, false);
|
||
CompletePending(rt.Started, false);
|
||
FailPending(rt.Finished);
|
||
rt.GadgetLoaded = null;
|
||
rt.Started = null;
|
||
rt.Finished = null;
|
||
}
|
||
}
|
||
|
||
private static void CompletePending<T>(TaskCompletionSource<T>? tcs, T value)
|
||
{
|
||
if (tcs is null) return;
|
||
try { tcs.TrySetResult(value); } catch { /* waiter already observed */ }
|
||
}
|
||
|
||
/// <summary>Releases any cached waiters without failing them; used when the shape changed but
|
||
/// readers may still be parked.</summary>
|
||
private static void FailPending<T>(TaskCompletionSource<T>? tcs) => CompletePending(tcs, default!);
|
||
|
||
// ------------------------------------------------------------------ 启动序列
|
||
|
||
/// <summary>
|
||
/// donor §6 的完整启动序列,减去 Lua 那一步。
|
||
///
|
||
/// ⛑️ <b>不要在 GAME dispatcher 里 await 这个方法</b>(donor §5 的原话):等待 gadget ACK 会
|
||
/// 占住那条唯一负责读 ACK 的连接。这里本就是给 HTTP/admin 线程调用的,天然安全;将来若有人
|
||
/// 想从 RPC handler 触发,必须先 Task.Run 出去。
|
||
///
|
||
/// 返回值 Message 里带 stages,用于判定卡在哪一环:
|
||
/// <c>ok waiting-for-interact</c> = 控制器已就位且客户端 ack 了 → 现在看客户端是否自己弹交互。
|
||
/// <c>controller-ack-timeout</c> = pathId/原生 DTO/世界未就绪,客户端没认这个物件。
|
||
/// </summary>
|
||
/// <param name="atOverride">
|
||
/// 面板指定的落点。给了就以它为准;否则按配置走(默认刷在广告牌上,见
|
||
/// <c>Gameplay.BlackoutControllerAtBillboard</c>)。
|
||
/// </param>
|
||
internal static async Task<(bool Ok, string Message, ulong Id)> StartBlackoutAsync(
|
||
TcpSession session, WorldEntryState state, Vec3? atOverride = null)
|
||
{
|
||
var rt = GetBlackoutState(session);
|
||
|
||
await rt.Gate.WaitAsync();
|
||
try
|
||
{
|
||
ulong previous;
|
||
bool running;
|
||
lock (rt.SyncRoot)
|
||
{
|
||
previous = rt.RuntimeInstanceId;
|
||
running = rt.Running && DateTime.UtcNow < rt.EndsUtc;
|
||
}
|
||
if (running)
|
||
return (false, "already-running", previous);
|
||
|
||
// Never hold two controllers in the same scene (donor §6.3).
|
||
if (previous != 0)
|
||
await RemoveBlackoutControllerAsync(session, previous);
|
||
|
||
lock (rt.SyncRoot)
|
||
{
|
||
rt.RuntimeInstanceId = 0;
|
||
rt.CreationSent = false;
|
||
rt.Running = false;
|
||
rt.TaskGraphSent = false;
|
||
}
|
||
|
||
// donor §6.2 说"每个场景代发一次"。这里改成每次启动都发:重进 / 换场景后客户端的
|
||
// 任务图很可能已经不在了,而这个包本身幂等,重发比"以为还在"便宜得多。
|
||
// ⛑️ 不发空 SyncPlayerAllTask —— donor 明确点过,我们这边也已知
|
||
// bootstrap currentTask=0 会让客户端出新问题。
|
||
{
|
||
await session.NotifyAsync(
|
||
MethodId.SyncPlayerLoadTaskSpoonDebug,
|
||
UxSerializer.Serialize(new SceneMethods.SyncPlayerLoadTaskSpoonDebugArgs4229938
|
||
{
|
||
taskId = BlackoutStartTaskId,
|
||
eventId = BlackoutTaskEventId,
|
||
}),
|
||
CancellationToken.None);
|
||
lock (rt.SyncRoot) rt.TaskGraphSent = true;
|
||
session.Log.Info($"[BLACKOUT] task graph sent task={BlackoutStartTaskId} event={BlackoutTaskEventId}");
|
||
}
|
||
|
||
var id = NextBlackoutId();
|
||
var ack = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
lock (rt.SyncRoot)
|
||
{
|
||
rt.RuntimeInstanceId = id;
|
||
rt.GadgetLoaded = ack;
|
||
}
|
||
|
||
// donor §6.3 要的是 identity transform (0,0,0):Gadgets_Hacker_Tetris 的画面是 prefab 里
|
||
// authored 的 6 块广告牌,坐标该由 prefab 决定。
|
||
// ⛔ 但实测这条路玩家根本碰不到按钮 —— 交互锚点落在世界原点,而玩家坐标是几千米级
|
||
// (实测 (3950, 0, 1817)),距离 4 km+,"走到跟前按 F"物理上不可能,于是"点了没反应"。
|
||
// ⇒ 默认改成刷在玩家脚下(PrivateServerConfig.Gameplay.BlackoutControllerAtPlayer),
|
||
// 先把 AskHack(44) → AskStartHackerTetris 这段链路跑通;等 GATE Lua(45437825) 那条
|
||
// 自动代按的路打通,再把这个开关拨回 false 恢复 donor 原文。
|
||
// 落点优先级:面板指定 > 广告牌坐标(默认) > 玩家脚下 > 世界原点。
|
||
// 官方玩法是「对着广告牌骇入」,所以默认必须落在广告牌上 —— 刷在玩家脚下会得到
|
||
// 「距离恒为 0 ⇒ 站哪都能骇入」的假象,与官方不符(2026-09-23 用户实测+定论)。
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
var at =
|
||
atOverride ??
|
||
(cfg.BlackoutControllerAtBillboard
|
||
? new Vec3(cfg.BlackoutBillboardX, cfg.BlackoutBillboardY, cfg.BlackoutBillboardZ)
|
||
: cfg.BlackoutControllerAtPlayer
|
||
? state.HasLastReportedPlayerTransform
|
||
? state.LastReportedPlayerPosition
|
||
: Profile.WorldSpawn
|
||
: new Vec3(0, 0, 0));
|
||
|
||
lock (rt.SyncRoot)
|
||
{
|
||
rt.AtX = at.X;
|
||
rt.AtY = at.Y;
|
||
rt.AtZ = at.Z;
|
||
}
|
||
|
||
await PushBlackoutControllerAsync(session, id, at);
|
||
|
||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(BlackoutAckTimeoutSeconds));
|
||
var loaded = false;
|
||
try
|
||
{
|
||
loaded = await ack.Task.WaitAsync(cts.Token);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
loaded = false;
|
||
}
|
||
|
||
if (!loaded)
|
||
{
|
||
session.Log.Warn($"[BLACKOUT] controller-ack-timeout id={id} seconds={BlackoutAckTimeoutSeconds} — suspect native DTO / pathId / world not gameplay-ready. Cleaning up.");
|
||
await RemoveBlackoutControllerAsync(session, id);
|
||
lock (rt.SyncRoot)
|
||
{
|
||
rt.RuntimeInstanceId = 0;
|
||
rt.GadgetLoaded = null;
|
||
FailPending(rt.Started);
|
||
rt.Started = null;
|
||
}
|
||
return (false, "controller-ack-timeout", id);
|
||
}
|
||
|
||
session.Log.Info($"[BLACKOUT] controller acked id={id} — waiting for the client to trigger AskStartHackerTetris on its own (no Lua push in this port)");
|
||
|
||
// 「摆了控制器但骇入点不出现」的自愈:见 ReArmBlackoutControllerAsync 的说明。
|
||
_ = ReArmBlackoutControllerAsync(session, rt);
|
||
|
||
return (true, "ok waiting-for-interact", id);
|
||
}
|
||
finally
|
||
{
|
||
rt.Gate.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>把控制器(重新)放进世界。首次下发与 re-arm 补发共用同一段,避免两处漂移。</summary>
|
||
private static async Task PushBlackoutControllerAsync(TcpSession session, ulong id, Vec3 at)
|
||
{
|
||
var info = new SceneMethods.GadgetEntityInfo
|
||
{
|
||
InstanceId = id,
|
||
Position = new() { X = at.X, Y = at.Y, Z = at.Z },
|
||
Facing = new() { X = 0f, Y = 0f, Z = 0f },
|
||
NavId = 0,
|
||
ForceLod0 = false,
|
||
IsTask = true,
|
||
Pack = new SceneMethods.PackedGadgetInfo
|
||
{
|
||
posX = at.X,
|
||
posY = at.Y,
|
||
posZ = at.Z,
|
||
eulerX = 0f,
|
||
eulerY = 0f,
|
||
eulerZ = 0f,
|
||
iScale = BlackoutPackScaleDonor,
|
||
uniqueId = id,
|
||
pathId = BlackoutControllerPathId,
|
||
delayDestroy = false,
|
||
startTaskId = BlackoutStartTaskId,
|
||
endTaskId = BlackoutEndTaskId,
|
||
extractionItemContainerId = 0,
|
||
isStatic = false,
|
||
},
|
||
};
|
||
|
||
await session.NotifyAsync(
|
||
MethodId.SyncGadgetAOIAddAndRemove,
|
||
UxSerializer.Serialize(new SceneMethods.SyncGadgetAOIAddAndRemoveArgs4229938
|
||
{
|
||
addInfos = [info],
|
||
addPackSyncInfos = [],
|
||
removeIds = [],
|
||
activeIds = [id],
|
||
inacticeIds = [],
|
||
reason = BlackoutAoiReason,
|
||
}),
|
||
CancellationToken.None);
|
||
session.Log.Info($"[BLACKOUT] controller pushed id={id} pathId={BlackoutControllerPathId} task={BlackoutStartTaskId}->{BlackoutEndTaskId} scale={BlackoutPackScaleDonor} reason={BlackoutAoiReason} at=({at.X:F1},{at.Y:F1},{at.Z:F1})");
|
||
}
|
||
|
||
/// <summary>
|
||
/// ⭐ 「摆了控制器但站到广告牌下没有骇入点」的自愈(2026-09-24 用户反馈「有时候不起作用」)。
|
||
///
|
||
/// 现象与判据:服务端侧每一步都成功(`[BLACKOUT] gadget ack matched=True`、
|
||
/// `controller acked`),但玩家走到落点看不到骇入提示 ⇒ 说明客户端把这条 add
|
||
/// **丢掉了**,而服务端无从感知。已定位的两条成因:
|
||
/// 1. <b>重号</b>:instanceId 原来是进程内计数器,服务端重启后从头发号,
|
||
/// 与客户端仍记得的旧 id 撞车 ⇒ 被当成"已存在"忽略(已改落盘,见
|
||
/// <see cref="BlackoutGadgetCounter"/>)。
|
||
/// 2. <b>下发时玩家不在落点附近</b>:面板「启动」常在玩家还在几百米外时按下,
|
||
/// 客户端按自己的流式/AOI 规则把物件丢掉,之后玩家传送过去也不会再加载。
|
||
///
|
||
/// ⇒ 所以在轮次真正开始前(<c>AskStartHackerTetris</c> 到达)周期性补发同一条 add,
|
||
/// 用同一个 id 和同一组坐标(幂等:重复 add 同一个 id 不会产生第二个物件)。
|
||
/// 补发在 <c>BlackoutRearmMax</c> 次后停止,避免变成永久后台推送(hard rule 37)。
|
||
/// </summary>
|
||
private static async Task ReArmBlackoutControllerAsync(TcpSession session, BlackoutRuntimeState rt)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
var max = Math.Max(0, cfg.BlackoutRearmMax);
|
||
if (max == 0)
|
||
return;
|
||
var delay = TimeSpan.FromSeconds(Math.Max(2, cfg.BlackoutRearmSeconds));
|
||
|
||
ulong id;
|
||
Vec3 at;
|
||
lock (rt.SyncRoot)
|
||
{
|
||
id = rt.RuntimeInstanceId;
|
||
at = new Vec3(rt.AtX, rt.AtY, rt.AtZ);
|
||
}
|
||
if (id == 0)
|
||
return;
|
||
|
||
for (var attempt = 1; attempt <= max; attempt++)
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(delay);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
return;
|
||
}
|
||
|
||
bool started, live;
|
||
lock (rt.SyncRoot)
|
||
{
|
||
started = rt.Running; // AskStartHackerTetris 已到达 ⇒ 不用再补
|
||
live = rt.RuntimeInstanceId == id; // 被 finish/复位换掉了 ⇒ 停手
|
||
}
|
||
if (started || !live)
|
||
return;
|
||
|
||
// 客户端挂起时不推(hard rule 37)。
|
||
if (IsBackgroundPushPaused(session))
|
||
continue;
|
||
|
||
try
|
||
{
|
||
await PushBlackoutControllerAsync(session, id, at);
|
||
session.Log.Info($"[BLACKOUT] re-arm {attempt}/{max} id={id}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[BLACKOUT] re-arm {attempt}/{max} failed id={id}: {ex.Message}");
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>AoiAddAndRemoveReason.AOIMove — GameRouter.Gadget.cs 实测在用的值。
|
||
/// donor 文档写的是 1,两者不一致时以本服务端已验证的 2 为准。</summary>
|
||
private const int BlackoutAoiReason = 2;
|
||
|
||
/// <summary>donor §6.4 用 20 秒。</summary>
|
||
private const int BlackoutAckTimeoutSeconds = 20;
|
||
|
||
/// <summary>Direct-AOI removal of our own controller (donor §9). Never touch other scene objects
|
||
/// here — flipping "all light sources" back on loses the scene's authored state.</summary>
|
||
internal static async Task RemoveBlackoutControllerAsync(TcpSession session, ulong id)
|
||
{
|
||
if (id == 0) return;
|
||
try
|
||
{
|
||
await session.NotifyAsync(
|
||
MethodId.SyncGadgetAOIAddAndRemove,
|
||
UxSerializer.Serialize(new SceneMethods.SyncGadgetAOIAddAndRemoveArgs4229938
|
||
{
|
||
addInfos = [],
|
||
addPackSyncInfos = [],
|
||
removeIds = [id],
|
||
activeIds = [],
|
||
inacticeIds = [id],
|
||
reason = BlackoutAoiReason,
|
||
}),
|
||
CancellationToken.None);
|
||
session.Log.Info($"[BLACKOUT] controller removed id={id}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[BLACKOUT] controller removal failed id={id}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------ C2S 三个回合
|
||
|
||
/// <summary>
|
||
/// AskTaskGadgetsListLoadComplete(67359333) — 客户端确认<b>具体这个控制器</b>加载完了。
|
||
///
|
||
/// ⛑️ donor §6.4 的警告在这里落实:<b>不能把任何 ACK 当成大停电确认</b>。列表里必须包含本次
|
||
/// 的 runtimeInstanceId 才放行 waiter。
|
||
///
|
||
/// 这个 MethodId 在 Lua 导出表里是 CSharpOnly,所以布局没有 Lua 侧证据兜底。解析失败时我们
|
||
/// 记日志并只回空成功 —— 表现为干净的超时,而不是把相邻字段读错。
|
||
/// </summary>
|
||
[Handler(MethodId.AskTaskGadgetsListLoadComplete)]
|
||
private async Task OnTaskGadgetsListLoadComplete(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var rt = GetBlackoutState(conn.Session);
|
||
ulong expected;
|
||
bool matched = false;
|
||
lock (rt.SyncRoot) expected = rt.RuntimeInstanceId;
|
||
|
||
if (expected == 0)
|
||
{
|
||
// Nobody asked for a controller; a stray ack is harmless but worth a line.
|
||
conn.Log.Info($"[BLACKOUT] gadget ack ignored: nothing pending bytes={msg.Body.Length}");
|
||
}
|
||
else if (msg.TryGetArgs<SceneMethods.AskTaskGadgetsListLoadCompleteArgs4229938>(out var args)
|
||
&& args?.gadgetIds is { } ids)
|
||
{
|
||
matched = ids.Contains(expected);
|
||
conn.Log.Info($"[BLACKOUT] gadget ack ids=[{string.Join(",", ids)}] expected={expected} matched={matched}");
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Warn($"[BLACKOUT] gadget ack undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)} expected={expected} — treating as no-ack (will time out cleanly)");
|
||
}
|
||
|
||
if (matched)
|
||
{
|
||
TaskCompletionSource<bool>? ack;
|
||
lock (rt.SyncRoot)
|
||
{
|
||
ack = rt.GadgetLoaded;
|
||
rt.GadgetLoaded = null;
|
||
}
|
||
CompletePending(ack, true);
|
||
}
|
||
|
||
if (msg.IsInvoke)
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskTaskPlateLoadComplete(67794218) — 可选的 plate ack。缺了不算失败(donor §6.2),
|
||
/// 所以这里只登记,不释放任何 waiter。
|
||
/// </summary>
|
||
[Handler(MethodId.AskTaskPlateLoadComplete)]
|
||
private async Task OnTaskPlateLoadComplete(Connection conn, UxRpcMessage msg)
|
||
{
|
||
if (msg.TryGetArgs<SceneMethods.AskTaskPlateLoadCompleteArgs4229938>(out var args) && args is not null)
|
||
conn.Log.Info($"[BLACKOUT] task plate loaded task={args.taskId}");
|
||
else
|
||
conn.Log.Info($"[BLACKOUT] task plate ack undecodable bytes={msg.Body.Length}");
|
||
|
||
if (msg.IsInvoke)
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskStartHackerTetris(63521495) — 图的连锁环节跑到这里,说明客户端真的按下去了。
|
||
///
|
||
/// ⛑️⛑️ <b>必须回答一个裸 u32 deadline</b>:客户端 <c>midToReturnMessageReader[63521495]</c>
|
||
/// 无条件执行 <c>reader:ReadUInt32()</c>。空回包让整条链卡住;回 0 则 deadline 立刻到期,
|
||
/// 表现为"刚亮起来就灭了"。
|
||
///
|
||
/// ⛑️ 别在这里 spawn 控制器 —— 这次调用本身就是它的图被触发的<b>结果</b>(donor §7)。
|
||
/// </summary>
|
||
[Handler(MethodId.AskStartHackerTetris)]
|
||
private async Task OnAskStartHackerTetris(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var rt = GetBlackoutState(conn.Session);
|
||
uint deadline;
|
||
|
||
await rt.Gate.WaitAsync();
|
||
try
|
||
{
|
||
DateTime ends;
|
||
bool alreadyRunning;
|
||
lock (rt.SyncRoot)
|
||
{
|
||
alreadyRunning = rt.Running && DateTime.UtcNow < rt.EndsUtc;
|
||
if (alreadyRunning)
|
||
ends = rt.EndsUtc;
|
||
else
|
||
{
|
||
rt.Running = true;
|
||
rt.CreationSent = false;
|
||
rt.EndsUtc = DateTime.UtcNow.AddSeconds(BlackoutPlaySeconds);
|
||
ends = rt.EndsUtc;
|
||
rt.Started = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
FailPending(rt.Finished);
|
||
rt.Finished = new TaskCompletionSource<uint>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
}
|
||
}
|
||
|
||
if (alreadyRunning)
|
||
{
|
||
// donor §7: 拒绝,且不带成功体 —— 客户端这一侧等的是 u32,但重入意味着UI 已经
|
||
// 有计时在跑,给错误码比给一个假 deadline 干净。
|
||
if (msg.IsInvoke) await conn.ReturnEmptyAsync(msg, BlackoutErrAlreadyRunning);
|
||
conn.Log.Warn($"[BLACKOUT] AskStart rejected: a round is already running until {ends:O}");
|
||
return;
|
||
}
|
||
|
||
deadline = (uint)new DateTimeOffset(ends).ToUnixTimeSeconds();
|
||
conn.Log.Info($"[BLACKOUT] round started deadline={deadline} ({BlackoutPlaySeconds}s) controller={(rt.RuntimeInstanceId)} — deadline basis must match TimeManager.ServerUnixTime (real UTC)");
|
||
}
|
||
finally
|
||
{
|
||
rt.Gate.Release();
|
||
}
|
||
|
||
// 四字节裸 u32:UxSerializer 对 typeof(uint) 走 WriteValue 的标量分支,不加任何标记。
|
||
if (msg.IsInvoke)
|
||
await conn.ReturnAsync(msg, deadline);
|
||
|
||
TaskCompletionSource<bool>? started;
|
||
lock (rt.SyncRoot) { started = rt.Started; rt.Started = null; }
|
||
CompletePending(started, true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// ReportHackerTetrisCreation(67652382) — 这是<b>图的进度通知,不是俄罗斯方块通关证明</b>
|
||
/// (donor §7 的标题词)。收到就去推那一下干扰脉冲。
|
||
///
|
||
/// ⛑️ 原子占位再发送:重复 Report / 轮次外的 Report 都必须忽略,否则会出现连续脉冲。
|
||
/// </summary>
|
||
[Handler(MethodId.ReportHackerTetrisCreation)]
|
||
private async Task OnReportHackerTetrisCreation(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var rt = GetBlackoutState(conn.Session);
|
||
var state = GetStateIfExists(conn.Session);
|
||
if (state is null)
|
||
{
|
||
conn.Log.Warn("[BLACKOUT] creation report ignored: no world state");
|
||
return;
|
||
}
|
||
|
||
bool claim;
|
||
ulong controller;
|
||
lock (rt.SyncRoot)
|
||
{
|
||
claim = rt.Running && DateTime.UtcNow < rt.EndsUtc && !rt.CreationSent;
|
||
if (claim) rt.CreationSent = true;
|
||
controller = rt.RuntimeInstanceId;
|
||
}
|
||
if (!claim)
|
||
{
|
||
conn.Log.Info($"[BLACKOUT] creation report duplicate/out-of-round bytes={msg.Body.Length} — ignored");
|
||
return;
|
||
}
|
||
|
||
var instanceId = NextBlackoutId();
|
||
|
||
// donor §7: prefer the live transform; fall back to the world entry point when the client
|
||
// has not reported one yet. Both come from our own store, so no extra handshake is needed.
|
||
var at = state.HasLastReportedPlayerTransform
|
||
? state.LastReportedPlayerPosition
|
||
: Profile.WorldSpawn;
|
||
|
||
// CreationConfig.Time = 0.3s: this is a 0.3-second interference blip, NOT the blackout.
|
||
var creation = new SceneMethods.SceneCreationInfo4229938
|
||
{
|
||
Id = instanceId,
|
||
ParentId = 0,
|
||
TargetId = 0,
|
||
DestructibleId = 0,
|
||
CreationId = BlackoutCreationId,
|
||
ParentPosition = new() { X = at.X, Y = at.Y, Z = at.Z },
|
||
Rotate = 0f,
|
||
ClientEnterOrLeave = true,
|
||
SourceSkillId = 0,
|
||
SourceDestructibleId = 0,
|
||
GadgetId = controller,
|
||
GadgetTransformId = 0,
|
||
};
|
||
|
||
await conn.NotifyAsync(MethodId.SyncAddCreation, UxSerializer.Serialize(creation));
|
||
conn.Log.Info($"[BLACKOUT] creation sent id={instanceId} creationId={BlackoutCreationId} controller={controller} at=({at.X:F1},{at.Y:F1},{at.Z:F1}) source={(state.HasLastReportedPlayerTransform ? "reported" : "world-spawn")}");
|
||
|
||
// Remove the network record shortly after; the graph owns the actual blackout clock.
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(TimeSpan.FromSeconds(1));
|
||
await conn.Session.NotifyAsync(
|
||
MethodId.SyncRemoveCreation,
|
||
UxSerializer.Serialize(instanceId),
|
||
CancellationToken.None);
|
||
conn.Log.Info($"[BLACKOUT] creation network record removed id={instanceId}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
conn.Log.Warn($"[BLACKOUT] creation removal failed id={instanceId}: {ex.Message}");
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskFinishHackerTetris(63554976) — 客户端报告本轮结束。读 u32 score;
|
||
/// <c>uint.MaxValue</c> 是被认可的"取消/无分"哨兵(donor §7)。
|
||
/// </summary>
|
||
[Handler(MethodId.AskFinishHackerTetris)]
|
||
private async Task OnAskFinishHackerTetris(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var rt = GetBlackoutState(conn.Session);
|
||
uint score = 0;
|
||
if (msg.TryGetArgs<SceneMethods.AskFinishHackerTetrisArgs4229938>(out var args) && args is not null)
|
||
score = args.score;
|
||
else
|
||
conn.Log.Info($"[BLACKOUT] finish without decodable score bytes={msg.Body.Length} — defaulting to 0");
|
||
|
||
bool wasRunning;
|
||
TaskCompletionSource<uint>? finished;
|
||
ulong controller;
|
||
lock (rt.SyncRoot)
|
||
{
|
||
wasRunning = rt.Running;
|
||
if (!wasRunning)
|
||
{
|
||
finished = null;
|
||
controller = 0;
|
||
}
|
||
else
|
||
{
|
||
rt.Running = false;
|
||
rt.EndsUtc = default;
|
||
finished = rt.Finished;
|
||
rt.Finished = null;
|
||
controller = rt.RuntimeInstanceId;
|
||
}
|
||
}
|
||
|
||
if (!wasRunning)
|
||
{
|
||
conn.Log.Info($"[BLACKOUT] finish ignored: no round running score={score}");
|
||
if (msg.IsInvoke) await conn.ReturnEmptyAsync(msg, BlackoutErrNotRunning);
|
||
return;
|
||
}
|
||
|
||
conn.Log.Info($"[BLACKOUT] round finished score={score} controller={controller}");
|
||
if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg);
|
||
|
||
// A dropped/expired server flag must still allow the next run, even though clearing our
|
||
// flag does NOT prove the client restored its lighting.
|
||
var _ = RemoveBlackoutControllerAsync(conn.Session, controller);
|
||
lock (rt.SyncRoot) rt.RuntimeInstanceId = 0;
|
||
CompletePending(finished, score);
|
||
}
|
||
}
|