Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.Quests.cs
T

345 lines
15 KiB
C#

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)];
}
}