feat: Upgrade server with HyNB v2.4 modular architecture, minigames, admin GM web panel, companion summons, and car radio
This commit is contained in:
1 parent
74e2963a12
commit
3e5adf1dc5
1571 files changed
+336468
-6348
No files matched your search
@@ -1,291 +1,146 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.Handlers;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Wardrobe & Fashion dressing engine for client 4229938.
|
||||
/// Handles dressing, customizing, and equipping fashion items and persists worn state per spirit.
|
||||
/// Fashion/wardrobe handlers. The client's appearance panel sends
|
||||
/// AskSetSpiritFashionsWithSource when the player confirms a skin. Without a
|
||||
/// server-side handler the request is dropped and the model reverts to the
|
||||
/// login default when the panel closes. We accept the choice and echo it back
|
||||
/// via SyncSetSpiritFashions so the worn skin persists.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
private async Task HandleAskSetSpiritFashionsWithSourceAsync(Connection conn, UxRpcMessage msg)
|
||||
// ★ 2026-10-02:补上**老版衣柜入口** AskSetSpiritFashions(63347169)(自 7k-v0.6
|
||||
// GameRouter.Fashion.cs 移植)。它与带 source 的新入口差 2 字节:
|
||||
// 老包 = [u32 spiritId][wear object],新包 = [u32 spiritId][short source][wear object]。
|
||||
// 做法是在偏移 4 处补一个 short 8(FashionWearSource.Spirit,小端 = 08 00),
|
||||
// 然后**走完全同一套**穿戴逻辑 —— 不另起一条分支,避免两套行为漂移。
|
||||
// ⛑ 已核对主线契约 AskSetSpiritFashionsWithSource.source 就是 short(FashionMethods.cs:11)
|
||||
// ⇒ 补 2 字节与捐赠端的字节拼接逐字节一致。
|
||||
[Handler(MethodId.AskSetSpiritFashionsWithSource, HandlerPacketKind.Invoke)]
|
||||
[Handler(MethodId.AskSetSpiritFashions, HandlerPacketKind.Invoke)]
|
||||
private async Task AskSetSpiritFashionsWithSource(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskSetSpiritFashionsWithSource>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritOrInstanceId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
state.CustomWornFashionsBySpirit[spiritId] = args.spiritWearFashionsInfo;
|
||||
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
// Notify client of updated worn fashion
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
var body = msg.Body;
|
||||
if (msg.MethodId == MethodId.AskSetSpiritFashions)
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = args.source,
|
||||
spiritWearFashionsInfo = args.spiritWearFashionsInfo,
|
||||
});
|
||||
|
||||
conn.Session.Log.Info($"[FASHION] AskSetSpiritFashionsWithSource: spirit={spiritId} source={args.source} items={args.spiritWearFashionsInfo.WearFashionInfoList.Count}");
|
||||
}
|
||||
|
||||
private async Task HandleAskSetSpiritFashionsAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskSetSpiritFashions>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
state.CustomWornFashionsBySpirit[spiritId] = args.spiritWearFashionsInfo;
|
||||
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = 0,
|
||||
spiritWearFashionsInfo = args.spiritWearFashionsInfo,
|
||||
});
|
||||
|
||||
conn.Session.Log.Info($"[FASHION] AskSetSpiritFashions: spirit={spiritId} items={args.spiritWearFashionsInfo.WearFashionInfoList.Count}");
|
||||
}
|
||||
|
||||
private async Task HandleAskModifySpiritWearFashionsOnlyWearWithSourceAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionsOnlyWearWithSource>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritOrInstanceId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
ApplyFashionWearDiff(currentWear, args.unwearFashionIdList, args.wearFashionInfoList);
|
||||
|
||||
var response = new GameMethods.ModifySpiritWearFashionsOnlyWearResponse
|
||||
{
|
||||
unwearFashionIdList = args.unwearFashionIdList,
|
||||
wearFashionInfoList = args.wearFashionInfoList,
|
||||
};
|
||||
await conn.ReturnAsync(msg, response);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = args.source,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
|
||||
conn.Session.Log.Info($"[FASHION] ModifySpiritWearFashionsOnlyWearWithSource: spirit={spiritId} unwear={args.unwearFashionIdList.Count} wear={args.wearFashionInfoList.Count}");
|
||||
}
|
||||
|
||||
private async Task HandleAskModifySpiritWearFashionsOnlyWearAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionsOnlyWear>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
ApplyFashionWearDiff(currentWear, args.unwearFashionIdList, args.wearFashionInfoList);
|
||||
|
||||
var response = new GameMethods.ModifySpiritWearFashionsOnlyWearResponse
|
||||
{
|
||||
unwearFashionIdList = args.unwearFashionIdList,
|
||||
wearFashionInfoList = args.wearFashionInfoList,
|
||||
};
|
||||
await conn.ReturnAsync(msg, response);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = 0,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
|
||||
conn.Session.Log.Info($"[FASHION] ModifySpiritWearFashionsOnlyWear: spirit={spiritId} unwear={args.unwearFashionIdList.Count} wear={args.wearFashionInfoList.Count}");
|
||||
}
|
||||
|
||||
private async Task HandleAskModifySpiritWearFashionsWithSourceAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionsWithSource>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritOrInstanceId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
ApplyFashionWearDiff(currentWear, args.unwearFashionIdList, args.wearFashionInfoList);
|
||||
ApplyFashionEditDiff(currentWear, args.uneditWearFashionIdList, args.editWearFashionEditInfoList);
|
||||
|
||||
var result = new GameMethods.ModifySpiritWearFashionResult
|
||||
{
|
||||
R0 = args.unwearFashionIdList,
|
||||
R1 = args.wearFashionInfoList,
|
||||
R2 = args.uneditWearFashionIdList,
|
||||
R3 = args.editWearFashionEditInfoList,
|
||||
};
|
||||
await conn.ReturnAsync(msg, result);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = args.source,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
|
||||
conn.Session.Log.Info($"[FASHION] ModifySpiritWearFashionsWithSource: spirit={spiritId}");
|
||||
}
|
||||
|
||||
private async Task HandleAskModifySpiritWearFashionEditInfosWithSourceAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionEditInfosWithSource>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritOrInstanceId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
ApplyFashionEditDiff(currentWear, args.uneditWearFashionIdList, args.editWearFashionEditInfoList);
|
||||
|
||||
var response = new GameMethods.ModifySpiritWearFashionEditInfosResponse
|
||||
{
|
||||
uneditWearFashionIdList = args.uneditWearFashionIdList,
|
||||
editWearFashionEditInfoList = args.editWearFashionEditInfoList,
|
||||
};
|
||||
await conn.ReturnAsync(msg, response);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = args.source,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleAskModifySpiritWearFashionEditInfosAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionEditInfos>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
ApplyFashionEditDiff(currentWear, args.uneditWearFashionIdList, args.editWearFashionEditInfoList);
|
||||
|
||||
var response = new GameMethods.ModifySpiritWearFashionEditInfosResponse
|
||||
{
|
||||
uneditWearFashionIdList = args.uneditWearFashionIdList,
|
||||
editWearFashionEditInfoList = args.editWearFashionEditInfoList,
|
||||
};
|
||||
await conn.ReturnAsync(msg, response);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = 0,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleAskSetSpiritWearFashionHiddenPartsWithSourceAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskSetSpiritWearFashionHiddenPartsWithSource>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritOrInstanceId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
currentWear.HiddenParts = args.hiddenParts;
|
||||
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = args.source,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleAskSetSpiritWearFashionHiddenPartsAsync(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskSetSpiritWearFashionHiddenParts>();
|
||||
var state = GetWorldState(msg.Context);
|
||||
var spiritId = args.spiritId;
|
||||
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
|
||||
|
||||
var currentWear = GetOrCreateSpiritWear(state, spiritId);
|
||||
currentWear.HiddenParts = args.hiddenParts;
|
||||
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
spiritId = spiritId,
|
||||
source = 0,
|
||||
spiritWearFashionsInfo = currentWear,
|
||||
});
|
||||
}
|
||||
|
||||
private static GameMethods.SpiritWearFashionsInfo GetOrCreateSpiritWear(WorldEntryState state, uint spiritId)
|
||||
{
|
||||
if (!state.CustomWornFashionsBySpirit.TryGetValue(spiritId, out var currentWear))
|
||||
{
|
||||
var defaultIds = ClientConfigRepository.DefaultFashionIds(spiritId);
|
||||
currentWear = RuntimePayloadFactory.WearFashionsForGameMethods(defaultIds);
|
||||
state.CustomWornFashionsBySpirit[spiritId] = currentWear;
|
||||
}
|
||||
return currentWear;
|
||||
}
|
||||
|
||||
private static void ApplyFashionWearDiff(
|
||||
GameMethods.SpiritWearFashionsInfo wear,
|
||||
List<uint> unwearIds,
|
||||
List<GameMethods.WearFashionInfo> newWear)
|
||||
{
|
||||
if (unwearIds.Count > 0)
|
||||
{
|
||||
var unwearSet = unwearIds.ToHashSet();
|
||||
wear.WearFashionInfoList.RemoveAll(x => unwearSet.Contains(x.FashionId));
|
||||
}
|
||||
|
||||
foreach (var item in newWear)
|
||||
{
|
||||
if (!wear.WearFashionInfoList.Any(x => x.FashionId == item.FashionId))
|
||||
if (body.Length is < 4 or > 4094)
|
||||
{
|
||||
wear.WearFashionInfoList.Add(item);
|
||||
conn.Log.Warn($"[FASHION] legacy wear rejected bytes={body.Length}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
return;
|
||||
}
|
||||
var withSource = new byte[body.Length + 2];
|
||||
body.AsSpan(0, 4).CopyTo(withSource);
|
||||
withSource[4] = 8; // FashionWearSource.Spirit(short 小端:08 00)
|
||||
body.AsSpan(4).CopyTo(withSource.AsSpan(6));
|
||||
body = withSource;
|
||||
}
|
||||
|
||||
var args = TryDecode4229938<GameMethods.AskSetSpiritFashionsWithSource>(body);
|
||||
if (args is null)
|
||||
{
|
||||
conn.Log.Warn($"[FASHION] AskSetSpiritFashionsWithSource undecodable bytes={msg.Body.Length}"
|
||||
+ (msg.MethodId == MethodId.AskSetSpiritFashions ? " (legacy entry, spliced)" : ""));
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
var spiritId = args.spiritOrInstanceId;
|
||||
var wearCount = args.spiritWearFashionsInfo?.WearFashionInfoList?.Count ?? 0;
|
||||
var fashionIds = wearCount > 0
|
||||
? string.Join(",", args.spiritWearFashionsInfo!.WearFashionInfoList.Select(x => x.FashionId))
|
||||
: "(none)";
|
||||
|
||||
conn.Log.Info($"[FASHION] wear spirit={spiritId} source={args.source} count={wearCount} ids=[{fashionIds}]");
|
||||
|
||||
// Answer before doing the work. This is an INVOKE, so the wardrobe panel sits blocked until
|
||||
// the response arrives — and the work below includes a disk write plus a notify. Replying
|
||||
// first keeps a slow save from freezing the UI. Nothing after this point can report failure
|
||||
// to the client, so the whole tail is wrapped and logged instead.
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
try
|
||||
{
|
||||
await ApplyWearAsync(conn, msg, spiritId, args);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
conn.Log.Warn($"[FASHION] wear apply failed spirit={spiritId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyFashionEditDiff(
|
||||
GameMethods.SpiritWearFashionsInfo wear,
|
||||
List<uint> uneditIds,
|
||||
List<GameMethods.WearFashionEditInfo> newEdits)
|
||||
private async Task ApplyWearAsync(
|
||||
Connection conn, UxRpcMessage msg, uint spiritId, GameMethods.AskSetSpiritFashionsWithSource args)
|
||||
{
|
||||
wear.WearFashionEditInfoList ??= [];
|
||||
// Remember the outfit so a re-login publishes it instead of the authored default. The
|
||||
// wardrobe panel never edits the worn value locally — SyncSetSpiritFashions is its only
|
||||
// writer — so without this the character silently undresses on every restart.
|
||||
var state = GetWorldState(msg.Context);
|
||||
var wear = args.spiritWearFashionsInfo ?? new GameMethods.SpiritWearFashionsInfo();
|
||||
var wornIds = (wear.WearFashionInfoList ?? [])
|
||||
.Where(x => x is not null && x.FashionId != 0)
|
||||
.Select(x => x.FashionId)
|
||||
.ToList();
|
||||
|
||||
if (uneditIds.Count > 0)
|
||||
// The id is the character template id (FightSpiritConfig), the same key SyncSetSpiritFashions
|
||||
// publishes with. Anything the roster does not know is refused so a bad id cannot poison the
|
||||
// snapshot and leave a character permanently naked.
|
||||
var knownSpirit = ClientConfigRepository.Characters().Any(x => x.TemplateId == spiritId);
|
||||
if (knownSpirit && wornIds.Count > 0)
|
||||
{
|
||||
var uneditSet = uneditIds.ToHashSet();
|
||||
wear.WearFashionEditInfoList.RemoveAll(x => uneditSet.Contains(x.FashionId));
|
||||
var source = wear.WearSourceInfo;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
state.FashionBySpirit[spiritId] = new FashionRecord4229938(
|
||||
wear.FunctionSuitId,
|
||||
source.Source,
|
||||
source.SourceId,
|
||||
wear.IsTryWear,
|
||||
wear.HiddenParts,
|
||||
wornIds);
|
||||
}
|
||||
|
||||
PlayerProgressStore.Save(state, conn.Log);
|
||||
}
|
||||
|
||||
foreach (var item in newEdits)
|
||||
// Echo the client's chosen wear back as authoritative SyncSetSpiritFashions.
|
||||
// This keeps the character model dressed in the selected skin after the
|
||||
// appearance panel closes, instead of snapping back to login defaults.
|
||||
var sync = new GameMethods.SyncSetSpiritFashions
|
||||
{
|
||||
var existingIndex = wear.WearFashionEditInfoList.FindIndex(x => x.FashionId == item.FashionId);
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
wear.WearFashionEditInfoList[existingIndex] = item;
|
||||
}
|
||||
else
|
||||
{
|
||||
wear.WearFashionEditInfoList.Add(item);
|
||||
}
|
||||
}
|
||||
spiritId = spiritId,
|
||||
source = args.source,
|
||||
spiritWearFashionsInfo = args.spiritWearFashionsInfo ?? new GameMethods.SpiritWearFashionsInfo(),
|
||||
};
|
||||
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, sync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wardrobe bookkeeping: "mark fashions as seen", "toggle favourite", "read suit list".
|
||||
///
|
||||
/// These are INVOKE requests that ask for nothing back — the client only waits for the ack. The
|
||||
/// reference branch answers them with an empty OK, and leaving them unrouted is worse than
|
||||
/// useless: an unanswered INVOKE leaves the caller awaiting forever, which is exactly the
|
||||
/// failure mode that shows up as a wardrobe button that never responds.
|
||||
///
|
||||
/// The bodies are deliberately not decoded. They are client-local bookkeeping (read receipts,
|
||||
/// favourite flags), and inventing a contract for bytes we have never seen would risk a decode
|
||||
/// exception on the only path whose job is to always answer.
|
||||
/// </summary>
|
||||
[Handler(MethodId.AskReadFashions)]
|
||||
[Handler(MethodId.AskFavoriteFashions)]
|
||||
[Handler(MethodId.AskReadFashionSuits)]
|
||||
private Task FashionBookkeepingAccept(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
// Logged with the byte count so a future "suits do not show up" bug has a starting point:
|
||||
// it tells us the client really sent the request, and how much it expected us to read.
|
||||
conn.Log.Info($"[FASHION] bookkeeping accepted method={msg.MethodId} bytes={msg.Body.Length}");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user