feat: Add metro transit, gadgets, shop, social apps, and traffic models checkpoint

This commit is contained in:
CAtlas0784 committed 2026-10-29 01:55:01 +07:00
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&lt;uint&gt;) 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/&lt;pid&gt;.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; }
}
}