Files

1700 lines
98 KiB
C#
Raw Permalink 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.
// Balloon ("打气球") zone handlers for client build 4229938.
//
// WHY THIS FILE EXISTS
// The client's balloon mini-game is complete and self-contained
// (lua\LX6\SGUI\StoreDefine\BalloonGameStore.lua + Manager\MiniGame\MiniGameDataManager.lua),
// but the server side had NO implementation at all. Both RPCs were being answered by
// DefaultReturnCatalog4229938's typed-default shells:
//
// [RPC4229938] unimplemented EnterBalloonZone [0x0404B74F] -> typed-default complex(...) 51b
// [RPC4229938] unimplemented LeaveBalloon [0x03FF7BF6] -> typed-default void 0b
//
// Live evidence (logs\packets-20260927-152501.log) — the player entered the stall and then
// pressed "返回" SIX times over ~70 s, every one of them answered error=0:
//
// 15:26:28.344 C2S INVOKE EnterBalloonZone [0x0404B74F] invoke=109695828 body=9b
// 15:26:28.347 S2C RETURN EnterBalloonZone [0x0404B74F] invoke=109695828 error=0 body=51b
// 15:26:32.194 C2S INVOKE LeaveBalloon [0x03FF7BF6] invoke=109695831 body=8b
// 15:26:32.197 S2C RETURN LeaveBalloon [0x03FF7BF6] invoke=109695831 error=0 body=0b
// ... x6 ...
//
// WHY error=0 STILL MEANT "退不出来"
// BalloonGameStore.OnClickBackBtn (lua, decompiled):
// if gMiniGameDataManager.curBalloonGadgetId == 0 then
// gClientToGameSceneDelegate:LeaveBalloon(gMiniGameDataManager.curBalloonGadgetId)
// .Callback = function (err, data)
// if err == LTConfig.MessageConfig.Ok then return end -- ★ Ok -> do nothing
// L50.L50App.Scene.ZoneGamePlayManager:EndContext()
// end
// end
// The decompiled branch direction is suspect (JMPF/JMPT), but the load-bearing fact is that
// replying Ok to LeaveBalloon does NOT close the panel either way.
//
// THE PATH THAT DOES CLOSE THE PANEL —— ★ R88m CORRECTION, READ THIS FIRST
//
// R88l claimed the close path was SyncGameGroundZoneInfo + StopBalloon. That was WRONG on the
// channel, right on the symptom. The zone gameplay context (which is what the balloon panel
// hangs off) is owned by C#, not by Lua:
//
// C# L50.Gameplay.ZoneGamePlay.ZoneGamePlayManager
// StartContext / EndContext / SyncGameGroundZoneState / SyncBalloonGameScore
// C# L50.Gameplay.ZoneGamePlay.BalloonGamePlayContext
// OnStarted / OnGameEnd / OnEnded / SyncBalloonGameScore / OnSceneItemBreak
// (Assets\ScriptNew\L50Game\GamePlay\ZoneGamePlay\BalloonGamePlayContext.cs;
// the Lua-visible arm is the ToLua wrapper
// L50_Gameplay_ZoneGamePlay_ZoneGamePlayManagerWrap — which is why a Lua-only search
// for balloon callers finds exactly one hit: BalloonGameStore.OnClickBackBtn's EndContext().)
//
// So the server must own TWO channels, not one:
//
// 1) EnterBalloonZone (67417935, Invoke): return a REAL BalloonZoneInfo object.
// ★ Unlike EnterBowlingZone — whose return is a bare u64 — this RPC's return reader is
// midToReturnMessageReader[67417935] = Base.ReadComplex(reader, Auto.Reader[680]).
// Returning the 51-byte all-zero shell left ZoneSessionId empty, so no session ever
// formed and nothing downstream was reachable.
// 2) Push SyncGameGroundZoneInfo (68004609, NOTIFY) with the same BalloonZoneInfo (tag 3).
// NOTE: this one is consumed by an OLDER Lua dispatcher whose branch table has NO balloon
// arm at all (Dart/Bowling/Racing/BBQ/ChineseChess/RingToss/Scratch). Kept for fidelity;
// it is NOT what opens or closes anything for balloon.
// 3) ★★★ Push SyncGameGroundZoneState (68383444, NOTIFY) — "started" on enter,
// GameOver + Dispose on leave. THIS is the channel ZoneGamePlayManager listens on.
// Omitting it means the zone context is never entered, so EndContext() has nothing to
// tear down and the panel cannot close — i.e. exactly the "退不出来" the player reported.
//
// SCORE PUSH: SyncBalloonGameScore (64893406) payload is a BARE Int32
// (midToReader[64893406] = function(reader) local score = reader.ReadInt32(reader) return score end).
// We push 0 at zone start and again at leave, which is what makes OnGameEnd rank "endType = 0"
// (score below Balloon_NormalEndCredit). The client computes the actual hits locally.
//
// SERIALIZATION: see BalloonWireContracts.cs — the byte layouts come from the client's own Lua
// serializer/reader (RPCSerializeAuto.Auto.WriteBalloonZoneInfo / RPCDeserializeAuto.Auto.Reader[680]),
// NOT from Drmk.Proxy/proxy/rpc_schema.json (stale older build).
using System.Collections.Concurrent;
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938; // R110: MiniGameStallCatalog4229938(官方摊位位姿)
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
internal sealed partial class GameRouter
{
/// <summary>Per-connection balloon session, parked in Session.Items like the bowling one.</summary>
private const string BalloonStateKey = "client4229938-balloon";
// 局内 session 后缀(只进日志与 ZoneSessionId 字符串,不是实体 id,但同样落盘保持单调)。
private const string BalloonZoneSeqCounter = "Balloon.ZoneSeq";
/// <summary>
/// ★★★ R89 — 进气球区前的手持武器实例 ID,退出时还原(key = spirit unitId)。
/// 用 TryAdd:重复进入同一局不会把「原来的枪」覆盖成玩具枪。
/// </summary>
private static readonly ConcurrentDictionary<ulong, ulong> BalloonSavedWeapon = new();
/// <summary>
/// ★★★ R89 — 当前一轮气球靶:key = spirit unitId,value =(气球实例 ID → 气球 cfgId)。
/// 只在 EnterBalloonZone 建立、LeaveBalloon 清空,用来判定「打爆的是不是本轮的气球」。
/// </summary>
private static readonly ConcurrentDictionary<ulong, Dictionary<ulong, uint>> BalloonTargets = new();
/// <summary>
/// ★ R141(2026-09-28)—— side-channel 落盘路径的「报一次」标志。
///
/// WHY:side-channel(把本局气球的真值落盘给客户端注入器读)原先写死
/// <c>D:\Ananta\gym_tools\_balloon_push.json</c> ⇒ **整包发给别人 / 换盘符 / 换个目录就断**:
/// 服务端在写 A、注入器在读 B ⇒ <c>pushN</c> 恒 0 ⇒ 气球一个都不出现。
/// 修法见 <c>PushBalloonWallAsync</c>:改成写「与安装位置无关」的共享目录
/// <c>%LOCALAPPDATA%\AnantaBalloon\balloon_push.json</c>,
/// 注入器 <c>gym_tools\AnantaBalloonLoader.exe</c> 按**同一规则**算同一路径。
/// 这个标志只用来把「到底用了哪个路径」打一次日志(判活用),不影响主流程。
/// </summary>
private static bool _balloonPushPathLogged;
private sealed class BalloonSession
{
internal ulong GadgetUId;
internal string ZoneSessionId = "";
internal long StartedUtcTicks;
internal int Score;
internal bool Active;
/// <summary>The REAL scene-item instance ids pushed at zone entry (R88o).</summary>
internal readonly List<ulong> BalloonIds = new();
/// <summary>
/// ★★★ R112 — 本轮每颗气球**实际下发**的 cfg(id → cfgId)。
/// 用于「按颜色算分」(BalloonWire.CreditForCfg)。旧代码的账本用的是
/// BalloonWire.BalloonSceneItemCfgIds 这个**固定 5 元组**,cfgSweep 生效时与实际不符。
/// </summary>
internal readonly Dictionary<ulong, uint> BalloonCfgById = new();
/// <summary>
/// ★★★ R123 — 上一局**没打掉**的那几颗(局末、撤保护之前抓下来)。
///
/// <para><b>为什么必须存它:</b>局末 <c>FinishBalloonRoundAsync</c> 会
/// ① <see cref="UnprotectDestructibles"/> 把本局 16 个 id 全摘出 <c>SceneItemGrid.Protected</c>、
/// ② <see cref="BalloonIds"/>.Clear()。⇒ 下一局 spawn 时**两条线索都没了**,
/// 服务端根本不知道上一局剩了哪几颗,而客户端 hold 里那几颗**还活着**
/// (整格重置的心跳只停止"保活",不会主动删客户端的 hold)。
/// 新球又落在**同一批 balloonWallSpots** 上 ⇒ 子弹先命中残留球的碰撞体、
/// 报的是旧 id ⇒ 服务端 <c>ignore break</c>,新球既打不掉也不加分。</para>
///
/// <para><b>实测铁证(R122,2026-09-28 08:19~08:20):</b>
/// R1 spawn <c>id 0x11..0x20</c> 只打爆 12 颗,未打掉 = <c>{0x15,0x17,0x1C,0x20}</c>;
/// R2(<c>0x21..0x30</c>)在 <c>08:20:19</c> 收到
/// <c>[BALLOON] ignore break n=1 first=BA1100000017</c> —— <c>0x17</c> **正是** R1 的残留之一。</para>
///
/// <para><b>用法:</b>下一局 spawn 时把这批 id 填进 AOI 报文的
/// <c>aoiInfo.RemoveIds</c>(<c>DestructibleGridAOIIncrease.removeIds</c>,
/// 与 <c>addUniqueIds</c> 成对;通道说明见 <c>GameRouter.GameGround.cs:655</c>),
/// 客户端据此删掉对应 hold;下发成功后清空。</para>
/// </summary>
internal readonly List<ulong> RetiredIds = new();
/// <summary>
/// ★★★★★ R126(2026-09-28)—— **局代**:每开一局 +1。
///
/// <para><b>为什么必须有它(用户实测:「第二轮结算时间非常快,没法正常测试」):</b>
/// 本类**按 session 复用、不每局新建**,而 <see cref="ArmBalloonRoundTimeout"/> 每局都
/// <c>Task.Run</c> 起一个 <c>Task.Delay(40s)</c>,醒来后只用 <c>session.Active</c> 当守卫。
/// 玩家在 40 s 内手动 <c>LeaveBalloon</c> 再马上重进时:上一局的任务醒来时
/// **看到的 Active 已经是下一局的 true** ⇒ 直接把新局结算掉。</para>
///
/// <para><b>实测铁证(R126,2026-09-28 09:01:29~09:03:15,5 局)——</b>
/// 每局的结束时刻**恰好等于上一局开始时刻 + 40 s**:
/// <code>
/// R2 结束 09:02:09 = R1 开始 09:01:29 + 40 (R2 只跑了 22 s)
/// R3 结束 09:02:27 = R2 开始 09:01:47 + 40 (R3 只跑了 9 s)
/// R4 结束 09:02:58 = R3 开始 09:02:18 + 40
/// R5 结束 09:03:15 = R4 开始 09:02:34 + 41
/// </code>
/// ⇒ 越往后局越短(玩家 9~25 s 就退再进),完全对得上。</para>
///
/// <para><b>用法:</b><c>SpawnBalloonsAsync</c> 开头 <c>++</c>,把当次的值闭包给定时任务;
/// 任务醒来与 <see cref="FinishBalloonRoundAsync"/> 都校验「发起者是不是当前局」。</para>
/// </summary>
internal int RoundGeneration;
}
private BalloonSession GetBalloonSession(RpcContext ctx)
{
if (ctx.Session.Items.TryGetValue(BalloonStateKey, out var raw) && raw is BalloonSession existing)
return existing;
var created = new BalloonSession();
ctx.Session.Items[BalloonStateKey] = created;
return created;
}
// ---------------------------------------------------------------------------------------
// EnterBalloonZone (67417935) — the gameplay door.
//
// Arg layout is ClientToGameSceneDelegate.lua (SerializerHelper.EnterBalloonZone_Serializer):
// gadgetuid : u64 (WriteUInt64)
// gametype : byte (CheckEnum(gametype, 66, 0), i.e. an enum with 66 members)
// -> 9 bytes. Confirmed live: "C2S INVOKE EnterBalloonZone [0x0404B74F] body=9b"
// with payload 01 18 20 E4 A3 08 00 00 00 :
// u64 LE = 0x08A3E4201801 (the stall gadget's instanceId)
// byte = 0x00 (gametype 0)
//
// The return is a COMPLEX BalloonZoneInfo (see the file header) — this is deliberately
// different from EnterBowlingZone's bare u64.
// ---------------------------------------------------------------------------------------
[Handler(MethodId.EnterBalloonZone, HandlerPacketKind.Invoke)]
private async Task EnterBalloonZone4229938(Connection conn, UxRpcMessage msg)
{
ulong gadgetUId = 0;
byte gameType = 0;
if (msg.Body is { Length: >= 9 })
{
gadgetUId = BitConverter.ToUInt64(msg.Body, 0);
gameType = msg.Body[8];
}
// ★★★ R89 — 先发枪,再推 zone。
// 客户端拿到 BalloonZoneInfo 会**立刻**开面板并读 currentWeapon
// (BalloonGameStore.lua:341 → gPlayerManager.infoSpirit.bindData.currentWeapon);
// 为 nil 就抛 `attempt to index local 'weapon'` ⇒ 面板起不来、气枪模型不出。
// 所以这一步必须在 StartBalloonZoneAsync(内含 zone-info 推送)之前完成。
if (PrivateServerConfigStore.Current.World.Content.MiniGameStalls.BalloonGrantWeapon)
await GrantBalloonWeaponAsync(conn, msg.Context);
var zoneInfo = await StartBalloonZoneAsync(conn, msg.Context, gadgetUId, gameType, "EnterBalloonZone");
// ★ Return the REAL object. The client reads Dispatch[680] straight out of the reply.
await conn.ReturnAsync(msg, zoneInfo);
}
/// <summary>
/// ★★★ R89 — 按官方武器发布管线的形状,把气枪(80300134)发布为当前手持武器。
/// 与 <c>GameRouter.Profiles.PublishWeaponSnapshot</c> 同一条链路:
/// SyncSpiritWeaponDetail(槽位视图)→ SyncSpiritSwitchWeaponAction(选中实例)。
///
/// ⛔ 不能用 <c>CombatCodec.SpiritWeaponSnapshot</c> —— 它会把 currentWeaponInstanceId
/// **强行塞进槽位列表**,而玩具枪不在玩家的常规配装上,必须手搓 SpiritWeaponDetail。
///
/// ⛔ 也**不要**补 <c>SyncAgentWeaponWheel</c>(68121167):对照包 2026-09-26 实测客户端解不开
/// (Player.log: deserialize failed mode=3 mid=68121167 len=24),且它只写
/// agentCurrentWeaponDict,仅 RobotDog/RobotFlyer/RobotSpider 三个机器人控件读它 ——
/// 气球面板要的 currentWeapon 来自 SyncSpiritWeaponDetail。
/// </summary>
private async Task GrantBalloonWeaponAsync(Connection conn, RpcContext ctx)
{
var state = GetWorldState(ctx);
var unitId = state.ActiveSpiritUnitId;
if (unitId == 0)
return;
// 只在首次进入时记录原武器;重复进入不会被玩具枪覆盖记录。
BalloonSavedWeapon.TryAdd(unitId, state.ActiveWeaponInstanceId);
var instanceId = BalloonWire.BalloonWeaponInstanceBase | (unitId & 0xFFFF_FFFFUL);
var weapon = new SceneMethods.WeaponDetail
{
SourceAgentSpoonId = 0,
SourceAgentId = 0,
SourceSceneItemId = 0,
IsLocked = false,
SourceSceneItemTaskUId = 0,
TemplateId = BalloonWire.BalloonWeaponTemplateId, // 80300134
Durability = -1, // 与 cfg.Durability = -1 一致(无限)
InstanceId = instanceId,
EventId = 0,
ReceivedTimeStamp = 0d,
OperatorFlags = 0,
SpecialLabel = null,
SceneItemHp = 1f,
StackCount = 1,
FightStyleId = 0, // 玩具枪不参与战斗流派
MagazineAmmo = PrivateServerConfigStore.Current.World.Content.MiniGameStalls.BalloonWeaponAmmo,
BulletDatas = new Auto.WeaponBulletDatas { BulletId = 0 },
BindPid = PrivateServerConfigStore.Current.Player.Pid,
IsPlayerLocked = false,
};
var snapshot = new SceneMethods.SpiritWeaponDetail
{
SpiritTid = state.ActiveSpiritTemplateId,
SpiritUid = unitId,
CurrentWeaponUid = instanceId,
WeaponSlots = new List<SceneMethods.WeaponDetail?> { weapon },
CurrentTempWeapon = null,
TempWeaponSlots = null,
VirtualWeaponSlots = new Dictionary<int, SceneMethods.WeaponDetail>(),
};
await conn.NotifyAsync(MethodId.SyncSpiritWeaponDetail, snapshot);
await conn.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction,
CombatCodec.SpiritSwitchWeapon(unitId, instanceId));
conn.Log.Info(
$"[BALLOON] weapon granted unit={unitId} template={BalloonWire.BalloonWeaponTemplateId} " +
$"instance={instanceId} ammo={PrivateServerConfigStore.Current.World.Content.MiniGameStalls.BalloonWeaponAmmo}");
}
/// <summary>
/// ★★★ R89 — 退出气球区时还原进气前的手持武器。
/// 这里**可以**用官方快照(原武器本来就在配装里),避免被玩具枪顶掉。
/// </summary>
private async Task RestoreBalloonWeaponAsync(Connection conn, RpcContext ctx)
{
var state = GetWorldState(ctx);
var unitId = state.ActiveSpiritUnitId;
if (unitId == 0)
return;
if (!BalloonSavedWeapon.TryRemove(unitId, out var previous) || previous == 0)
return;
var snapshot = CombatCodec.SpiritWeaponSnapshot(unitId, state.ActiveSpiritTemplateId, previous);
await conn.NotifyAsync(MethodId.SyncSpiritWeaponDetail, snapshot);
await conn.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction,
CombatCodec.SpiritSwitchWeapon(unitId, previous));
conn.Log.Info($"[BALLOON] weapon restored unit={unitId} instance={previous}");
}
/// <summary>
/// Shared body: mint the per-connection zone session, build the BalloonZoneInfo, push the
/// SyncGameGroundZoneInfo the client's manager gates on, and hand a copy back for the Invoke
/// reply of EnterBalloonZone. Returns the zone info so both doors (EnterBalloonZone, and a
/// future GmEnterBalloonZone) can shape their own reply.
/// </summary>
private async Task<BalloonZoneInfo> StartBalloonZoneAsync(
Connection conn, RpcContext ctx, ulong gadgetUId, byte gameType, string source)
{
var state = GetWorldState(ctx);
var session = GetBalloonSession(ctx);
session.GadgetUId = gadgetUId;
session.ZoneSessionId = $"{BalloonWire.ZoneSessionPrefix}-{ctx.Session.Id}-{IdCounterStore.Next(BalloonZoneSeqCounter, 1)}";
session.StartedUtcTicks = DateTime.UtcNow.Ticks;
session.Score = 0;
session.Active = true;
var playerPos = state.LastReportedPlayerPosition;
conn.Log.Info(
$"[BALLOON] {source} gadgetUId={gadgetUId} gameType={gameType} " +
$"zone={session.ZoneSessionId} " +
$"player=({playerPos.X:F1},{playerPos.Y:F1},{playerPos.Z:F1})");
// ★★ R88n — the session ids and the target cfg. R88o completed this: see below.
// Leaving TargetSceneItemCfgId at 0 told the panel "no target" (BalloonGameStore.OnShow
// reads data.targetSceneItemId), and an empty BalloonInstanceIdList gave the C#
// BalloonGamePlayContext nothing to key its balloonInstanceIdSet on.
var zoneInfo = new BalloonZoneInfo
{
// ★★★ R93 — TargetModelId 不再填 0:客户端 BalloonGameStore.OnShow 读 data.targetId 赋给
// self.targetModelId,0 会让面板拿不到目标气球模型。对照包 Lyco 实测用 BalloonGameSceneItemCfgIds[0]
// (当时=80005796 Special),TargetModelId 与 TargetSceneItemCfgId 客户端语义不严格区分,填 cfg id 即可。
// ★ R100:数组已换成「派对气球」,故此处自动变为 80001468;TargetSceneItemCfgId 同步换。
TargetModelId = BalloonWire.BalloonSceneItemCfgIds[0], // R100: 80001468(派对气球 a)
TargetSceneItemCfgId = BalloonWire.TargetSceneItemCfgId, // R100: 80001468
Score = 0,
BalloonInstanceIdList = new List<ulong>(), // filled below (real ids)
ZoneSessionId = session.ZoneSessionId, // ★ MUST be non-empty
GadgetUId = gadgetUId,
TaskId = 0,
StartReason = BalloonWire.ZoneStartReasonSingle,
GameTypeRandom = false,
SyncReason = BalloonWire.ZoneSyncReasonEnter, // MUST be Enter(0): the manager branches on it
ZoneType = BalloonWire.ZoneTypeBalloon,
// ★★★ R93 — ZoneState 用 GameStart(2) 而非 Display(1):对照包 Lyco 实测 GameStart,
// 客户端 ZoneGamePlayManager 靠 GameStart 才 StartContext(BalloonGamePlayContext)。
// Display(1) 是 Idle(0) 与 GameStart(2) 之间的过渡态,直接回包给客户端会导致 context 起不来。
ZoneState = BalloonWire.ZoneStateGameStart,
ParticipantInfos = new List<BalloonParticipantInfo>(), // empty, never null
CountDownInfo = new GameGroundZoneCountDownInfo(),
};
// ★★★ R88o — ACTUALLY SPAWN THE BALLOONS, then hand their REAL instance ids to the client.
//
// Why the R88n version (which put *cfg* ids in this list) still showed an empty wall:
// the C# consumer keys on instance ids, not cfg ids —
// L50.Gameplay.ZoneGamePlay.BalloonGamePlayContext
// TargetModelId | TargetSceneItemId | balloonInstanceIdSet | playerScoreDic
// | ListenSceneItemBreak | OnSceneItemBreak
// and the scene-item hold is only created by the explicit destructible push
// (SyncDestructibleGridAOIDecrease.aoiInfo.addInfos), exactly as bowling does.
// Doing it here — before the zone-info push — means the ids in the list are ids the client
// has already been told about, so the wait never times out.
var balloonIds = await SpawnBalloonsAsync(conn, ctx, session, source);
zoneInfo.BalloonInstanceIdList = balloonIds;
session.BalloonIds.Clear();
session.BalloonIds.AddRange(balloonIds);
// ★★★ R89 — 登记本轮靶账本,AskBreakDestructibleObject(s) 靠它判定「打爆的是不是气球」。
// ★★★ R112 修正:账本里的 cfg 必须取 **spawn 实际下发**的那个。
// 旧代码写 `cfgs[i % cfgs.Length]`(BalloonWire 的固定 5 元组),cfgSweep 生效时
// 与实际不符 ⇒ 计分会按错颜色。
var targets = new Dictionary<ulong, uint>();
foreach (var bid in balloonIds)
targets[bid] = session.BalloonCfgById.TryGetValue(bid, out var bidCfg)
? bidCfg
: BalloonWire.BalloonSceneItemCfgIds[0];
var spiritUnitId = state.ActiveSpiritUnitId;
if (spiritUnitId != 0)
BalloonTargets[spiritUnitId] = targets;
conn.Log.Info(
$"[BALLOON] zone objects targetCfgId={zoneInfo.TargetSceneItemCfgId} " +
$"balloons={zoneInfo.BalloonInstanceIdList.Count} targets={targets.Count} " +
$"[{string.Join(",", zoneInfo.BalloonInstanceIdList)}]");
await conn.NotifyAsync(MethodId.SyncGameGroundZoneInfo, zoneInfo);
conn.Log.Info($"[BALLOON] pushed SyncGameGroundZoneInfo tag=3 zone={session.ZoneSessionId} score=0 source={source}");
// ★★ SyncGameGroundZoneState (68383444) is the channel ZoneGamePlayManager actually
// listens on (C#: L50.Gameplay.ZoneGamePlay.ZoneGamePlayManager.SyncGameGroundZoneState
// -> BalloonGamePlayContext.OnStarted). Without it the zone gameplay context is never
// "entered", so EndContext() on leave has nothing to tear down and the panel stays.
//
// ★★★ R93 — 只推 GameStart(2) 一次,不再推 Display(1)。对照包 Lyco 实测只推 GameStart;
// 多推一次 Display 会让 ZoneGamePlayManager 先把 context 置为 Display(未开始),
// 紧接着 GameStart 又切一次,客户端状态机在两次切换之间可能直接判定结束 → OnEnded
// → BalloonGameStore.OnGameEnd 读 resultActiveCtrlEnum(nil) 抛异常 → 面板关不掉("退出不了")。
await PushZoneStateAsync(conn, gadgetUId, BalloonWire.ZoneStateGameStart);
conn.Log.Info($"[BALLOON] pushed SyncGameGroundZoneState GameStart zone={session.ZoneSessionId} type={BalloonWire.ZoneTypeBalloon}");
// Push the initial score so the client's panel starts from a known value. The payload is a
// bare Int32 — see the file header.
await conn.NotifyAsync(MethodId.IGameToClient_SyncBalloonGameScore, 0);
conn.Log.Info("[BALLOON] pushed SyncBalloonGameScore score=0");
// Hand the Invoke reply a COPY (the notifier already consumed this instance).
return zoneInfo;
}
/// <summary>
/// Spawn the breakable balloons as real scene items and return their instance ids.
///
/// ★★★ R88o — THIS is the piece that makes "墙上有气球" actually true, and it is the exact
/// same three-step dance bowling already does (GameRouter.GameGround.cs:683-930):
/// 1) mint real instance ids BalloonInstanceIdBase + ordinal(1..count)
/// 2) push them as DestructibleInfo
/// SyncDestructibleGridAOIDecrease (68969593) .aoiInfo.addInfos ← creates the hold
/// SyncDestructibleObjectAddList (68963429) ← the object add
/// with CfgId = the balloon cfg id, PathId = 五色各一 (R89/R100),
/// ForceLod0 = false, IScale = 100 (R109, 原 1000 作废), State = Active
/// ★★★ R100:CfgId 与 PathId 已**成对**换成资源确实随包的「派对气球」
/// (旧 80005796..80005800 的美术资源不存在 —— 四条证据链见 BalloonWireContracts.cs)。
/// 3) hand those SAME ids back to the caller for BalloonZoneInfo.BalloonInstanceIdList
///
/// WHY THIS IS NOT OPTIONAL — the C# consumer keys on INSTANCE ids, not cfg ids:
/// L50.Gameplay.ZoneGamePlay.BalloonGamePlayContext holds
/// TargetModelId | TargetSceneItemId | balloonInstanceIdSet | playerScoreDic
/// and implements OnSceneItemBreak. A break only scores if the broken item's instance id is
/// in balloonInstanceIdSet, and nothing can be broken at all unless the item was spawned.
///
/// ★★★ R89 LAYOUT — 已改为对照包(Lyco-v0.2.1,真机跑通)的摆法:玩家**前方 6m** 的 4×3 墙。
/// ⛔ 旧的「摊位固定坐标 10×2」不再用。ClientConfig 的 Balloon_RightNum = 10 是**棋盘上限**,
/// 不是本局数量;对照包实测本局 = 12(4 列 × 3 行)。
/// </summary>
/// <summary>
/// ★★★★★ R122(2026-09-28)—— 把 <c>SceneItemGrid.Protected</c> 里**不属于本局**的气球 id
/// 全部摘掉并返回(供 AOI 报文的 <c>aoiInfo.RemoveIds</c> 使用)。
///
/// <para>为什么必须有这一步:<c>Protected</c> 是 <c>Dictionary&lt;ulong,…&gt;</c>、**只增不减**,
/// 而 5 s 心跳会把表里每个 id 都塞回 <c>AddInfos</c> + <c>ExceptIds</c>
/// (<c>SceneItemGrid.cs:344-382</c>)⇒ 上一局没打掉的球会被**永远保活**。</para>
///
/// <para>判定只用高位前缀 <see cref="BalloonWire.IsBalloonInstanceId"/>(<c>0xBA11</c>),
/// 所以不会误伤保龄球(<c>0x9C7B</c> 段)或普通场景物。
/// <paramref name="currentRoundIds"/> = 本局刚分配的那 16 个(它们此刻还没进 Protected,
/// 传进来只是兜底:任何在本局名单里的 id 一律不删)。</para>
/// </summary>
private static List<ulong> PurgeStaleBalloonProtection(
TcpSession session, IEnumerable<ulong> currentRoundIds)
{
var owner = SceneItemGridStateOf(session);
var keep = new HashSet<ulong>(currentRoundIds);
var stale = new List<ulong>();
lock (owner.SyncRoot)
{
foreach (var id in owner.Protected.Keys)
if (BalloonWire.IsBalloonInstanceId(id) && !keep.Contains(id))
stale.Add(id);
foreach (var id in stale)
owner.Protected.Remove(id);
}
stale.Sort();
return stale;
}
/// <summary>
/// ★★★ R123(2026-09-28)—— 本局 16 个 id 里,**此刻仍在 <c>Protected</c> 的**(= 本局没打掉的)。
///
/// <para><b>为什么在局末抓、而不是下一局抓:</b><c>FinishBalloonRoundAsync</c> 会紧接着调
/// <see cref="UnprotectDestructibles"/> 把本局 id 全摘出 <c>Protected</c>,
/// 摘完就再也查不到"哪几颗没打掉"了(R122 的 <c>PurgeStaleBalloonProtection</c>
/// 在下一局 spawn 时才查 ⇒ 表已空 ⇒ 日志 <c>purge stale</c> 恒 0、修了个寂寞)。</para>
///
/// <para>结果存进 <see cref="BalloonSession.RetiredIds"/>,由下一局 spawn 作为
/// <c>aoiInfo.RemoveIds</c> 下发。</para>
/// </summary>
private static List<ulong> SurvivingBalloonIds(TcpSession session, IEnumerable<ulong> roundIds)
{
var owner = SceneItemGridStateOf(session);
var alive = new List<ulong>();
lock (owner.SyncRoot)
foreach (var id in roundIds)
if (owner.Protected.ContainsKey(id))
alive.Add(id);
alive.Sort();
return alive;
}
private async Task<List<ulong>> SpawnBalloonsAsync(
Connection conn, RpcContext ctx, BalloonSession session, string source)
{
var state = GetWorldState(ctx);
var anchor = state.LastReportedPlayerPosition;
var cfgStall = PrivateServerConfigStore.Current.World.Content.MiniGameStalls;
// ★★★ R89 — 改为对照包(Lyco-v0.2.1,真机跑通)的摆放法:
// 不再用摊位固定坐标摆一面 10×2 的「棋盘」,而是在**玩家当前位姿的前方**摆 4×3 的墙。
// 理由:私服没有关卡烘焙的摊位摆位(铁律 8「自指锚点」),玩家站位才是权威;
// 且 ClientConfig 的 Balloon_RightNum=10 / CameraMinX.. 是**棋盘上限**,不是本局数量。
// Unity 朝向约定:绕 Y 轴 yaw 度 ⇒ forward = (sin,0,cos),right = (cos,0,-sin)。
float yaw = state.LastReportedPlayerRotation.Y;
double rad = yaw * Math.PI / 180.0;
float fx = (float)Math.Sin(rad), fz = (float)Math.Cos(rad);
float rx = (float)Math.Cos(rad), rz = -(float)Math.Sin(rad);
var count = cfgStall.BalloonCount;
if (count < 1) count = BalloonWire.BalloonCount;
// ★★★★★ R111 —— 摆位参数改为**配置驱动**(private-server.json 热重载 ⇒ 改完重新进区即生效,
// **不用重启服务端**)。旧版是 const(对照包 Lyco 的值),已全部作废。
var rows = Math.Max(1, cfgStall.BalloonWallRows); // 对照包 BalloonSpawnRows = 3
var cols = Math.Max(1, count / rows); // 12 / 3 = 4 列
var lateralStep = cfgStall.BalloonWallLateralStep; // R111: 1.2 → 0.9
var forward = cfgStall.BalloonWallForward; // player 锚点的前移量(默认 6)
var baseHeight = cfgStall.BalloonWallBaseHeight; // R111: 1.2 → 1.25
var heightStep = cfgStall.BalloonWallHeightStep; // R111: 0.7 → 0.5
// ★★★ R112 — 每局重新生成 ⇒ 账本先清空,避免上一局的 cfg 残留。
session.BalloonCfgById.Clear();
// ★★★★★ R126 — **开一局就换一个「局代」**。本局末布防的 40 s 看门狗会把它闭包进去,
// 醒来时若发现当前局代已变(= 已经是下一局了)⇒ 自我作废,绝不结算下一局。
// 病因与实测铁证见 BalloonSession.RoundGeneration 的注释。
var roundGen = ++session.RoundGeneration;
var cfgs = BalloonWire.BalloonSceneItemCfgIds; // 5 colours
var paths = BalloonWire.BalloonSceneItemPathIds; // 5 colours (R89)
// ★★★★★ R111 —— 按列换 (cfg, pathId) 组(美术 A/B)。空 = 内置 5 色派对气球。
var cfgSweep = cfgStall.BalloonCfgSweep ?? System.Array.Empty<string>();
var colCfg = new uint[cols];
var colPath = new int[cols];
for (var col = 0; col < cols; col++)
{
var ok = false;
if (cfgSweep.Length > 0)
{
var spec = cfgSweep[Math.Min(col, cfgSweep.Length - 1)] ?? string.Empty;
var pair = spec.Split(':');
if (pair.Length >= 2 &&
uint.TryParse(pair[0].Trim(), out var c0) &&
int.TryParse(pair[1].Trim(), out var p0))
{ colCfg[col] = c0; colPath[col] = p0; ok = true; }
}
if (!ok) { colCfg[col] = cfgs[col % cfgs.Length]; colPath[col] = paths[col % paths.Length]; }
}
if (cfgSweep.Length > 0)
conn.Log.Info("[BALLOON] cfg sweep on: " + string.Join(" | ",
System.Linq.Enumerable.Range(0, cols).Select(i => $"col{i}=cfg{colCfg[i]}/path{colPath[i]}")));
var infos = new List<DestructibleInfo>(count);
var ids = new List<ulong>(count);
var cells = new Dictionary<(int, int), bool>();
// ★★★★★ R109 — IScale 默认值 1000 → 100(详见 BalloonWire.DefaultIScale 的更正段)。
// 保龄球(唯一被实测确认看得见的同族)用 100;气球用 1000 且从来没人看见过。
// ⛔ 0 仍是 Gadget 通道的语义,别混(同名不同尺度)。
var iScale = cfgStall.BalloonIScale;
// ★★★★★ R109 — 诊断扫描:非空时**按列**给不同 IScale,一次重启同时试 4 个候选值。
// 列号 = ordinal/rows(cols 外层、rows 内层)⇒ 用户只要报告「哪一列看得见」。
var scaleSweep = cfgStall.BalloonIScaleSweep;
if (scaleSweep is { Length: > 0 })
conn.Log.Info($"[BALLOON] IScale SWEEP on: col0={scaleSweep[0]} " +
$"col1={(scaleSweep.Length > 1 ? scaleSweep[1] : scaleSweep[0])} " +
$"col2={(scaleSweep.Length > 2 ? scaleSweep[2] : scaleSweep[0])} " +
$"col3={(scaleSweep.Length > 3 ? scaleSweep[3] : scaleSweep[0])} " +
$"(rows={rows}, 列序从玩家左手边起)");
// ★★★ R89 — 实例 id:固定 48 位区间 base + 序号(1..count)。
// 绝不用 gadget 散列(低 32 位大时 id 到 49 位,经 Lua double 回传丢精度 ⇒ found=0)。
const int gridCellSize = 40; // 与 WorldContent 的 40m cell 一致
var useLegacyIds = cfgStall.BalloonUseLegacyInstanceIds;
// ★★★★★ R118(2026-09-27)—— **实例 id 必须「每局换一段」**。
//
// 原来非 legacy 分支是常数 `BalloonInstanceIdBase` ⇒ **每一局都是同样那 16 个 id**。
// 铁证(`logs/console-20260927-231258.log` 一局 16 颗 id 固定 0xBA11…0001–0010)+
// 用户实测两条相互印证的现象:
// ① 「金色球打完再下一次重新游戏时不会再刷新出来」——客户端对 uniqueId 有**跨局持久**的
// "已销毁" 记账,第二局收到同一批 id 直接**拒绝重建**(金色正是 `destructible_*` 家族,
// 见 R117 §5:它才会被客户端永久记住);
// ② 「第二轮打球直接不会掉下去了,钉死在墙上」——非金色那族虽然会被重建,
// 但带着上一局的残留状态 ⇒ 站着不动、打不动。
// ⇒ 每局分配新一段 id,客户端每次看到的都是**新物件**,两条现象一起消掉。
// ⚠ 三条硬约束(踩了就会"只生成 1 个"或"查不到"):
// 1) 偏移必须是注入侧 `MAX_BATCH = 32` 的**整数倍**,否则 idx=(id-base)%32 会串位;
// 2) 全程 ≤48 位(Lua number 只有 53 位尾数 —— 铁律 39);
// 3) 高位必须仍以 `ba11` 开头(注入侧 `isBalloon()` 靠前缀认领)。
// ★★★★★ R119 —— 计数器**换成独立的新键**,让**第一局的 id 段等于**
// 「唯一被实测证明能计分的那一局」(`0xBA11_0000_0001..0010`)。
// 理由与步长由 128 压到 16 的取证过程见 BalloonInstanceIdRoundStride 的注释。
// ⇒ 第一局 = A/B 的对照组;如果第一局恢复正常,就**确证** id 段是根因。
var batch = useLegacyIds
? (ulong)IdCounterStore.Next(BalloonWire.BalloonSceneItemIdBatchCounter, 0)
: (ulong)IdCounterStore.Next(BalloonWire.BalloonInstanceRoundCounter, 0);
var batchBase = useLegacyIds
? BalloonWire.BalloonSceneItemIdBase + batch * (ulong)count
: BalloonWire.BalloonInstanceIdBase + batch * BalloonWire.BalloonInstanceIdRoundStride;
// ★★★★★ R110 —— 锚点:**优先用官方摊位机关自己的位姿**,而不是「玩家前方 6 m」。
//
// 病因(R110 三维几何实证,龙栖村气球摊;详见 PrivateServerConfig.BalloonWallAnchor):
// 官方摊位 Gadget_DaQiQiu (-4750.50,168.60,-2827.60) yaw=315.0
// 玩家上报位姿 (-4749.00,168.50,-2830.30) yaw=313.3 ⇒ 摊位在正前方 2.97 m
// 旧算法玩家前方 6 m ⇒ 墙中心 (-4753.36,-2826.18) = 比摊位再往前 3.19 m
// ⇒ 整面墙落在摊位/背景板之后、只从地面 1.2 m 起 ⇒ 被结构遮住 = 什么都看不见。
// ⇒ 服务端载荷 / 注入 / 资源 / hold / transform 全对、物件确实存在,只是几何上被挡住。
//
// 扫描:BalloonWallAnchorSweep 按列给「锚点:前移量」,一次重启同时试多个摆位。
var sweep = cfgStall.BalloonWallAnchorSweep ?? System.Array.Empty<string>();
var wantStall = string.Equals(cfgStall.BalloonWallAnchor, "stall", StringComparison.OrdinalIgnoreCase)
|| Array.Exists(sweep, s => (s ?? string.Empty)
.TrimStart().StartsWith("stall", StringComparison.OrdinalIgnoreCase));
MiniGameStallItem4229938? stallHit = null;
var stallDist = -1f;
if (wantStall)
{
var match = string.IsNullOrEmpty(cfgStall.BalloonWallStallMatch) ? "DaQiQiu" : cfgStall.BalloonWallStallMatch;
var best = float.MaxValue;
foreach (var it in MiniGameStallCatalog4229938.Spots.SelectMany(s => s.Items))
{
if (it.Name is null || it.Name.IndexOf(match, StringComparison.OrdinalIgnoreCase) < 0) continue;
var dx = it.X - anchor.X;
var dz = it.Z - anchor.Z;
var d2 = dx * dx + dz * dz;
if (d2 < best) { best = d2; stallHit = it; }
}
if (stallHit is not null) stallDist = MathF.Sqrt(best);
conn.Log.Info(stallHit is null
? $"[BALLOON] 锚点查找:摊位表里没有名字含 '{match}' 的机关 ⇒ 全部退回玩家锚点"
: $"[BALLOON] 锚点查找:命中 {stallHit.Name}({stallHit.PathId}) " +
$"@({stallHit.X:F2},{stallHit.Y:F2},{stallHit.Z:F2}) yaw={stallHit.Yaw:F1} " +
$"距玩家上报位置 {stallDist:F2}m(告警半径 {cfgStall.BalloonWallStallSearchRadius:F1}m)");
}
// 每列的锚点规格:基底(bx,by,bz) + 该锚点的 forward/right + 沿 forward 的前移量
//
// ★★★★★ R111 —— **sweep 为空时所有列共用同一个 fwd ⇒ 平墙**。
// 旧代码在 `rawSpec is null` 时写的是 `forward`(player 锚点的 const 6.0),
// 却忽略了 ballotWallStallForward ⇒ stall 模式下前移量恒为 0(把墙压在摊位原点上)。
// 现在按 mode 取:stall ⇒ BalloonWallStallForward / player ⇒ BalloonWallForward。
var stallForward = cfgStall.BalloonWallStallForward;
var colSpec = new (float bx, float by, float bz, float cfx, float cfz,
float crx, float crz, float fwd, string tag)[cols];
for (var col = 0; col < cols; col++)
{
var rawSpec = sweep.Length > 0 ? (sweep[Math.Min(col, sweep.Length - 1)] ?? string.Empty) : null;
var mode = string.Equals(cfgStall.BalloonWallAnchor, "stall", StringComparison.OrdinalIgnoreCase)
? "stall" : "player";
var fwd = mode == "stall" ? stallForward : forward;
if (rawSpec is not null)
{
var parts = rawSpec.Split(':');
mode = parts[0].Trim().ToLowerInvariant();
fwd = (parts.Length > 1 && float.TryParse(parts[1], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var f2)) ? f2 : 0f;
}
if (mode == "stall" && stallHit is not null)
{
var srad = stallHit.Yaw * Math.PI / 180.0;
colSpec[col] = (stallHit.X, stallHit.Y, stallHit.Z,
(float)Math.Sin(srad), (float)Math.Cos(srad),
(float)Math.Cos(srad), -(float)Math.Sin(srad), fwd, "stall");
}
else
{
colSpec[col] = (anchor.X, anchor.Y, anchor.Z, fx, fz, rx, rz,
fwd, mode == "stall" ? "player(fallback)" : "player");
}
}
conn.Log.Info("[BALLOON] 锚点按列: " + string.Join(" | ", colSpec.Select((c, i) =>
$"col{i}={c.tag}@({c.bx:F2},{c.by:F2},{c.bz:F2}) fwd={c.fwd:F2}m")));
// ★★★★★ R115 —— **官方球位表**(`balloonWallSpots`,逐点世界坐标)。
//
// 病因(R115 运行时探针实锤,见 _r114k_out.txt):
// 摊位 prefab 里 `QiQiuBan`(气球板)挂着官方那面墙 —— 节点 `BalloonBoard_Square2`
// (wp -4752.203,168.6,-2827.108, active=true) 有 **16 个 `BalloonPointXY` 子节点**,
// 它们的**世界坐标就是官方球位**(4 列 × 4 行,列距 0.4 m、行距 ≈0.348 m,
// 整面墙仅 1.2 m × 1.05 m、离地 0.775–1.82 m)。
// 而 R110/R111 那套「锚点 + forward/lateral/baseHeight/heightStep」是**我们自己编的**,
// 算出来是 4.6 m 宽 × 2.6 m 高的散布 ⇒ 看着「不在官方位置」。
// ⇒ 本表非空且全部可解析时,几何算法整段停用,位置一律取本表。
var spots = cfgStall.BalloonWallSpots ?? System.Array.Empty<string>();
var spotXyz = new (float x, float y, float z)[spots.Length];
var spotOk = 0;
for (var i = 0; i < spots.Length; i++)
{
var parts = (spots[i] ?? string.Empty).Split(',');
if (parts.Length >= 3
&& float.TryParse(parts[0].Trim(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var sx)
&& float.TryParse(parts[1].Trim(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var sy)
&& float.TryParse(parts[2].Trim(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var sz))
{
spotXyz[i] = (sx, sy, sz);
spotOk++;
}
}
var useSpots = spots.Length > 0 && spotOk == spots.Length;
conn.Log.Info(useSpots
? $"[BALLOON] 官方球位表 on: {spots.Length} 点(BalloonBoard_Square2 烘焙坐标)⇒ 几何算法停用"
: $"[BALLOON] 官方球位表 off(配置 {spots.Length} 项 / 可解析 {spotOk} 项)⇒ 退回几何算法");
// ★★★★★ R118(2026-09-27)—— **把官方那 16 个点「拉开间距」**。
//
// 用户实测(R117→R118 两轮):「球变成一面墙,都凑在一起」→「球的位置差不多了,但是要分散开」。
// 官方 `BalloonBoard_Square2` 的 4×4 点确实很挤:**列距 0.400 m / 行距 0.348 m,
// 整面墙只有 1.2 m × 1.045 m**,而球本身的包围盒是 0.185 × **0.3566** × 0.185
// (`_r109_authoring.json`)⇒ 上下**几乎贴住**,看起来就是"一坨"。
// ⇒ 以质心为轴按倍率放大(1.0 = 官方原样)。热重载键 `balloonWallSpotSpread`。
// ⚠ 只是"拉开",不改中心 —— 用户说"位置差不多了"。
var spotSpread = cfgStall.BalloonWallSpotSpread;
if (useSpots && spotSpread > 0f && MathF.Abs(spotSpread - 1f) > 0.001f)
{
var sscx = 0f;
var sscy = 0f;
var sscz = 0f;
for (var i = 0; i < spotXyz.Length; i++)
{
sscx += spotXyz[i].x;
sscy += spotXyz[i].y;
sscz += spotXyz[i].z;
}
sscx /= spotXyz.Length;
sscy /= spotXyz.Length;
sscz /= spotXyz.Length;
for (var i = 0; i < spotXyz.Length; i++)
spotXyz[i] = (sscx + (spotXyz[i].x - sscx) * spotSpread,
sscy + (spotXyz[i].y - sscy) * spotSpread,
sscz + (spotXyz[i].z - sscz) * spotSpread);
conn.Log.Info(
$"[BALLOON] 球位间距 ×{spotSpread:F2}(质心=({sscx:F2},{sscy:F2},{sscz:F2}))" +
$"⇒ 官方 4×4 的 0.400/0.348 列行距已拉开");
}
// ★★★★★ R116(2026-09-27)—— **球位整体朝玩家一侧外移,避开摊位那块「会动的布」**。
//
// 用户实测:「气球墙有一块会动的布,这块布经常会把球挡住一点」+「球变成一面墙,都凑在一起」。
// 官方那 16 个点是**贴在板上**的(板正面就是摊位装饰:`p_map01_level_sister_tile_board`
// 招牌帘布 / Lianzi 帘子,探针实测它们与球位在同一 y 区间 169.4–170.4)。
// ⇒ 把整组点沿「板法线 ≈ 球位质心 → 玩家」的水平方向外移 balloonWallSpotPull 米,
// 球就浮在帘布**前面**,既不穿模也不会被布挡。
//
// 方向是量出来的:板局部 +Z ≈ 世界 (0.707, 0, -0.707)(由 BalloonPoint 的局部 X 步进
// (-0.282,-0.282)/0.4 反解出 yaw=135°),而「质心→玩家」实测 ≈ (0.68, 0, -0.73)——
// 两者一致 ⇒ 「朝玩家」就是「沿板面法线往外」。热重载可调,0 = 官方原始位。
var spotPull = cfgStall.BalloonWallSpotPull;
if (useSpots && spotPull != 0f)
{
var spcx = 0f;
var spcz = 0f;
for (var i = 0; i < spotXyz.Length; i++) { spcx += spotXyz[i].x; spcz += spotXyz[i].z; }
spcx /= spotXyz.Length;
spcz /= spotXyz.Length;
var dxp = anchor.X - spcx;
var dzp = anchor.Z - spcz;
var dl = MathF.Sqrt(dxp * dxp + dzp * dzp);
if (dl > 0.05f)
{
var ux = dxp / dl;
var uz = dzp / dl;
for (var i = 0; i < spotXyz.Length; i++)
spotXyz[i] = (spotXyz[i].x + ux * spotPull, spotXyz[i].y,
spotXyz[i].z + uz * spotPull);
conn.Log.Info(
$"[BALLOON] 球位外移 {spotPull:F2}m 朝玩家 dir=({ux:F2},{uz:F2}) " +
$"质心=({spcx:F2},{spcz:F2}) ⇒ 避开摊位帘布/招牌");
}
}
// ★★★★★ R127(2026-09-28)—— **每局重排球位**(打散"4×4 方阵"的规律感)。
//
// 用户实测:「16 个球全部按规律排好,没有挑战性」「16 个球可以不按规则出现吗」。
// 官方 16 点是 4×4 网格 ⇒ 即使 spotSpread 把它们拉开,排列**仍然有规律**(一眼看穿行/列)。
// 开启 balloonWallRandomize 后,每局在本表点集所定义的**同一片区域**内重新随机撒点:
// 区域 = 「主轴(2D PCA) × Y 范围」;深度沿主轴法线**保持不变**
// ⇒ 仍是"上下 + 左右铺开、不往前后排"(用户明确要求);
// 拒绝采样保证 (主轴位移, Y) 平面内两两距离 ≥ balloonWallMinGap(球包围盒 0.185×0.357×0.185)。
// 颜色仍由 ordinal 决定(col = (ordinal-1)/rows)⇒ 4 色各 4 颗,只是位置被打散。
// ⚠ 新增配置键 ⇒ 必须重编 + 重启服务端(铁律 61)。
if (useSpots && cfgStall.BalloonWallRandomize && spotXyz.Length >= 2)
{
var rn = spotXyz.Length;
var rcx = 0f;
var rcz = 0f;
var ryMin = float.MaxValue;
var ryMax = float.MinValue;
for (var i = 0; i < rn; i++)
{
rcx += spotXyz[i].x;
rcz += spotXyz[i].z;
if (spotXyz[i].y < ryMin) ryMin = spotXyz[i].y;
if (spotXyz[i].y > ryMax) ryMax = spotXyz[i].y;
}
rcx /= rn;
rcz /= rn;
var sxx = 0f;
var sxz = 0f;
var szz = 0f;
for (var i = 0; i < rn; i++)
{
var dx = spotXyz[i].x - rcx;
var dz = spotXyz[i].z - rcz;
sxx += dx * dx;
sxz += dx * dz;
szz += dz * dz;
}
var axisTheta = 0.5f * MathF.Atan2(2f * sxz, sxx - szz);
var dirX = MathF.Cos(axisTheta);
var dirZ = MathF.Sin(axisTheta);
if (sxx + szz < 1e-6f) { dirX = 1f; dirZ = 0f; }
var halfW = 0f;
for (var i = 0; i < rn; i++)
halfW = MathF.Max(halfW,
MathF.Abs((spotXyz[i].x - rcx) * dirX + (spotXyz[i].z - rcz) * dirZ));
var gap = cfgStall.BalloonWallMinGap;
if (gap <= 0f) gap = 0.42f;
var spanY = ryMax - ryMin;
// ★★★★★ R140(2026-09-28)—— **帘子接缝禁带**(球不许落在三块帘子之间的木隔断上)。
//
// 用户实测(截图):「位置可以了,但是有球会被三块板子中间的隔断挡住」。
// 探针实测:三块帘子 `Lianzi`×3 的 local x = −2.423 / −0.805 / +0.811(间距 1.618 m)
// ⇒ 接缝在 local x ≈ −1.614 与 +0.003;而 R139 写进 `balloonWallSpots` 的四个「列」
// 正好是**帘子边界**(local x = −3.232 / −1.615 / +0.002 / +1.619)
// ⇒ 第 2、3 列**整列压在接缝上**,随机撒点后同样会落到那里。
// ⇒ 把「接缝两侧 balloonWallForbiddenHalfWidth 米」当禁带,采到就拒绝。
// `balloonWallForbiddenSpans` = 禁带**中心**的世界坐标("x,z"),沿主轴投影成 t 区间。
var forbidT = new System.Collections.Generic.List<(float lo, float hi)>();
var forbSpans = cfgStall.BalloonWallForbiddenSpans ?? System.Array.Empty<string>();
var forbHalf = cfgStall.BalloonWallForbiddenHalfWidth;
if (forbHalf <= 0f) forbHalf = 0.27f;
for (var i = 0; i < forbSpans.Length; i++)
{
var fp = (forbSpans[i] ?? string.Empty).Split(',');
if (fp.Length >= 2
&& float.TryParse(fp[0].Trim(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var seamX)
&& float.TryParse(fp[1].Trim(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var seamZ))
{
var ft = (seamX - rcx) * dirX + (seamZ - rcz) * dirZ;
forbidT.Add((ft - forbHalf, ft + forbHalf));
}
}
if (forbidT.Count > 0)
{
var sbF = new System.Text.StringBuilder();
for (var i = 0; i < forbidT.Count; i++)
sbF.Append(i == 0 ? $"({forbidT[i].lo:F3},{forbidT[i].hi:F3})"
: $" ({forbidT[i].lo:F3},{forbidT[i].hi:F3})");
conn.Log.Info(
$"[BALLOON] 球位禁带 ×{forbidT.Count}(半宽 {forbHalf:F2}m)t={sbF} " +
$"⇒ 避开三块帘子之间的木隔断");
}
var rng = new Random();
var placedT = new float[rn];
var placedY = new float[rn];
var made = 0;
for (var attempt = 0; attempt < 30000 && made < rn; attempt++)
{
var t = (float)(rng.NextDouble() * 2.0 - 1.0) * halfW;
var yy = ryMin + (float)rng.NextDouble() * spanY;
var inForbid = false;
for (var k = 0; k < forbidT.Count; k++)
{
if (t > forbidT[k].lo && t < forbidT[k].hi) { inForbid = true; break; }
}
if (inForbid) continue;
var ok = true;
for (var k = 0; k < made; k++)
{
var dl = t - placedT[k];
var dy = yy - placedY[k];
if (dl * dl + dy * dy < gap * gap) { ok = false; break; }
}
if (!ok) continue;
placedT[made] = t;
placedY[made] = yy;
made++;
}
if (made == rn)
{
for (var i = 0; i < rn; i++)
spotXyz[i] = (rcx + dirX * placedT[i], placedY[i], rcz + dirZ * placedT[i]);
conn.Log.Info(
$"[BALLOON] 球位重排(每局随机) on: {rn} 点 | 轴=({dirX:F2},{dirZ:F2}) " +
$"左右半宽 {halfW:F2}m | Y {ryMin:F2}~{ryMax:F2} | 最小间距 {gap:F2}m " +
$"| 禁带 {forbidT.Count}");
}
else
{
conn.Log.Info(
$"[BALLOON] 球位重排(每局随机) 放弃:只排出 {made}/{rn} 点" +
$"(间距 {gap:F2}m 过紧 / 禁带 {forbidT.Count} 段)⇒ 用固定球位表");
}
}
var ordinal = 0;
for (var col = 0; col < cols && ordinal < count; col++)
{
var cs = colSpec[col];
var lateral = (col - (cols - 1) * 0.5f) * lateralStep;
for (var row = 0; row < rows && ordinal < count; row++)
{
ordinal++;
var id = batchBase + (ulong)ordinal;
var color = (ordinal - 1) % cfgs.Length;
float x, y, z;
if (useSpots && ordinal - 1 < spots.Length)
{
var sp = spotXyz[ordinal - 1];
x = sp.x; y = sp.y; z = sp.z;
}
else
{
x = cs.bx + cs.cfx * cs.fwd + cs.crx * lateral;
y = cs.by + baseHeight + row * heightStep;
z = cs.bz + cs.cfz * cs.fwd + cs.crz * lateral;
}
var pos = new UXVector3 { X = x, Y = y, Z = z };
// ★★★ R112 — 提取「这一颗实际用的 cfg」:既写进 DestructibleInfo,也记进账本。
// 旧代码账本(BalloonTargets)用的是 BalloonWire.BalloonSceneItemCfgIds 固定数组,
// 一旦 cfgSweep 生效就与实际下发的不一致 ⇒ 会按**错误颜色**算分。
var useCfg = cfgSweep.Length > 0 ? colCfg[col] : cfgs[color];
infos.Add(new DestructibleInfo
{
InstanceId = id,
UniqueId = id,
CfgId = useCfg,
PathId = cfgSweep.Length > 0
? colPath[col]
: (cfgStall.BalloonUseSharedPathId
? BalloonWire.BalloonPathId // R100: 895168017 = 派对气球 a(cfg 80001468)
: paths[color]), // R100 五色路径(与 cfgs[] 逐项对应,见 BalloonWireContracts.cs)
Position = pos,
Facing = new UXVector3(),
// ★ R109 — 扫描开启时按**列**取值(col 0..3),否则统一用 iScale。
IScale = (scaleSweep is { Length: > 0 })
? scaleSweep[Math.Min(col, scaleSweep.Length - 1)]
: iScale,
NavId = 0,
// ★★★ R100 定案 —— **ForceLod0 已被证明不是「看不见」的原因,故回退为 false。**
// R100 一度改成 true(理由:GameRouter.Gadget.cs:137 的 gym 道具用 true 且可见)。
// 但同一批代码里 `GameRouter.GameGround.cs:714 / :737`(**保龄球的球和瓶**,
// 是唯一被实测证明能渲染出 SceneItem 的兄弟玩法)用的是 **false** —— 它也看得见。
// ⇒ ForceLod0 与可见性无关。真实原因是 R100 的新定案:
// 旧 cfg 80005796..80005800 的**美术资源从未随包发布**(见 BalloonWireContracts.cs
// 顶部那段四条证据链)。本轮只改「cfg + pathId」这一对变量,其余保持原值。
ForceLod0 = false,
BindPid = 0,
Hp = 1f, // 一枪一个
State = BalloonWire.DestructibleStateActive, // 0 = Active
BreakStage = 0,
OccupantInfos = new List<SceneDeviceOccupantInfo>(),
DropWeaponId = 0,
StackCount = 1,
EffectIds = new List<int>(),
NoSleep = true,
ReleaserId = 0,
SkillInfo = null,
});
ids.Add(id);
session.BalloonCfgById[id] = useCfg; // ★★★ R112 按颜色算分的账本
cells[((int)MathF.Floor(x / gridCellSize), (int)MathF.Floor(z / gridCellSize))] = true;
}
}
var px = anchor.X;
var pz = anchor.Z;
var cx = (int)MathF.Floor(px / gridCellSize);
var cz = (int)MathF.Floor(pz / gridCellSize);
cells[(cx, cz)] = true;
var indexList = new List<SceneMethods.GridIndex>();
foreach (var key in cells.Keys)
indexList.Add(new SceneMethods.GridIndex { X = key.Item1, Z = key.Item2 });
// ★★★★★ R122(2026-09-28)—— **开新一局前,先把上一局没打掉的球删掉。**
//
// 用户实测原话:「如果我在第一轮没有把气球打完,那么那些气球并不会被销毁,
// 所以在第二轮的时候还是会出问题」。
// 实测铁证(console-latest.log,同一会话):
// R1 07:56:08 spawn(…0011..0020)→ 只打爆 14 颗 → 剩 …001D/001E
// R1 07:56:48 Timeout(40s) 局末 → 撤保护 → 07:56:50 心跳 bytes 回到 1642(整格重置)
// R2 07:57:15 / 07:57:22 客户端**仍然**为 …001D/…001E 发破坏报文
// ⇒ `[BALLOON] ignore break n=1 … first=BA110000001D/001E`
// ⇒ 「整格重置」**并不会销毁客户端已经建好的 hold**,残留球跨局活着,
// 且与 R2 的新球落在**同一个球位**(同一面 16 个 balloonWallSpots)
// ⇒ 子弹先命中残留球的碰撞体、报的是旧 id ⇒ 服务端忽略、新球打不掉也不加分。
//
// 两条正交的修法(配置项 BalloonRemoveStaleIds 统一开关):
// ① 从 `SceneItemGrid.Protected` 摘掉这批历史 id(否则 5 s 心跳一直保活它们);
// ② 把它们的 id 填进本次 AOI 报文的 `aoiInfo.RemoveIds` —— 这是
// `DestructibleGridAOIIncrease.removeIds` 的本职(与 `addUniqueIds` 成对;
// 通道说明见 GameRouter.GameGround.cs:655),客户端据此删除对应 hold。
//
// ★★★★★ R123 更正 —— R122 版在这里改成 `PurgeStaleBalloonProtection`,**结构上不可能命中**:
// 局末 `FinishBalloonRoundAsync` 已经 ① 撤保护 ② `BalloonIds.Clear()`,
// 所以下一局 spawn 时 `Protected` 里**一个气球 id 都没有** ⇒ 返回空
// ⇒ 日志 `purge stale` 恒 0、`RemoveIds` 恒空。实测(2026-09-28 08:19~08:20,两局)
// 正是如此:0 条 `purge stale`、`ignore break` 仍在(first=BA1100000017 ∈ R1 未打掉集合)。
// ⇒ 真正要知道的那批 id 必须**在局末、撤保护之前**抓下来
// (`SurvivingBalloonIds` → `BalloonSession.RetiredIds`)。
// 本条仍保留 `PurgeStaleBalloonProtection` 作兜底(覆盖「中途退场没走局末」的残目)。
var staleIds = new List<ulong>();
if (cfgStall.BalloonRemoveStaleIds)
{
staleIds.AddRange(session.RetiredIds); // 上一局正经结算后的残留(主路径)
staleIds.AddRange(PurgeStaleBalloonProtection(ctx.Session, ids)); // 兜底:未走局末的残目
staleIds = staleIds.Distinct().OrderBy(x => x).ToList();
}
if (staleIds.Count > 0)
conn.Log.Info($"[BALLOON] retire stale n={staleIds.Count} " +
$"first={staleIds[0]:X} last={staleIds[^1]:X} " +
"⇒ 上一局残留:随本次 AOI 报文 RemoveIds 下发删除(客户端应删除对应 hold)");
try
{
// 1) the hold-creating channel (identical shape to bowling's).
var gridArgs = new SyncDestructibleGridAOIDecreaseArgs4229938
{
aoiInfo = new DestructibleGridAOIIncrease
{
PlayerStandardIndex = new SceneMethods.GridIndex { X = cx, Z = cz },
AddInfos = infos,
IndexList = indexList,
AddUniqueIds = new List<ulong>(ids),
// ★ R123 — 上一局残留下发删除(空 = 上一局打干净了,正常)。
RemoveIds = staleIds,
Reason = BowlingWire.AoiReasonInit,
},
addGridInfo = new SceneMethods.GridAOIDecrease
{
SectorIdList = new List<int> { 0 },
BuildingId = 0,
StandardIndexList = indexList
.Select(g => new SceneMethods.GridIndex { X = g.X, Z = g.Z }).ToList(),
ExceptIds = new List<ulong>(),
},
removeGridInfo = new SceneMethods.GridAOIDecrease(),
};
var gridBytes = UxSerializer.Serialize(gridArgs);
await conn.NotifyRawAsync(MethodId.SyncDestructibleGridAOIDecrease, gridBytes);
// ★ R123 — 残留已随上面的报文下发,清账(放在 send 之后:send 抛异常则保留,下次重试)。
session.RetiredIds.Clear();
// 2) the object-add channel (NotifyRaw, NOT Notify: bytes are already a UX body).
var addBytes = UxSerializer.Serialize(new DestructibleObjectAddList
{
AddInfos = infos,
Reason = BalloonWire.DestructibleCreateReasonBalloon, // Balloon = 18
});
// ★★★ R98 — SIDE-CHANNEL(照抄 GameRouter.GameGround.cs:877-928 的保龄球版)。
//
// WHY: 客户端的场景物层**只有一道门** ——
// SceneItemBlob.TryGetSceneItemPlaceInfoDataByPlaceId(uniqueId, out MA_Ref, out blobType)
// 它是对「关卡烘焙摆位表」的查表,而官方关卡**没有烘焙任何气球**
// (80005796..80005800 的 placeInfo 不在摊位区任何 vfc 块里)。
// ★ R100 补充(这两件事**互相独立**,别混为一谈):
// ①「查表查不到」= 客户端不建 hold ⇒ 修法是 R98 这道 side-channel + balloon_fix.js;
// ②「美术资源没随包」= hold 建出来了、就是画不出 ⇒ 修法是换 cfg+pathId(R100)。
// R99 扩了 isOurs 后 ① 已解决(实测 hold 12/12、GameObject 非空、位姿正确),
// 剩下的现象由 ② 解释 ⇒ **本轮不需要再动注入侧**。
// 本服合成的 0xBA11_xxxx_xxxx id 查不到 ⇒ 返回 false ⇒ 不建 hold ⇒
// 「面板开了、枪拿得起来、就是看不见气球」(R97 定案,R94「不需要注入」已作废)。
// ⇒ 客户端补丁 balloon_fix.js 必须伪造这次查表,而伪造要的就是这里的真值
// (尤其位置:气球墙在玩家前方 4×3,依赖当时的 yaw,客户端自己算会错一帧)。
//
// 落盘时机 = Notify **之前**,保证客户端收到 push 时文件已经在盘上。
// 失败绝不影响主流程(整段吞异常)。
try
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
var sb = new System.Text.StringBuilder();
sb.Append("{\"t\":").Append(DateTimeOffset.UtcNow.ToUnixTimeSeconds())
.Append(",\"kind\":\"balloon\",\"count\":").Append(infos.Count)
.Append(",\"yaw\":").Append(yaw.ToString("0.###", inv))
.Append(",\"items\":[");
for (int i = 0; i < infos.Count; i++)
{
var it = infos[i];
if (i > 0) sb.Append(',');
sb.Append("{\"id\":").Append(it.UniqueId)
.Append(",\"cfg\":").Append(it.CfgId)
.Append(",\"path\":").Append(it.PathId)
.Append(",\"kind\":\"balloon\"")
.Append(",\"x\":").Append(it.Position.X.ToString("0.#####", inv))
.Append(",\"y\":").Append(it.Position.Y.ToString("0.#####", inv))
.Append(",\"z\":").Append(it.Position.Z.ToString("0.#####", inv))
.Append(",\"scale\":").Append(it.IScale)
.Append(",\"i\":").Append(i)
.Append('}');
}
sb.Append("],\"player\":{\"x\":").Append(anchor.X.ToString("0.####", inv))
.Append(",\"z\":").Append(anchor.Z.ToString("0.####", inv))
.Append("},\"source\":\"").Append(source).Append("\"}");
var json = sb.ToString();
// ★ R141(2026-09-28)—— 落盘点必须与「服务端装在哪个目录」无关。
// 原先只有下面第 2 条那个写死的 D:\Ananta 路径 ⇒ 整包发给朋友后
// 服务端在写 A、注入器在读 B ⇒ pushN 恒 0 ⇒「气球一个都不出现」。
// 第 1 条是**机器相关但安装位置无关**的共享目录,注入器按同一规则算同一路径
// (gym_tools\bf_loader.py 的 push_path(),两侧必须保持一致,改一处要改两处)。
var sharedPush = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AnantaBalloon",
"balloon_push.json");
foreach (var p in new[]
{
sharedPush,
@"D:\Ananta\gym_tools\_balloon_push.json",
Path.Combine(AppContext.BaseDirectory, "config", "balloon_push.json"),
})
{
try
{
var dir = Path.GetDirectoryName(p);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(p, json);
}
catch { }
}
if (!_balloonPushPathLogged)
{
_balloonPushPathLogged = true;
conn.Log.Info($"[BALLOON] side-channel → {sharedPush}(与安装位置无关;注入器读同一路径)");
}
}
catch { }
await conn.NotifyRawAsync(MethodId.SyncDestructibleObjectAddList, addBytes);
// ★★★ R93 — 关键补丁:把 12 个气球登记为「受保护的可破坏物」。
//
// 病因(v1.9 真机实测,2026-09-27 18:18):气球 18:18:19 生成后,
// 场景物网格心跳(heartbeatMs=5000)在 18:18:23 就重发了同一格 (-119,-71) 的
// SyncDestructibleGridAOIDecrease,其 aoiInfo.AddInfos 为空 ⇒ 客户端按自己的
// 烘焙 SceneItemBlob 重建该格 ⇒ 我服合成出来的气球(blob 里没有)被整片抹掉。
// 结果 = 气球打不着、分数恒 0、客户端从不发 AskBreakDestructibleObjects。
//
// 登记后,心跳会把它们的 id 放进 addGridInfo.ExceptIds,并把 DestructibleInfo
// 重新塞回 aoiInfo.AddInfos ⇒ 气球在整局内稳定存在。
ProtectDestructibles(ctx.Session, infos.Select(i => (i.InstanceId, i.Position.X, i.Position.Z, (object)i)));
// ★★★ 2026-09-30 —— 一次性延迟重推(替补被关掉的 5 s 心跳重试通道)。
//
// 【背景】sceneItems.heartbeatMs=0(物件闪烁修复,§40)把「心跳把 Protected 的
// 球重新塞回 aoiInfo.addInfos ⇒ 客户端重建 hold 再查一次 placeInfo」这条
// 恢复路径也带走了。而注入器(balloon_fix.js r147 起)的 QUERY hook 是
// 「250 ms 定时器看到新鲜 push 文件才挂」,比客户端收到 AddList 后**当场**
// 查表晚 ~0.3–1 s ⇒ 首批 N 个 id 全部 MISS ⇒ 没有重推就永远不显示
// (实测 2026-09-30 15:0x:气球一个都看不见)。
//
// 【做法】spawn 后 +1.8 s / +6 s 各重发一次**同一条** grid 报文
// (与 :1079 逐字节相同)。客户端按 AOI 快照语义重建这些 hold ⇒ 第二次
// 查表时 hook 已挂上、PUSH 已读入、TPL 已有(常驻种子挂载,见修复 ⑬)
// ⇒ FAKED 成功,气球显示。+6 s 那发是「注入器恰好慢半拍」的保险。
// ⛑ 内容与首发完全相同 ⇒ 幂等(重建同一批 hold),不会抹窗口里其他物件
// (首发同款报文实测不抹)。会话断了就静默停(catch return)。
_ = Task.Run(async () =>
{
try
{
await Task.Delay(1800);
await conn.NotifyRawAsync(MethodId.SyncDestructibleGridAOIDecrease, gridBytes);
conn.Log.Info("[BALLOON] 延迟重推 gridAOIDecrease (+1.8s) —— 替补 heartbeatMs=0 后丢失的重试通道");
await Task.Delay(4200);
await conn.NotifyRawAsync(MethodId.SyncDestructibleGridAOIDecrease, gridBytes);
conn.Log.Info("[BALLOON] 延迟重推 gridAOIDecrease (+6s)");
}
catch { }
});
var firstPath = cfgStall.BalloonUseSharedPathId ? BalloonWire.BalloonPathId : paths[0];
conn.Log.Info(
$"[BALLOON] spawn n={ids.Count} cells={indexList.Count} " +
$"gridAOIDecrease(68969593)={gridBytes.Length}b objectAddList(68963429)={addBytes.Length}b " +
$"reason=Balloon(18) itemTag=1 anchor=({anchor.X:F1},{anchor.Y:F1},{anchor.Z:F1}) " +
$"yaw={yaw:F1} first={ids[0]:X} last={ids[^1]:X} cfg0={cfgs[0]} path0={firstPath} " +
$"legacyIds={useLegacyIds} roundBatch={batch} " +
$"sharedPathId={cfgStall.BalloonUseSharedPathId} " +
$"iScale={iScale} lod0=false source={source} protected={ids.Count}");
}
catch (Exception ex)
{
conn.Log.Warn($"[BALLOON] balloon spawn failed ({source}): {ex.Message}");
}
// ★★★★★ R116 — 一局限时(官方 Balloon_CountDown = 40 s),到点服务端主动结算。
// ★ R126:把本局的「局代」一起交给看门狗(见 BalloonSession.RoundGeneration)。
ArmBalloonRoundTimeout(conn, ctx, session, cfgStall.BalloonRoundSeconds, roundGen);
return ids;
}
/// <summary>
/// ★★★★★ R116(2026-09-27)—— **一局限时看门狗**。
///
/// <para>用户实测/提问:「打完了不应该是结算游戏吗,现在得自己手动离开」
/// 「这种小游戏肯定会限制只能打几枪吧,不然不限制谁都能打完,没有挑战性」。</para>
///
/// <para>官方口径(<c>ConfigDump_v3/Static/PoiGameConfig.static.json</c>):
/// <c>Balloon_CountDown = 40</c>(一局 40 秒)+ 分值 绿1/蓝3/紫5/金10
/// + 结算线 <c>Balloon_GoodEndCredit = 50</c> / <c>Balloon_NormalEndCredit = 25</c>。
/// 客户端 <c>BalloonGameStore.OnGameEnd</c> 就是按这两条线排 endType,并在 1.5 s 后
/// 自己 <c>gPanelManager:Close(BALLOON_PANEL)</c>。</para>
///
/// <para>到点后走与「打光最后一颗」完全相同的结束链路
/// (<see cref="FinishBalloonRoundAsync"/>:final score → GameOver → Dispose)。
/// 客户端提前手动退出时 <c>session.Active</c> 已为 false,这里直接返回(幂等)。</para>
/// </summary>
/// <param name="roundGen">
/// ★★★★★ R126 — 布防那一刻的 <see cref="BalloonSession.RoundGeneration"/>。
/// 醒来时若当前局代已变,说明**本局早被玩家手动退出、这是上一局留下的残骸**
/// ⇒ 直接丢弃(改前它会把下一局结算掉,用户症状:「第二轮结算时间非常快」)。
/// </param>
private void ArmBalloonRoundTimeout(
Connection conn, RpcContext ctx, BalloonSession session, int seconds, int roundGen)
{
if (seconds <= 0)
{
conn.Log.Info("[BALLOON] 限时关闭(balloonRoundSeconds<=0)⇒ 本局无时限");
return;
}
conn.Log.Info($"[BALLOON] 限时 {seconds}s 已布防(gen={roundGen},到点自动结算)");
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(seconds));
// ★★★★★ R126 — 双守卫:① 本局是否还活跃;② **本局还是"我这局"吗**。
// 旧代码只有 ①,而 session 是跨局复用的对象 ⇒ 上一局的任务醒来时读到的
// Active 是下一局的 true,于是把新局掐掉(实测:每局结束 = 上一局开始 + 40 s)。
if (!session.Active || session.RoundGeneration != roundGen)
{
conn.Log.Info(
$"[BALLOON] 限时任务作废 gen={roundGen} 当前gen={session.RoundGeneration} " +
$"active={session.Active} ⇒ 上一局留下的看门狗,丢弃、**不结算**");
return;
}
conn.Log.Info(
$"[BALLOON] 限时到({seconds}s)⇒ 主动结算 score={session.Score} " +
$"(GoodEnd>{BalloonWire.BalloonGoodEndCredit} / NormalEnd>{BalloonWire.BalloonNormalEndCredit})");
await FinishBalloonRoundAsync(conn, ctx, session, $"Timeout({seconds}s)", roundGen);
}
catch (Exception ex)
{
conn.Log.Warn($"[BALLOON] round timeout failed: {ex.Message}");
}
});
}
/// <summary>
/// Push SyncGameGroundZoneState (68383444). Wire = 30 bytes, no markers:
/// GameGroundZoneIdentifier (inline struct) GadgetUId u64 | TaskId u32 | ZoneType u8
/// state u8
/// GameGroundZoneCountDownInfo (inline) 4× u32
/// See BalloonWireContracts.cs for the derivation and for why this — not
/// SyncGameGroundZoneInfo — is the signal that opens/closes the balloon zone context.
/// </summary>
private static Task PushZoneStateAsync(Connection conn, ulong gadgetUId, byte state)
=> conn.NotifyAsync(MethodId.SyncGameGroundZoneState, new GameGroundZoneStatePush
{
ZoneIdentifier = new GameGroundZoneIdentifier
{
GadgetUId = gadgetUId,
TaskId = 0,
ZoneType = BalloonWire.ZoneTypeBalloon,
},
State = state,
CountDownInfo = new GameGroundZoneCountDownInfo(),
});
// ---------------------------------------------------------------------------------------
// LeaveBalloon (67075062) — the gameplay exit door.
//
// ClientToGameSceneDelegate.LeaveBalloon(gadgetuid : u64) -> 8 bytes, void return
// ("C2S INVOKE LeaveBalloon [0x03FF7BF6] body=8b").
//
// ★ Replying Ok is NOT enough to close the panel (see the file header). The close is driven by
// MiniGameDataManager.StopBalloon -> store.OnGameEnd -> Timer(1.5s) -> Close(BALLOON_PANEL).
// So before we reply, we push the END signal: a final SyncBalloonGameScore plus a
// SyncGameGroundZoneInfo carrying ZoneState = GameOver(3). Either of those is what the
// client's manager watches for, and pushing both makes the intent unambiguous.
// ---------------------------------------------------------------------------------------
[Handler(MethodId.LeaveBalloon, HandlerPacketKind.Both)]
private async Task LeaveBalloon4229938(Connection conn, UxRpcMessage msg)
{
ulong gadgetUId = 0;
if (msg.Body is { Length: >= 8 })
gadgetUId = BitConverter.ToUInt64(msg.Body, 0);
var session = GetBalloonSession(msg.Context);
var wasActive = session.Active;
// ★★★★★ R115 —— 结束链路抽成 FinishBalloonRoundAsync,供「手动退出」与「清光自动结算」共用。
// 旧代码把整段收尾写在 if (wasActive) 里,自动结算无从复用。
if (wasActive)
{
await FinishBalloonRoundAsync(conn, msg.Context, session, "LeaveBalloon");
}
else if (msg.Context is not null)
{
// ★★★ R89 — 客户端可能在 zone 还没建立时就点了退出,那把枪同样要收回去。
await RestoreBalloonWeaponAsync(conn, msg.Context);
}
conn.Log.Info($"[BALLOON] LeaveBalloon gadgetUId={gadgetUId} wasActive={wasActive} -> Ok");
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>
/// ★★★★★ R115 —— **结束一局**(唯一实现;LeaveBalloon 与「清光自动结算」都走这里)。
///
/// <para>为什么要抽出来:「打完了不结算、得自己手动离开」(R115 用户实测)——
/// 旧实现把结束链路绑死在 <c>LeaveBalloon</c> 上,而客户端**不会**在打光最后一颗球时
/// 自己发这个包(packets 实测:12/12 打完后 5 秒内 C2S 无任何包,直到玩家点退出)
/// ⇒ 面板永远不关。现在 <c>ScoreBalloonBreaksAsync</c> 在 <c>targets.Count == 0</c>
/// 时直接调本方法。</para>
///
/// <para>链路(三者缺一不可,见本文件头的 contract):
/// ① final <c>SyncBalloonGameScore</c>(客户端按总分排 endType);
/// ② <c>SyncGameGroundZoneInfo</c> + <c>ZoneState = GameOver(3)</c>(带**与开局完全相同**的
/// balloon id 列表 —— 客户端拿它对账,空列表会被忽略);
/// ③ <c>SyncGameGroundZoneState = Dispose(4)</c> —— ★ 唯一能真正撕掉 zone context 的通道;
/// 只推 ② 关不掉面板;推 GameOver 会走客户端有 bug 的 <c>OnGameEnd</c>(R93 定案)。</para>
/// </summary>
/// <param name="expectedGen">
/// ★★★★★ R126 — 发起者的「局代」(<see cref="BalloonSession.RoundGeneration"/>);
/// <c>-1</c> = 不校验(当前局自身的流程:手动 LeaveBalloon、清光自动结算)。
/// 只有 40 s 看门狗会传具体值 —— 它是唯一的**异步延迟**发起者,也是唯一可能
/// 「跨局醒来」的调用点。
/// </param>
private async Task FinishBalloonRoundAsync(
Connection conn, RpcContext? ctx, BalloonSession session, string reason, int expectedGen = -1)
{
if (!session.Active)
return;
// ★★★★★ R126 — 只允许「本局自己的发起者」结算本局。
if (expectedGen >= 0 && expectedGen != session.RoundGeneration)
{
conn.Log.Info(
$"[BALLOON] 结算请求作废 发起gen={expectedGen} 当前gen={session.RoundGeneration} " +
$"reason={reason} ⇒ 绝不让上一局的看门狗掐掉本局");
return;
}
// 0) 收枪(避免玩家揣着玩具枪离开摊位)。
if (ctx is not null)
await RestoreBalloonWeaponAsync(conn, ctx);
// 1) final score
await conn.NotifyAsync(MethodId.IGameToClient_SyncBalloonGameScore, session.Score);
// 2) zone over: ZoneState = GameOver(3)。id 列表必须与开局一致。
var endBalloonIds = new List<ulong>(session.BalloonIds);
var endInfo = new BalloonZoneInfo
{
TargetModelId = BalloonWire.TargetModelIdNone,
TargetSceneItemCfgId = BalloonWire.TargetSceneItemCfgId,
Score = session.Score,
BalloonInstanceIdList = endBalloonIds,
ZoneSessionId = session.ZoneSessionId,
GadgetUId = session.GadgetUId,
TaskId = 0,
StartReason = BalloonWire.ZoneStartReasonSingle,
GameTypeRandom = false,
SyncReason = BalloonWire.ZoneSyncReasonEnter,
ZoneType = BalloonWire.ZoneTypeBalloon,
ZoneState = BalloonWire.ZoneStateGameOver,
ParticipantInfos = new List<BalloonParticipantInfo>(),
CountDownInfo = new GameGroundZoneCountDownInfo(),
};
await conn.NotifyAsync(MethodId.SyncGameGroundZoneInfo, endInfo);
// 3) ★ 决定性的一推:Dispose 是终态,直接撕 context(不经过有 bug 的 OnGameEnd)。
await PushZoneStateAsync(conn, session.GadgetUId, BalloonWire.ZoneStateDispose);
conn.Log.Info(
$"[BALLOON] {reason} pushed END score={session.Score} zone={session.ZoneSessionId} " +
$"state=Dispose type={BalloonWire.ZoneTypeBalloon} balloons={endBalloonIds.Count}");
session.GadgetUId = 0;
session.Active = false;
session.Score = 0;
// ★★★★★ R123 — **先抓残留、再撤保护**(顺序反了这一步就白做)。
//
// 撤保护(下一行)会把本局 16 个 id 全摘出 `SceneItemGrid.Protected`,
// 之后再也无法区分「打掉的」与「没打掉的」;而客户端 hold 里没打掉的那几颗
// 并不会因为「整格重置」心跳而消失(实测 R122:R1 未打掉 {0x15,0x17,0x1C,0x20},
// R2 仍收到 ignore break first=BA1100000017)。
// ⇒ 此刻仍在 Protected 的就是「没打掉的」,存进 RetiredIds 让下一局 spawn 下发 RemoveIds。
// ⚠ 必须带 `BalloonIds.Count > 0` 守卫:本方法可能被调用两次(Timeout 之后又 Leave),
// 第二次 `BalloonIds` 已空,若无守卫会把刚记下的残留**冲成空**。
if (ctx is not null && session.BalloonIds.Count > 0)
{
session.RetiredIds.Clear();
session.RetiredIds.AddRange(SurvivingBalloonIds(ctx.Session, session.BalloonIds));
if (session.RetiredIds.Count > 0)
conn.Log.Info($"[BALLOON] retire record n={session.RetiredIds.Count} " +
$"first={session.RetiredIds[0]:X} last={session.RetiredIds[^1]:X} " +
"⇒ 本局未打掉的球,已记入 RetiredIds,下一局 spawn 时下发 RemoveIds");
}
// ★★★ R93 — 撤下气球保护(本局结束,气球已被客户端清掉,再保护就是幽灵条目)。
if (ctx is not null && session.BalloonIds.Count > 0)
UnprotectDestructibles(ctx.Session, session.BalloonIds);
session.BalloonIds.Clear();
session.BalloonCfgById.Clear();
// ★★★ R89 — 清账本(用 placements 前不能清,这里已经撤完实体了)。
if (ctx is not null)
{
var unitId = GetWorldState(ctx).ActiveSpiritUnitId;
if (unitId != 0)
BalloonTargets.TryRemove(unitId, out _);
}
}
// ---------------------------------------------------------------------------------------
// AskBreakDestructibleObject (67463592) — ★★★ R89 计分闭环,R88o 完全缺失的那一环。
//
// 不打这个 handler 的后果:客户端打碎气球后上报 → 走到 DefaultUnknownInvoke ⇒
// 一条 Warn + 空应答。客户端 err=0 以为受理,**永远等不到分数推送** ⇒
// 不打结分、面板分数恒 0、也没有爆破碎屑的分数气泡。
//
// 线格式(客户端 AskBreakDestructibleObject_Serializer,**权威** = AutoGen):
// u64 breaker
// Complex <DestructibleBrokenInfo> brokeninfos: // ★ 先写 1 字节标记 0xFF
// u64 InstanceId | u32 Stage | u8 BrokenType | UXVector3 Position | UXVector3 Facing
//
// ★★★ 2026-09-27 修正(R93,原判定有误,此处为更正后结论):
// 旧代码把第 8 字节起直接当 u64 读(BitConverter.ToUInt64(body, 8)),**是错的**。
// 真实第 8 字节是 SerializeBase.WriteComplex 写的**对象标记 0xFF**
// (RPCSerializeBase.lua:77-104:WriteComplex 只写 1 字节 mark 再写内联字段,
// **不带长度前缀**),真正的 InstanceId 在**偏移 9**。
// 读偏 1 字节 ⇒ 取到 0xFF + InstanceId 前 7 字节 拼成的垃圾值 ⇒
// targets.Remove 永不命中 ⇒ 每次都走 else「ignore break」⇒
// **分数永远 0、气球打了没反应**(真机日志 31 处 score=0 + 零处 broken 即此)。
//
// 非气球破坏(普通箱子/罐子)⇒ 与本 handler 注册前行为一致:回空 ACK,不改任何状态。
// ---------------------------------------------------------------------------------------
[Handler(MethodId.AskBreakDestructibleObject, HandlerPacketKind.Invoke)]
private async Task AskBreakDestructibleObject4229938(Connection conn, UxRpcMessage msg)
{
var body = msg.Body;
if (body is null || body.Length < 17 || msg.Context is null)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var breaker = BitConverter.ToUInt64(body, 0);
// 跳过 Complex 对象标记(0xFF;null 时为 0x00)。带 0x00 ⇒ 无 brokeninfos,直接空 ACK。
int off = 8;
byte mark = body[off];
if (mark == 0x00)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
off++; // 吃掉 0xFF 标记
if (body.Length < off + 8)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var brokenId = BitConverter.ToUInt64(body, off);
var state = GetWorldState(msg.Context);
var unitId = state.ActiveSpiritUnitId;
// ★ 诊断:整包 hex + 解析出的 id —— 首次真机若仍不命中,直接看 off=9 是否就是气球 InstanceId。
conn.Log.Info(
$"[BALLOON] break-raw len={body.Length} mark=0x{mark:X2} off={off} breaker={breaker} " +
$"id={brokenId} unit={unitId} @9={BitConverter.ToUInt64(body, 9):X} hex={Convert.ToHexString(body)}");
if (unitId != 0 &&
BalloonTargets.TryGetValue(unitId, out var targets) &&
targets.TryGetValue(brokenId, out var brokenCfg))
{
targets.Remove(brokenId);
// ★ R116 — 同复数版:立刻从保护名单摘掉,否则心跳(5s)会把它重建出来(打了又刷)。
UnprotectDestructibles(msg.Context.Session, new[] { brokenId });
var session = GetBalloonSession(msg.Context);
// ★★★★★ R112 — 官方「按颜色算分」(PoiGameConfig.static.json):
// 绿 1 / 蓝 3 / 紫 5 / 金 10。旧代码固定 +10(BalloonScorePerBalloon)已作废。
var credit = BalloonWire.CreditForCfg(brokenCfg);
session.Score += credit;
var score = session.Score;
conn.Log.Info(
$"[BALLOON] broken id={brokenId} cfg={brokenCfg} credit={credit} " +
$"breaker={breaker} score={score} left={targets.Count}");
// SyncBalloonGameScore(64893406) 的载荷就是一个 int32
// (客户端 reader:midToReader[64893406] = function(reader) return reader.ReadInt32(reader) end)。
await conn.NotifyAsync(MethodId.IGameToClient_SyncBalloonGameScore, score);
}
else
{
// 不是本轮气球:保持与注册前一致的空 ACK。
conn.Log.Info($"[BALLOON] ignore break id={brokenId} breaker={breaker} unit={unitId}");
}
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>
/// ★★★★★ R112(2026-09-27)—— **复数版破坏计分:客户端真正发的是这一条。**
///
/// 实机取证(logs/packets-latest.log 22:18:29):
/// C2S NOTIFY AskBreakDestructibleObjects [0x040BF2FC] body=48b
/// DECODED {"breaker":0,"brokenInfos":[{"InstanceId":204582177210376(=0xBA1100000008),
/// "Stage":0,"BrokenType":1,…}]}
/// ⇒ InstanceId 正是服务端登记、注入伪造的那颗气球,解码正确。
///
/// ⛔ 病因:单数版 `AskBreakDestructibleObject`(67463592) 挂着一整套计分逻辑,但
/// 客户端**从不发那一条**;而复数版过去只打一行日志 ⇒ **打爆气球永远不加分**。
/// (GameRouter.Balloon.cs:721 的老注释说「客户端从不发 AskBreakDestructibleObjects」是**反的**。)
///
/// 本方法与单数版共用同一套账本(BalloonTargets)与同一套「按颜色算分」(CreditForCfg)。
/// </summary>
internal async Task<int> ScoreBalloonBreaksAsync(
Connection conn, RpcContext ctx, ulong breaker, IReadOnlyList<ulong> brokenIds)
{
if (ctx is null || brokenIds.Count == 0)
return 0;
var state = GetWorldState(ctx);
var unitId = state.ActiveSpiritUnitId;
if (unitId == 0 || !BalloonTargets.TryGetValue(unitId, out var targets))
{
conn.Log.Info($"[BALLOON] break n={brokenIds.Count} no active balloon round (unit={unitId}) => ignored");
return 0;
}
var session = GetBalloonSession(ctx);
var gained = 0;
var hits = 0;
// ★★★★★ R116 — 被打爆的 id 收集起来,循环后从「受保护」名单里摘掉(见下方注释)。
List<ulong>? justBroken = null;
foreach (var brokenId in brokenIds)
{
if (!targets.TryGetValue(brokenId, out var cfg))
continue;
targets.Remove(brokenId);
(justBroken ??= new List<ulong>()).Add(brokenId);
var credit = BalloonWire.CreditForCfg(cfg);
session.Score += credit;
gained += credit;
hits++;
conn.Log.Info(
$"[BALLOON] broken id={brokenId} cfg={cfg} credit={credit} breaker={breaker} " +
$"score={session.Score} left={targets.Count}");
}
// ★★★★★ R116(2026-09-27)—— **止住「打了又刷出来」**。
//
// 用户实测:「紫,蓝,绿球在不停的出现,掉落,出现,掉落」「打了还会再刷新」
// 「只有金色球正常,所以永远结束不了」。服务端日志给出了硬证据:同一批 id 被打爆
// 之后又收到 11 次 `ignore break`(同一个 InstanceId 再爆一遍,账本里已经没有它了)。
//
// 病因是**我们自己**:spawn 时 16 颗球被 `ProtectDestructibles` 登记进
// SceneItemGrid 的 `Protected`,而网格心跳(heartbeatMs=5000)会把 Protected 里的条目
// 塞回 `addGridInfo.AddInfos` 一起重发(SceneItemGrid.cs:358,R93 加的机制)⇒
// 客户端每 5 秒按服务端重发的 DestructibleInfo **重建**已打爆的球(本地销毁状态被覆盖)。
// 结果:永远打不完 ⇒ 永远不进结算;而且重生的球再挨一枪时服务端只能 ignore。
//
// ⇒ 打爆一颗,就立刻把它从 Protected 里摘掉:心跳只会重发「还活着」的球。
// (整局结束时 LeaveBalloon 依旧会做一次全量 Unprotect,重复摘除是幂等的。)
if (justBroken is not null)
{
UnprotectDestructibles(ctx.Session, justBroken);
conn.Log.Info(
$"[BALLOON] unprotect n={justBroken.Count} ⇒ 心跳(5s)不再重建已打爆的球 " +
$"(left={targets.Count})");
}
if (hits == 0)
{
// ★★★★★ R119 —— **兜底摘除**(与计分无关,纯防御)。
// 客户端有可能报一个「id 在我们段内,但服务端账本里已经没有了」的球
// (跨局残留 / 重发的球再挨一枪)。这种情况下如果什么都不做,那颗球会一直留在
// `SceneItemGrid.Protected` 里,被 5 s 心跳反复重发 ⇒ 客户端重建 ⇒ **打完又刷新**。
// ⇒ 只要 id 落在本局段内(高 32 位 = 0xBA11)就摘掉;重复摘除是幂等的。
var segIds = new List<ulong>();
foreach (var brokenId in brokenIds)
if (BalloonWire.IsBalloonInstanceId(brokenId))
segIds.Add(brokenId);
if (segIds.Count > 0)
{
UnprotectDestructibles(ctx.Session, segIds);
conn.Log.Info($"[BALLOON] unprotect(seg-fallback) n={segIds.Count} " +
$"first={segIds[0]:X} ⇒ 心跳不再重建这些段内 id");
}
conn.Log.Info($"[BALLOON] ignore break n={brokenIds.Count} breaker={breaker} unit={unitId} " +
$"first={brokenIds[0]:X}");
return 0;
}
// SyncBalloonGameScore(64893406) 载荷 = 裸 int32(客户端 reader: reader.ReadInt32)。
await conn.NotifyAsync(MethodId.IGameToClient_SyncBalloonGameScore, session.Score);
conn.Log.Info($"[BALLOON] pushed SyncBalloonGameScore score={session.Score} " +
$"(+{gained} from {hits} balloon(s), breaker={breaker})");
// ★★★★★ R115 — **清光即自动结算**。
// 用户实测(R115):「打完了不应该是结算游戏吗,现在得自己手动离开」。
// packets 铁证:12/12 打完后 5 秒内 C2S 一个包都没有(下一次 C2S 就是玩家点退出的
// LeaveBalloon,23:06:03)⇒ 客户端**不会**自己发结束包 ⇒ 结束链路必须由服务端在
// 最后一颗被判定时主动触发,否则面板永远不关。
if (targets.Count == 0)
await FinishBalloonRoundAsync(conn, ctx, session, $"AutoFinish(last broken, hits={hits})");
return gained;
}
/// <summary>
/// GmEnterBalloonZone (69363970) — the GM-console door into the same zone entry.
///
/// Like its bowling twin (GmEnterBowlingZone 69135013) this is a DIFFERENT RPC from
/// EnterBalloonZone, and neither implies the other. Arg layout is
/// ClientToGameSceneGMDelegate.lua (SerializerHelper.GmEnterBalloonZone_Serializer):
/// gadgetuid : u64 (WriteUInt64)
/// gametype : u32 (WriteUInt32) &lt;- u32 here, u8 in the gameplay door
/// -> 12 bytes. This one is void-returning, unlike EnterBalloonZone which returns the object.
///
/// The in-world GM console (what /api/gm/send drives via SyncDoGmCommand) only ever sends this
/// id, so with EnterBalloonZone whitelisted and this one missing, the console command would
/// fall through to the typed-default body and the client would do nothing.
/// </summary>
[Handler(MethodId.GmEnterBalloonZone, HandlerPacketKind.Invoke)]
private async Task GmEnterBalloonZone4229938(Connection conn, UxRpcMessage msg)
{
ulong gadgetUId = 0;
uint gameTypeRaw = 0;
if (msg.Body is { Length: >= 12 })
{
gadgetUId = BitConverter.ToUInt64(msg.Body, 0);
gameTypeRaw = BitConverter.ToUInt32(msg.Body, 8);
}
// gameType is a u32 on this wire and a byte everywhere downstream; clamp is safe because
// the client's own gametype enum only defines small values.
var gameType = gameTypeRaw <= byte.MaxValue ? (byte)gameTypeRaw : (byte)0;
conn.Log.Info($"[BALLOON] GmEnterBalloonZone gadgetUId={gadgetUId} gameType={gameTypeRaw} bytes={msg.Body.Length}");
await StartBalloonZoneAsync(conn, msg.Context, gadgetUId, gameType, "GmEnterBalloonZone");
await conn.ReturnEmptyOkAsync(msg);
}
}