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

190 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Ananta.SDK.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;
}
}