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; /// /// 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. /// 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" ]; /// Stable per-session contact book, seeded with summon + city NPC contacts. private static Dictionary> ContactsOf(TcpSession session) { if (session.Items.TryGetValue(PhoneContactsKey, out var raw) && raw is Dictionary> 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; } /// Real NPC names from the AgentConfig catalog become callable city contacts. private static List CityContacts() { var result = new List(); 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'); /// Pushes every character's contact book (call after SyncPlayerInfo at world entry). 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(); 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(); 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(); 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); // Handled in GameRouter.Phone.cs with full call logging and option routing 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); } }