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; /// /// Seymour 大停电 (Blackout) — 2026-09-23 新增,改编自旧版 donor 文档 Blackout_IMPLEMENTATION_RU.md。 /// /// 核心原则(donor 原文的关键一句):服务端 запускает нативный граф клиента,不自己做灭灯。 /// 我们要做的只有三件事:把控制器 Gadget 放进世界、回答它的 deadline、把 0.3 秒的干扰 Creation /// 推出去。剩下的灭灯、广告牌换肤、俄罗斯方块 UI、到点收拾,全在客户端自己的图里(GraphId 36003094)。 /// /// 与 donor 实现的两处故意差异: /// 1. 不下发 Lua。donor 用 SyncDoToLuaMaster(45437825) 走 GATE 通道调 /// DoHackPressClickAction + ReleaseContextEvent 去"替玩家按那个按钮"。本服务器从未实现 /// GATE Lua 下发(全仓只有 MethodId 常量),为一个 arrests 尚未验证的功能先搭一整条 /// Lua 通道不划算。所以这里改成:刷出控制器后什么都不做,等客户端自己触发。 /// 如果 Gadgets_Hacker_Tetris 自带 HackInteract 交互点,玩家走近就能按,链路天然成立。 /// 这条假设是否成立,就是本次要实测的东西 —— 见 的日志判据。 /// 2. 电池不无脑补满。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 秒干扰脉冲,不是停电本体。 /// internal sealed partial class GameRouter { // ---- 常量:全部来自 build 4229938 自己的素材,不是 donor 的旧值 ---- /// 控制器 pathId。uid 1166913245,与 GraphId 36003094 一并来自 /// readable_world_dump_4229938_final/05_SCENEITEMS_GADGETS/server_compatible/Gadget.csv /// 的第 2936 行;这份 PathMap 里 GraphId 36003094 唯一。 internal const int BlackoutControllerPathId = 1166913245; /// 触发大停电的任务图。TaskCatalog.json: Id=60002222 Name="骇入触发大停电" /// RelatedRaid=23300888。中文名与 donor 的 task 完全一致。 internal const uint BlackoutStartTaskId = 60002222; /// donor 给的结束任务。TaskCatalog 里是 "!!未命名任务!!"(官方占位),照抄无妨。 internal const uint BlackoutEndTaskId = 60002224; /// SyncPlayerLoadTaskSpoonDebug 的 eventId。 internal const uint BlackoutTaskEventId = 34; /// 干扰脉冲。CreationConfig.json: 56860922 "【黑客】大停电-范围干扰" Time=0.3。 internal const uint BlackoutCreationId = 56860922; /// HackerConfig.static.json: HackerTetris_PlayTime = 60。 internal const int BlackoutPlaySeconds = 60; /// /// iScale 的封装值。⛑️ 与 里普通物件用的 100 /// 不是一回事:donor 明确指出这里是 packed fixed-point(104960100 = 0x6419064, /// 高 12 位是整数部分 100),普通放置物件一直用 100 且经banner 实测无异常,而 task-gadget /// 这条路径只有 donor 的实机值作证据。留成命名常量便于一条中发现缩了就切。 /// internal const int BlackoutPackScaleDonor = 104_960_100; /// 运行时 instanceId 段。唯一性即可,donor 用 4.72e12 附近;刻意避开 /// WorldEntryState.NextSpawnedGadgetId 的 9.5e12 段,免得两个账本串味。 private const ulong BlackoutGadgetIdBase = 4_720_000_000_000UL; /// /// ⛑️⛑️ 号段高水位必须落盘(hard rule 104)。原来是 `private static int` + Interlocked, /// 服务端一重启就从 4_720_000_000_001 重新发号 —— 而客户端可能仍记着上一轮那个 id /// (同一个 gadget 还在场景里 / 交互表还没清)⇒ 同一个 instanceId 被下发两遍, /// 客户端把它当成"已存在"直接忽略 ⇒ **控制器刷出来了但骇入点不出现**, /// 症状完全不像重号,正是「有时候不起作用」的来源之一。 /// private const string BlackoutGadgetCounter = "blackout-controller"; /// donor §7:已有未过期的轮次时拒绝再启。 private const int BlackoutErrAlreadyRunning = 65401064; /// donor §7:没有正在进行的轮次。 private const int BlackoutErrNotRunning = 65401066; private const string BlackoutStateKey = "client4229938-blackout"; /// 下一个控制器 / creation 的 instanceId(落盘,见 )。 private static ulong NextBlackoutId() => BlackoutGadgetIdBase + unchecked((ulong)IdCounterStore.Next(BlackoutGadgetCounter, 1)); /// Per-session 大停电状态。donor §5 要求按 session + 场景代存,不能做成进程级全局。 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; /// 本轮控制器的落点 —— 补发(re-arm)时要用同一组坐标。 public float AtX; public float AtY; public float AtZ; public TaskCompletionSource? GadgetLoaded; public TaskCompletionSource? Started; public TaskCompletionSource? Finished; } /// Read-only snapshot for the admin panel — no internal types cross the boundary. 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; } /// 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. 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(TaskCompletionSource? tcs, T value) { if (tcs is null) return; try { tcs.TrySetResult(value); } catch { /* waiter already observed */ } } /// Releases any cached waiters without failing them; used when the shape changed but /// readers may still be parked. private static void FailPending(TaskCompletionSource? tcs) => CompletePending(tcs, default!); // ------------------------------------------------------------------ 启动序列 /// /// donor §6 的完整启动序列,减去 Lua 那一步。 /// /// ⛑️ 不要在 GAME dispatcher 里 await 这个方法(donor §5 的原话):等待 gadget ACK 会 /// 占住那条唯一负责读 ACK 的连接。这里本就是给 HTTP/admin 线程调用的,天然安全;将来若有人 /// 想从 RPC handler 触发,必须先 Task.Run 出去。 /// /// 返回值 Message 里带 stages,用于判定卡在哪一环: /// ok waiting-for-interact = 控制器已就位且客户端 ack 了 → 现在看客户端是否自己弹交互。 /// controller-ack-timeout = pathId/原生 DTO/世界未就绪,客户端没认这个物件。 /// /// /// 面板指定的落点。给了就以它为准;否则按配置走(默认刷在广告牌上,见 /// Gameplay.BlackoutControllerAtBillboard)。 /// 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(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(); } } /// 把控制器(重新)放进世界。首次下发与 re-arm 补发共用同一段,避免两处漂移。 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})"); } /// /// ⭐ 「摆了控制器但站到广告牌下没有骇入点」的自愈(2026-09-24 用户反馈「有时候不起作用」)。 /// /// 现象与判据:服务端侧每一步都成功(`[BLACKOUT] gadget ack matched=True`、 /// `controller acked`),但玩家走到落点看不到骇入提示 ⇒ 说明客户端把这条 add /// **丢掉了**,而服务端无从感知。已定位的两条成因: /// 1. 重号:instanceId 原来是进程内计数器,服务端重启后从头发号, /// 与客户端仍记得的旧 id 撞车 ⇒ 被当成"已存在"忽略(已改落盘,见 /// )。 /// 2. 下发时玩家不在落点附近:面板「启动」常在玩家还在几百米外时按下, /// 客户端按自己的流式/AOI 规则把物件丢掉,之后玩家传送过去也不会再加载。 /// /// ⇒ 所以在轮次真正开始前(AskStartHackerTetris 到达)周期性补发同一条 add, /// 用同一个 id 和同一组坐标(幂等:重复 add 同一个 id 不会产生第二个物件)。 /// 补发在 BlackoutRearmMax 次后停止,避免变成永久后台推送(hard rule 37)。 /// 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; } } } /// AoiAddAndRemoveReason.AOIMove — GameRouter.Gadget.cs 实测在用的值。 /// donor 文档写的是 1,两者不一致时以本服务端已验证的 2 为准。 private const int BlackoutAoiReason = 2; /// donor §6.4 用 20 秒。 private const int BlackoutAckTimeoutSeconds = 20; /// 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. 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 三个回合 /// /// AskTaskGadgetsListLoadComplete(67359333) — 客户端确认具体这个控制器加载完了。 /// /// ⛑️ donor §6.4 的警告在这里落实:不能把任何 ACK 当成大停电确认。列表里必须包含本次 /// 的 runtimeInstanceId 才放行 waiter。 /// /// 这个 MethodId 在 Lua 导出表里是 CSharpOnly,所以布局没有 Lua 侧证据兜底。解析失败时我们 /// 记日志并只回空成功 —— 表现为干净的超时,而不是把相邻字段读错。 /// [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(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? ack; lock (rt.SyncRoot) { ack = rt.GadgetLoaded; rt.GadgetLoaded = null; } CompletePending(ack, true); } if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); } /// /// AskTaskPlateLoadComplete(67794218) — 可选的 plate ack。缺了不算失败(donor §6.2), /// 所以这里只登记,不释放任何 waiter。 /// [Handler(MethodId.AskTaskPlateLoadComplete)] private async Task OnTaskPlateLoadComplete(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(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); } /// /// AskStartHackerTetris(63521495) — 图的连锁环节跑到这里,说明客户端真的按下去了。 /// /// ⛑️⛑️ 必须回答一个裸 u32 deadline:客户端 midToReturnMessageReader[63521495] /// 无条件执行 reader:ReadUInt32()。空回包让整条链卡住;回 0 则 deadline 立刻到期, /// 表现为"刚亮起来就灭了"。 /// /// ⛑️ 别在这里 spawn 控制器 —— 这次调用本身就是它的图被触发的结果(donor §7)。 /// [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(TaskCreationOptions.RunContinuationsAsynchronously); FailPending(rt.Finished); rt.Finished = new TaskCompletionSource(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? started; lock (rt.SyncRoot) { started = rt.Started; rt.Started = null; } CompletePending(started, true); } /// /// ReportHackerTetrisCreation(67652382) — 这是图的进度通知,不是俄罗斯方块通关证明 /// (donor §7 的标题词)。收到就去推那一下干扰脉冲。 /// /// ⛑️ 原子占位再发送:重复 Report / 轮次外的 Report 都必须忽略,否则会出现连续脉冲。 /// [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}"); } }); } /// /// AskFinishHackerTetris(63554976) — 客户端报告本轮结束。读 u32 score; /// uint.MaxValue 是被认可的"取消/无分"哨兵(donor §7)。 /// [Handler(MethodId.AskFinishHackerTetris)] private async Task OnAskFinishHackerTetris(Connection conn, UxRpcMessage msg) { var rt = GetBlackoutState(conn.Session); uint score = 0; if (msg.TryGetArgs(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? 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); } }