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; /// /// 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. /// 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 QuestsOf(TcpSession session) { if (session.Items.TryGetValue("questStates", out var raw) && raw is Dictionary dict) return dict; dict = []; session.Items["questStates"] = dict; return dict; } /// Builds the full SyncPlayerAllTask container from the session's quest states. private static SceneMethods.SyncPlayerAllTask4229938 BuildTaskContainer(TcpSession session) { var state = GetStateIfExists(session); var quests = QuestsOf(session); var taskInfos = new List(); 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); } /// Accepts a task: records state (with counter slots from TaskConfig) and pushes. 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); } /// Submits a task: marks Submited, pushes, then auto-accepts the NextTasks chain. 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); } } /// Counter report: clamp to authored max; all full → auto-complete + advance chain. 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)]; } }