345 lines
15 KiB
C#
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)];
|
|
}
|
|
}
|