190 lines
11 KiB
C#
190 lines
11 KiB
C#
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;
|
||
}
|
||
}
|