This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.GangBoss.cs
T

189 lines
11 KiB
C#
Raw Normal View History

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;
/// <summary>
/// Gang-boss summon wheel (嘉姆 / 帮派Boss, Tab then dial to the second wheel),
/// methodId 63745420 <c>AskSummonGangMember</c>.
///
/// 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.
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>
/// 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.
/// </summary>
private static readonly HashSet<uint> s_observedSummonSentIds = [];
private static readonly object s_observedLock = new();
/// <summary>Returns true the first time this slot id is seen, so the caller can log it once.</summary>
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<uint>(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);
}
/// <summary>
/// "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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}