feat: Add metro transit, gadgets, shop, social apps, and traffic models checkpoint
This commit is contained in:
1 parent
caa42b84ed
commit
74e2963a12
79 files changed
+527962
-496
No files matched your search
@@ -0,0 +1,173 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Phone contacts app ("NETWORK OF CONTACTS"). The list is push-only: the login snapshot
|
||||
/// seeds the two summon numbers, and AddSpiritPhoneInfos refreshes each character's book.
|
||||
/// This ports PrivateServer1's handbook push (GameRouter.Baseline.cs PushSpiritHandbookAsync)
|
||||
/// and adds the C2S add/edit/delete actions as a sandbox (session-side list, re-pushed).
|
||||
/// City contacts bind REAL agent-catalog NPC names so the app lists actual city residents.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
private const string PhoneContactsKey = "phoneContacts";
|
||||
|
||||
private static (string Remark, string Number)[] SummonContacts { get; } =
|
||||
[
|
||||
(string.Empty, "142857"), // "Call vehicle" quick summon
|
||||
(string.Empty, "206337"), // "Express Depot"
|
||||
];
|
||||
|
||||
/// <summary>Stable per-session contact book, seeded with summon + city NPC contacts.</summary>
|
||||
private static Dictionary<uint, List<Auto.PhoneContact>> ContactsOf(TcpSession session)
|
||||
{
|
||||
if (session.Items.TryGetValue(PhoneContactsKey, out var raw) && raw is Dictionary<uint, List<Auto.PhoneContact>> dict)
|
||||
return dict;
|
||||
dict = [];
|
||||
var city = CityContacts();
|
||||
foreach (var character in ClientConfigRepository.Characters())
|
||||
dict[character.TemplateId] = SummonContacts
|
||||
.Select(c => new Auto.PhoneContact { Remark = c.Remark, PhoneNumber = c.Number })
|
||||
.Concat(city)
|
||||
.ToList();
|
||||
session.Items[PhoneContactsKey] = dict;
|
||||
return dict;
|
||||
}
|
||||
|
||||
/// <summary>Real NPC names from the AgentConfig catalog become callable city contacts.</summary>
|
||||
private static List<Auto.PhoneContact> CityContacts()
|
||||
{
|
||||
var result = new List<Auto.PhoneContact>();
|
||||
var picked = 0;
|
||||
foreach (var npc in NpcCatalog4229938.All)
|
||||
{
|
||||
if (npc.Category != "citizen" || npc.GeneralModelId == 0 || string.IsNullOrWhiteSpace(npc.Name))
|
||||
continue;
|
||||
var name = NameTranslator.Agent(npc.Name);
|
||||
if (string.IsNullOrWhiteSpace(name) || name == npc.Name && ContainsCjk(name))
|
||||
continue; // keep untranslated CN names out of the English book
|
||||
result.Add(new Auto.PhoneContact { Remark = name, PhoneNumber = $"110{npc.Id % 100000000:D8}" });
|
||||
if (++picked >= 24)
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool ContainsCjk(string text)
|
||||
=> text.Any(c => c is >= '\u4E00' and <= '\u9FFF');
|
||||
|
||||
/// <summary>Pushes every character's contact book (call after SyncPlayerInfo at world entry).</summary>
|
||||
internal static async Task PushSpiritHandbookAsync(TcpSession session, CancellationToken token = default)
|
||||
{
|
||||
var contacts = ContactsOf(session);
|
||||
foreach (var character in ClientConfigRepository.Characters())
|
||||
{
|
||||
var list = contacts.TryGetValue(character.TemplateId, out var l) ? l : [];
|
||||
await session.NotifyAsync(MethodId.AddSpiritPhoneInfos, UxSerializer.Serialize(
|
||||
new GameMethods.AddSpiritPhoneInfos4229938
|
||||
{
|
||||
spiritId = character.TemplateId,
|
||||
phoneInfos = new Auto.PhoneInfos
|
||||
{
|
||||
ContactList = list,
|
||||
ContactGroupList = [],
|
||||
CallRecordList = [],
|
||||
ContactOutgoingCallTimesDict = [],
|
||||
},
|
||||
}), token);
|
||||
}
|
||||
session.Log.Info($"[HANDBOOK] contacts pushed spirits={contacts.Count} city={contacts.Values.FirstOrDefault()?.Count ?? 0}");
|
||||
}
|
||||
|
||||
// ---------------- client-driven phone contact actions (sandbox) ----------------
|
||||
|
||||
[Handler(MethodId.AskPhoneAddContact, HandlerPacketKind.Invoke)]
|
||||
private async Task PhoneAddContact(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskPhoneAddContact4229938>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
var book = ContactsOf(conn.Session);
|
||||
if (!book.TryGetValue(args.spiritid, out var list))
|
||||
book[args.spiritid] = list = [];
|
||||
if (list.All(c => c.PhoneNumber != args.phonenumber))
|
||||
{
|
||||
list.Add(new Auto.PhoneContact { Remark = args.contactname, PhoneNumber = args.phonenumber });
|
||||
conn.Log.Info($"[PHONE] add contact spirit={args.spiritid} '{args.contactname}' {args.phonenumber}");
|
||||
await PushSpiritBookAsync(conn.Session, args.spiritid);
|
||||
}
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskPhoneEditContact, HandlerPacketKind.Invoke)]
|
||||
private async Task PhoneEditContact(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskPhoneEditContact4229938>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
var book = ContactsOf(conn.Session);
|
||||
if (book.TryGetValue(args.spiritid, out var list))
|
||||
{
|
||||
var contact = list.FirstOrDefault(c => c.PhoneNumber == args.oldphonenumber);
|
||||
if (contact is not null)
|
||||
{
|
||||
contact.PhoneNumber = args.newphonenumber;
|
||||
contact.Remark = args.contactname;
|
||||
conn.Log.Info($"[PHONE] edit contact spirit={args.spiritid} '{args.contactname}' {args.oldphonenumber}->{args.newphonenumber}");
|
||||
await PushSpiritBookAsync(conn.Session, args.spiritid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskPhoneDeleteContact, HandlerPacketKind.Invoke)]
|
||||
private async Task PhoneDeleteContact(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskPhoneDeleteContact4229938>();
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
var book = ContactsOf(conn.Session);
|
||||
if (book.TryGetValue(args.spiritid, out var list) && list.RemoveAll(c => c.PhoneNumber == args.phonenumber) > 0)
|
||||
{
|
||||
conn.Log.Info($"[PHONE] delete contact spirit={args.spiritid} {args.phonenumber}");
|
||||
await PushSpiritBookAsync(conn.Session, args.spiritid);
|
||||
}
|
||||
}
|
||||
|
||||
// Group management and contact options stay accepted-but-static in the sandbox: the
|
||||
// client keeps its own optimistic UI state and the server list stays the source of truth.
|
||||
[Handler(MethodId.AskPhoneAddContactGroup, HandlerPacketKind.Invoke)]
|
||||
private Task PhoneAddContactGroup(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
[Handler(MethodId.AskPhoneEditContactGroup, HandlerPacketKind.Invoke)]
|
||||
private Task PhoneEditContactGroup(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
[Handler(MethodId.AskPhoneDeleteContactGroup, HandlerPacketKind.Invoke)]
|
||||
private Task PhoneDeleteContactGroup(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
[Handler(MethodId.AskPhoneAddContactToGroup, HandlerPacketKind.Invoke)]
|
||||
private Task PhoneAddContactToGroup(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
[Handler(MethodId.AskPhoneContactOptionAction, HandlerPacketKind.Invoke)]
|
||||
private Task PhoneContactOptionAction(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
private async Task PushSpiritBookAsync(TcpSession session, uint spiritId, CancellationToken token = default)
|
||||
{
|
||||
var contacts = ContactsOf(session);
|
||||
var list = contacts.TryGetValue(spiritId, out var l) ? l : [];
|
||||
await session.NotifyAsync(MethodId.AddSpiritPhoneInfos, UxSerializer.Serialize(
|
||||
new GameMethods.AddSpiritPhoneInfos4229938
|
||||
{
|
||||
spiritId = spiritId,
|
||||
phoneInfos = new Auto.PhoneInfos
|
||||
{
|
||||
ContactList = list,
|
||||
ContactGroupList = [],
|
||||
CallRecordList = [],
|
||||
ContactOutgoingCallTimesDict = [],
|
||||
},
|
||||
}), token);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
@@ -394,26 +396,4 @@ internal sealed partial class GameRouter
|
||||
private static Task AskActivityCancelRedPoint(Connection conn, UxRpcMessage msg)
|
||||
=> conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
// Story & Quest Task Handlers
|
||||
[Handler(MethodId.AskAcceptTask, HandlerPacketKind.Invoke)]
|
||||
private static Task AskAcceptTask(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[TASK] AskAcceptTask accepted");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskAcceptAndSetCurrentTask, HandlerPacketKind.Invoke)]
|
||||
private static Task AskAcceptAndSetCurrentTask(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[TASK] AskAcceptAndSetCurrentTask accepted");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSubmitTask, HandlerPacketKind.Invoke)]
|
||||
private static Task AskSubmitTask(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[TASK] AskSubmitTask accepted");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Gadget / SceneDevice interaction handlers (build 4229938).
|
||||
///
|
||||
/// Grid-streamed gadgets (see GadgetStreamingEngine) render from the client's baked data,
|
||||
/// but actually USING them is a server round-trip: pressing F runs the gadget's local graph
|
||||
/// which fires AskInteractCmd and AskSceneDevice* asks. This router answers each ask the way
|
||||
/// a single-player authoritative server would — ack the check-index, then echo the change
|
||||
/// back through the matching Sync* notify so the gadget graph proceeds (vending machine UI,
|
||||
/// claw machine, doors, arcades...).
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
[Handler(MethodId.AskInteractCmd, HandlerPacketKind.Notify)]
|
||||
private async Task AskInteractCmd(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var data = msg.GetArgs<SceneMethods.InteractCmdData4229938>();
|
||||
conn.Log.Info($"[GADGET] interact cmd type={data.CmdType} receiver={data.Receiver} sender={data.Sender}");
|
||||
await conn.Session.NotifyAsync(MethodId.SyncInteractCmd, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncInteractCmd4229938 { Data = data }), CancellationToken.None);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskInteractCmds, HandlerPacketKind.Notify)]
|
||||
private async Task AskInteractCmds(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var data = msg.GetArgs<SceneMethods.AskInteractCmds4229938>();
|
||||
conn.Log.Info($"[GADGET] interact cmds x{data.Data.Count}");
|
||||
await conn.Session.NotifyAsync(MethodId.IGameSceneToClient_SyncInteractCmds, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncInteractCmds4229938 { Data = data.Data }), CancellationToken.None);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSceneDeviceChangeState, HandlerPacketKind.Invoke)]
|
||||
private async Task AskSceneDeviceChangeState(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskSceneDeviceChangeState4229938>();
|
||||
conn.Log.Info($"[GADGET] change state type={args.Type} id={args.Info.Id} stateType={args.Info.StateType} state={args.Info.State} check={args.Info.StateCheckIndex}");
|
||||
// Ack the requested check index so the gadget graph's condition passes, then mirror
|
||||
// the new state back — the client applies it on the Sync, not on the invoke return.
|
||||
await conn.ReturnAsync(msg, new SceneMethods.SceneDeviceCheckIndexCallback4229938
|
||||
{
|
||||
CheckIndex = args.Info.StateCheckIndex,
|
||||
Error = 0,
|
||||
});
|
||||
await conn.Session.NotifyAsync(MethodId.SyncSceneDeviceStateChange, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncSceneDeviceStateChange4229938
|
||||
{
|
||||
Type = args.Type,
|
||||
Id = args.Info.Id,
|
||||
StateType = args.Info.StateType,
|
||||
State = args.Info.State,
|
||||
StateCheckIndex = args.Info.StateCheckIndex,
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSceneDeviceRecordValue, HandlerPacketKind.Invoke)]
|
||||
private async Task AskSceneDeviceRecordValue(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskSceneDeviceRecordValue4229938>();
|
||||
conn.Log.Info($"[GADGET] record value type={args.Type} id={args.Info.Id} name={args.Info.ValueName} value='{args.Info.Value}' check={args.Info.ValueCheckIndex}");
|
||||
await conn.ReturnAsync(msg, new SceneMethods.SceneDeviceCheckIndexCallback4229938
|
||||
{
|
||||
CheckIndex = args.Info.ValueCheckIndex,
|
||||
Error = 0,
|
||||
});
|
||||
// Mirror the recorded value so the device graph sees the new value on Sync.
|
||||
await conn.Session.NotifyAsync(MethodId.SyncSceneDeviceValueChange, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncSceneDeviceValueChange4229938
|
||||
{
|
||||
Type = args.Type,
|
||||
Id = args.Info.Id,
|
||||
ValueName = args.Info.ValueName,
|
||||
Value = args.Info.Value,
|
||||
ValueCheckIndex = args.Info.ValueCheckIndex,
|
||||
}), CancellationToken.None);
|
||||
await conn.Session.NotifyAsync(MethodId.SyncSceneDeviceValueChange, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncSceneDeviceValueChange4229938
|
||||
{
|
||||
Type = args.Type,
|
||||
Id = args.Info.Id,
|
||||
ValueName = args.Info.ValueName,
|
||||
Value = args.Info.Value,
|
||||
ValueCheckIndex = args.Info.ValueCheckIndex,
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSceneDeviceSendSignal, HandlerPacketKind.Invoke)]
|
||||
private async Task AskSceneDeviceSendSignal(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskSceneDeviceSendSignal4229938>();
|
||||
conn.Log.Info($"[GADGET] send signal type={args.Type} id={args.Id} signal='{args.SignalName}'");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await conn.Session.NotifyAsync(MethodId.SyncSceneDeviceSignalSend, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncSceneDeviceSignalSend4229938
|
||||
{
|
||||
Type = args.Type,
|
||||
Id = args.Id,
|
||||
SignalName = args.SignalName,
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// NPC dialog (F on an NPC): the client resolves the dialog id from its own
|
||||
// config and asks us to run it; we echo the dialog back so the UI opens.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Handler(MethodId.AskAgentStartNpcDialog, HandlerPacketKind.Notify)]
|
||||
private async Task AskAgentStartNpcDialog(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskAgentStartNpcDialog4229938>();
|
||||
conn.Log.Info($"[NPC] start dialog agent={args.AgentEntityId} dialog={args.NpcDialogId} duration={args.DialogDuration}");
|
||||
await conn.Session.NotifyAsync(MethodId.SyncAgentPrepareStartNpcDialog, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncAgentPrepareStartNpcDialog4229938
|
||||
{
|
||||
AgentId = args.AgentEntityId,
|
||||
NpcDialogId = args.NpcDialogId,
|
||||
NpcSoundId = 0,
|
||||
VirtualNpc = false,
|
||||
EntireDialogId = 0,
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskAgentFinishNpcDialog, HandlerPacketKind.Notify)]
|
||||
private async Task AskAgentFinishNpcDialog(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskAgentFinishNpcDialog4229938>();
|
||||
conn.Log.Info($"[NPC] finish dialog agent={args.AgentEntityId} dialog={args.NpcDialogId}");
|
||||
await conn.Session.NotifyAsync(MethodId.SyncAgentFinishNpcDialog, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncAgentFinishNpcDialog4229938
|
||||
{
|
||||
AgentId = args.AgentEntityId,
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Gadget doors / scene rooms: interior transitions and door teleports.
|
||||
// The client drives the movement itself; the server only acknowledges so
|
||||
// the gadget graph doesn't stall on the ask.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Gadget doors / scene rooms: the client asks on door enter/leave and the
|
||||
// geometry of the interior only loads once the server answers with
|
||||
// SyncSpoonRoomState — without it the player falls through the world.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Handler(MethodId.AskGadgetSceneRoomTrigger, HandlerPacketKind.Invoke)]
|
||||
private async Task AskGadgetSceneRoomTrigger(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskGadgetSceneRoomTrigger4229938>();
|
||||
conn.Log.Info($"[ROOM] scene room trigger room={args.SceneRoomId} gadget={args.GadgetUniqueId} enter={args.IsEnter}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await PushRoomStateAsync(conn.Session, args.SceneRoomId, args.IsEnter);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskEnterSceneRoom, HandlerPacketKind.Invoke)]
|
||||
private async Task AskEnterSceneRoom(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskEnterSceneRoom4229938>();
|
||||
conn.Log.Info($"[ROOM] enter room={args.SceneRoomId} vehicle={args.IsVehicle}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await PushRoomStateAsync(conn.Session, args.SceneRoomId, enabled: true);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskExistSceneRoom, HandlerPacketKind.Invoke)]
|
||||
private async Task AskExistSceneRoom(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskExistSceneRoom4229938>();
|
||||
conn.Log.Info($"[ROOM] leave room={args.SceneRoomId} vehicle={args.IsVehicle}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await PushRoomStateAsync(conn.Session, args.SceneRoomId, enabled: false);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskAgentSceneRoomTrigger, HandlerPacketKind.Invoke)]
|
||||
private async Task AskAgentSceneRoomTrigger(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskAgentSceneRoomTrigger4229938>();
|
||||
conn.Log.Info($"[ROOM] agent room trigger room={args.SceneRoomId} agent={args.SpoonAgentId} enter={args.IsEnter}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await PushRoomStateAsync(conn.Session, args.SceneRoomId, args.IsEnter);
|
||||
}
|
||||
|
||||
private static async Task PushRoomStateAsync(TcpSession session, int sceneRoomId, bool enabled)
|
||||
{
|
||||
await session.NotifyAsync(MethodId.SyncSpoonRoomState, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncSpoonRoomState4229938
|
||||
{
|
||||
SceneRoomId = sceneRoomId,
|
||||
Enabled = enabled,
|
||||
TaskId = 0,
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// NPC dating ("เดต") — the InviteRide family. The client's GM console can
|
||||
// request a date with an NPC (GmStartInviteRideNpc); the server activates
|
||||
// that NPC as the ride companion and pushes its interaction menu property.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Handler(MethodId.GmStartInviteRideNpc, HandlerPacketKind.Invoke)]
|
||||
private async Task GmStartInviteRideNpc(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.GmStartInviteRideNpc4229938>();
|
||||
conn.Log.Info($"[DATE] start invite-ride npc={args.NpcId}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
// Activate the companion...
|
||||
await conn.Session.NotifyAsync(MethodId.SyncInviteRideNpcInfo, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncInviteRideNpcInfo4229938
|
||||
{
|
||||
InviteRideNpcInstanceId = args.NpcId,
|
||||
IsInviteRideNpcActive = true,
|
||||
}), CancellationToken.None);
|
||||
// ...and give it the dating interaction menu (Type=21; menu entries render
|
||||
// from the client's own configs — empty list keeps the default menu).
|
||||
await conn.Session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentPropertyChange, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncAgentPropertyInviteRideChange4229938
|
||||
{
|
||||
AgentId = args.NpcId,
|
||||
Type = 21,
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Gift giving to an NPC — accepted, inventory is sandbox-unlimited.</summary>
|
||||
[Handler(MethodId.AskInteractNpcWithGift, HandlerPacketKind.Invoke)]
|
||||
private Task AskInteractNpcWithGift(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskInteractNpcWithGift4229938>();
|
||||
conn.Log.Info($"[NPC] gift activity={args.ActivityCfgId} item={args.ItemId} x{args.Count}");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.Gameplay.Traffic;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Light rail / metro scheduling.
|
||||
///
|
||||
/// The client owns the whole carriage simulation: MetroManager builds the carriages, drives them
|
||||
/// along the baked MetroPath of a line and plays the gate/door animation. All the server has to do is
|
||||
/// answer AskGetAllMetroInfos and then keep telling the client "train X of line Y has been running for
|
||||
/// Z seconds" through SyncRunningMetroInfos - MetroClientInfo has no position field at all
|
||||
/// (Id, LineId, ElapsedTime, IsFinalTrain). That is why this feature needs no client patch.
|
||||
///
|
||||
/// The schedule comes straight out of the client's own Rail*Config tables (RailLineConfig /
|
||||
/// RailStopConfig / RailStationConfig), which the panel exposes as "where is the train, which stop is
|
||||
/// it at, how long / how far until the next one".
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
private const string MetroKey = "client4229938-metro";
|
||||
private const int MetroDefaultPushMs = 250;
|
||||
|
||||
/// <summary>
|
||||
/// ElapsedTime is a reading of the *server* clock, not a scene second count. The client converts it
|
||||
/// with Metro.GetClientTimeFromServerTime(t) = t * INV_SERVER_TIME_FACTOR = t / 15, because the
|
||||
/// in-game clock the server reports runs SERVER_TIME_FACTOR = 15 times faster than real time.
|
||||
/// The baked MetroPath timeline - and therefore the RailStopConfig arrival seconds we schedule with -
|
||||
/// is real seconds (the Bayshore ring takes 1123 s for roughly 12 m/s between its stations), so what
|
||||
/// we push has to be the timetable second multiplied by 15.
|
||||
/// Pushing the raw timetable second made every carriage sit around the beginning of its own line
|
||||
/// crawling at 1/15 speed, which is exactly what round 55 was called in to fix.
|
||||
/// </summary>
|
||||
private const double MetroDefaultTimeFactor = 15.0;
|
||||
|
||||
/// <summary>
|
||||
/// Lines enabled on a fresh session: the four city light-rail rings
|
||||
/// (1 = Bayshore inner, 2 = Bayshore outer, 3 = Central, 4 = East).
|
||||
/// The Chongxiao lines 9..14 and the developer test paths 5..8 belong to other scenes, so they stay
|
||||
/// off until the panel turns them on - the client cannot find a MetroPath for a line the loaded
|
||||
/// scene does not contain.
|
||||
/// </summary>
|
||||
private static readonly uint[] MetroDefaultLines = [1, 2, 3, 4];
|
||||
|
||||
internal sealed class MetroSessionState
|
||||
{
|
||||
internal object SyncRoot { get; } = new();
|
||||
|
||||
/// <summary>Master switch. When false the loop idles without stopping, so nothing has to restart.</summary>
|
||||
internal bool Enabled = true;
|
||||
|
||||
/// <summary>Push cadence in milliseconds.</summary>
|
||||
internal int PushMs = MetroDefaultPushMs;
|
||||
|
||||
/// <summary>Multiplier applied to the timetable second before it goes out as ElapsedTime.</summary>
|
||||
internal double TimeFactor = MetroDefaultTimeFactor;
|
||||
|
||||
/// <summary>Lines currently being pushed.</summary>
|
||||
internal HashSet<uint> Lines = [];
|
||||
|
||||
/// <summary>
|
||||
/// The scene has actually asked for the metro list at least once. Pushes stay silent until then:
|
||||
/// the client only has its MetroPaths after it has loaded the scene, so an early
|
||||
/// SyncRunningMetroInfos could reach a half-built MetroManager.
|
||||
/// </summary>
|
||||
internal bool Requested;
|
||||
|
||||
/// <summary>Seconds of service folded away by a previous stop, so a restart does not jump the trains.</summary>
|
||||
internal double OffsetSeconds;
|
||||
|
||||
internal DateTime StartedUtc = DateTime.UtcNow;
|
||||
internal CancellationTokenSource? Cts;
|
||||
internal Task? Loop;
|
||||
internal long Ticks;
|
||||
internal long Pushes;
|
||||
internal long TrainsSent;
|
||||
internal DateTime LastPushUtc = DateTime.MinValue;
|
||||
internal string? LastError;
|
||||
|
||||
internal double ElapsedSeconds => OffsetSeconds + (DateTime.UtcNow - StartedUtc).TotalSeconds;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- state
|
||||
|
||||
private static MetroSessionState? TryGetMetroState(TcpSession session)
|
||||
=> session.Items.TryGetValue(MetroKey, out var raw) && raw is MetroSessionState state ? state : null;
|
||||
|
||||
private static MetroSessionState GetMetroState(TcpSession session)
|
||||
{
|
||||
lock (session.Items)
|
||||
{
|
||||
if (session.Items.TryGetValue(MetroKey, out var raw) && raw is MetroSessionState existing)
|
||||
return existing;
|
||||
|
||||
var created = new MetroSessionState();
|
||||
foreach (var id in MetroDefaultLines)
|
||||
created.Lines.Add(id);
|
||||
session.Items[MetroKey] = created;
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureMetroLoop(TcpSession session, MetroSessionState owner)
|
||||
{
|
||||
lock (owner.SyncRoot)
|
||||
{
|
||||
if (owner.Loop is { IsCompleted: false })
|
||||
return;
|
||||
|
||||
// Carry the service time over so a restart continues the same timetable instead of resetting.
|
||||
owner.OffsetSeconds += (DateTime.UtcNow - owner.StartedUtc).TotalSeconds;
|
||||
owner.StartedUtc = DateTime.UtcNow;
|
||||
owner.Cts?.Dispose();
|
||||
owner.Cts = new CancellationTokenSource();
|
||||
owner.Loop = Task.Run(() => RunMetroLoopAsync(session, owner, owner.Cts.Token));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- protocol
|
||||
|
||||
/// <summary>
|
||||
/// The scene asks once per load, with no arguments, and expects the list of metros currently
|
||||
/// running. Answering with an empty/placeholder list is why no carriage ever appeared before.
|
||||
/// </summary>
|
||||
[Handler(MethodId.AskGetAllMetroInfos, HandlerPacketKind.Invoke)]
|
||||
private async Task AskGetAllMetroInfos(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var owner = GetMetroState(msg.Context.Session);
|
||||
lock (owner.SyncRoot)
|
||||
owner.Requested = true;
|
||||
|
||||
var infos = BuildMetroInfos(owner);
|
||||
conn.Log.Info($"[METRO] AskGetAllMetroInfos -> {infos.Count} train(s) lines=[{MetroLineSummary(owner)}]");
|
||||
await conn.ReturnAsync(msg, infos);
|
||||
EnsureMetroLoop(msg.Context.Session, owner);
|
||||
}
|
||||
|
||||
private static List<GameMethods.MetroClientInfo> BuildMetroInfos(MetroSessionState owner)
|
||||
{
|
||||
List<uint> lines;
|
||||
double elapsed;
|
||||
double timeFactor;
|
||||
lock (owner.SyncRoot)
|
||||
{
|
||||
lines = owner.Lines.OrderBy(id => id).ToList();
|
||||
elapsed = owner.ElapsedSeconds;
|
||||
timeFactor = owner.TimeFactor;
|
||||
}
|
||||
|
||||
var result = new List<GameMethods.MetroClientInfo>();
|
||||
foreach (var lineId in lines)
|
||||
{
|
||||
if (!RailCatalog4229938.TryGetLine(lineId, out var line))
|
||||
continue;
|
||||
|
||||
// Line 2 is the same track as line 1 but counter-clockwise, so both are pushed and the
|
||||
// client draws the two directions of the ring.
|
||||
var count = line.EffectiveTrainCount;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
result.Add(new GameMethods.MetroClientInfo
|
||||
{
|
||||
Id = (int)((lineId * 100) + (uint)i),
|
||||
LineId = lineId,
|
||||
ElapsedTime = (float)(RailCatalog4229938.ElapsedForTrain(line, i, elapsed) * timeFactor),
|
||||
IsFinalTrain = false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string MetroLineSummary(MetroSessionState owner)
|
||||
{
|
||||
lock (owner.SyncRoot)
|
||||
return string.Join(",", owner.Lines.OrderBy(id => id));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the loop
|
||||
|
||||
private static async Task RunMetroLoopAsync(TcpSession session, MetroSessionState owner, CancellationToken token)
|
||||
{
|
||||
session.Log.Info("[METRO] loop started");
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TickMetroAsync(session, owner, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
session.Log.Info("[METRO] loop stopped: the session was disposed");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (owner.SyncRoot)
|
||||
owner.LastError = ex.Message;
|
||||
session.Log.Warn($"[METRO] tick failed: {ex.Message}");
|
||||
}
|
||||
|
||||
int period;
|
||||
lock (owner.SyncRoot)
|
||||
period = owner.PushMs;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Math.Clamp(period, 50, 2000), token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
session.Log.Info("[METRO] loop exit");
|
||||
}
|
||||
|
||||
private static async Task TickMetroAsync(TcpSession session, MetroSessionState owner, CancellationToken token)
|
||||
{
|
||||
lock (owner.SyncRoot)
|
||||
{
|
||||
owner.Ticks++;
|
||||
if (!owner.Enabled || !owner.Requested)
|
||||
return;
|
||||
}
|
||||
|
||||
var infos = BuildMetroInfos(owner);
|
||||
if (infos.Count == 0)
|
||||
return;
|
||||
|
||||
await session.NotifyAsync(MethodId.SyncRunningMetroInfos, UxSerializer.Serialize(infos), token);
|
||||
|
||||
long pushes;
|
||||
lock (owner.SyncRoot)
|
||||
{
|
||||
owner.Pushes++;
|
||||
owner.TrainsSent += infos.Count;
|
||||
owner.LastPushUtc = DateTime.UtcNow;
|
||||
pushes = owner.Pushes;
|
||||
}
|
||||
|
||||
if (pushes <= 3 || pushes % 400 == 0)
|
||||
session.Log.Info($"[METRO] push #{pushes} trains={infos.Count} lines=[{MetroLineSummary(owner)}]");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- panel API (future wiring)
|
||||
|
||||
/// <summary>
|
||||
/// Read-only snapshot for the web panel: the whole catalog plus, for every running train, the stop
|
||||
/// it is calling at, the next stop, and the remaining time / straight-line distance to it.
|
||||
/// </summary>
|
||||
internal static object MetroStatus(TcpSession? session)
|
||||
{
|
||||
if (session is null)
|
||||
return new { ok = false, online = false, error = "session offline" };
|
||||
|
||||
var owner = TryGetMetroState(session);
|
||||
if (owner is null)
|
||||
{
|
||||
return new
|
||||
{
|
||||
ok = true,
|
||||
online = true,
|
||||
started = false,
|
||||
lines = MetroCatalogView([]),
|
||||
trains = Array.Empty<object>(),
|
||||
message = "the scene has not requested the metro list yet",
|
||||
};
|
||||
}
|
||||
|
||||
bool enabled;
|
||||
int pushMs;
|
||||
double timeFactor;
|
||||
bool requested;
|
||||
long ticks, pushes, trainsSent;
|
||||
string? lastError;
|
||||
double elapsed;
|
||||
HashSet<uint> lines;
|
||||
bool running;
|
||||
lock (owner.SyncRoot)
|
||||
{
|
||||
enabled = owner.Enabled;
|
||||
pushMs = owner.PushMs;
|
||||
timeFactor = owner.TimeFactor;
|
||||
requested = owner.Requested;
|
||||
ticks = owner.Ticks;
|
||||
pushes = owner.Pushes;
|
||||
trainsSent = owner.TrainsSent;
|
||||
lastError = owner.LastError;
|
||||
elapsed = owner.ElapsedSeconds;
|
||||
lines = [.. owner.Lines];
|
||||
running = owner.Loop is { IsCompleted: false };
|
||||
}
|
||||
|
||||
var trains = MetroTrainView(owner, out var stoppedSeconds);
|
||||
return new
|
||||
{
|
||||
ok = true,
|
||||
online = true,
|
||||
started = true,
|
||||
enabled,
|
||||
running,
|
||||
requested,
|
||||
pushMs,
|
||||
timeFactor = Math.Round(timeFactor, 4),
|
||||
ticks,
|
||||
pushes,
|
||||
trainsSent,
|
||||
lastPushUtc = owner.LastPushUtc == DateTime.MinValue ? null : owner.LastPushUtc.ToString("HH:mm:ss"),
|
||||
lastError,
|
||||
serviceSeconds = Math.Round(elapsed, 1),
|
||||
lines = MetroCatalogView(lines),
|
||||
trains,
|
||||
stoppedSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
private static object[] MetroCatalogView(HashSet<uint> enabled)
|
||||
{
|
||||
var result = new List<object>();
|
||||
foreach (var line in RailCatalog4229938.AllLines)
|
||||
{
|
||||
var stops = RailCatalog4229938.TryGetRoute(line.Id, out var route)
|
||||
? route.Stops
|
||||
: Array.Empty<RailStop4229938>();
|
||||
var stationList = stops
|
||||
.Select(stop =>
|
||||
{
|
||||
var name = RailCatalog4229938.TryGetStation(stop.StationId, out var station)
|
||||
? station.DisplayName(false)
|
||||
: stop.StopName;
|
||||
return new
|
||||
{
|
||||
stopId = stop.Id,
|
||||
name,
|
||||
chinese = RailCatalog4229938.TryGetStation(stop.StationId, out var Chinese)
|
||||
? Chinese.DisplayName(true)
|
||||
: stop.StopName,
|
||||
arrivalSeconds = Math.Round(stop.ArrivalTime, 1),
|
||||
dwellSeconds = Math.Round(stop.InsertTime, 1),
|
||||
defaultExit = stop.DefaultExitStop,
|
||||
stationId = stop.StationId,
|
||||
};
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
id = line.Id,
|
||||
name = line.LineName,
|
||||
prefab = line.BlobName,
|
||||
ring = line.RingLine,
|
||||
isTestPath = line.IsTestPath,
|
||||
trainTemplateId = line.TrainTemplateId,
|
||||
totalTravelSeconds = Math.Round(line.TotalTravelDuration, 1),
|
||||
departureIntervalSeconds = line.DepartureInterval,
|
||||
maxTrainAmount = line.MaxTrainAmount,
|
||||
trainCount = line.EffectiveTrainCount,
|
||||
firstTrain = $"{line.FirstTrainHour:D2}:{line.FirstTrainMinute:D2}",
|
||||
lastTrain = $"{line.LastTrainHour:D2}:{line.LastTrainMinute:D2}",
|
||||
defaultEnabled = MetroDefaultLines.Contains(line.Id),
|
||||
enabled = enabled.Contains(line.Id),
|
||||
stopCount = stops.Count,
|
||||
stations = stationList,
|
||||
});
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static object[] MetroTrainView(MetroSessionState owner, out int stoppedSeconds)
|
||||
{
|
||||
List<uint> lines;
|
||||
double elapsed;
|
||||
lock (owner.SyncRoot)
|
||||
{
|
||||
lines = owner.Lines.OrderBy(id => id).ToList();
|
||||
elapsed = owner.ElapsedSeconds;
|
||||
}
|
||||
|
||||
var result = new List<object>();
|
||||
stoppedSeconds = 0;
|
||||
foreach (var lineId in lines)
|
||||
{
|
||||
if (!RailCatalog4229938.TryGetLine(lineId, out var line) || !RailCatalog4229938.TryGetRoute(lineId, out var route))
|
||||
continue;
|
||||
|
||||
var count = line.EffectiveTrainCount;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var trainElapsed = RailCatalog4229938.ElapsedForTrain(line, i, elapsed);
|
||||
var progress = RailCatalog4229938.ProgressAt(line, trainElapsed);
|
||||
if (progress is null)
|
||||
continue;
|
||||
|
||||
if (progress.AtStation) stoppedSeconds++;
|
||||
|
||||
var position = MetroInterpolate(progress);
|
||||
result.Add(new
|
||||
{
|
||||
id = (int)((lineId * 100) + (uint)i),
|
||||
index = i,
|
||||
lineId,
|
||||
lineName = line.LineName,
|
||||
prefab = line.BlobName,
|
||||
elapsedSeconds = Math.Round(trainElapsed, 1),
|
||||
atStation = progress.AtStation,
|
||||
// Index along the line (1-based for humans), which is what the timetable uses.
|
||||
stopIndex = progress.StopIndex + 1,
|
||||
stopName = StopName(progress.CurrentStop),
|
||||
stopStation = progress.CurrentStation?.EnglishName ?? string.Empty,
|
||||
stopStationChinese = progress.CurrentStation?.ChineseName ?? string.Empty,
|
||||
nextStopIndex = progress.NextStopIndex >= 0 ? progress.NextStopIndex + 1 : 0,
|
||||
nextStopName = progress.NextStop is null ? string.Empty : StopName(progress.NextStop),
|
||||
nextStation = progress.NextStation?.EnglishName ?? string.Empty,
|
||||
nextStationChinese = progress.NextStation?.ChineseName ?? string.Empty,
|
||||
secondsToNext = Math.Round(progress.SecondsToNext, 1),
|
||||
metresToNext = Math.Round(progress.MetresToNext, 1),
|
||||
isLastStop = progress.NextStop is null,
|
||||
x = position?.X,
|
||||
y = position?.Y,
|
||||
z = position?.Z,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static string StopName(RailStop4229938 stop)
|
||||
{
|
||||
if (RailCatalog4229938.TryGetStation(stop.StationId, out var station))
|
||||
return station.DisplayName(false);
|
||||
return string.IsNullOrWhiteSpace(stop.StopName) ? $"Stop {stop.Id}" : stop.StopName;
|
||||
}
|
||||
|
||||
private readonly record struct MetroPoint(double X, double Y, double Z);
|
||||
|
||||
/// <summary>
|
||||
/// Where the train is between two platforms. The stop tables only carry arrival times, so the fill
|
||||
/// is a straight line between the two station anchors scaled by the remaining fraction of the leg -
|
||||
/// good enough for a status readout, and it is the same information the client itself animates from.
|
||||
/// Null when the line has no station anchors (the depot ends of the ring, or the test paths).
|
||||
/// </summary>
|
||||
private static MetroPoint? MetroInterpolate(RailTrainProgress progress)
|
||||
{
|
||||
var current = progress.CurrentStation;
|
||||
if (current is null)
|
||||
return null;
|
||||
|
||||
var next = progress.NextStation;
|
||||
if (next is null || progress.AtStation || progress.NextStop is null)
|
||||
return new MetroPoint(Math.Round(current.X, 1), Math.Round(current.Y, 1), Math.Round(current.Z, 1));
|
||||
|
||||
var leg = progress.NextStop.ArrivalTime - progress.CurrentStop.ArrivalTime;
|
||||
if (leg <= 0.001)
|
||||
return new MetroPoint(Math.Round(current.X, 1), Math.Round(current.Y, 1), Math.Round(current.Z, 1));
|
||||
|
||||
var fraction = Math.Clamp(progress.SecondsToNext / leg, 0, 1);
|
||||
return new MetroPoint(
|
||||
Math.Round(next.X + ((current.X - next.X) * fraction), 1),
|
||||
Math.Round(next.Y + ((current.Y - next.Y) * fraction), 1),
|
||||
Math.Round(next.Z + ((current.Z - next.Z) * fraction), 1));
|
||||
}
|
||||
|
||||
internal static object SetMetroEnabled(TcpSession? session, bool enabled)
|
||||
{
|
||||
if (session is null)
|
||||
return new { ok = false, error = "session offline" };
|
||||
|
||||
var owner = GetMetroState(session);
|
||||
lock (owner.SyncRoot)
|
||||
owner.Enabled = enabled;
|
||||
|
||||
session.Log.Info($"[METRO] master switch -> {(enabled ? "on" : "off")}");
|
||||
EnsureMetroLoop(session, owner);
|
||||
return MetroStatus(session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initial intersection list for SyncAetherAIInitDatas: one entry per mapped intersection
|
||||
/// ZONE (InterZones.json — real city zone-graph indexes), carrying its current light
|
||||
/// period, so the client's MassTraffic can resolve each entry against its baked
|
||||
/// StaticIntersectionData blob and run ambient traffic from the moment the scene loads.
|
||||
/// </summary>
|
||||
internal static List<SceneMethods.ClientTrafficIntersectionInitInfo4229938> BuildAetherIntersectionInitList()
|
||||
{
|
||||
var result = new List<SceneMethods.ClientTrafficIntersectionInitInfo4229938>();
|
||||
if (!CityTrafficEngine.Instance.AmbientIntersectionInit)
|
||||
return result;
|
||||
foreach (var zonePeriod in CityTrafficEngine.Instance
|
||||
.GetAetherIntersectionZonePeriods(DateTimeOffset.UtcNow.ToUnixTimeSeconds()))
|
||||
{
|
||||
result.Add(new SceneMethods.ClientTrafficIntersectionInitInfo4229938
|
||||
{
|
||||
InstanceId = zonePeriod.InstanceId,
|
||||
ZoneIndex = zonePeriod.ZoneIndex,
|
||||
CurrentState = 0, // MassTrafficIntersectionState.Start
|
||||
CurrentPeriodIndex = zonePeriod.CurrentPeriod,
|
||||
NextPeriodIndex = zonePeriod.NextPeriod,
|
||||
RailPeriodIndex = 0,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,16 @@ internal sealed partial class GameRouter
|
||||
await session.NotifyAsync(MethodId.SyncManagedLogicAgent,
|
||||
UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(id, Profile.PlayerPid, 0)), CancellationToken.None);
|
||||
|
||||
// Gate-opener for NPC interaction: EnableInteract=true is what makes the client
|
||||
// show the F prompt / interaction menu for this NPC (AgentPropertyType 0).
|
||||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentPropertyChange,
|
||||
UxSerializer.Serialize(new SceneMethods.SyncAgentPropertyBoolChange4229938
|
||||
{
|
||||
AgentId = id,
|
||||
Type = 0, // AgentPropertyType.EnableInteract
|
||||
V = new SceneMethods.AgentPropertyDataBool4229938 { V = true },
|
||||
}), CancellationToken.None);
|
||||
|
||||
if (poi is { UsageId: not 0 })
|
||||
{
|
||||
await session.NotifyAsync(MethodId.SyncAgentBelongings,
|
||||
@@ -244,8 +254,15 @@ internal sealed partial class GameRouter
|
||||
}
|
||||
}
|
||||
|
||||
private const string AetherNpcInitSentKey = "aetherNpcInitSent";
|
||||
|
||||
private static async Task SendAetherInitForNpcAsync(TcpSession session)
|
||||
{
|
||||
// One init per session — re-sending the same AetherAIInitDatas repeatedly right
|
||||
// after world entry is redundant (and noisy).
|
||||
if (session.Items.ContainsKey(AetherNpcInitSentKey))
|
||||
return;
|
||||
session.Items[AetherNpcInitSentKey] = true;
|
||||
var settings = PrivateServerConfigStore.Current.Gameplay.Vehicles;
|
||||
var activeRaidId = GetStateIfExists(session)?.ActiveRaidId ?? Profile.RaidId;
|
||||
var hasZoneGraph = activeRaidId == PrivateServerConfigStore.Current.World.RaidId;
|
||||
@@ -254,9 +271,9 @@ internal sealed partial class GameRouter
|
||||
RaidId = activeRaidId,
|
||||
HasZoneGraph = hasZoneGraph,
|
||||
ZoneStorageDataHandle = hasZoneGraph ? settings.ZoneStorageDataHandle : 0,
|
||||
Intersections = [],
|
||||
Intersections = BuildAetherIntersectionInitList(),
|
||||
}), CancellationToken.None);
|
||||
session.Log.Info($"[AETHER] init sent raid={activeRaidId} zoneGraph={hasZoneGraph} intersections=empty");
|
||||
session.Log.Info($"[AETHER] init sent raid={activeRaidId} zoneGraph={hasZoneGraph} intersections={BuildAetherIntersectionInitList().Count}");
|
||||
}
|
||||
|
||||
internal static GameMethods.NpcAgentSyncClientInfo4229938 CreateStaticNpcSyncInfo(
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
using System.Text.Json;
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.Configuration;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Quest container engine, ported from the PrivateServer1 (Drmk) pack. The client's quest
|
||||
/// UI renders EXACTLY the SyncPlayerAllTask container the server pushes — accept/submit/
|
||||
/// counter flows all push the refreshed container. Story chain (TaskConfig.NextTasks)
|
||||
/// advances automatically on submit; the intro cutscene is the story root task.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
private const byte QuestStateNotAccept = 0;
|
||||
private const byte QuestStateAccepted = 1;
|
||||
private const byte QuestStateSubmited = 3;
|
||||
|
||||
private sealed class QuestEntry
|
||||
{
|
||||
public byte State;
|
||||
public int[] Counters = [];
|
||||
}
|
||||
|
||||
private static Dictionary<uint, QuestEntry> QuestsOf(TcpSession session)
|
||||
{
|
||||
if (session.Items.TryGetValue("questStates", out var raw) && raw is Dictionary<uint, QuestEntry> dict)
|
||||
return dict;
|
||||
dict = [];
|
||||
session.Items["questStates"] = dict;
|
||||
return dict;
|
||||
}
|
||||
|
||||
/// <summary>Builds the full SyncPlayerAllTask container from the session's quest states.</summary>
|
||||
private static SceneMethods.SyncPlayerAllTask4229938 BuildTaskContainer(TcpSession session)
|
||||
{
|
||||
var state = GetStateIfExists(session);
|
||||
var quests = QuestsOf(session);
|
||||
var taskInfos = new List<SceneMethods.TaskViewData4229938>();
|
||||
uint current = 0;
|
||||
if (state is not null && state.CurrentQuestTaskId != 0)
|
||||
current = state.CurrentQuestTaskId;
|
||||
|
||||
lock (state?.SyncRoot ?? new object())
|
||||
{
|
||||
foreach (var kv in quests)
|
||||
{
|
||||
taskInfos.Add(new SceneMethods.TaskViewData4229938
|
||||
{
|
||||
TaskId = kv.Key,
|
||||
CounterValues = kv.Value.Counters.ToList(),
|
||||
State = kv.Value.State,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new SceneMethods.SyncPlayerAllTask4229938
|
||||
{
|
||||
TaskInfos = taskInfos,
|
||||
CurrentTask = current,
|
||||
LoginGameServer = true,
|
||||
};
|
||||
}
|
||||
|
||||
internal static async Task PushTaskContainerAsync(TcpSession session, CancellationToken token = default)
|
||||
{
|
||||
var container = BuildTaskContainer(session);
|
||||
await session.NotifyAsync(MethodId.SyncPlayerAllTask, UxSerializer.Serialize(container), token);
|
||||
session.Log.Info($"[QUEST] SyncPlayerAllTask tasks={container.TaskInfos.Count} current={container.CurrentTask}");
|
||||
}
|
||||
|
||||
internal static async Task PushCurrentTaskAsync(TcpSession session, byte type, uint taskId, bool firstTime, CancellationToken token = default)
|
||||
{
|
||||
await session.NotifyAsync(MethodId.SyncCurrentTask, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncCurrentTaskInfo
|
||||
{
|
||||
Type = type,
|
||||
TaskId = taskId,
|
||||
EventId = 0,
|
||||
FirstTime = firstTime,
|
||||
Reason = 0,
|
||||
}), token);
|
||||
}
|
||||
|
||||
/// <summary>Accepts a task: records state (with counter slots from TaskConfig) and pushes.</summary>
|
||||
internal static async Task AcceptQuestAsync(TcpSession session, uint taskId, CancellationToken token = default)
|
||||
{
|
||||
var quests = QuestsOf(session);
|
||||
if (!quests.TryGetValue(taskId, out var entry))
|
||||
{
|
||||
var counterCount = TaskCatalog4229938.TryGet(taskId, out var cat) ? cat.CounterCount : 0;
|
||||
entry = new QuestEntry { Counters = new int[counterCount] };
|
||||
}
|
||||
entry.State = QuestStateAccepted;
|
||||
quests[taskId] = entry;
|
||||
|
||||
await PushTaskContainerAsync(session, token);
|
||||
await PushCurrentTaskAsync(session, 0, taskId, firstTime: true, token);
|
||||
await EnsureQuestNpcAsync(session, taskId, token);
|
||||
}
|
||||
|
||||
/// <summary>Submits a task: marks Submited, pushes, then auto-accepts the NextTasks chain.</summary>
|
||||
internal static async Task SubmitQuestAsync(TcpSession session, uint taskId, CancellationToken token = default)
|
||||
{
|
||||
var quests = QuestsOf(session);
|
||||
if (!quests.TryGetValue(taskId, out var entry))
|
||||
entry = new QuestEntry { Counters = [] };
|
||||
entry.State = QuestStateSubmited;
|
||||
quests[taskId] = entry;
|
||||
|
||||
await PushTaskContainerAsync(session, token);
|
||||
await PushCurrentTaskAsync(session, 1, taskId, firstTime: false, token);
|
||||
|
||||
if (TaskCatalog4229938.TryGet(taskId, out var cat))
|
||||
foreach (var nextId in cat.NextTasks)
|
||||
{
|
||||
var next = QuestsOf(session);
|
||||
if (!next.TryGetValue(nextId, out var nextEntry))
|
||||
{
|
||||
var counterCount = TaskCatalog4229938.TryGet(nextId, out var c2) ? c2.CounterCount : 0;
|
||||
nextEntry = new QuestEntry { Counters = new int[counterCount] };
|
||||
}
|
||||
nextEntry.State = QuestStateAccepted;
|
||||
next[nextId] = nextEntry;
|
||||
await PushTaskContainerAsync(session, token);
|
||||
var nextName = TaskCatalog4229938.TryGet(nextId, out var n2) ? n2.Name : "?";
|
||||
session.Log.Info($"[QUEST] chain next task={nextId} '{nextName}'");
|
||||
await EnsureQuestNpcAsync(session, nextId, token);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Counter report: clamp to authored max; all full → auto-complete + advance chain.</summary>
|
||||
internal static async Task ReportCounterAsync(TcpSession session, uint taskId, int index, int value, bool finish, CancellationToken token = default)
|
||||
{
|
||||
var quests = QuestsOf(session);
|
||||
if (!quests.TryGetValue(taskId, out var entry))
|
||||
return;
|
||||
|
||||
var configured = TaskCatalog4229938.TryGet(taskId, out var cat) ? cat.CounterCount : 0;
|
||||
if (configured == 0)
|
||||
return;
|
||||
|
||||
if (entry.Counters.Length != configured)
|
||||
entry.Counters = new int[configured];
|
||||
if (index >= 0 && index < entry.Counters.Length)
|
||||
entry.Counters[index] = finish ? 1 : Math.Clamp(value, 0, 1);
|
||||
|
||||
await PushTaskContainerAsync(session, token);
|
||||
|
||||
var allFull = entry.Counters.All(c => c > 0);
|
||||
var name = TaskCatalog4229938.TryGet(taskId, out var c3) ? c3.Name : taskId.ToString();
|
||||
if (!allFull)
|
||||
{
|
||||
session.Log.Info($"[QUEST] counter task={taskId} '{name}' index={index} value={value}");
|
||||
return;
|
||||
}
|
||||
|
||||
// All counters satisfied → submit + advance the chain.
|
||||
session.Log.Info($"[QUEST] counters complete task={taskId} '{name}' → submit");
|
||||
await SubmitQuestAsync(session, taskId, token);
|
||||
}
|
||||
|
||||
// ---------------- client-driven handlers ----------------
|
||||
|
||||
[Handler(MethodId.AskAcceptTask, HandlerPacketKind.Invoke)]
|
||||
private async Task QuestAcceptTask(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
var taskId = FirstUInt32(msg.Body);
|
||||
conn.Log.Info($"[QUEST] client accept task={taskId}");
|
||||
if (taskId == 0)
|
||||
return;
|
||||
await AcceptQuestAsync(conn.Session, taskId, CancellationToken.None);
|
||||
await PushCurrentTaskAsync(conn.Session, 1, taskId, firstTime: false, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskAcceptAndSetCurrentTask, HandlerPacketKind.Invoke)]
|
||||
private async Task QuestAcceptAndSetCurrentTask(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
var taskId = FirstUInt32(msg.Body);
|
||||
conn.Log.Info($"[QUEST] accept+set current task={taskId}");
|
||||
if (taskId == 0)
|
||||
return;
|
||||
await AcceptQuestAsync(conn.Session, taskId, CancellationToken.None);
|
||||
await PushCurrentTaskAsync(conn.Session, 1, taskId, firstTime: false, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskChangeTaskCounterValue, HandlerPacketKind.Invoke)]
|
||||
private async Task QuestChangeTaskCounterValue(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
var (taskId, index, value) = ParseCounterBody(msg.Body);
|
||||
conn.Log.Info($"[QUEST] counter change task={taskId} index={index} value={value}");
|
||||
if (taskId == 0)
|
||||
return;
|
||||
await ReportCounterAsync(conn.Session, taskId, index, value, finish: false, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskFinishTaskCounter, HandlerPacketKind.Invoke)]
|
||||
private async Task QuestFinishTaskCounter(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
var (taskId, index, _) = ParseCounterBody(msg.Body);
|
||||
conn.Log.Info($"[QUEST] counter finish task={taskId} index={index}");
|
||||
if (taskId == 0)
|
||||
return;
|
||||
await ReportCounterAsync(conn.Session, taskId, index, 0, finish: true, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskSubmitTask, HandlerPacketKind.Invoke)]
|
||||
private async Task QuestSubmitTask(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var taskId = FirstUInt32(msg.Body);
|
||||
conn.Log.Info($"[QUEST] client submit task={taskId}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
if (taskId == 0)
|
||||
return;
|
||||
await SubmitQuestAsync(conn.Session, taskId, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Guide/story-flow acknowledgements (client-driven guides; no server state needed).
|
||||
[Handler(MethodId.AskDoGuide, HandlerPacketKind.Invoke)]
|
||||
private Task QuestDoGuide(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info($"[STORY] AskDoGuide guide={FirstUInt32(msg.Body)}");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskFinishGuide, HandlerPacketKind.Invoke)]
|
||||
private Task QuestFinishGuide(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info($"[STORY] AskFinishGuide guide={FirstUInt32(msg.Body)}");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
private static uint FirstUInt32(byte[] body)
|
||||
=> body.Length >= 4 ? BitConverter.ToUInt32(body, 0) : 0u;
|
||||
|
||||
private static (uint TaskId, int Index, int Value) ParseCounterBody(byte[] body)
|
||||
{
|
||||
if (body.Length < 4)
|
||||
return (0, 0, 0);
|
||||
var taskId = BitConverter.ToUInt32(body, 0);
|
||||
var index = body.Length >= 8 ? BitConverter.ToInt32(body, 4) : 0;
|
||||
var value = body.Length >= 12 ? BitConverter.ToInt32(body, 8) : 0;
|
||||
return (taskId, index, value);
|
||||
}
|
||||
|
||||
// ---------------- tracked-quest NPC + map pin (หมุด) ----------------
|
||||
//
|
||||
// TaskConfig has no world positions, so the server runs ONE living "quest giver" NPC per
|
||||
// session: standing ~10m ahead of the player whenever a task is accepted/advanced, with a
|
||||
// SyncTrackGPS pin on its position. The NPC moves to a fresh spot per task and stays for
|
||||
// the whole chain (submit auto-accepts NextTasks, which re-runs EnsureQuestNpcAsync).
|
||||
|
||||
private sealed record QuestNpcState(ulong EntityId, uint TaskId);
|
||||
|
||||
private const string QuestNpcKey = "questNpc";
|
||||
|
||||
internal static async Task EnsureQuestNpcAsync(TcpSession session, uint taskId, CancellationToken token = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = GetStateIfExists(session);
|
||||
if (state is null)
|
||||
return;
|
||||
float px, py, pz, facing;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (!state.Ready || !state.HasLastReportedPlayerTransform)
|
||||
return; // world entry auto-accept: too early, the NPC joins on the next in-game accept
|
||||
px = state.LastReportedPlayerPosition.X;
|
||||
py = state.LastReportedPlayerPosition.Y;
|
||||
pz = state.LastReportedPlayerPosition.Z;
|
||||
facing = state.LastReportedPlayerRotation.Y;
|
||||
}
|
||||
|
||||
var rad = facing * MathF.PI / 180f;
|
||||
// 10m in front, 4m to the right: off the roadway, inside the sidewalk band.
|
||||
var x = px + MathF.Sin(rad) * 10f + MathF.Cos(rad) * 4f;
|
||||
var z = pz + MathF.Cos(rad) * 10f - MathF.Sin(rad) * 4f;
|
||||
var facePlayer = (facing + 180f) % 360f;
|
||||
|
||||
if (session.Items.TryGetValue(QuestNpcKey, out var raw) && raw is QuestNpcState npc)
|
||||
{
|
||||
// Reuse the living NPC: walk it to the new spot and refresh the pin.
|
||||
var move = WorldCodec.PositionAndFacing(npc.EntityId, new Vec3(x, py, z), facePlayer,
|
||||
WorldCodec.SetPositionType.Gm, continueMove: true, moveId: 0);
|
||||
await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing, UxSerializer.Serialize(move), token);
|
||||
await PushQuestMarkerAsync(session, taskId, x, py, z, token);
|
||||
session.Items[QuestNpcKey] = npc with { TaskId = taskId };
|
||||
session.Log.Info($"[QUEST] npc moved to task={taskId} pos={x:F0},{py:F0},{z:F0}");
|
||||
return;
|
||||
}
|
||||
|
||||
var formwork = RandomQuestNpcFormwork();
|
||||
if (formwork == 0)
|
||||
return;
|
||||
var (ok, message, ids) = await SpawnStaticNpcAtAsync(session, formwork, poiActionId: 2, x, py, z, facePlayer);
|
||||
if (!ok || ids.Length == 0)
|
||||
{
|
||||
session.Log.Warn($"[QUEST] npc spawn failed task={taskId}: {message}");
|
||||
return;
|
||||
}
|
||||
session.Items[QuestNpcKey] = new QuestNpcState(ids[0], taskId);
|
||||
await PushQuestMarkerAsync(session, taskId, x, py, z, token);
|
||||
session.Log.Info($"[QUEST] npc spawned task={taskId} formwork={formwork} entity={ids[0]} pin=on");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
session.Log.Warn($"[QUEST] npc/pin update failed task={taskId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task PushQuestMarkerAsync(TcpSession session, uint taskId, float x, float y, float z, CancellationToken token)
|
||||
{
|
||||
await session.NotifyAsync(MethodId.SyncTrackGPS, UxSerializer.Serialize(
|
||||
new SceneMethods.TeamTrackGPS4229938
|
||||
{
|
||||
TrackPosition = new SceneMethods.UxVector3(x, y, z),
|
||||
TrackGPSId = $"quest-{taskId}",
|
||||
TrackGPSType = 0,
|
||||
}), token);
|
||||
}
|
||||
|
||||
private static uint[]? _questNpcPool;
|
||||
|
||||
private static uint RandomQuestNpcFormwork()
|
||||
{
|
||||
_questNpcPool ??= NpcCatalog4229938.All
|
||||
.Where(n => n.Category == "citizen" && n.GeneralModelId != 0)
|
||||
.Select(n => n.Id)
|
||||
.ToArray();
|
||||
var pool = _questNpcPool;
|
||||
return pool.Length == 0 ? 0 : pool[Random.Shared.Next(pool.Length)];
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,607 @@
|
||||
using System.Text.Json;
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.Configuration;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// NPC shop / vehicle 4S dealer / tuning system, ported from the PrivateServer1 (Drmk)
|
||||
/// share pack (battle-tested against build 4229938). The client renders exactly the
|
||||
/// inventory the server pushes via SyncFullCommodityInfos; purchases are recorded
|
||||
/// server-side per vehicle config and echoed back in spawns + the owned-vehicle list.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
[Handler(MethodId.AskNpcShopCommodityInfo, HandlerPacketKind.Invoke)]
|
||||
private Task AskNpcShopCommodityInfo(Connection conn, UxRpcMessage msg)
|
||||
private static readonly Lazy<Dictionary<uint, List<uint>>> ShopCommodityCache = new(LoadShopCommodities);
|
||||
|
||||
/// <summary>commodityId → (partId, partTag, isSuit), chained like the client's
|
||||
/// GenCommodityItem: ShopCommodityConfig.BindId → ConsumableConfig.BindId →
|
||||
/// VehiclePartConfig.PartTag, or VehiclePartSuitConfig for body kits.</summary>
|
||||
private static readonly Lazy<Dictionary<uint, (uint PartId, uint PartTag, bool IsSuit)>> CommodityPartCache = new(LoadCommodityParts);
|
||||
|
||||
/// <summary>vehicleConfigId → DefaultSuitId (VehicleConfig) — the appearance BASE.</summary>
|
||||
private static readonly Lazy<Dictionary<uint, uint>> DefaultSuitCache = new(LoadDefaultSuits);
|
||||
|
||||
/// <summary>partId → PartTag (VehiclePartConfig).</summary>
|
||||
private static readonly Lazy<Dictionary<uint, uint>> PartTagCache = new(LoadPartTags);
|
||||
|
||||
/// <summary>suitId → suit part list (VehiclePartSuitConfig.Suit array).</summary>
|
||||
private static readonly Lazy<Dictionary<uint, List<uint>>> SuitRowsCache = new(LoadSuitRows);
|
||||
|
||||
private const uint ContinentalShopId = 94100019u;
|
||||
private static readonly Vec3 ContinentalShopCreatePosition = new(3607.90991f, -0.1f, 2353.90991f);
|
||||
private const float ContinentalShopCreateFacing = -31.1864891f;
|
||||
|
||||
private static readonly Dictionary<uint, uint> ContinentalVehicleCommodities = new()
|
||||
{
|
||||
conn.Log.Info("[SHOP] AskNpcShopCommodityInfo invoke");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
[94201018u] = 81005002u,
|
||||
[94201034u] = 81005005u,
|
||||
[94201054u] = 81005007u,
|
||||
};
|
||||
|
||||
/// <summary>The two authored repair shops: Entrance = street door marker,
|
||||
/// Interior/Facing = parking spot inside the garage (west / seaside).</summary>
|
||||
internal static readonly (SceneMethods.UxVector3 Entrance, SceneMethods.UxVector3 Interior, float Facing)[] ShopInteriors =
|
||||
[
|
||||
(new(1439.10803f, 1.5f, 2379.6001f), new(1443.23f, -19.992f, 2351.428f), 180f), // west
|
||||
(new(2791.15601f, 1.37899995f, 1286.39001f), new(2783.912f, -11.92f, 1262.419f), 0f), // seaside
|
||||
];
|
||||
|
||||
private static readonly uint[] ExtraTuningCommodities =
|
||||
[
|
||||
// Default suit commodities for the 94100019 vehicles — without these
|
||||
// NewCarStoreMgr has no PartTag=18 mapping and logs "no valid suit".
|
||||
94220759u, 94220761u,
|
||||
94220353u, 94220354u, 94220468u, 94220355u, 94220356u, 94220357u, 94220358u,
|
||||
94220771u, 94220772u, 94220773u, 94220774u, 94220775u, 94220776u, 94220777u,
|
||||
94220778u, 94220779u, 94220780u, 94220781u, 94220782u, 94220783u, 94220784u,
|
||||
94220785u,
|
||||
];
|
||||
|
||||
[Handler(MethodId.AskNpcShop, HandlerPacketKind.Invoke)]
|
||||
private async Task AskNpcShop(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (!msg.TryGetArgs<GameMethods.AskNpcShop>(out var args) || args is null)
|
||||
{
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
return;
|
||||
}
|
||||
// ORDER MATTERS: push BEFORE the ack — the client's callback reads the shop
|
||||
// dict synchronously on ack; pushing after leaves every tuning tab locked.
|
||||
ShopCommodityCache.Value.TryGetValue(args.shopid, out var ids);
|
||||
var allIds = (ids ?? []).Concat(ExtraTuningCommodities).Distinct().ToList();
|
||||
var items = allIds.Select(id => new GameMethods.ShopCommodityItem
|
||||
{
|
||||
TemplateId = id,
|
||||
Count = 1,
|
||||
Status = 0,
|
||||
Discount = 100,
|
||||
MaxBuyCount = 0,
|
||||
}).ToList();
|
||||
await conn.Session.NotifyAsync(MethodId.IGameToClient_SyncFullCommodityInfos, UxSerializer.Serialize(
|
||||
new GameMethods.SyncFullCommodityInfos
|
||||
{
|
||||
shopId = args.shopid,
|
||||
infos = items,
|
||||
}), CancellationToken.None);
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
conn.Log.Info($"[SHOP] npc shop={args.shopid} pushed {items.Count} commodities (before ack)");
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskReadCommodities, HandlerPacketKind.Invoke)]
|
||||
private Task AskReadCommodities(Connection conn, UxRpcMessage msg)
|
||||
[Handler(MethodId.AskNpcShopCommodityInfo, HandlerPacketKind.Invoke)]
|
||||
private async Task AskNpcShopCommodityInfo(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[SHOP] AskReadCommodities invoke");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
if (!msg.TryGetArgs<GameMethods.AskNpcShopCommodityInfo>(out var args) || args is null)
|
||||
{
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
return;
|
||||
}
|
||||
ShopCommodityCache.Value.TryGetValue(args.shopid, out var ids);
|
||||
var items = (ids ?? []).Select(id => new GameMethods.ShopCommodityItem
|
||||
{
|
||||
TemplateId = id,
|
||||
Count = 1,
|
||||
Status = 0,
|
||||
Discount = 100,
|
||||
MaxBuyCount = 0,
|
||||
}).ToList();
|
||||
await conn.ReturnAsync(msg, new GameMethods.NpcShopCommodityInfo
|
||||
{
|
||||
CurrentDiscount = 100,
|
||||
CommodityInfoList = items,
|
||||
});
|
||||
conn.Log.Info($"[SHOP] commodity info shop={args.shopid} items={items.Count}");
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskVehicleShopSpawnVehicle, HandlerPacketKind.Invoke)]
|
||||
private async Task AskVehicleShopSpawnVehicle(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
|
||||
{
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
return;
|
||||
}
|
||||
if (!msg.TryGetArgs<GameMethods.AskVehicleShopSpawnVehicle>(out var args) || args is null || args.vehicleid == 0)
|
||||
{
|
||||
conn.Log.Warn($"[SHOP] spawn-vehicle bad args bytes={msg.Body.Length}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
return;
|
||||
}
|
||||
var state = GetStateIfExists(conn.Session);
|
||||
var shop = args.shopid switch
|
||||
{
|
||||
94100070u => ShopInteriors[1],
|
||||
94100069u => ShopInteriors[0],
|
||||
_ => NearestShopStruct(state is { Ready: true, HasLastReportedPlayerTransform: true }
|
||||
? new Vec3(state.LastReportedPlayerPosition.X, state.LastReportedPlayerPosition.Y, state.LastReportedPlayerPosition.Z)
|
||||
: null) ?? ShopInteriors[0],
|
||||
};
|
||||
var continental = args.shopid == ContinentalShopId;
|
||||
var spawnPos = continental
|
||||
? ContinentalShopCreatePosition
|
||||
: new Vec3(shop.Interior.X, shop.Interior.Y, shop.Interior.Z);
|
||||
var facing = continental ? ContinentalShopCreateFacing : shop.Facing;
|
||||
var result = await SpawnDirectAsync(
|
||||
conn.Session, args.vehicleid, spawnPos, facing, rightOffset: 0f, sourceType: 0,
|
||||
reason: $"vehicle-shop shop={args.shopid} bind={args.isbind}{(continental ? " continental" : "")}", pushSummonStart: true);
|
||||
if (!result.Ok)
|
||||
{
|
||||
conn.Log.Warn($"[SHOP] spawn-vehicle rejected: {result.Message}");
|
||||
await conn.ReturnAsync(msg, new GameMethods.AskVehicleShopSpawnVehicleResult { vehicleUid = 0 });
|
||||
return;
|
||||
}
|
||||
await conn.ReturnAsync(msg, new GameMethods.AskVehicleShopSpawnVehicleResult { vehicleUid = result.EntityId });
|
||||
// Snap the freshly-spawned car onto the parking spot explicitly (the client's own
|
||||
// parking-waypoint lookup can land it "in the wall where you look").
|
||||
await conn.Session.NotifyAsync(MethodId.SyncTeleportVehicle, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncTeleportVehicle
|
||||
{
|
||||
EntityId = result.EntityId,
|
||||
Position = new SceneMethods.UxVector3(spawnPos.X, spawnPos.Y, spawnPos.Z),
|
||||
Rotation = new SceneMethods.UxVector3(0f, facing, 0f),
|
||||
Velocity = 0f,
|
||||
Reset = true,
|
||||
MoveToken = 0,
|
||||
}), CancellationToken.None);
|
||||
conn.Log.Info($"[SHOP] spawn-vehicle shop={args.shopid} config={args.vehicleid} entity={result.EntityId} pos=({spawnPos.X:F1},{spawnPos.Y:F1},{spawnPos.Z:F1}){(continental ? " continental" : "")}");
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskBuyModifyParts, HandlerPacketKind.Invoke)]
|
||||
private async Task AskBuyModifyParts(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (!msg.TryGetArgs<GameMethods.AskBuyModifyParts>(out var args) || args is null)
|
||||
{
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
return;
|
||||
}
|
||||
// Tuning purchase: money/limits are not simulated — ack ok; the client applies
|
||||
// the parts locally, the server records them so spawns + the owned list echo them.
|
||||
var parts = args.commodityiddict is null ? "-" : string.Join(",", args.commodityiddict.Select(kv => kv.Key + "x" + kv.Value));
|
||||
var removed = args.removedparttypes is null ? "-" : string.Join(",", args.removedparttypes);
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
var world = GetStateIfExists(conn.Session);
|
||||
if (world is not null && args.vehicleid != 0)
|
||||
{
|
||||
lock (world.SyncRoot)
|
||||
{
|
||||
if (!world.VehiclePartsByConfig.TryGetValue(args.vehicleid, out var list))
|
||||
world.VehiclePartsByConfig[args.vehicleid] = list = new();
|
||||
if (args.removedparttypes is { Count: > 0 })
|
||||
{
|
||||
list.RemoveAll(p => args.removedparttypes.Contains(p.Type));
|
||||
if (args.removedparttypes.Contains(18))
|
||||
world.VehicleSuitByConfig.Remove(args.vehicleid);
|
||||
}
|
||||
if (args.commodityiddict is not null)
|
||||
foreach (var kv in args.commodityiddict)
|
||||
{
|
||||
if (!CommodityPartCache.Value.TryGetValue(kv.Key, out var part))
|
||||
continue;
|
||||
if (part.IsSuit)
|
||||
world.VehicleSuitByConfig[args.vehicleid] = part.PartId;
|
||||
else
|
||||
{
|
||||
list.RemoveAll(p => p.Type == part.PartTag);
|
||||
list.Add((part.PartTag, part.PartId));
|
||||
}
|
||||
}
|
||||
}
|
||||
VehicleTuningStore.Save(Profile.PlayerPid, world);
|
||||
await PushUnlockedVehiclesAsync(conn.Session, $"tuning-buy vehicle={args.vehicleid}");
|
||||
}
|
||||
conn.Log.Info($"[SHOP] buy-modify shop={args.shopid} vehicle={args.vehicleid} parts=[{parts}] removed=[{removed}] -> ok");
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskBuyCommodities, HandlerPacketKind.Invoke)]
|
||||
private async Task AskBuyCommodities(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (!msg.TryGetArgs<GameMethods.AskBuyCommodities>(out var args) || args is null)
|
||||
{
|
||||
conn.Log.Warn($"[SHOP] buy-commodities bad args bytes={msg.Body.Length}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
return;
|
||||
}
|
||||
var items = args.commodityiddict is null
|
||||
? "-"
|
||||
: string.Join(",", args.commodityiddict.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}x{kv.Value}"));
|
||||
var vehicleConfigId = 0u;
|
||||
if (args.shopid == ContinentalShopId && args.commodityiddict is { } commodityIds)
|
||||
{
|
||||
foreach (var commodityId in commodityIds.Keys)
|
||||
{
|
||||
if (ContinentalVehicleCommodities.TryGetValue(commodityId, out var configId))
|
||||
{
|
||||
vehicleConfigId = configId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.Log.Info($"[SHOP] buy-commodities shop={args.shopid} items=[{items}] vehicle={vehicleConfigId} -> ok{(args.shopid == ContinentalShopId ? " (4S)" : "")}");
|
||||
|
||||
if (vehicleConfigId != 0)
|
||||
{
|
||||
var world = GetStateIfExists(conn.Session);
|
||||
if (world is not null)
|
||||
{
|
||||
var changed = false;
|
||||
lock (world.SyncRoot)
|
||||
{
|
||||
changed |= world.SessionUnlockedVehicleConfigs.Add(vehicleConfigId);
|
||||
if (args.commodityiddict is { } purchased)
|
||||
{
|
||||
foreach (var kv in purchased)
|
||||
{
|
||||
if (!CommodityPartCache.Value.TryGetValue(kv.Key, out var part))
|
||||
continue;
|
||||
if (part.IsSuit)
|
||||
{
|
||||
if (world.VehicleSuitByConfig.GetValueOrDefault(vehicleConfigId) != part.PartId)
|
||||
{
|
||||
world.VehicleSuitByConfig[vehicleConfigId] = part.PartId;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!world.VehiclePartsByConfig.TryGetValue(vehicleConfigId, out var list))
|
||||
world.VehiclePartsByConfig[vehicleConfigId] = list = new();
|
||||
list.RemoveAll(p => p.Type == part.PartTag);
|
||||
list.Add((part.PartTag, part.PartId));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
await PushUnlockedVehiclesAsync(conn.Session, $"4S-buy vehicle={vehicleConfigId}");
|
||||
}
|
||||
}
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskBuyCommodity, HandlerPacketKind.Invoke)]
|
||||
private Task AskBuyCommodity(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[SHOP] AskBuyCommodity invoke");
|
||||
var args = msg.GetArgs<GameMethods.AskBuyCommodity>();
|
||||
conn.Log.Info($"[SHOP] buy commodity={args.commodityid} shop={args.shopid} count={args.count} -> ok");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskBuyCommodities, HandlerPacketKind.Invoke)]
|
||||
private Task AskBuyCommodities(Connection conn, UxRpcMessage msg)
|
||||
/// <summary>AskReadCommodities: same inventory push as AskNpcShop — the client renders
|
||||
/// what the server pushed. Body: {shopid:u32}.</summary>
|
||||
[Handler(MethodId.AskReadCommodities, HandlerPacketKind.Invoke)]
|
||||
private async Task AskReadCommodities(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[SHOP] AskBuyCommodities invoke");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
var shopId = FirstUInt32(msg.Body);
|
||||
ShopCommodityCache.Value.TryGetValue(shopId, out var ids);
|
||||
var items = (ids ?? []).Select(id => new GameMethods.ShopCommodityItem
|
||||
{
|
||||
TemplateId = id,
|
||||
Count = 1,
|
||||
Status = 0,
|
||||
Discount = 100,
|
||||
MaxBuyCount = 0,
|
||||
}).ToList();
|
||||
await conn.Session.NotifyAsync(MethodId.IGameToClient_SyncFullCommodityInfos, UxSerializer.Serialize(
|
||||
new GameMethods.SyncFullCommodityInfos
|
||||
{
|
||||
shopId = shopId,
|
||||
infos = items,
|
||||
}), CancellationToken.None);
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
conn.Log.Info($"[SHOP] read commodities shop={shopId} items={items.Count}");
|
||||
}
|
||||
|
||||
/// <summary>Bag purchases: sandbox-unlimited economy — ack and let the client add to bag.</summary>
|
||||
[Handler(MethodId.AskBuyCommodityToBag, HandlerPacketKind.Invoke)]
|
||||
private Task AskBuyCommodityToBag(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[SHOP] AskBuyCommodityToBag invoke");
|
||||
conn.Log.Info($"[SHOP] buy-to-bag bytes={msg.Body.Length}");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskBuyCommoditiesToBag, HandlerPacketKind.Invoke)]
|
||||
private Task AskBuyCommoditiesToBag(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[SHOP] AskBuyCommoditiesToBag invoke");
|
||||
conn.Log.Info($"[SHOP] buy-commodities-to-bag bytes={msg.Body.Length}");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskVehicleShopDestroyParking, HandlerPacketKind.Invoke)]
|
||||
private Task AskVehicleShopDestroyParking(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<GameMethods.AskVehicleShopDestroyParking>();
|
||||
conn.Log.Info($"[SHOP] destroy parking shop={args.shopid}");
|
||||
return conn.ReturnEmptyOkAsync(msg);
|
||||
}
|
||||
|
||||
/// <summary>Push the owned-vehicle list (with appearance overrides) to the session.</summary>
|
||||
internal static async Task PushUnlockedVehiclesAsync(TcpSession session, string reason)
|
||||
{
|
||||
var world = GetStateIfExists(session);
|
||||
var result = BuildUnlockedResult(world);
|
||||
await session.NotifyAsync(MethodId.SyncAllUnlockedVehicles, UxSerializer.Serialize(result), CancellationToken.None);
|
||||
session.Log.Info($"[VEHICLE] unlocked list re-pushed ({reason}) count={result.Vehicles.Count}");
|
||||
}
|
||||
|
||||
/// <summary>Appearance = default suit (base) + bought part overrides merged by PartTag.</summary>
|
||||
internal static SceneMethods.VehicleClientPart[] BuildAppearance(WorldEntryState? world, uint configId, out uint suit, bool includeStatParts)
|
||||
{
|
||||
suit = DefaultSuitCache.Value.TryGetValue(configId, out var ds) ? ds : 0u;
|
||||
var bought = new List<(uint Type, uint PartId)>();
|
||||
if (world is not null)
|
||||
{
|
||||
lock (world.SyncRoot)
|
||||
{
|
||||
if (world.VehiclePartsByConfig.TryGetValue(configId, out var remembered))
|
||||
bought.AddRange(remembered);
|
||||
if (world.VehicleSuitByConfig.TryGetValue(configId, out var boughtSuit))
|
||||
suit = boughtSuit;
|
||||
}
|
||||
}
|
||||
|
||||
var tags = PartTagCache.Value;
|
||||
var list = new List<(uint Type, uint PartId)>();
|
||||
if (suit != 0 && SuitRowsCache.Value.TryGetValue(suit, out var suitParts))
|
||||
{
|
||||
foreach (var id in suitParts)
|
||||
{
|
||||
if (id != 0 && tags.TryGetValue(id, out var tag))
|
||||
list.Add((tag, id));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (type, id) in bought)
|
||||
{
|
||||
if (type == 18)
|
||||
continue; // bought suit is applied via the SuitId base above
|
||||
if (!includeStatParts && type is 13 or 20 or 21 or 22)
|
||||
continue; // stat-only tags never reach the visual echo
|
||||
list.RemoveAll(p => p.Type == type);
|
||||
list.Add((type, id));
|
||||
}
|
||||
|
||||
return list.Select(p => new SceneMethods.VehicleClientPart { Type = p.Type, ConfigId = p.PartId }).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>BuildUnlockedResult with per-vehicle appearance overrides + session unlocks.</summary>
|
||||
internal static SceneMethods.AskGetUnlockedVehiclesResult BuildUnlockedResult(WorldEntryState? world)
|
||||
{
|
||||
var fleet = PrivateServerConfigStore.Current.Gameplay.Vehicles.FleetIds.ToList();
|
||||
if (world is not null)
|
||||
{
|
||||
lock (world.SyncRoot)
|
||||
fleet.AddRange(world.SessionUnlockedVehicleConfigs);
|
||||
}
|
||||
return new SceneMethods.AskGetUnlockedVehiclesResult
|
||||
{
|
||||
Vehicles = fleet.Distinct().Select(id =>
|
||||
{
|
||||
var parts = BuildAppearance(world, id, out var suit, includeStatParts: true);
|
||||
return new SceneMethods.PlayerVehicleClientDetail
|
||||
{
|
||||
Id = id,
|
||||
Parts = parts.ToList(),
|
||||
SuitId = suit,
|
||||
IsPersistent = true,
|
||||
};
|
||||
}).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Pick the shop whose street entrance is nearest the given position.</summary>
|
||||
internal static SceneMethods.UxVector3? NearestShop(Vec3? playerPos)
|
||||
=> NearestShopStruct(playerPos)?.Entrance;
|
||||
|
||||
private static (SceneMethods.UxVector3 Entrance, SceneMethods.UxVector3 Interior, float Facing)? NearestShopStruct(Vec3? playerPos)
|
||||
{
|
||||
if (playerPos is null) return null;
|
||||
var best = ShopInteriors[0];
|
||||
var bestD2 = float.MaxValue;
|
||||
foreach (var s in ShopInteriors)
|
||||
{
|
||||
var dx = s.Entrance.X - playerPos.Value.X;
|
||||
var dz = s.Entrance.Z - playerPos.Value.Z;
|
||||
var d2 = dx * dx + dz * dz;
|
||||
if (d2 < bestD2) { bestD2 = d2; best = s; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static string? FindDumpDir()
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
|
||||
return Directory.Exists(root) ? root : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<uint, List<uint>> LoadShopCommodities()
|
||||
{
|
||||
var result = new Dictionary<uint, List<uint>>();
|
||||
var root = FindDumpDir();
|
||||
if (root is null) return result;
|
||||
try
|
||||
{
|
||||
var shops = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "ShopConfig.json"))).RootElement.GetProperty("records");
|
||||
var groups = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "ShopCommodityGroupConfig.json"))).RootElement.GetProperty("records");
|
||||
var groupIds = new Dictionary<uint, List<uint>>();
|
||||
foreach (var g in groups.EnumerateArray())
|
||||
{
|
||||
var gid = g.GetProperty("Id").GetUInt32();
|
||||
var list = new List<uint>();
|
||||
if (g.TryGetProperty("CommodityIDList", out var arr) && arr.ValueKind == JsonValueKind.Array)
|
||||
foreach (var c in arr.EnumerateArray())
|
||||
list.Add(c.GetUInt32());
|
||||
groupIds[gid] = list;
|
||||
}
|
||||
foreach (var s in shops.EnumerateArray())
|
||||
{
|
||||
var sid = s.GetProperty("Id").GetUInt32();
|
||||
var merged = new List<uint>();
|
||||
if (s.TryGetProperty("CommodityGroupIdList", out var garr) && garr.ValueKind == JsonValueKind.Array)
|
||||
foreach (var gidNode in garr.EnumerateArray())
|
||||
if (groupIds.TryGetValue(gidNode.GetUInt32(), out var list))
|
||||
merged.AddRange(list);
|
||||
if (merged.Count > 0)
|
||||
result[sid] = merged;
|
||||
}
|
||||
Console.WriteLine($"[SHOP] shop commodity map ready: {result.Count} shops.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SHOP] shop table load failed: {ex.Message}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<uint, (uint PartId, uint PartTag, bool IsSuit)> LoadCommodityParts()
|
||||
{
|
||||
var result = new Dictionary<uint, (uint, uint, bool)>();
|
||||
var root = FindDumpDir();
|
||||
if (root is null) return result;
|
||||
try
|
||||
{
|
||||
uint U(JsonElement e, string name) => e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetUInt32() : 0;
|
||||
var consumables = new Dictionary<uint, uint>();
|
||||
foreach (var c in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "ConsumableConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
|
||||
{
|
||||
var id = U(c, "LT.ConfigGen.ICommonItemConfig.Id");
|
||||
if (id != 0)
|
||||
consumables[id] = U(c, "LT.ConfigGen.ICommonItemConfig.BindId");
|
||||
}
|
||||
var tags = new Dictionary<uint, uint>();
|
||||
foreach (var p in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "VehiclePartConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
|
||||
{
|
||||
var id = U(p, "Id");
|
||||
if (id != 0)
|
||||
tags[id] = U(p, "PartTag");
|
||||
}
|
||||
var suits = new HashSet<uint>();
|
||||
foreach (var s in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "VehiclePartSuitConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
|
||||
{
|
||||
var id = U(s, "Id");
|
||||
if (id != 0)
|
||||
suits.Add(id);
|
||||
}
|
||||
foreach (var s in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "ShopCommodityConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
|
||||
{
|
||||
var cid = U(s, "Id");
|
||||
if (cid == 0 || !consumables.TryGetValue(U(s, "BindId"), out var partId) || partId == 0)
|
||||
continue;
|
||||
if (tags.TryGetValue(partId, out var tag))
|
||||
result[cid] = (partId, tag, false);
|
||||
else if (suits.Contains(partId))
|
||||
result[cid] = (partId, 18, true);
|
||||
}
|
||||
Console.WriteLine($"[SHOP] commodity-part map ready: {result.Count} commodities.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SHOP] commodity-part map load failed: {ex.Message}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<uint, uint> LoadDefaultSuits()
|
||||
{
|
||||
var result = new Dictionary<uint, uint>();
|
||||
var root = FindDumpDir();
|
||||
if (root is null) return result;
|
||||
try
|
||||
{
|
||||
foreach (var row in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "VehicleConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
|
||||
{
|
||||
if (!row.TryGetProperty("Id", out var idNode) || !idNode.TryGetUInt32(out var id) || id == 0)
|
||||
continue;
|
||||
if (row.TryGetProperty("DefaultSuitId", out var s) && s.TryGetUInt32(out var suit) && suit != 0)
|
||||
result[id] = suit;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SHOP] default-suit map load failed: {ex.Message}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<uint, uint> LoadPartTags()
|
||||
{
|
||||
var result = new Dictionary<uint, uint>();
|
||||
var root = FindDumpDir();
|
||||
if (root is null) return result;
|
||||
try
|
||||
{
|
||||
foreach (var p in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "VehiclePartConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
|
||||
{
|
||||
var id = U32(p, "Id");
|
||||
if (id != 0)
|
||||
result[id] = U32(p, "PartTag");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SHOP] part-tag map load failed: {ex.Message}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<uint, List<uint>> LoadSuitRows()
|
||||
{
|
||||
var result = new Dictionary<uint, List<uint>>();
|
||||
var root = FindDumpDir();
|
||||
if (root is null) return result;
|
||||
try
|
||||
{
|
||||
foreach (var s in JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "VehiclePartSuitConfig.json"))).RootElement.GetProperty("records").EnumerateArray())
|
||||
{
|
||||
var id = U32(s, "Id");
|
||||
if (id == 0 || !s.TryGetProperty("Suit", out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
continue;
|
||||
var parts = new List<uint>();
|
||||
foreach (var p in arr.EnumerateArray())
|
||||
parts.Add(p.ValueKind == JsonValueKind.Number ? p.GetUInt32() : 0u);
|
||||
result[id] = parts;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SHOP] suit rows load failed: {ex.Message}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static uint U32(JsonElement e, string name)
|
||||
=> e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetUInt32() : 0u;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Spoon server-action output ports. The client's spoon task graph asks the SERVER which
|
||||
/// output port to take (AskGetSpoonServerOutputPortIndexByType, UXRPCTask<uint>) or to
|
||||
/// perform an action (AskDoSpoonServerActionByType). We answer port 0 — byte-identical to
|
||||
/// the typed-default fallback — and, for do-action, push the nearest repair shop's
|
||||
/// inventory so the mechanic's "assortment" dialog branch opens with content.
|
||||
/// Full param decoding (SpoonServerActionParam) is deferred: the raw body is logged.
|
||||
/// </summary>
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
[Handler(MethodId.AskGetSpoonServerOutputPortIndexByType, HandlerPacketKind.Invoke)]
|
||||
private Task SpoonOutputPortProbe(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info($"[SPOON] output-port probe bytes={msg.Body.Length} -> port 0");
|
||||
return conn.ReturnAsync(msg, 0u);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskDoSpoonServerActionByType, HandlerPacketKind.Invoke)]
|
||||
private async Task SpoonServerAction(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info($"[SPOON] do-action bytes={msg.Body.Length} -> ok (port 0)");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
// Push the nearest repair shop's inventory when the player is close to one —
|
||||
// the mechanic's assortment dialog opens empty without this push.
|
||||
var state = GetStateIfExists(conn.Session);
|
||||
if (state is null || !state.HasLastReportedPlayerTransform)
|
||||
return;
|
||||
var px = state.LastReportedPlayerPosition.X;
|
||||
var pz = state.LastReportedPlayerPosition.Z;
|
||||
var west = ShopInteriors[0];
|
||||
var seaside = ShopInteriors[1];
|
||||
float D2(SceneMethods.UxVector3 e) => (px - e.X) * (px - e.X) + (pz - e.Z) * (pz - e.Z);
|
||||
var (shopId, dist2) = D2(west.Entrance) <= D2(seaside.Entrance) ? (94100069u, D2(west.Entrance)) : (94100070u, D2(seaside.Entrance));
|
||||
if (dist2 > 60f * 60f)
|
||||
return;
|
||||
ShopCommodityCache.Value.TryGetValue(shopId, out var ids);
|
||||
var items = (ids ?? []).Select(id => new RpcTypes.Client4229938.Methods.Game.ShopCommodityItem
|
||||
{
|
||||
TemplateId = id,
|
||||
Count = 1,
|
||||
Status = 0,
|
||||
Discount = 100,
|
||||
MaxBuyCount = 0,
|
||||
}).ToList();
|
||||
await conn.Session.NotifyAsync(MethodId.IGameToClient_SyncFullCommodityInfos, UxSerializer.Serialize(
|
||||
new RpcTypes.Client4229938.Methods.Game.SyncFullCommodityInfos
|
||||
{
|
||||
shopId = shopId,
|
||||
infos = items,
|
||||
}), CancellationToken.None);
|
||||
conn.Log.Info($"[SPOON] do-action -> pushed {items.Count} commodities shop={shopId} (dist={MathF.Sqrt(dist2):F0}m)");
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,8 @@ internal sealed partial class GameRouter
|
||||
/// rightOffset meters to the right of the facing, +0.15m up.
|
||||
/// </summary>
|
||||
internal static async Task<(bool Ok, string Message, ulong EntityId, ulong Token, Vec3 SpawnPos)> SpawnDirectAsync(
|
||||
TcpSession session, uint configId, Vec3 nearPos, float facing, float rightOffset, byte sourceType, string reason)
|
||||
TcpSession session, uint configId, Vec3 nearPos, float facing, float rightOffset, byte sourceType, string reason,
|
||||
bool pushSummonStart = false)
|
||||
{
|
||||
if (configId == 0)
|
||||
return (false, "vehicle 0 does not exist", 0, 0, default);
|
||||
@@ -229,8 +230,8 @@ internal sealed partial class GameRouter
|
||||
CreateSourceType = sourceType,
|
||||
EntityId = entityId,
|
||||
VehicleConfigId = configId,
|
||||
Parts = [],
|
||||
SuitId = 0,
|
||||
Parts = BuildAppearance(GetStateIfExists(session), configId, out var enterSuit, includeStatParts: false).ToList(),
|
||||
SuitId = enterSuit,
|
||||
Position = pos,
|
||||
Facing = facing,
|
||||
EulerAngles = euler,
|
||||
@@ -255,6 +256,15 @@ internal sealed partial class GameRouter
|
||||
await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncChangeVehicleInteractable { VehicleInstanceId = entityId, Interactable = true }),
|
||||
CancellationToken.None);
|
||||
// Register the car as the player's own summon (ApplyCarManager.IsMySummonVehicle
|
||||
// reads DriveVehiclesManager.CurrentSummonVehicle, which only this packet fills) —
|
||||
// the autoshop/tuning gates reject "not-own" cars without it.
|
||||
if (pushSummonStart)
|
||||
{
|
||||
await session.NotifyAsync(MethodId.SyncStartSummonVehicle, UxSerializer.Serialize(
|
||||
new SceneMethods.SummonVehicleResult { VehicleEntityId = entityId, TaskToken = token }),
|
||||
CancellationToken.None);
|
||||
}
|
||||
session.Log.Info($"[VEHICLE] DIRECT_SPAWN reason={reason} config={configId} entity={entityId} token={token} seats={seatCount} spawn=({spawn.X:F1},{spawn.Y:F1},{spawn.Z:F1})");
|
||||
return (true, "spawned", entityId, token, spawn);
|
||||
}
|
||||
@@ -269,20 +279,7 @@ internal sealed partial class GameRouter
|
||||
session.Log.Info($"[VEHICLE] destroy entity={entityId} reason={reason}");
|
||||
}
|
||||
|
||||
internal static SceneMethods.AskGetUnlockedVehiclesResult BuildUnlockedResult()
|
||||
{
|
||||
var fleet = PrivateServerConfigStore.Current.Gameplay.Vehicles.FleetIds;
|
||||
return new SceneMethods.AskGetUnlockedVehiclesResult
|
||||
{
|
||||
Vehicles = fleet.Select(id => new SceneMethods.PlayerVehicleClientDetail
|
||||
{
|
||||
Id = id,
|
||||
Parts = [],
|
||||
SuitId = 0,
|
||||
IsPersistent = true,
|
||||
}).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Push the owned fleet right after world entry finalization (first gameplay movement),
|
||||
@@ -303,7 +300,9 @@ internal sealed partial class GameRouter
|
||||
}
|
||||
if (!first)
|
||||
return;
|
||||
var result = BuildUnlockedResult();
|
||||
// Restore persisted tuning purchases (parts/suits/session unlocks) from disk.
|
||||
VehicleTuningStore.LoadInto(Profile.PlayerPid, world);
|
||||
var result = BuildUnlockedResult(world);
|
||||
await ctx.NotifyAsync(MethodId.SyncAllUnlockedVehicles, result);
|
||||
ctx.Session.Log.Info($"[VEHICLE] garage auto-published count={result.Vehicles.Count}");
|
||||
}
|
||||
@@ -337,9 +336,7 @@ internal sealed partial class GameRouter
|
||||
RaidId = raidId,
|
||||
HasZoneGraph = hasZoneGraph,
|
||||
ZoneStorageDataHandle = hasZoneGraph ? settings.ZoneStorageDataHandle : 0,
|
||||
Intersections = [],
|
||||
Vehicles = [],
|
||||
StaticVehicles = [],
|
||||
Intersections = BuildAetherIntersectionInitList(),
|
||||
});
|
||||
ctx.Session.Log.Info($"[VEHICLE] aether-init raid={raidId} zoneGraph={hasZoneGraph} lists=empty");
|
||||
}
|
||||
@@ -347,7 +344,7 @@ internal sealed partial class GameRouter
|
||||
[Handler(MethodId.AskGetUnlockedVehicles, HandlerPacketKind.Invoke)]
|
||||
private Task AskGetUnlockedVehicles(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var result = BuildUnlockedResult();
|
||||
var result = BuildUnlockedResult(GetStateIfExists(conn.Session));
|
||||
conn.Log.Info($"[VEHICLE] unlocked list -> {result.Vehicles.Count} vehicles");
|
||||
return conn.ReturnAsync(msg, result);
|
||||
}
|
||||
@@ -379,6 +376,16 @@ internal sealed partial class GameRouter
|
||||
conn.Log.Info($"[VEHICLE] AskPlayerStartEnterOrExitVehicle vehicle={drive.VehicleEntityId} enter={drive.EnterOrLeave} seat={drive.SeatIndex}");
|
||||
if (drive.EnterOrLeave)
|
||||
{
|
||||
// Aether traffic vehicles (server-driven cars + their NPC drivers) are NOT logic
|
||||
// vehicles: running the logic-vehicle boarding flow on them crashes the client.
|
||||
// The proper takeover is the borrow flow below (AskAetherAIBorrowVehicle), so a
|
||||
// direct press here is only honoured for non-Aether vehicles.
|
||||
if (Ananta.Server.Gameplay.Traffic.CityTrafficEngine.Instance.IsAetherTrafficVehicle(drive.VehicleEntityId)
|
||||
|| Ananta.Server.Gameplay.Traffic.CityTrafficEngine.Instance.IsAetherTrafficNpc(drive.VehicleEntityId))
|
||||
{
|
||||
conn.Log.Info($"[VEHICLE] F-press on Aether traffic vehicle={drive.VehicleEntityId} deferred to borrow flow");
|
||||
return;
|
||||
}
|
||||
Ananta.Server.Gameplay.Traffic.CityTrafficEngine.Instance.NotifyVehicleBoarding(drive.VehicleEntityId);
|
||||
await ForceEnterVehicleAsync(conn.Session, drive.VehicleEntityId);
|
||||
}
|
||||
@@ -393,6 +400,61 @@ internal sealed partial class GameRouter
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traffic-car takeover: the client asks to borrow an Aether vehicle, the server grants it
|
||||
/// by detaching the car from the traffic simulation, removing its AI driver/passengers and
|
||||
/// flipping the control type to RaidVehicle so the player can drive it.
|
||||
/// </summary>
|
||||
[Handler(MethodId.AskAetherAIBorrowVehicle, HandlerPacketKind.Invoke)]
|
||||
private async Task AskAetherAIBorrowVehicle(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskAetherAIBorrowVehicle4229938>();
|
||||
conn.Log.Info($"[VEHICLE] borrow aether vehicle={args.VehicleInstanceId}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
|
||||
var (found, driverNpcId, passengerIds) = Ananta.Server.Gameplay.Traffic.CityTrafficEngine.Instance.DetachForBorrow(args.VehicleInstanceId);
|
||||
if (found)
|
||||
{
|
||||
var session = conn.Session;
|
||||
if (driverNpcId != 0)
|
||||
await TrafficAndCrowdDriver.Instance.RemoveAetherVehicleNpcAsync(session, driverNpcId);
|
||||
foreach (var passengerId in passengerIds)
|
||||
await TrafficAndCrowdDriver.Instance.RemoveAetherVehicleNpcAsync(session, passengerId);
|
||||
}
|
||||
|
||||
await session_AckBorrowAsync(conn.Session, args.VehicleInstanceId);
|
||||
}
|
||||
|
||||
private static async Task session_AckBorrowAsync(TcpSession session, ulong vehicleInstanceId)
|
||||
{
|
||||
await session.NotifyAsync(MethodId.SyncAetherAIBorrowVehicle, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncAetherAIBorrowVehicle4229938
|
||||
{
|
||||
VehicleInstanceId = vehicleInstanceId,
|
||||
}), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncAetherAIChangeVehicleControl, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncAetherAIChangeVehicleControlType4229938
|
||||
{
|
||||
VehicleId = vehicleInstanceId,
|
||||
ControlType = 2, // VehicleControlType.RaidVehicle — player-drivable
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Player left a borrowed car: flip it back to an Aether traffic vehicle.</summary>
|
||||
[Handler(MethodId.AskAetherAIRaidVehicleRegressAether, HandlerPacketKind.Invoke)]
|
||||
private async Task AskAetherAIRaidVehicleRegressAether(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
var args = msg.GetArgs<SceneMethods.AskAetherAIRaidVehicleRegressAether4229938>();
|
||||
conn.Log.Info($"[VEHICLE] regress aether vehicle={args.VehicleInstanceId}");
|
||||
await conn.ReturnEmptyOkAsync(msg);
|
||||
await conn.Session.NotifyAsync(MethodId.SyncAetherAIChangeVehicleControl, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncAetherAIChangeVehicleControlType4229938
|
||||
{
|
||||
VehicleId = args.VehicleInstanceId,
|
||||
ControlType = 1, // VehicleControlType.AetherVehicle — back to traffic
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskPlayerFinishEnterOrExitVehicle, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.AskVehicleStartMove, HandlerPacketKind.Notify)]
|
||||
[Handler(MethodId.AskVehicleStopMove, HandlerPacketKind.Notify)]
|
||||
@@ -537,20 +599,8 @@ internal sealed partial class GameRouter
|
||||
}
|
||||
|
||||
// Metro / Subway System Handlers
|
||||
[Handler(MethodId.AskGetAllMetroInfos, HandlerPacketKind.Invoke)]
|
||||
private Task AskGetAllMetroInfos(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
conn.Log.Info("[METRO] GetAllMetroInfos requested -> returning active metro train lines");
|
||||
// Active trains for rail lines 1, 2, 3, 4 across the city
|
||||
var list = new List<MetroClientInfo>
|
||||
{
|
||||
new() { Id = 1001, LineId = 1, ElapsedTime = 12.0f, IsFinalTrain = false },
|
||||
new() { Id = 1002, LineId = 2, ElapsedTime = 25.0f, IsFinalTrain = false },
|
||||
new() { Id = 1003, LineId = 3, ElapsedTime = 38.0f, IsFinalTrain = false },
|
||||
new() { Id = 1004, LineId = 4, ElapsedTime = 5.0f, IsFinalTrain = false }
|
||||
};
|
||||
return conn.ReturnAsync(msg, list);
|
||||
}
|
||||
// AskGetAllMetroInfos is handled by GameRouter.Metro.cs (real rail scheduling from the
|
||||
// client's Rail*Config tables + a SyncRunningMetroInfos push loop).
|
||||
|
||||
[Handler(MethodId.AskMetroGadgetIds, HandlerPacketKind.Invoke)]
|
||||
private Task AskMetroGadgetIds(Connection conn, UxRpcMessage msg)
|
||||
|
||||
@@ -73,6 +73,9 @@ internal sealed partial class GameRouter
|
||||
|
||||
await ctx.NotifyAsync(MethodId.SendServerTimeGame, LoginCodec.ServerTime());
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerInfo, RuntimePayloadFactory.MinimalPlayerInfo4229938());
|
||||
// Phone contacts book per character (summon numbers + real city NPC contacts) —
|
||||
// the NETWORK OF CONTACTS app renders exactly this push.
|
||||
await PushSpiritHandbookAsync(ctx.Session);
|
||||
await SendEnterSceneIfNeeded(ctx);
|
||||
}
|
||||
|
||||
@@ -378,16 +381,55 @@ internal sealed partial class GameRouter
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize story task tracking
|
||||
// Initialize story task tracking — the INTRO cutscene (train / name entry / gender
|
||||
// select) is driven by the story root task 60004938 (PrivateServer1-proven): the
|
||||
// client plays the opening when the current task is the story root.
|
||||
const uint StoryRootTaskId = 60004938u;
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerSingleTask, new SceneMethods.SyncPlayerSingleTask4229938
|
||||
{
|
||||
TaskId = StoryRootTaskId,
|
||||
View = new SceneMethods.TaskViewData4229938
|
||||
{
|
||||
TaskId = StoryRootTaskId,
|
||||
State = 1, // Accepted
|
||||
},
|
||||
State = new SceneMethods.TaskStateData4229938 { State = 1, Reason = 0 },
|
||||
});
|
||||
await ctx.NotifyAsync(MethodId.SyncCurrentTask, new SceneMethods.SyncCurrentTaskInfo
|
||||
{
|
||||
Type = 1,
|
||||
TaskId = 10001,
|
||||
TaskId = StoryRootTaskId,
|
||||
EventId = 0,
|
||||
FirstTime = true,
|
||||
Reason = 0
|
||||
});
|
||||
|
||||
// Establish the quest system with the current task and an empty furniture inventory
|
||||
// (houses render clean instead of blocking on furniture state).
|
||||
if (ctx.Session.Items.TryGetValue("taskStates", out var taskRaw) && taskRaw is Dictionary<uint, QuestEntry> taskStates)
|
||||
{
|
||||
foreach (var (taskId, entry) in taskStates.Where(kv => kv.Value.State == QuestStateAccepted))
|
||||
{
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerSingleTask, new SceneMethods.SyncPlayerSingleTask4229938
|
||||
{
|
||||
TaskId = taskId,
|
||||
View = new SceneMethods.TaskViewData4229938
|
||||
{
|
||||
TaskId = taskId,
|
||||
CounterValues = entry.Counters.ToList(),
|
||||
State = entry.State,
|
||||
},
|
||||
State = new SceneMethods.TaskStateData4229938 { State = entry.State, Reason = 0 },
|
||||
});
|
||||
}
|
||||
}
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerAllTask, new SceneMethods.SyncPlayerAllTask4229938
|
||||
{
|
||||
CurrentTask = StoryRootTaskId,
|
||||
LoginGameServer = true,
|
||||
});
|
||||
await ctx.NotifyAsync(MethodId.SyncHouseFurnitureInfo, new SceneMethods.SyncHouseFurnitureInfo4229938());
|
||||
|
||||
ctx.Session.Log.Info($"[WORLD-V7] ready generation={generation} scene={sceneId} session={sessionId} exactGuide=true sceneComplete=first actorPresentation=client-owned buffs=deferred-first-movement noStory=false noAOI=true");
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,43 @@ internal sealed partial class GameRouter
|
||||
MethodId.AskTaffyMotoLeaveRush,
|
||||
MethodId.OnTafeiMotorColliding,
|
||||
MethodId.AskGetOffMotor,
|
||||
|
||||
// NPC interaction / dialog / dating (AgentProperty + InviteRide).
|
||||
MethodId.AskInteractCmd,
|
||||
MethodId.AskInteractCmds,
|
||||
MethodId.AskAgentStartNpcDialog,
|
||||
MethodId.AskAgentFinishNpcDialog,
|
||||
MethodId.GmStartInviteRideNpc,
|
||||
MethodId.AskInteractNpcWithGift,
|
||||
|
||||
// Scene rooms (interiors: houses, shops — the anti-fall-through gate).
|
||||
MethodId.AskGadgetSceneRoomTrigger,
|
||||
MethodId.AskEnterSceneRoom,
|
||||
MethodId.AskExistSceneRoom,
|
||||
MethodId.AskAgentSceneRoomTrigger,
|
||||
|
||||
// Vehicle 4S dealer / tuning shop (repair-shop port).
|
||||
MethodId.AskNpcShop,
|
||||
MethodId.AskVehicleShopSpawnVehicle,
|
||||
MethodId.AskVehicleShopDestroyParking,
|
||||
MethodId.AskBuyModifyParts,
|
||||
|
||||
// Spoon server actions (mechanic assortment branch).
|
||||
MethodId.AskGetSpoonServerOutputPortIndexByType,
|
||||
MethodId.AskDoSpoonServerActionByType,
|
||||
|
||||
// Quest counter completion (sandbox shortcut).
|
||||
MethodId.AskFinishTaskCounter,
|
||||
|
||||
// Phone contacts app (NETWORK OF CONTACTS) — sandbox contact management.
|
||||
MethodId.AskPhoneAddContact,
|
||||
MethodId.AskPhoneEditContact,
|
||||
MethodId.AskPhoneDeleteContact,
|
||||
MethodId.AskPhoneAddContactGroup,
|
||||
MethodId.AskPhoneEditContactGroup,
|
||||
MethodId.AskPhoneDeleteContactGroup,
|
||||
MethodId.AskPhoneAddContactToGroup,
|
||||
MethodId.AskPhoneContactOptionAction,
|
||||
};
|
||||
|
||||
internal RpcRouter Build()
|
||||
|
||||
@@ -2,16 +2,56 @@ using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.Gameplay.Crowd;
|
||||
using Ananta.Server.Gameplay.Traffic;
|
||||
using Ananta.Server.Gameplay.World;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
internal sealed class TrafficAndCrowdDriver : ITrafficVehicleDriver, IUrbanCrowdDriver
|
||||
internal sealed class TrafficAndCrowdDriver : ITrafficVehicleDriver, IUrbanCrowdDriver, IGadgetStreamDriver
|
||||
{
|
||||
public static TrafficAndCrowdDriver Instance { get; } = new();
|
||||
|
||||
public async Task SendGadgetGridAoiAsync(
|
||||
TcpSession session, IReadOnlyList<(int X, int Z)> cells, IReadOnlyList<int> sectorIds, (int X, int Z) playerCell, bool firstPush)
|
||||
{
|
||||
var packet = new SceneMethods.SyncGadgetGridAOIDecrease4229938
|
||||
{
|
||||
AoiInfo = new SceneMethods.GadgetGridAOIIncrease4229938
|
||||
{
|
||||
PlayerStandardIndex = new SceneMethods.GadgetGridIndex4229938(playerCell.Item1, playerCell.Item2),
|
||||
IndexList = cells.Select(c => new SceneMethods.GadgetGridIndex4229938(c.X, c.Z)).ToList(),
|
||||
// Init on the first push of a session, AOIMove afterwards.
|
||||
Reason = firstPush ? (byte)0 : (byte)2,
|
||||
},
|
||||
// Sector list comes from the baked cell map (CellSectors.json) so indoor cells
|
||||
// address their building's sector; falls back to outdoor sector 0.
|
||||
AddGridInfo = new SceneMethods.GridAOIDecrease4229938
|
||||
{
|
||||
SectorIdList = sectorIds.ToList(),
|
||||
BuildingId = 0,
|
||||
StandardIndexList = cells.Select(c => new SceneMethods.GadgetGridIndex4229938(c.X, c.Z)).ToList(),
|
||||
},
|
||||
};
|
||||
await session.NotifyAsync(MethodId.SyncGadgetGridAOIDecrease, UxSerializer.Serialize(packet), CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task SendSceneItemGridAoiAsync(
|
||||
TcpSession session, IReadOnlyList<(int X, int Z)> cells, (int X, int Z) playerCell, bool firstPush)
|
||||
{
|
||||
var packet = new SceneMethods.SyncDestructibleGridAOIIncrease4229938
|
||||
{
|
||||
AoiInfo = new SceneMethods.DestructibleGridAOIIncrease4229938
|
||||
{
|
||||
PlayerStandardIndex = new SceneMethods.GadgetGridIndex4229938(playerCell.Item1, playerCell.Item2),
|
||||
IndexList = cells.Select(c => new SceneMethods.GadgetGridIndex4229938(c.X, c.Z)).ToList(),
|
||||
Reason = firstPush ? (byte)0 : (byte)2,
|
||||
},
|
||||
};
|
||||
await session.NotifyAsync(MethodId.SyncDestructibleGridAOIIncrease, UxSerializer.Serialize(packet), CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task<(bool Ok, ulong EntityId)> SpawnTrafficVehicleAsync(
|
||||
TcpSession session, uint configId, float x, float y, float z, float yaw)
|
||||
{
|
||||
@@ -27,6 +67,13 @@ internal sealed class TrafficAndCrowdDriver : ITrafficVehicleDriver, IUrbanCrowd
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, int> _moveTokens = new();
|
||||
|
||||
/// <summary>
|
||||
/// Reset flag for the SyncTeleportVehicle reposition channel. true mirrors the proven
|
||||
/// panel "To me" teleport semantics (hard snap each packet); false lets the client blend
|
||||
/// using the scalar Velocity. Toggle live via POST /api/traffic/config {"moveReset":false}.
|
||||
/// </summary>
|
||||
public static bool VehicleMoveReset = true;
|
||||
|
||||
public async Task SendVehicleMoveAsync(
|
||||
TcpSession session, ulong entityId, float x, float y, float z, float yaw, float vx, float vz)
|
||||
{
|
||||
@@ -42,9 +89,270 @@ internal sealed class TrafficAndCrowdDriver : ITrafficVehicleDriver, IUrbanCrowd
|
||||
MoveToken = token,
|
||||
};
|
||||
await session.NotifyAsync(MethodId.SyncVehicleMove, UxSerializer.Serialize(moveData), CancellationToken.None);
|
||||
|
||||
// Channel B: SyncTeleportVehicle — the server->client reposition packet the client is
|
||||
// known to apply (same channel as the panel's "To me"). Sent alongside the mirror
|
||||
// packet above so whichever channel this client build honors moves the car.
|
||||
var teleport = new SceneMethods.SyncTeleportVehicle
|
||||
{
|
||||
EntityId = entityId,
|
||||
Position = new SceneMethods.UxVector3(x, y, z),
|
||||
Rotation = new SceneMethods.UxVector3(0f, yaw, 0f),
|
||||
Velocity = MathF.Sqrt(vx * vx + vz * vz),
|
||||
Reset = VehicleMoveReset,
|
||||
MoveToken = token,
|
||||
};
|
||||
await session.NotifyAsync(MethodId.SyncTeleportVehicle, UxSerializer.Serialize(teleport), CancellationToken.None);
|
||||
|
||||
GameRouter.TrackVehicleMove(entityId, x, y, z, yaw);
|
||||
}
|
||||
|
||||
public async Task SpawnAetherVehicleAsync(
|
||||
TcpSession session, ulong entityId, uint configId, uint colorPartId, int laneHandle,
|
||||
float distanceAlongLane, float speed, double timestamp, float x, float y, float z, float yaw)
|
||||
{
|
||||
// Aether vehicles live only in the client's traffic simulation (VehicleControlType.AetherVehicle);
|
||||
// they are intentionally NOT registered as scene entities / SummonedVehicles.
|
||||
await session.NotifyAsync(MethodId.SyncAetherAIVehicleAddData, UxSerializer.Serialize(
|
||||
new SceneMethods.ClientVehicleInitData4229938
|
||||
{
|
||||
Id = entityId,
|
||||
Position = new SceneMethods.UxVector3(x, y, z),
|
||||
Facing = yaw,
|
||||
EulerAngles = new SceneMethods.UxVector3(0f, yaw, 0f),
|
||||
VehicleConfigId = configId,
|
||||
VehicleColorPartId = colorPartId,
|
||||
VehicleLightState = 0,
|
||||
LaneHandle = laneHandle,
|
||||
DistanceAlongLane = distanceAlongLane,
|
||||
NextVehicleId = 0,
|
||||
Timestamp = timestamp,
|
||||
ControlType = 1,
|
||||
Speed = speed,
|
||||
DustRatio = 0f,
|
||||
Parts = [],
|
||||
SuitId = 0,
|
||||
RandomFraction = Random.Shared.NextSingle(),
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task SpawnAetherVehiclesAsync(TcpSession session, IReadOnlyList<AetherVehicleInit> vehicles)
|
||||
{
|
||||
// The 4229938 client only implements the SINGLE SyncAetherAIVehicleAddData (68916691);
|
||||
// a previous batch id (68571034) does not exist in the client's method table and its
|
||||
// packets were dropped, so flush each spawn as its own notify.
|
||||
//
|
||||
// Full sequence per vehicle (battle-tested in the PrivateServer1 pack): the LEGACY
|
||||
// scene-entity spawn (SyncLogicVehicleEnter + SyncSpawnVehicle) is what creates the
|
||||
// drive-layer BaseVehicle — without it AetherVehicleLifecycle flush drops every car
|
||||
// with "Flush dropped missing BaseVehicle". ChangeVehicleControl pins it to the
|
||||
// aether AI and ForceGo starts the drive immediately.
|
||||
foreach (var v in vehicles)
|
||||
{
|
||||
var pos = new SceneMethods.UxVector3(v.X, v.Y, v.Z);
|
||||
var euler = new SceneMethods.UxVector3(0f, v.Yaw, 0f);
|
||||
await session.NotifyAsync(MethodId.SyncLogicVehicleEnter, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncLogicVehicleEnter
|
||||
{
|
||||
EntityId = v.EntityId,
|
||||
VehicleConfigId = v.ConfigId,
|
||||
CreateSourceType = 0,
|
||||
Parts = [],
|
||||
SuitId = 0,
|
||||
LicensePlate = string.Empty,
|
||||
Interactable = false,
|
||||
MoveToken = 0,
|
||||
Position = pos,
|
||||
EulerAngles = euler,
|
||||
VehicleSpoonName = null,
|
||||
}), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncSpawnVehicle, UxSerializer.Serialize(
|
||||
new SceneMethods.VehicleClientInfo
|
||||
{
|
||||
ControllerPid = 0,
|
||||
CreateSourceType = 0,
|
||||
EntityId = v.EntityId,
|
||||
VehicleConfigId = v.ConfigId,
|
||||
Parts = [],
|
||||
SuitId = 0,
|
||||
Position = pos,
|
||||
Facing = v.Yaw,
|
||||
EulerAngles = euler,
|
||||
Velocity = v.Speed,
|
||||
IsStatic = false,
|
||||
DeformStatus = 0,
|
||||
SeatInfos = [],
|
||||
SpoonId = 0,
|
||||
IsDynamicGo = false,
|
||||
VehicleEnemyId = 0,
|
||||
DisableNavigation = false,
|
||||
Interactable = false,
|
||||
MoveToken = 0,
|
||||
LicensePlate = string.Empty,
|
||||
}), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncAetherAIVehicleAddData, UxSerializer.Serialize(
|
||||
new SceneMethods.ClientVehicleInitData4229938
|
||||
{
|
||||
VehicleConfigId = v.ConfigId,
|
||||
VehicleColorPartId = v.ColorPartId,
|
||||
VehicleLightState = 0,
|
||||
LaneHandle = v.LaneHandle,
|
||||
DistanceAlongLane = v.Distance,
|
||||
NextVehicleId = 0,
|
||||
Timestamp = v.Timestamp,
|
||||
ControlType = 1,
|
||||
Speed = v.Speed,
|
||||
DustRatio = 0f,
|
||||
Parts = [],
|
||||
SuitId = 0,
|
||||
RandomFraction = Random.Shared.NextSingle(),
|
||||
Id = v.EntityId,
|
||||
Position = new SceneMethods.UxVector3(v.X, v.Y, v.Z),
|
||||
Facing = v.Yaw,
|
||||
EulerAngles = new SceneMethods.UxVector3(0f, v.Yaw, 0f),
|
||||
}), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncAetherAIChangeVehicleControl, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncAetherAIChangeVehicleControlType4229938
|
||||
{
|
||||
VehicleId = v.EntityId,
|
||||
ControlType = 1, // VehicleControlType.AetherVehicle
|
||||
}), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncAetherAIVehicleForceGo, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncAetherAIVehicleForceGo4229938
|
||||
{
|
||||
instanceId = v.EntityId,
|
||||
forceGo = true,
|
||||
}), CancellationToken.None);
|
||||
// Hand driving to the client's own traffic AI: cruise through the lane chain
|
||||
// (stop for cars/peds/lights, steer around obstacles) — the server does not
|
||||
// stream lane positions in this mode.
|
||||
if (v.CruisePoints is { Count: > 0 })
|
||||
{
|
||||
var cruise = new SceneMethods.CruiseAITaskParameters4229938
|
||||
{
|
||||
TargetPointList = v.CruisePoints.Select(p => new Ananta.Server.RpcTypes.Client4229938.Auto.UXVector3 { X = p.X, Y = p.Y, Z = p.Z }).ToList(),
|
||||
CruiseType = (byte)SceneMethods.E_TaskVehicleCruiseType4229938.GoToTargetInOrderLoop,
|
||||
configFlags = (byte)(SceneMethods.TaskVehicleCruiseConfigFlags4229938.SlowDownWhenArriveTarget
|
||||
| SceneMethods.TaskVehicleCruiseConfigFlags4229938.DynamicSpeed),
|
||||
minSpeed = MathF.Max(1f, v.Speed * 0.5f),
|
||||
maxSpeed = MathF.Max(2f, v.Speed * 1.3f),
|
||||
ArrivalDistance = 6f,
|
||||
Token = (ulong)Interlocked.Increment(ref _aiTaskTokenSeq),
|
||||
taskAIConfigId = AiConfigPool[Random.Shared.Next(AiConfigPool.Length)],
|
||||
defaultSpeed = v.Speed,
|
||||
drivingFlags = (int)(SceneMethods.VehicleTaskDrivingFlags4229938.DFStopForCars
|
||||
| SceneMethods.VehicleTaskDrivingFlags4229938.DFStopForPeds
|
||||
| SceneMethods.VehicleTaskDrivingFlags4229938.DFStopAtLights
|
||||
| SceneMethods.VehicleTaskDrivingFlags4229938.DFSteerAroundObjects),
|
||||
initSpeed = v.Speed,
|
||||
};
|
||||
await session.NotifyAsync(MethodId.SyncAssignVehicleAITask, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncAssignVehicleAITask4229938
|
||||
{
|
||||
vehicleUId = v.EntityId,
|
||||
parameters = cruise,
|
||||
}), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncChangeVehicleAITaskState, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncChangeVehicleAITaskState4229938
|
||||
{
|
||||
vehicleUID = v.EntityId,
|
||||
taskToken = cruise.Token,
|
||||
status = (byte)SceneMethods.VehicleAIStatus4229938.Running,
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly uint[] AiConfigPool = [16, 20, 21, 22, 23, 24, 26, 27, 28, 29, 30];
|
||||
private static long _aiTaskTokenSeq;
|
||||
|
||||
public async Task SpawnAetherVehicleNpcAsync(
|
||||
TcpSession session, ulong npcId, uint npcFormworkId, ulong vehicleId, byte seatIndex)
|
||||
{
|
||||
await session.NotifyAsync(MethodId.SyncAetherAIVehicleNpcAdd, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncAetherAIVehicleNpcAdd4229938
|
||||
{
|
||||
initData = new SceneMethods.ClientVehicleNpcInitData4229938
|
||||
{
|
||||
Id = npcId,
|
||||
NpcFormworkId = npcFormworkId,
|
||||
BindVehicleId = vehicleId,
|
||||
SeatIndex = seatIndex,
|
||||
},
|
||||
}), CancellationToken.None);
|
||||
|
||||
// Give the driver its drive behavior (PrivateServer1: VehicleNpcTaskId from the
|
||||
// behavior-task catalog) — without it the driver NPC is invisible/passive.
|
||||
var taskId = Ananta.Server.ClientData.Client4229938.BehaviorTaskCatalog.VehicleNpcTaskId;
|
||||
if (taskId != 0)
|
||||
{
|
||||
await session.NotifyAsync(MethodId.SyncDoAetherAgentBehaviorTaskDef, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncDoAetherAgentBehaviorTaskDefine4229938
|
||||
{
|
||||
agentEntityId = npcId,
|
||||
taskId = (ulong)Interlocked.Increment(ref _behaviorTaskSeq),
|
||||
behaviorTaskId = taskId,
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static long _behaviorTaskSeq;
|
||||
|
||||
public Task RemoveAetherVehicleNpcAsync(TcpSession session, ulong npcId)
|
||||
{
|
||||
// SyncAetherAINpcRemove (68332284) is the only Aether NPC remove in the client's method
|
||||
// table; the previously used id 68173578 ("SyncAetherAIVehicleNpcRemove") does not exist.
|
||||
return session.NotifyAsync(MethodId.SyncAetherAINpcRemove, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncAetherAINpcRemove4229938 { Pid = npcId }), CancellationToken.None);
|
||||
}
|
||||
|
||||
public Task SendAetherIntersectionUpdateAsync(
|
||||
TcpSession session, ulong intersectionInstanceId, int currentPeriod, int nextPeriod, byte state)
|
||||
{
|
||||
return session.NotifyAsync(MethodId.SyncAetherAIIntersectionUpdateData, UxSerializer.Serialize(
|
||||
new SceneMethods.ClientTrafficIntersectionPeriodUpdateInfo4229938
|
||||
{
|
||||
IntersectionIndex = intersectionInstanceId,
|
||||
CurrentState = state,
|
||||
CurrentPeriodIndex = (byte)currentPeriod,
|
||||
NextPeriodIndex = (byte)nextPeriod,
|
||||
RailPeriodIndex = 0,
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
|
||||
public Task SendAetherLaneDatasAsync(
|
||||
TcpSession session, List<AetherLaneSample> samples, double timestamp)
|
||||
{
|
||||
var packet = new SceneMethods.SyncAetherAIVehicleLaneDatas4229938
|
||||
{
|
||||
Data = samples.Select(s => new SceneMethods.ClientVehicleLaneData4229938
|
||||
{
|
||||
Id = s.EntityId,
|
||||
LaneHandle = s.LaneHandle,
|
||||
DistanceAlongLane = s.Distance,
|
||||
Status = s.Status,
|
||||
}).ToList(),
|
||||
LaneChangeData = [],
|
||||
TimeStamp = timestamp,
|
||||
};
|
||||
return session.NotifyAsync(MethodId.SyncAetherAIVehicleLaneDatas, UxSerializer.Serialize(packet), CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task RemoveAetherVehicleAsync(TcpSession session, ulong entityId)
|
||||
{
|
||||
// Aether unregister + legacy scene-entity destroy (the BaseVehicle pair).
|
||||
await session.NotifyAsync(MethodId.SyncAetherAIVehicleRemove, UxSerializer.Serialize(
|
||||
new SceneMethods.SyncAetherAIVehicleRemove4229938 { Pid = entityId }), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncDestroyVehicle, UxSerializer.Serialize(
|
||||
new SceneMethods.DestroyVehicle
|
||||
{
|
||||
VehicleEntityId = entityId,
|
||||
VehicleDestroyType = 0,
|
||||
Distance = 0,
|
||||
DynamicGoId = 0,
|
||||
}), CancellationToken.None);
|
||||
}
|
||||
|
||||
public bool IsVehiclePlayerControlled(ulong entityId)
|
||||
{
|
||||
return GameRouter.TryGetSummoned(entityId, out var summoned) && summoned?.ControllerPid != 0;
|
||||
@@ -52,18 +360,31 @@ internal sealed class TrafficAndCrowdDriver : ITrafficVehicleDriver, IUrbanCrowd
|
||||
|
||||
public (bool HasPlayer, float X, float Y, float Z, float Yaw, uint RaidId) GetPlayerPosition(TcpSession session)
|
||||
{
|
||||
var state = GameRouter.GetStateIfExists(session);
|
||||
if (state is null)
|
||||
return (false, 0, 0, 0, 0, 0);
|
||||
|
||||
lock (state.SyncRoot)
|
||||
// Sessions disposed right after a client disconnect are probed by the background
|
||||
// loops for a few ticks — treat them as "no player" instead of throwing.
|
||||
try
|
||||
{
|
||||
if (!state.Ready || !state.HasLastReportedPlayerTransform)
|
||||
var state = GameRouter.GetStateIfExists(session);
|
||||
if (state is null)
|
||||
return (false, 0, 0, 0, 0, 0);
|
||||
|
||||
var pos = state.LastReportedPlayerPosition;
|
||||
var rot = state.LastReportedPlayerRotation;
|
||||
return (true, pos.X, pos.Y, pos.Z, rot.Y, state.ActiveRaidId);
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (!state.Ready || !state.HasLastReportedPlayerTransform)
|
||||
return (false, 0, 0, 0, 0, 0);
|
||||
|
||||
var pos = state.LastReportedPlayerPosition;
|
||||
var rot = state.LastReportedPlayerRotation;
|
||||
return (true, pos.X, pos.Y, pos.Z, rot.Y, state.ActiveRaidId);
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return (false, 0, 0, 0, 0, 0);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return (false, 0, 0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Text.Json;
|
||||
using Ananta.Server.Configuration;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Persists per-player vehicle tuning (bought parts / suits / session unlocks) to
|
||||
/// save/vehicles/<pid>.json so purchases survive a server restart.
|
||||
/// </summary>
|
||||
internal static class VehicleTuningStore
|
||||
{
|
||||
private static string DirFor(ulong pid)
|
||||
{
|
||||
string baseDir;
|
||||
try { baseDir = PrivateServerConfigStore.ProjectRoot; }
|
||||
catch { baseDir = AppContext.BaseDirectory; }
|
||||
return Path.Combine(baseDir, "save", "vehicles");
|
||||
}
|
||||
|
||||
private static string FileFor(ulong pid) => Path.Combine(DirFor(pid), $"{pid}.json");
|
||||
|
||||
public static void Save(ulong pid, WorldEntryState world)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = DirFor(pid);
|
||||
Directory.CreateDirectory(dir);
|
||||
var dto = new TuningDto
|
||||
{
|
||||
Unlocked = world.SessionUnlockedVehicleConfigs.ToList(),
|
||||
Parts = world.VehiclePartsByConfig.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => kv.Value.Select(p => new PartDto { Type = p.Type, PartId = p.PartId }).ToList()),
|
||||
Suits = new Dictionary<uint, uint>(world.VehicleSuitByConfig),
|
||||
};
|
||||
var json = JsonSerializer.Serialize(dto, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(FileFor(pid), json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[TUNING] save failed pid={pid}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Loads saved tuning into the (fresh) world state — called on world entry.</summary>
|
||||
public static void LoadInto(ulong pid, WorldEntryState world)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = FileFor(pid);
|
||||
if (!File.Exists(path))
|
||||
return;
|
||||
var dto = JsonSerializer.Deserialize<TuningDto>(File.ReadAllText(path));
|
||||
if (dto is null)
|
||||
return;
|
||||
lock (world.SyncRoot)
|
||||
{
|
||||
foreach (var id in dto.Unlocked)
|
||||
world.SessionUnlockedVehicleConfigs.Add(id);
|
||||
foreach (var kv in dto.Parts)
|
||||
world.VehiclePartsByConfig[kv.Key] = kv.Value.Select(p => (p.Type, p.PartId)).ToList();
|
||||
foreach (var kv in dto.Suits)
|
||||
world.VehicleSuitByConfig[kv.Key] = kv.Value;
|
||||
}
|
||||
Console.WriteLine($"[TUNING] loaded saved tuning pid={pid} vehicles={dto.Unlocked.Count} parts={dto.Parts.Count}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[TUNING] load failed pid={pid}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TuningDto
|
||||
{
|
||||
public List<uint> Unlocked { get; set; } = [];
|
||||
public Dictionary<uint, List<PartDto>> Parts { get; set; } = [];
|
||||
public Dictionary<uint, uint> Suits { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class PartDto
|
||||
{
|
||||
public uint Type { get; set; }
|
||||
public uint PartId { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user