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

712 lines
32 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.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);
}
}