using Ananta.SDK.Rpc; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Handlers; using Ananta.Server.Protocol.Client4229938; namespace Ananta.Server.Handlers.Game; /// /// Gang-boss summon wheel (嘉姆 / 帮派Boss, Tab then dial to the second wheel), /// methodId 63745420 AskSummonGangMember. /// /// THE ONE SENTENCE THAT MATTERS: this is a real C2S round trip, so unlike the Z switch wheel /// the server decides what appears. That is why this wheel can surface characters the Z wheel /// never can — the Z wheel's membership lives in FightSpiritConfig.CanJoin behind a client-side /// gate, while this one hands the server an id and waits to see what comes back. /// /// What arrives is a single uint32 and what it means is not stable across builds: measurements on /// the reference branch saw the same slot send a gang-member row id (10520022) at one point and /// the display row's AgentQuoteId (40651920) at another. Neither is required to name something /// spawnable — 10520022 matches no config this server owns at all. /// /// Two things make this build more forgiving than the reference: /// /// 1. SlotOverrides lets sent-id be remapped server-side (see SummonWheelSettings), so the /// outcome never depends on guessing which id shape the client picked. /// 2. A failure prints the exact id plus the literal JSON to fix it, so an unresolvable slot /// costs one run to configure instead of one round of guesswork. /// /// The reply body is ignored by the client: summonCb reads only the retcode and maps a non-zero /// one through MessageConfig, which has no row for "this member cannot be projected" — an error /// would surface as an empty toast. So this always answers OK and reports through the console. /// internal sealed partial class GameRouter { /// /// Every slot id the client has actually sent since startup, in first-seen order. /// /// The wheel's slot ids are CompanionAgentConfig row ids and there is no server-side table that /// says which seven the current client picked, so the only way to know them is to watch the /// wire. The panel shows these so the operator can configure a slot without first clicking it /// and then reading the console — and so a different client version's ids get noticed. /// private static readonly HashSet s_observedSummonSentIds = []; private static readonly object s_observedLock = new(); /// Returns true the first time this slot id is seen, so the caller can log it once. private static bool NoteObservedSummonSentId(uint sentId) { if (sentId == 0) return false; lock (s_observedLock) return s_observedSummonSentIds.Add(sentId); } [Handler(MethodId.AskSummonGangMember, HandlerPacketKind.Invoke)] private async Task AskSummonGangMember(Connection conn, UxRpcMessage msg) { uint sentId = 0; if (!msg.TryGetArgs(out sentId)) conn.Log.Warn($"[SUMMON-WHEEL] body is not a single uint32 (len={msg.Body.Length}, hex={Convert.ToHexString(msg.Body)})"); // The panel that used to list these is gone, so the console is the only place a new slot id // shows up. Needed because sentId is a CompanionAgentConfig row id and nothing server-side // says which seven this client picked. if (NoteObservedSummonSentId(sentId)) conn.Log.Info($"[SUMMON-WHEEL] first sighting of slot id {sentId} (CompanionAgentConfig row); " + "add it to content.summonWheel.slotOverrides in private-server.json to point it at a character"); if (sentId == 0) { conn.Log.Warn("[SUMMON-WHEEL] AskSummonGangMember without a member id; replying OK so the wheel does not surface a bogus server error"); await conn.ReturnEmptyOkAsync(msg); return; } var requestId = ResolveSummonTarget(sentId, out var overrideLabel); if (overrideLabel is not null) conn.Log.Info($"[SUMMON-WHEEL] slot sent {sentId} remapped to {requestId} ({overrideLabel})"); // All-zero pose == "place it next to the player" on both paths (see the Spawn*Async bodies), // fanned out sideways so consecutive summons do not stack on one coordinate. var (spiritOk, spiritMessage) = await GameAdminBridge.SpawnNpcAsync(requestId, 0f, 0f, 0f, 0f); if (spiritOk) { conn.Log.Info($"[SUMMON-WHEEL] slot sent {sentId} -> character spirit: {spiritMessage}"); await conn.ReturnEmptyOkAsync(msg); return; } // Fall through to the raw-agent channel: it covers ids that are plain AgentConfig rows with // no owning FightSpirit (the 40xxxxx family that no character projects from). var (agentOk, agentMessage, _) = await GameAdminBridge.SpawnAgentAsync(requestId, 0f, 0f, 0f, 0f, hostile: false); if (agentOk) { conn.Log.Info($"[SUMMON-WHEEL] slot sent {sentId} -> ally agent: {agentMessage}"); await conn.ReturnEmptyOkAsync(msg); return; } // ⛑ 2026-10-01 companion channel: every companion template carries EnemyClassType=1, so the // summon catalog files it under hostile and the call above is refused by the // `entry.Hostile != hostile` gate — which is why the two 嘉姆 slots (10520000 -> 40651922 晴, // 10520017 -> 40155016 盖斯) summoned nothing at all even once their ids were mapped. // A companion is not an enemy, so it goes through the hostile channel only to satisfy that // gate, then immediately out of everything that makes an unit an enemy: no enemy-ledger // registration (engage:false), no explicit player/hostile relation (forceEnemyRelation:false), // and camp 0 instead of 4 so the player's own combat AI does not target it. That is the same // combination the tactical-support NPCs have spawned with since 2026-09-25. var (companionOk, companionMessage, companionUid) = await GameAdminBridge.SpawnAgentAsync( requestId, 0f, 0f, 0f, 0f, hostile: true, engage: false, forceEnemyRelation: false, // ⛑ 2026-10-01:原来是 0(MonsterDefault)⇒ 客户端把随从当敌人。模板自带的 Camp // 是 1 = UnitCamp.PlayerDefault ⇒ Companion 行为树才会接管(见 FactionInvasion.cs // 的同名注释)。值从 gameplay.aether.companionCamp 读,设 4 即一键回滚。 campOverride: PrivateServerConfigStore.Current.Gameplay.Aether.CompanionCamp); if (companionOk) { conn.Log.Info($"[SUMMON-WHEEL] slot sent {sentId} -> companion agent: {companionMessage}"); // 2026-10-01:光生成出来是不够的 —— 那样客户端只把它当成一个不打人的敌人 // (血条在、打不死、还能被玩家锁定)。真正的随从要走原生那两个包,见 // GameRouter.CompanionBind.cs。⛑ 必须等 spawn 包落地:客户端消费方会当场 // 拿 masterId / companionId 去 SceneDataMgr 查单位,太早推查不到。 await Task.Delay(250); var (bindOk, bindMessage) = await BindCompanionAsync(companionUid); conn.Log.Info($"[SUMMON-WHEEL] companion bind: {(bindOk ? "OK" : "FAILED")} {bindMessage}"); await conn.ReturnEmptyOkAsync(msg); return; } conn.Log.Warn($"[SUMMON-WHEEL] slot sent {sentId} could not be spawned. As character spirit: {spiritMessage}. As ally agent: {agentMessage}. As companion agent: {companionMessage}."); conn.Log.Warn($"[SUMMON-WHEEL] To point this slot at a specific character, add it to private-server.json under content.summonWheel.slotOverrides, e.g. " + $"{{\"sentId\": {sentId}, \"targetId\": 40140041, \"label\": \"Ringo\"}}. TargetId accepts a FightSpirit id (1502xxxx), " + "a cultivation id (9105xxxx) or an owning Agent id (40140041 = Ringo). Restart the server afterwards."); await conn.ReturnEmptyOkAsync(msg); } /// /// "Disband all" on the same wheel (63243016). Without a handler the button is a dead no-op and /// every summoned character stays in the world forever — there is no other despawn path. /// [Handler(MethodId.AskDestroyAllGangMember, HandlerPacketKind.Invoke)] private async Task AskDestroyAllGangMember(Connection conn, UxRpcMessage msg) { var (ok, message) = await GameAdminBridge.DismissSummonedNpcsAsync(); if (ok) conn.Log.Info($"[SUMMON-WHEEL] disband all -> {message}"); else conn.Log.Warn($"[SUMMON-WHEEL] disband all failed: {message}"); await conn.ReturnEmptyOkAsync(msg); } /// /// Maps the id the client sent onto the id to actually spawn. Returns the input unchanged when /// this slot is not configured, which keeps the reference branch's behaviour as the default. /// private static uint ResolveSummonTarget(uint sentId, out string? label) { label = null; // An operator override always wins: it is how a slot gets repointed at a playable // character instead of the companion the official table names. var overrides = PrivateServerConfigStore.Current?.Content?.SummonWheel?.SlotOverrides; if (overrides is not null) { for (var i = 0; i < overrides.Length; i++) { var slot = overrides[i]; if (slot is null || slot.SentId != sentId || slot.TargetId == 0) continue; label = string.IsNullOrWhiteSpace(slot.Label) ? "configured override" : slot.Label; return slot.TargetId; } } // ⛑ 2026-10-01: the wheel sends a CompanionAgentConfig row id (10520000 / 10520017 / …). // Until this table existed those ids went straight to the spawn helpers, where they can // never resolve — they are neither a FightSpirit nor an AgentConfig row — so every // companion slot failed with "could not be spawned" and the wheel looked dead. // AgentQuoteId is the translation the official table already carries. if (CompanionAgentSlotCatalog4229938.TryResolve(sentId, out var companion) && companion is not null) { label = string.IsNullOrWhiteSpace(companion.Name) ? $"CompanionAgentConfig -> {companion.AgentId}" : $"CompanionAgentConfig -> {companion.AgentId} {companion.Name}"; return companion.AgentId; } return sentId; } }