// 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
{
/// Per-connection balloon session, parked in Session.Items like the bowling one.
private const string BalloonStateKey = "client4229938-balloon";
// 局内 session 后缀(只进日志与 ZoneSessionId 字符串,不是实体 id,但同样落盘保持单调)。
private const string BalloonZoneSeqCounter = "Balloon.ZoneSeq";
///
/// ★★★ R89 — 进气球区前的手持武器实例 ID,退出时还原(key = spirit unitId)。
/// 用 TryAdd:重复进入同一局不会把「原来的枪」覆盖成玩具枪。
///
private static readonly ConcurrentDictionary BalloonSavedWeapon = new();
///
/// ★★★ R89 — 当前一轮气球靶:key = spirit unitId,value =(气球实例 ID → 气球 cfgId)。
/// 只在 EnterBalloonZone 建立、LeaveBalloon 清空,用来判定「打爆的是不是本轮的气球」。
///
private static readonly ConcurrentDictionary> BalloonTargets = new();
///
/// ★ R141(2026-09-28)—— side-channel 落盘路径的「报一次」标志。
///
/// WHY:side-channel(把本局气球的真值落盘给客户端注入器读)原先写死
/// D:\Ananta\gym_tools\_balloon_push.json ⇒ **整包发给别人 / 换盘符 / 换个目录就断**:
/// 服务端在写 A、注入器在读 B ⇒ pushN 恒 0 ⇒ 气球一个都不出现。
/// 修法见 PushBalloonWallAsync:改成写「与安装位置无关」的共享目录
/// %LOCALAPPDATA%\AnantaBalloon\balloon_push.json,
/// 注入器 gym_tools\AnantaBalloonLoader.exe 按**同一规则**算同一路径。
/// 这个标志只用来把「到底用了哪个路径」打一次日志(判活用),不影响主流程。
///
private static bool _balloonPushPathLogged;
private sealed class BalloonSession
{
internal ulong GadgetUId;
internal string ZoneSessionId = "";
internal long StartedUtcTicks;
internal int Score;
internal bool Active;
/// The REAL scene-item instance ids pushed at zone entry (R88o).
internal readonly List BalloonIds = new();
///
/// ★★★ R112 — 本轮每颗气球**实际下发**的 cfg(id → cfgId)。
/// 用于「按颜色算分」(BalloonWire.CreditForCfg)。旧代码的账本用的是
/// BalloonWire.BalloonSceneItemCfgIds 这个**固定 5 元组**,cfgSweep 生效时与实际不符。
///
internal readonly Dictionary BalloonCfgById = new();
///
/// ★★★ R123 — 上一局**没打掉**的那几颗(局末、撤保护之前抓下来)。
///
/// 为什么必须存它:局末 FinishBalloonRoundAsync 会
/// ① 把本局 16 个 id 全摘出 SceneItemGrid.Protected、
/// ② .Clear()。⇒ 下一局 spawn 时**两条线索都没了**,
/// 服务端根本不知道上一局剩了哪几颗,而客户端 hold 里那几颗**还活着**
/// (整格重置的心跳只停止"保活",不会主动删客户端的 hold)。
/// 新球又落在**同一批 balloonWallSpots** 上 ⇒ 子弹先命中残留球的碰撞体、
/// 报的是旧 id ⇒ 服务端 ignore break,新球既打不掉也不加分。
///
/// 实测铁证(R122,2026-09-28 08:19~08:20):
/// R1 spawn id 0x11..0x20 只打爆 12 颗,未打掉 = {0x15,0x17,0x1C,0x20};
/// R2(0x21..0x30)在 08:20:19 收到
/// [BALLOON] ignore break n=1 first=BA1100000017 —— 0x17 **正是** R1 的残留之一。
///
/// 用法:下一局 spawn 时把这批 id 填进 AOI 报文的
/// aoiInfo.RemoveIds(DestructibleGridAOIIncrease.removeIds,
/// 与 addUniqueIds 成对;通道说明见 GameRouter.GameGround.cs:655),
/// 客户端据此删掉对应 hold;下发成功后清空。
///
internal readonly List RetiredIds = new();
///
/// ★★★★★ R126(2026-09-28)—— **局代**:每开一局 +1。
///
/// 为什么必须有它(用户实测:「第二轮结算时间非常快,没法正常测试」):
/// 本类**按 session 复用、不每局新建**,而 每局都
/// Task.Run 起一个 Task.Delay(40s),醒来后只用 session.Active 当守卫。
/// 玩家在 40 s 内手动 LeaveBalloon 再马上重进时:上一局的任务醒来时
/// **看到的 Active 已经是下一局的 true** ⇒ 直接把新局结算掉。
///
/// 实测铁证(R126,2026-09-28 09:01:29~09:03:15,5 局)——
/// 每局的结束时刻**恰好等于上一局开始时刻 + 40 s**:
///
/// 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
///
/// ⇒ 越往后局越短(玩家 9~25 s 就退再进),完全对得上。
///
/// 用法:SpawnBalloonsAsync 开头 ++,把当次的值闭包给定时任务;
/// 任务醒来与 都校验「发起者是不是当前局」。
///
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);
}
///
/// ★★★ R89 — 按官方武器发布管线的形状,把气枪(80300134)发布为当前手持武器。
/// 与 GameRouter.Profiles.PublishWeaponSnapshot 同一条链路:
/// SyncSpiritWeaponDetail(槽位视图)→ SyncSpiritSwitchWeaponAction(选中实例)。
///
/// ⛔ 不能用 CombatCodec.SpiritWeaponSnapshot —— 它会把 currentWeaponInstanceId
/// **强行塞进槽位列表**,而玩具枪不在玩家的常规配装上,必须手搓 SpiritWeaponDetail。
///
/// ⛔ 也**不要**补 SyncAgentWeaponWheel(68121167):对照包 2026-09-26 实测客户端解不开
/// (Player.log: deserialize failed mode=3 mid=68121167 len=24),且它只写
/// agentCurrentWeaponDict,仅 RobotDog/RobotFlyer/RobotSpider 三个机器人控件读它 ——
/// 气球面板要的 currentWeapon 来自 SyncSpiritWeaponDetail。
///
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 { weapon },
CurrentTempWeapon = null,
TempWeaponSlots = null,
VirtualWeaponSlots = new Dictionary(),
};
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}");
}
///
/// ★★★ R89 — 退出气球区时还原进气前的手持武器。
/// 这里**可以**用官方快照(原武器本来就在配装里),避免被玩具枪顶掉。
///
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}");
}
///
/// 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.
///
private async Task 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(), // 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(), // 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();
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;
}
///
/// 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 行)。
///
///
/// ★★★★★ R122(2026-09-28)—— 把 SceneItemGrid.Protected 里**不属于本局**的气球 id
/// 全部摘掉并返回(供 AOI 报文的 aoiInfo.RemoveIds 使用)。
///
/// 为什么必须有这一步:Protected 是 Dictionary<ulong,…>、**只增不减**,
/// 而 5 s 心跳会把表里每个 id 都塞回 AddInfos + ExceptIds
/// (SceneItemGrid.cs:344-382)⇒ 上一局没打掉的球会被**永远保活**。
///
/// 判定只用高位前缀 (0xBA11),
/// 所以不会误伤保龄球(0x9C7B 段)或普通场景物。
/// = 本局刚分配的那 16 个(它们此刻还没进 Protected,
/// 传进来只是兜底:任何在本局名单里的 id 一律不删)。
///
private static List PurgeStaleBalloonProtection(
TcpSession session, IEnumerable currentRoundIds)
{
var owner = SceneItemGridStateOf(session);
var keep = new HashSet(currentRoundIds);
var stale = new List();
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;
}
///
/// ★★★ R123(2026-09-28)—— 本局 16 个 id 里,**此刻仍在 Protected 的**(= 本局没打掉的)。
///
/// 为什么在局末抓、而不是下一局抓:FinishBalloonRoundAsync 会紧接着调
/// 把本局 id 全摘出 Protected,
/// 摘完就再也查不到"哪几颗没打掉"了(R122 的 PurgeStaleBalloonProtection
/// 在下一局 spawn 时才查 ⇒ 表已空 ⇒ 日志 purge stale 恒 0、修了个寂寞)。
///
/// 结果存进 ,由下一局 spawn 作为
/// aoiInfo.RemoveIds 下发。
///
private static List SurvivingBalloonIds(TcpSession session, IEnumerable roundIds)
{
var owner = SceneItemGridStateOf(session);
var alive = new List();
lock (owner.SyncRoot)
foreach (var id in roundIds)
if (owner.Protected.ContainsKey(id))
alive.Add(id);
alive.Sort();
return alive;
}
private async Task> 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();
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(count);
var ids = new List(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();
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();
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();
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(),
DropWeaponId = 0,
StackCount = 1,
EffectIds = new List(),
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();
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();
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(ids),
// ★ R123 — 上一局残留下发删除(空 = 上一局打干净了,正常)。
RemoveIds = staleIds,
Reason = BowlingWire.AoiReasonInit,
},
addGridInfo = new SceneMethods.GridAOIDecrease
{
SectorIdList = new List { 0 },
BuildingId = 0,
StandardIndexList = indexList
.Select(g => new SceneMethods.GridIndex { X = g.X, Z = g.Z }).ToList(),
ExceptIds = new List(),
},
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;
}
///
/// ★★★★★ R116(2026-09-27)—— **一局限时看门狗**。
///
/// 用户实测/提问:「打完了不应该是结算游戏吗,现在得自己手动离开」
/// 「这种小游戏肯定会限制只能打几枪吧,不然不限制谁都能打完,没有挑战性」。
///
/// 官方口径(ConfigDump_v3/Static/PoiGameConfig.static.json):
/// Balloon_CountDown = 40(一局 40 秒)+ 分值 绿1/蓝3/紫5/金10
/// + 结算线 Balloon_GoodEndCredit = 50 / Balloon_NormalEndCredit = 25。
/// 客户端 BalloonGameStore.OnGameEnd 就是按这两条线排 endType,并在 1.5 s 后
/// 自己 gPanelManager:Close(BALLOON_PANEL)。
///
/// 到点后走与「打光最后一颗」完全相同的结束链路
/// (:final score → GameOver → Dispose)。
/// 客户端提前手动退出时 session.Active 已为 false,这里直接返回(幂等)。
///
///
/// ★★★★★ R126 — 布防那一刻的 。
/// 醒来时若当前局代已变,说明**本局早被玩家手动退出、这是上一局留下的残骸**
/// ⇒ 直接丢弃(改前它会把下一局结算掉,用户症状:「第二轮结算时间非常快」)。
///
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}");
}
});
}
///
/// 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.
///
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);
}
///
/// ★★★★★ R115 —— **结束一局**(唯一实现;LeaveBalloon 与「清光自动结算」都走这里)。
///
/// 为什么要抽出来:「打完了不结算、得自己手动离开」(R115 用户实测)——
/// 旧实现把结束链路绑死在 LeaveBalloon 上,而客户端**不会**在打光最后一颗球时
/// 自己发这个包(packets 实测:12/12 打完后 5 秒内 C2S 无任何包,直到玩家点退出)
/// ⇒ 面板永远不关。现在 ScoreBalloonBreaksAsync 在 targets.Count == 0
/// 时直接调本方法。
///
/// 链路(三者缺一不可,见本文件头的 contract):
/// ① final SyncBalloonGameScore(客户端按总分排 endType);
/// ② SyncGameGroundZoneInfo + ZoneState = GameOver(3)(带**与开局完全相同**的
/// balloon id 列表 —— 客户端拿它对账,空列表会被忽略);
/// ③ SyncGameGroundZoneState = Dispose(4) —— ★ 唯一能真正撕掉 zone context 的通道;
/// 只推 ② 关不掉面板;推 GameOver 会走客户端有 bug 的 OnGameEnd(R93 定案)。
///
///
/// ★★★★★ R126 — 发起者的「局代」();
/// -1 = 不校验(当前局自身的流程:手动 LeaveBalloon、清光自动结算)。
/// 只有 40 s 看门狗会传具体值 —— 它是唯一的**异步延迟**发起者,也是唯一可能
/// 「跨局醒来」的调用点。
///
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(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(),
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 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);
}
///
/// ★★★★★ 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)。
///
internal async Task ScoreBalloonBreaksAsync(
Connection conn, RpcContext ctx, ulong breaker, IReadOnlyList 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? justBroken = null;
foreach (var brokenId in brokenIds)
{
if (!targets.TryGetValue(brokenId, out var cfg))
continue;
targets.Remove(brokenId);
(justBroken ??= new List()).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();
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;
}
///
/// 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) <- 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.
///
[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);
}
}