feat(gameplay): integrate DRMK NPC spawner, dynamic scene presets, pure combat combo router, and clean character switching
This commit is contained in:
1 parent
3fd7922ac8
commit
b5fc15e4ec
20 files changed
+2329029
-53
No files matched your search
@@ -3,6 +3,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.Configuration;
|
||||
using Ananta.Server.Handlers.Game;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
@@ -136,6 +137,10 @@ internal sealed class DebugApiServer(PrivateServerConfig config, GameSessionHub
|
||||
await WriteJsonAsync(ctx, await PlayCutsceneAsync(await ReadBodyAsync(ctx.Request, token), token), token);
|
||||
else if (method == "POST" && path == "/api/timeline/play")
|
||||
await WriteJsonAsync(ctx, await PlayTimelineAsync(await ReadBodyAsync(ctx.Request, token), token), token);
|
||||
else if (method == "POST" && path == "/api/npc/spawn")
|
||||
await WriteJsonAsync(ctx, await NpcSpawnAsync(await ReadBodyAsync(ctx.Request, token), token), token);
|
||||
else if (method == "GET" && path == "/api/scenes")
|
||||
await WriteJsonAsync(ctx, Scenes(), token);
|
||||
else
|
||||
await WriteJsonAsync(ctx, new { ok = false, error = "unknown route" }, token, 404);
|
||||
}
|
||||
@@ -551,6 +556,61 @@ internal sealed class DebugApiServer(PrivateServerConfig config, GameSessionHub
|
||||
return await SendPlayerTeleportAsync(session, unitId, x, y, z, facing, "teleport", token);
|
||||
}
|
||||
|
||||
private static object Scenes()
|
||||
{
|
||||
return new
|
||||
{
|
||||
presets = SceneCatalog4229938.Presets.Select(p => new
|
||||
{
|
||||
key = p.Key,
|
||||
label = p.Label,
|
||||
raidId = p.RaidId,
|
||||
instanceId = p.InstanceId,
|
||||
universeId = p.UniverseId,
|
||||
x = p.X,
|
||||
y = p.Y,
|
||||
z = p.Z,
|
||||
facing = p.Facing,
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class NpcSpawnRequest
|
||||
{
|
||||
public List<AdminNpcSpawnItem>? Items { get; set; }
|
||||
public float XSpacing { get; set; } = 1.5f;
|
||||
public float ZSpacing { get; set; } = 1.5f;
|
||||
public int MaxPerRow { get; set; } = 10;
|
||||
}
|
||||
|
||||
private async Task<object> NpcSpawnAsync(string json, CancellationToken token)
|
||||
{
|
||||
var session = hub.Current;
|
||||
if (session is null)
|
||||
return new { ok = false, message = "no live game session (is the client in the world?)" };
|
||||
|
||||
NpcSpawnRequest? cmd;
|
||||
try
|
||||
{
|
||||
cmd = JsonSerializer.Deserialize<NpcSpawnRequest>(json, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new { ok = false, message = "bad request: " + ex.Message };
|
||||
}
|
||||
|
||||
if (cmd?.Items is not { Count: >= 1 } || cmd.Items.Any(i => i is null || i.NpcFormworkId == 0)
|
||||
|| !float.IsFinite(cmd.XSpacing) || cmd.XSpacing < 0f || !float.IsFinite(cmd.ZSpacing) || cmd.ZSpacing < 0f)
|
||||
{
|
||||
return new { ok = false, message = "provide at least one item with a non-zero npcFormworkId, finite non-negative X/Z spacing, and maxPerRow of at least 1" };
|
||||
}
|
||||
if (cmd.MaxPerRow < 1)
|
||||
return new { ok = false, message = "maxPerRow must be at least 1" };
|
||||
|
||||
var result = await GameRouter.SpawnStaticNpcsAsync(session, cmd.Items, cmd.XSpacing, cmd.ZSpacing, cmd.MaxPerRow);
|
||||
return new { ok = result.Ok, message = result.Message, ids = result.Ids.Select(id => id.ToString()).ToArray() };
|
||||
}
|
||||
|
||||
private async Task<object> SwitchSceneAsync(string json, CancellationToken token)
|
||||
{
|
||||
var session = hub.Current;
|
||||
@@ -566,6 +626,22 @@ internal sealed class DebugApiServer(PrivateServerConfig config, GameSessionHub
|
||||
{
|
||||
using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json);
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("preset", out var pp) && pp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var key = pp.GetString();
|
||||
var preset = SceneCatalog4229938.Presets.FirstOrDefault(p =>
|
||||
string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||
if (preset != null)
|
||||
{
|
||||
raidId = preset.RaidId;
|
||||
instanceId = preset.InstanceId;
|
||||
universeId = preset.UniverseId;
|
||||
x = preset.X;
|
||||
y = preset.Y;
|
||||
z = preset.Z;
|
||||
facing = preset.Facing;
|
||||
}
|
||||
}
|
||||
if (root.TryGetProperty("raidId", out var pr) || root.TryGetProperty("raid", out pr))
|
||||
raidId = pr.GetUInt32();
|
||||
if (root.TryGetProperty("instanceId", out var pi) || root.TryGetProperty("instance", out pi))
|
||||
|
||||
@@ -104,17 +104,26 @@
|
||||
<div id="summoned">—</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="border-color:#00bcd4">
|
||||
<h2 style="color:#4dd0e1">👥 NPC Spawner (เสก NPC / ตัวละครเดินดิน)</h2>
|
||||
<div class="muted">One NPC per line: AgentConfig ID, optionally followed by a POI action ID (เช่น 40130709, 2).</div>
|
||||
<textarea id="npcItems" rows="6" style="width:100%;box-sizing:border-box;background:#101215;color:#d7dce2;border:1px solid #444;border-radius:6px;padding:6px;font-family:inherit;margin:6px 0;resize:vertical" placeholder="40130001, 0" spellcheck="false">40130709, 2</textarea>
|
||||
<div>
|
||||
<label>X spacing</label><input id="npcXSpacing" type="number" min="0" step="any" value="1.5" style="width:70px">
|
||||
<label>Z spacing</label><input id="npcZSpacing" type="number" min="0" step="any" value="1.5" style="width:70px">
|
||||
<label>Max per row</label><input id="npcMaxPerRow" type="number" min="1" step="1" value="10" style="width:70px">
|
||||
<button id="npcSpawn" style="background:#0097a7;margin-left:8px" onclick="spawnNpcs()">Spawn NPCs</button>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:4px">NPCs are placed in a player-relative grid in front of you.</div>
|
||||
<pre id="npcResult" style="background:#101215;border:1px solid #333;border-radius:6px;padding:8px;max-height:160px;overflow-y:auto;white-space:pre-wrap;margin-top:6px;font-size:12px"></pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Scene ("raid") switch</h2>
|
||||
<div style="margin-bottom:8px">
|
||||
Preset:
|
||||
<select id="scenepreset" onchange="onPresetChange()" style="min-width:380px">
|
||||
<option value="longqi">Longqi Village (raid 23300999) (raid 23300999)</option>
|
||||
<option value="longqi_tea">Longqi Village — Tea Field (Scenic)</option>
|
||||
<option value="longqi_temple">Longqi Village — Hushi Zen Temple</option>
|
||||
<option value="longqi_wukang">Longqi Village — Wukang Mansion</option>
|
||||
<option value="maincity">Main City (WorldMap_Release) (raid 23300888)</option>
|
||||
<option value="airport">Main City — Airport Terminal (raid 23300888)</option>
|
||||
<option value="loading">Loading scene presets...</option>
|
||||
</select>
|
||||
<button style="background:#2e7d32;margin-left:8px" onclick="switchToPreset()">Switch to preset</button>
|
||||
</div>
|
||||
@@ -408,21 +417,86 @@ const scenePresets = {
|
||||
maincity: { raid: 23300888, instance: 20001222, universe: 76000888, x: 3142.67, y: 0, z: 2522.78, facing: 0 },
|
||||
airport: { raid: 23300888, instance: 20001222, universe: 76000888, x: -4468.3, y: -6.15, z: -2597.5, facing: 85.5 }
|
||||
};
|
||||
function onPresetChange() {
|
||||
const sel = document.getElementById('scenepreset').value;
|
||||
const p = scenePresets[sel];
|
||||
if (!p) return;
|
||||
document.getElementById('s_raid').value = p.raid;
|
||||
document.getElementById('s_instance').value = p.instance;
|
||||
document.getElementById('s_universe').value = p.universe;
|
||||
document.getElementById('s_x').value = p.x;
|
||||
document.getElementById('s_y').value = p.y;
|
||||
document.getElementById('s_z').value = p.z;
|
||||
document.getElementById('s_f').value = p.facing;
|
||||
let npcBusy = false;
|
||||
async function spawnNpcs() {
|
||||
if (npcBusy) return;
|
||||
const lines = document.getElementById('npcItems').value.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
||||
const items = lines.map(line => {
|
||||
const parts = line.split(/[\s,;]+/);
|
||||
return { npcFormworkId: Number(parts[0]), poiActionId: Number(parts[1] || 0) };
|
||||
});
|
||||
const output = document.getElementById('npcResult');
|
||||
if (!items.length || items.some(item => !Number.isInteger(item.npcFormworkId) || item.npcFormworkId < 1 || item.npcFormworkId > 4294967295 || !Number.isInteger(item.poiActionId) || item.poiActionId < 0 || item.poiActionId > 4294967295)) {
|
||||
output.textContent = 'Enter valid numeric IDs, one NPC per line.';
|
||||
return;
|
||||
}
|
||||
const xSp = Number(document.getElementById('npcXSpacing').value);
|
||||
const zSp = Number(document.getElementById('npcZSpacing').value);
|
||||
const maxPerRow = Number(document.getElementById('npcMaxPerRow').value);
|
||||
npcBusy = true;
|
||||
document.getElementById('npcSpawn').disabled = true;
|
||||
output.textContent = 'Spawning NPCs...';
|
||||
try {
|
||||
const r = await api('/api/npc/spawn', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items, xSpacing: xSp, zSpacing: zSp, maxPerRow })
|
||||
});
|
||||
output.textContent = JSON.stringify(r, null, 2);
|
||||
} catch (err) {
|
||||
output.textContent = 'Spawn request failed: ' + err.message;
|
||||
} finally {
|
||||
npcBusy = false;
|
||||
document.getElementById('npcSpawn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScenes() {
|
||||
try {
|
||||
const r = await api('/api/scenes');
|
||||
const sel = document.getElementById('scenepreset');
|
||||
if (!sel || !r.presets) return;
|
||||
sel.innerHTML = '';
|
||||
r.presets.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.key;
|
||||
opt.text = p.label;
|
||||
opt.dataset.p = JSON.stringify(p);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.onchange = onPresetChange;
|
||||
if (sel.options.length) onPresetChange();
|
||||
} catch (err) {
|
||||
console.warn('loadScenes failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function onPresetChange() {
|
||||
const sel = document.getElementById('scenepreset');
|
||||
if (!sel || !sel.selectedOptions[0] || !sel.selectedOptions[0].dataset.p) return;
|
||||
const p = JSON.parse(sel.selectedOptions[0].dataset.p);
|
||||
document.getElementById('s_raid').value = p.raidId;
|
||||
document.getElementById('s_instance').value = p.instanceId;
|
||||
document.getElementById('s_universe').value = p.universeId;
|
||||
document.getElementById('s_x').value = p.x.toFixed(1);
|
||||
document.getElementById('s_y').value = p.y.toFixed(1);
|
||||
document.getElementById('s_z').value = p.z.toFixed(1);
|
||||
document.getElementById('s_f').value = p.facing.toFixed(1);
|
||||
}
|
||||
|
||||
async function switchToPreset() {
|
||||
onPresetChange();
|
||||
await switchSceneCustom();
|
||||
const sel = document.getElementById('scenepreset');
|
||||
if (sel && sel.value) {
|
||||
const r = await api('/api/world/switch-scene', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preset: sel.value })
|
||||
});
|
||||
log('switch scene preset: ' + JSON.stringify(r));
|
||||
refresh();
|
||||
} else {
|
||||
await switchSceneCustom();
|
||||
}
|
||||
}
|
||||
async function switchSceneCustom() {
|
||||
const body = {
|
||||
@@ -520,6 +594,7 @@ async function playTimeline() {
|
||||
|
||||
|
||||
loadFleet();
|
||||
loadScenes();
|
||||
refresh();
|
||||
setInterval(refresh, 2000);
|
||||
document.getElementById('timeslider').addEventListener('input', e => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Text.Json;
|
||||
using Ananta.Server.Configuration;
|
||||
|
||||
namespace Ananta.Server.ClientData.Client4229938;
|
||||
|
||||
internal sealed record NpcCatalogEntry4229938(
|
||||
uint Id,
|
||||
string Name,
|
||||
uint GeneralModelId,
|
||||
uint AgentPersonaId,
|
||||
uint AnimSetTag,
|
||||
uint BehaviorActionConfigId,
|
||||
uint ActionGroupId,
|
||||
uint AiSettingId,
|
||||
uint InteractSettingId);
|
||||
|
||||
internal static class NpcCatalog4229938
|
||||
{
|
||||
private static readonly Lazy<Dictionary<uint, NpcCatalogEntry4229938>> Cache = new(Load);
|
||||
|
||||
internal static bool TryGet(uint id, out NpcCatalogEntry4229938 entry)
|
||||
=> Cache.Value.TryGetValue(id, out entry!);
|
||||
|
||||
private static Dictionary<uint, NpcCatalogEntry4229938> Load()
|
||||
{
|
||||
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
|
||||
var path = Path.Combine(root, "AgentConfig.json");
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array)
|
||||
throw new InvalidDataException($"AgentConfig does not contain records: {path}");
|
||||
var result = new Dictionary<uint, NpcCatalogEntry4229938>();
|
||||
foreach (var row in records.EnumerateArray())
|
||||
{
|
||||
if (!row.TryGetProperty("Id", out var idNode) || !idNode.TryGetUInt32(out var id) || id == 0)
|
||||
continue;
|
||||
var name = row.TryGetProperty("Name", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() ?? "" : "";
|
||||
var model = row.TryGetProperty("GeneralModelId", out var m) && m.TryGetUInt32(out var modelId) ? modelId : 0;
|
||||
static uint UInt32(JsonElement source, string propertyName)
|
||||
=> source.TryGetProperty(propertyName, out var node) && node.TryGetUInt32(out var value) ? value : 0;
|
||||
|
||||
var sex = UInt32(row, "SexType");
|
||||
var age = UInt32(row, "Age");
|
||||
var economicLevel = UInt32(row, "EconomicLevel");
|
||||
var refined = economicLevel >= 3;
|
||||
var persona = (sex, age >= 45, refined) switch
|
||||
{
|
||||
(1, false, false) when age < 18 => 45200003u,
|
||||
(2, false, false) when age < 18 => 45200004u,
|
||||
(1, false, false) => 45200007u,
|
||||
(2, false, false) => 45200008u,
|
||||
(1, false, true) => 45200009u,
|
||||
(2, false, true) => 45200010u,
|
||||
(1, true, false) => 45200011u,
|
||||
(2, true, false) => 45200012u,
|
||||
(1, true, true) => 45200013u,
|
||||
(2, true, true) => 45200014u,
|
||||
_ => 0u,
|
||||
};
|
||||
var animSetTag = UInt32(row, "AnimStereotype");
|
||||
if (animSetTag == 0)
|
||||
animSetTag = UInt32(row, "BattleAnimType");
|
||||
result[id] = new(id, name, model, persona, animSetTag,
|
||||
UInt32(row, "Action"), UInt32(row, "ActionGroupId"),
|
||||
UInt32(row, "AI"), UInt32(row, "InteractSetting"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text.Json;
|
||||
using Ananta.Server.Configuration;
|
||||
|
||||
namespace Ananta.Server.ClientData.Client4229938;
|
||||
|
||||
internal sealed record PoiActionCatalogEntry4229938(uint Id, uint MainActionGroup, uint[] MainActions)
|
||||
{
|
||||
internal uint UsageId { get; init; }
|
||||
internal uint[] BelongingSceneItems { get; init; } = [];
|
||||
internal uint SelectMainAction(ulong entityId)
|
||||
=> MainActions.Length == 0 ? 0 : MainActions[(int)(entityId % (ulong)MainActions.Length)];
|
||||
}
|
||||
|
||||
internal static class PoiActionCatalog4229938
|
||||
{
|
||||
private static readonly Lazy<Dictionary<uint, PoiActionCatalogEntry4229938>> Cache = new(Load);
|
||||
|
||||
internal static bool TryGet(uint id, out PoiActionCatalogEntry4229938 entry)
|
||||
=> Cache.Value.TryGetValue(id, out entry!);
|
||||
|
||||
private static Dictionary<uint, PoiActionCatalogEntry4229938> Load()
|
||||
{
|
||||
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
|
||||
var path = Path.Combine(root, "UrbanDiversityPOIActionConfig.json");
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array)
|
||||
throw new InvalidDataException($"UrbanDiversityPOIActionConfig does not contain records: {path}");
|
||||
|
||||
var result = new Dictionary<uint, PoiActionCatalogEntry4229938>();
|
||||
using var usagesDoc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "BelongingBelongingUsageConfig.json")));
|
||||
var usages = usagesDoc.RootElement.GetProperty("records").EnumerateArray()
|
||||
.ToDictionary(row => row.GetProperty("Id").GetUInt32());
|
||||
foreach (var row in records.EnumerateArray())
|
||||
{
|
||||
if (!row.TryGetProperty("Id", out var idNode) || !idNode.TryGetUInt32(out var id) || id == 0)
|
||||
continue;
|
||||
var actionGroup = row.TryGetProperty("MainActionGroup", out var groupNode) && groupNode.TryGetUInt32(out var group)
|
||||
? group
|
||||
: 0;
|
||||
var actions = row.TryGetProperty("MainActions", out var actionsNode) && actionsNode.ValueKind == JsonValueKind.Array
|
||||
? actionsNode.EnumerateArray()
|
||||
.Select(node => node.TryGetUInt32(out var action) ? action : 0)
|
||||
.Where(action => action != 0)
|
||||
.ToArray()
|
||||
: [];
|
||||
var usageId = row.TryGetProperty("UsageId", out var usageNode) ? usageNode.GetUInt32() : 0;
|
||||
uint[] belongingItems = [];
|
||||
if (usageId != 0)
|
||||
{
|
||||
if (!usages.TryGetValue(usageId, out var usage))
|
||||
throw new InvalidDataException($"POI {id} refers to missing belonging usage {usageId}");
|
||||
belongingItems = usage.GetProperty("BelongingSceneItem").EnumerateArray()
|
||||
.Select(node => node.GetUInt32()).Where(item => item != 0).Distinct().ToArray();
|
||||
// Type-based usages choose from the NPC's inventory. For standalone
|
||||
// admin NPCs, seed the usage's configured representative item.
|
||||
if (belongingItems.Length == 0 && usage.GetProperty("BelongingType").GetArrayLength() != 0)
|
||||
{
|
||||
var slotItem = usage.GetProperty("SlotSceneItem").GetUInt32();
|
||||
if (slotItem != 0) belongingItems = [slotItem];
|
||||
}
|
||||
}
|
||||
result[id] = new(id, actionGroup, actions) { UsageId = usageId, BelongingSceneItems = belongingItems };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Text.Json;
|
||||
using Ananta.Server.Configuration;
|
||||
|
||||
namespace Ananta.Server.ClientData.Client4229938;
|
||||
|
||||
internal sealed record ScenePresetEntry4229938(
|
||||
string Key, string Label, uint RaidId, ulong InstanceId, uint UniverseId,
|
||||
float X, float Y, float Z, float Facing);
|
||||
|
||||
/// <summary>
|
||||
/// Scene ("raid") preset catalog built from the game dumps:
|
||||
/// RaidConfig.json (raid → SceneId = instance id, Multiverse = universe id) and
|
||||
/// MapentranceConfig.json (entrance → RaidId + Coordinate/MapTeleportPos + facing).
|
||||
/// Verified: Nova raid 23300888 → scene/instance 20001222, Lingyun 23300999 → 20001223.
|
||||
/// </summary>
|
||||
internal static class SceneCatalog4229938
|
||||
{
|
||||
private static readonly Lazy<IReadOnlyList<ScenePresetEntry4229938>> Cache = new(Load);
|
||||
|
||||
internal static IReadOnlyList<ScenePresetEntry4229938> Presets => Cache.Value;
|
||||
|
||||
internal static bool TryGetByRaid(uint raidId, out ScenePresetEntry4229938 preset)
|
||||
{
|
||||
foreach (var p in Cache.Value)
|
||||
{
|
||||
if (p.RaidId != raidId)
|
||||
continue;
|
||||
preset = p;
|
||||
return true;
|
||||
}
|
||||
preset = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ScenePresetEntry4229938> Load()
|
||||
{
|
||||
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
|
||||
var raids = LoadRecords(Path.Combine(root, "RaidConfig.json"));
|
||||
var entrances = LoadRecords(Path.Combine(root, "MapentranceConfig.json"));
|
||||
|
||||
var raidById = new Dictionary<uint, JsonElement>();
|
||||
foreach (var row in raids)
|
||||
{
|
||||
if (row.TryGetProperty("Id", out var id) && id.TryGetUInt32(out var raidId) && raidId != 0)
|
||||
raidById[raidId] = row;
|
||||
}
|
||||
|
||||
uint defaultUniverse;
|
||||
try { defaultUniverse = PrivateServerConfigStore.Current.World.UniverseId; }
|
||||
catch { defaultUniverse = 76000888; }
|
||||
|
||||
var result = new List<ScenePresetEntry4229938>();
|
||||
foreach (var row in entrances)
|
||||
{
|
||||
if (!row.TryGetProperty("Id", out var idNode) || !idNode.TryGetUInt32(out var entranceId))
|
||||
continue;
|
||||
if (!row.TryGetProperty("RaidId", out var raidNode) || !raidNode.TryGetUInt32(out var raidId) || raidId == 0)
|
||||
continue;
|
||||
var coords = row.TryGetProperty("MapTeleportPos", out var tp) && tp.ValueKind == JsonValueKind.Array && tp.GetArrayLength() >= 3
|
||||
? tp
|
||||
: row.TryGetProperty("Coordinate", out var c) && c.ValueKind == JsonValueKind.Array && c.GetArrayLength() >= 3 ? c : default;
|
||||
if (coords.ValueKind != JsonValueKind.Array)
|
||||
continue;
|
||||
var xyz = coords.EnumerateArray().Take(3).ToArray();
|
||||
if (xyz.Any(e => e.ValueKind != JsonValueKind.Number))
|
||||
continue;
|
||||
float x = (float)xyz[0].GetDouble(), y = (float)xyz[1].GetDouble(), z = (float)xyz[2].GetDouble();
|
||||
if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z))
|
||||
continue;
|
||||
float facing = row.TryGetProperty("EntranceFacing", out var f) && f.ValueKind == JsonValueKind.Number
|
||||
? (float)f.GetDouble() : 0f;
|
||||
|
||||
ulong instanceId = 0;
|
||||
uint universeId = defaultUniverse;
|
||||
if (raidById.TryGetValue(raidId, out var raid))
|
||||
{
|
||||
if (raid.TryGetProperty("SceneId", out var s) && s.TryGetUInt64(out var sceneId) && sceneId != 0)
|
||||
instanceId = sceneId;
|
||||
if (raid.TryGetProperty("Multiverse", out var m) && m.TryGetUInt32(out var multi) && multi != 0)
|
||||
universeId = multi;
|
||||
}
|
||||
if (instanceId == 0)
|
||||
continue;
|
||||
|
||||
var name = row.TryGetProperty("Name", out var n) && n.ValueKind == JsonValueKind.String
|
||||
? n.GetString() ?? string.Empty : string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
name = $"Entrance {entranceId}";
|
||||
result.Add(new ScenePresetEntry4229938(
|
||||
$"entrance-{entranceId}", $"{name} (raid {raidId})",
|
||||
raidId, instanceId, universeId, x, y, z, facing));
|
||||
}
|
||||
|
||||
result.Sort((a, b)
|
||||
=> a.RaidId != b.RaidId ? a.RaidId.CompareTo(b.RaidId) : string.Compare(a.Label, b.Label, StringComparison.Ordinal));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<JsonElement> LoadRecords(string path)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array)
|
||||
throw new InvalidDataException($"Config does not contain records: {path}");
|
||||
// Clone: the document is disposed on return.
|
||||
return records.EnumerateArray().Select(e => e.Clone()).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json;
|
||||
using Ananta.Server.Configuration;
|
||||
|
||||
namespace Ananta.Server.ClientData.Client4229938;
|
||||
|
||||
internal sealed record WeatherEntry4229938(uint Id, string Name);
|
||||
|
||||
/// <summary>Weather catalog from WeatherConfig.json (5 entries in this build).</summary>
|
||||
internal static class WeatherCatalog4229938
|
||||
{
|
||||
private static readonly Lazy<IReadOnlyList<WeatherEntry4229938>> Cache = new(Load);
|
||||
|
||||
internal static IReadOnlyList<WeatherEntry4229938> All => Cache.Value;
|
||||
|
||||
private static IReadOnlyList<WeatherEntry4229938> Load()
|
||||
{
|
||||
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
|
||||
var path = Path.Combine(root, "WeatherConfig.json");
|
||||
if (!File.Exists(path))
|
||||
return [];
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var result = new List<WeatherEntry4229938>();
|
||||
if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array)
|
||||
return result;
|
||||
foreach (var row in records.EnumerateArray())
|
||||
{
|
||||
if (!row.TryGetProperty("Id", out var id) || !id.TryGetUInt32(out var weatherId) || weatherId == 0)
|
||||
continue;
|
||||
var name = row.TryGetProperty("Name", out var n) && n.ValueKind == JsonValueKind.String
|
||||
? n.GetString() ?? string.Empty : string.Empty;
|
||||
result.Add(new WeatherEntry4229938(weatherId, string.IsNullOrWhiteSpace(name) ? $"Weather {weatherId}" : name));
|
||||
}
|
||||
return result.OrderBy(w => w.Id).ToList();
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ internal sealed class WorldEntryState
|
||||
internal bool RestoreResourcesAfterActiveSkill { get; set; }
|
||||
internal int ActiveClientSkillInstanceId { get; set; }
|
||||
internal long ActiveSkillStartedTicks { get; set; }
|
||||
internal Dictionary<uint, long> SkillCooldownUntilTicks { get; } = [];
|
||||
internal Dictionary<ulong, uint> StaticNpcPreparedPlotEvents { get; } = new();
|
||||
internal ulong ActiveWeaponInstanceId { get; set; }
|
||||
internal uint ActiveFightStyleId { get; set; }
|
||||
internal Dictionary<uint, ulong> LastWeaponBySpirit { get; } = [];
|
||||
|
||||
@@ -38,29 +38,6 @@ internal sealed partial class GameRouter
|
||||
return;
|
||||
}
|
||||
|
||||
var cooldown = weapon.Cooldown(style, req.SkillId, 0f);
|
||||
var nowTicks = Environment.TickCount64;
|
||||
if (cooldown > 0f)
|
||||
{
|
||||
if (state.SkillCooldownUntilTicks.TryGetValue(req.SkillId, out var untilTicks) && nowTicks < untilTicks)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[COMBAT] reject skill={req.SkillId} cooldown remaining={untilTicks - nowTicks}ms");
|
||||
return;
|
||||
}
|
||||
state.SkillCooldownUntilTicks[req.SkillId] = nowTicks + (long)(cooldown * 1000f);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Prevent rapid re-triggering of the same active or unique skill while the animation is executing
|
||||
if (state.ActiveSkillId == req.SkillId &&
|
||||
(req.SkillId == weapon.ActiveSkill(style) || req.SkillId == weapon.UniqueSkill(style)) &&
|
||||
(nowTicks - state.ActiveSkillStartedTicks) < 1200)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[COMBAT] ignore rapid spam of active/unique skill={req.SkillId} elapsed={nowTicks - state.ActiveSkillStartedTicks}ms");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state.CombatUseCount++;
|
||||
state.RestoreResourcesAfterActiveSkill |=
|
||||
req.SkillId == weapon.ActiveSkill(style) ||
|
||||
@@ -107,9 +84,21 @@ internal sealed partial class GameRouter
|
||||
state.ActiveClientSkillInstanceId = 0;
|
||||
state.ActiveSkillStartedTicks = 0;
|
||||
|
||||
// Do not immediately force full recharge here, allowing skills to respect their configured cooldowns.
|
||||
// Ordinary attacks must finish without any server-side skill-state rewrite. Re-publishing the
|
||||
// whole charge/resource snapshot after each common attack can make the client save a new action
|
||||
// while the previous trigger graph is still iterating, which restarts the first animation on
|
||||
// every click. Only cooldown/charge abilities and ultimates need sandbox restoration.
|
||||
if (!restoreResources)
|
||||
return;
|
||||
|
||||
// The private server runs an unrestricted combat sandbox: restore charges and ultimate energy
|
||||
// only after the complete authored ability chain has ended, never between combo/hold stages.
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerAllSkillChargeData,
|
||||
CombatCodec.AllSkillCharges(state.ActiveSpiritUnitId, weapon, style));
|
||||
await ctx.NotifyAsync(MethodId.SyncFightResource,
|
||||
CombatCodec.FightResource(state.ActiveSpiritUnitId, CombatCodec.UltimateResourceId, CombatCodec.UltimateResourceMax));
|
||||
await ctx.NotifyAsync(MethodId.SyncFightResourceFreeState,
|
||||
CombatCodec.FightResourceFreeState(state.ActiveSpiritUnitId, CombatCodec.UltimateResourceId, true));
|
||||
}
|
||||
|
||||
Task OnSkillHit(RpcContext ctx, SceneMethods.SkillHitData hit)
|
||||
@@ -398,7 +387,7 @@ internal sealed partial class GameRouter
|
||||
ctx.Session.Log.Info($"[ARMORY] slots committed operation={operation} spirit={spiritId} active={isActive} current={currentWeaponId} unique={slots.Where(x => x != 0).Distinct().Count()} slots={slots.Count}");
|
||||
}
|
||||
|
||||
private static bool IsEditableWeaponSlot(int index) => index >= 0 && index < 16;
|
||||
private static bool IsEditableWeaponSlot(int index) => index >= 1 && index < 16;
|
||||
|
||||
private static List<ulong> WeaponSlotIds(WorldEntryState state, uint spiritId)
|
||||
{
|
||||
@@ -584,9 +573,6 @@ internal sealed partial class GameRouter
|
||||
CombatWeaponDefinition weapon,
|
||||
CombatStyleDefinition style)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
state.CombatProfilePublished = true;
|
||||
|
||||
// Switching/individually styling one weapon must never overwrite the character-wide
|
||||
// FightStyleInfo map. OnSwitchFightStyle publishes that map explicitly.
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerAllSkillChargeData,
|
||||
@@ -595,7 +581,6 @@ internal sealed partial class GameRouter
|
||||
await PublishSkillBindings(ctx, unitId, weapon, style);
|
||||
await ctx.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon,
|
||||
CombatCodec.SpiritLastUsedWeapon(templateId, weapon.InstanceId));
|
||||
ctx.Session.Log.Info($"[COMBAT] profile published unit={unitId} template={templateId} weapon={weapon.TemplateId}/{weapon.InstanceId} style={style.Id} skills={style.CommonSkill},{style.HeavyCommonSkill},{style.DodgeSkill},{style.ControlSkill},{weapon.ActiveSkill(style)},{weapon.UniqueSkill(style)}");
|
||||
}
|
||||
|
||||
private static CombatStyleDefinition ResolveWeaponStyle(WorldEntryState state, CombatWeaponDefinition weapon)
|
||||
@@ -618,3 +603,4 @@ internal sealed partial class GameRouter
|
||||
return ResolveWeaponStyle(state, weapon);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
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;
|
||||
|
||||
public sealed record AdminNpcSpawnItem(uint NpcFormworkId, uint PoiActionId);
|
||||
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
private static long s_nextStaticNpcId = 8_300_000_000_000_000_000L;
|
||||
private static long s_nextNpcBelongingId = 8_310_000_000_000_000_000L;
|
||||
|
||||
internal static Task<(bool Ok, string Message, ulong[] Ids)> SpawnStaticNpcAsync(
|
||||
TcpSession session, uint npcFormworkId, uint poiActionId)
|
||||
=> SpawnStaticNpcsAsync(session, [new(npcFormworkId, poiActionId)], 1.5f, 1.5f, 10);
|
||||
|
||||
internal static async Task<(bool Ok, string Message, ulong[] Ids)> SpawnStaticNpcsAsync(
|
||||
TcpSession session, IReadOnlyList<AdminNpcSpawnItem> items, float xSpacing, float zSpacing, int maxPerRow)
|
||||
{
|
||||
if (items.Count < 1)
|
||||
return (false, "provide at least one NPC item", []);
|
||||
if (!float.IsFinite(xSpacing) || xSpacing < 0f || !float.IsFinite(zSpacing) || zSpacing < 0f)
|
||||
return (false, "NPC X/Z spacing must be finite non-negative numbers", []);
|
||||
if (maxPerRow < 1)
|
||||
return (false, "maximum NPCs per row must be at least 1", []);
|
||||
// Validate the entire batch before sending any NPCs.
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item is null || item.NpcFormworkId > int.MaxValue || !NpcCatalog4229938.TryGet(item.NpcFormworkId, out _))
|
||||
return (false, $"unknown NPC formwork {item?.NpcFormworkId}; expected an AgentConfig Id", []);
|
||||
if (item.PoiActionId != 0 && !PoiActionCatalog4229938.TryGet(item.PoiActionId, out _))
|
||||
return (false, $"unknown NPC POI action {item.PoiActionId}", []);
|
||||
}
|
||||
|
||||
var state = GetStateIfExists(session);
|
||||
if (state is null)
|
||||
return (false, "no player world state", []);
|
||||
Vec3 near;
|
||||
float facing;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
|
||||
return (false, "wait until the player is ready in the scene with a known position", []);
|
||||
near = state.LastReportedPlayerPosition;
|
||||
facing = state.LastReportedPlayerRotation.Y;
|
||||
}
|
||||
if (!float.IsFinite(near.X) || !float.IsFinite(near.Y) || !float.IsFinite(near.Z) || !float.IsFinite(facing))
|
||||
return (false, "player transform is not finite", []);
|
||||
|
||||
var ids = new List<ulong>();
|
||||
var yaw = facing * MathF.PI / 180f;
|
||||
var npcFacing = (facing + 180f) % 360f;
|
||||
if (npcFacing > 180f)
|
||||
npcFacing -= 360f;
|
||||
try
|
||||
{
|
||||
// The static-NPC lifecycle creates the logical agent and its pedestrian data.
|
||||
await SendAetherInitForNpcAsync(session);
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var item = items[i];
|
||||
session.Log.Info($"[{i}] Spawn npc formwork: {item.NpcFormworkId}. poi: {item.PoiActionId}");
|
||||
var id = unchecked((ulong)Interlocked.Increment(ref s_nextStaticNpcId));
|
||||
// Begin directly in front of the player, then lay out a
|
||||
// player-relative grid with the requested number of NPCs per row.
|
||||
var forward = 3f + (i / maxPerRow) * zSpacing;
|
||||
var right = (i % maxPerRow) * xSpacing;
|
||||
NpcCatalog4229938.TryGet(item.NpcFormworkId, out var npc);
|
||||
PoiActionCatalog4229938.TryGet(item.PoiActionId, out var poi);
|
||||
var mainActionId = poi?.SelectMainAction(id) ?? 0;
|
||||
var position = new SceneMethods.UxVector3(
|
||||
near.X + MathF.Sin(yaw) * forward + MathF.Cos(yaw) * right,
|
||||
near.Y,
|
||||
near.Z + MathF.Cos(yaw) * forward - MathF.Sin(yaw) * right);
|
||||
var agentSyncClientInfo = CreateStaticNpcSyncInfo(item, npc, poi, mainActionId);
|
||||
// A standalone battle projection omits persona and static pedestrian POI
|
||||
// state. Let AddAetherAIStaticNpc create the complete logical entity;
|
||||
// ForceGo requests the full character without replacing that lifecycle.
|
||||
await session.NotifyAsync(MethodId.SyncAetherAIStaticNpcAddData,
|
||||
UxSerializer.Serialize(new GameMethods.ClientStaticNpcInitData4229938
|
||||
{
|
||||
StaticNpcInfoId = item.NpcFormworkId,
|
||||
NpcFormworkId = item.NpcFormworkId,
|
||||
AgentPersonaId = npc.AgentPersonaId,
|
||||
SPoiActionId = 0,
|
||||
CPoiActionId = item.PoiActionId,
|
||||
UrbanDiversityId = 88888000,
|
||||
IgnoreAllStim = false,
|
||||
TaskRelated = false,
|
||||
NpcPid = (int)item.NpcFormworkId,
|
||||
AgentSyncClientInfo = agentSyncClientInfo,
|
||||
ForceGo = true,
|
||||
SourceType = 1, // UX.Game.StaticNpcSourceType.Spoon (4229938).
|
||||
Id = id,
|
||||
Position = position,
|
||||
Facing = npcFacing,
|
||||
EulerAngles = new SceneMethods.UxVector3(0, npcFacing, 0),
|
||||
}), CancellationToken.None);
|
||||
ids.Add(id);
|
||||
lock (state.SyncRoot)
|
||||
state.StaticNpcPreparedPlotEvents[id] = 0;
|
||||
// Static creation registers the logical entity and its sync module,
|
||||
// but does not assign a simulation owner. Without this notification,
|
||||
// LogicAgentModule.ApplyMasterPolicy(false) disables the full-model
|
||||
// state machine after the ECS-to-GO POI handoff.
|
||||
await session.NotifyAsync(MethodId.SyncManagedLogicAgent,
|
||||
UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(id, Profile.PlayerPid, 0)), CancellationToken.None);
|
||||
if (poi is { UsageId: not 0 })
|
||||
{
|
||||
// The usage loader requires owned items before it can bind them.
|
||||
// Inventory is cached on AgentData; usage is retained by the ECS
|
||||
// handle and transferred by ECSToGOBelongingTransition.
|
||||
await session.NotifyAsync(MethodId.SyncAgentBelongings,
|
||||
UxSerializer.Serialize(new GameMethods.SyncNpcBelongings4229938
|
||||
{
|
||||
Id = id,
|
||||
Items = poi.BelongingSceneItems.Select(configId => new GameMethods.NpcBelongingItem4229938
|
||||
{
|
||||
InstanceId = unchecked((ulong)Interlocked.Increment(ref s_nextNpcBelongingId)),
|
||||
ConfigId = configId,
|
||||
}).ToList(),
|
||||
DebugAgentId = item.NpcFormworkId,
|
||||
}), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncAgentUseBelongingChanged,
|
||||
UxSerializer.Serialize(new GameMethods.SyncNpcBelongingUsage4229938
|
||||
{
|
||||
Id = id,
|
||||
UsageIds = [poi.UsageId],
|
||||
}), CancellationToken.None);
|
||||
session.Log.Info($"[NPC] POI_BELONGINGS_SENT entity={id} poi={poi.Id} usage={poi.UsageId} items={string.Join(',', poi.BelongingSceneItems)}");
|
||||
}
|
||||
// Store the active POI in the logical POI module at normal playback speed.
|
||||
// Do not periodically replay it: reactions must be able to interrupt it.
|
||||
if (item.PoiActionId != 0)
|
||||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentPlayPOIAction,
|
||||
UxSerializer.Serialize(new GameMethods.ClientNpcPoiActionData4229938
|
||||
{
|
||||
Id = id,
|
||||
CPoiActionId = item.PoiActionId,
|
||||
IsActive = true,
|
||||
PlayPoiSpeed = 1f,
|
||||
}), CancellationToken.None);
|
||||
session.Log.Info($"[NPC] STATIC_PEDESTRIAN_SPAWN_SENT formwork={item.NpcFormworkId} model={npc.GeneralModelId} persona={npc.AgentPersonaId} poiAction={item.PoiActionId} action={mainActionId}/{poi?.MainActionGroup ?? 0} behaviorActionConfig={npc.BehaviorActionConfigId} activityConfig={agentSyncClientInfo.AgentDataSetsActivityCfgId} ai={npc.AiSettingId} entity={id} managedPid={Profile.PlayerPid} spoon={agentSyncClientInfo.SpoonAgentId} forceGo=true ignoreStim=false poiSpeed=1 fashion={agentSyncClientInfo.FashionSuitId} pos={position.X},{position.Y},{position.Z} playerPos={near}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"sent {ids.Count}/{items.Count} NPCs before send failed: {ex.Message}", ids.ToArray());
|
||||
}
|
||||
return (true, $"sent spawn for {ids.Count} static NPC(s) in front of you", ids.ToArray());
|
||||
}
|
||||
|
||||
private static async Task SendAetherInitForNpcAsync(TcpSession session)
|
||||
{
|
||||
var settings = PrivateServerConfigStore.Current.Gameplay.Vehicles;
|
||||
var activeRaidId = GetStateIfExists(session)?.ActiveRaidId ?? Profile.RaidId;
|
||||
var hasZoneGraph = activeRaidId == PrivateServerConfigStore.Current.World.RaidId;
|
||||
await session.NotifyAsync(MethodId.SyncAetherAIInitDatas, UxSerializer.Serialize(new SceneMethods.AetherAIInitData
|
||||
{
|
||||
RaidId = activeRaidId,
|
||||
HasZoneGraph = hasZoneGraph,
|
||||
ZoneStorageDataHandle = hasZoneGraph ? settings.ZoneStorageDataHandle : 0,
|
||||
Intersections = [],
|
||||
}), CancellationToken.None);
|
||||
session.Log.Info($"[AETHER] init sent raid={activeRaidId} zoneGraph={hasZoneGraph} intersections=empty");
|
||||
}
|
||||
|
||||
internal static GameMethods.NpcAgentSyncClientInfo4229938 CreateStaticNpcSyncInfo(
|
||||
AdminNpcSpawnItem item, NpcCatalogEntry4229938 npc,
|
||||
PoiActionCatalogEntry4229938? poi, uint mainActionId)
|
||||
{
|
||||
return new GameMethods.NpcAgentSyncClientInfo4229938
|
||||
{
|
||||
stimIDList = new List<int>(),
|
||||
CanBeExaminedByPolice = true,
|
||||
indoorList = new(),
|
||||
roomIds = new List<int>(),
|
||||
SpoonAgentId = (int)item.NpcFormworkId,
|
||||
treeName = "PedBase",
|
||||
petPerformData = "",
|
||||
spawnEffectId = new List<uint>(),
|
||||
randomModelCfgId = 0,
|
||||
LeaveDistance = 10000,
|
||||
approachDistance = 10000,
|
||||
FashionSuitId = 11190001,
|
||||
actionId = mainActionId,
|
||||
actionGroupId = poi?.MainActionGroup ?? 0,
|
||||
initPoiActionId = item.PoiActionId,
|
||||
useDefaultPoiOnReturn = item.PoiActionId != 0,
|
||||
returnPoiActionIds = item.PoiActionId == 0 ? [] : [item.PoiActionId],
|
||||
AgentDataSetsActivityCfgId = 0,
|
||||
InteractId = npc.InteractSettingId,
|
||||
AISetting = npc.AiSettingId,
|
||||
beHitType = 1, // Civilian hit reaction without combat damage.
|
||||
agentStimType = 0,
|
||||
forbidStimulateType = 0,
|
||||
isAttackInSafeMode = true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
[Handler(MethodId.AskPreparePlotEvent, HandlerPacketKind.Notify)]
|
||||
private Task AskPreparePlotEvent(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.Body.Length != SceneMethods.AskPreparePlotEventArgs4229938.WireSize
|
||||
|| !msg.TryGetArgs<SceneMethods.AskPreparePlotEventArgs4229938>(out var args)
|
||||
|| args is null || !HasFinitePosition(args.Source.Source)
|
||||
|| !HasFinitePosition(args.Source.Source2))
|
||||
{
|
||||
conn.Log.Warn($"[PLOT] AskPreparePlotEvent invalid payload bytes={msg.Body.Length}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
conn.Log.Info($"[PLOT] AskPreparePlotEvent event={args.EventId} agent={args.AgentEntityId} "
|
||||
+ $"response={args.ResponseId} source=[{FormatPlotTarget(args.Source.Source)}] "
|
||||
+ $"source2=[{FormatPlotTarget(args.Source.Source2)}]");
|
||||
var state = GetStateIfExists(conn.Session);
|
||||
if (state is not null)
|
||||
lock (state.SyncRoot)
|
||||
if (state.StaticNpcPreparedPlotEvents.ContainsKey(args.AgentEntityId))
|
||||
state.StaticNpcPreparedPlotEvents[args.AgentEntityId] = args.EventId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskEndPreparePlotEvent, HandlerPacketKind.Notify)]
|
||||
private Task AskEndPreparePlotEvent(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.Body.Length != SceneMethods.AskEndPreparePlotEventArgs4229938.WireSize
|
||||
|| !msg.TryGetArgs<SceneMethods.AskEndPreparePlotEventArgs4229938>(out var args)
|
||||
|| args is null)
|
||||
{
|
||||
conn.Log.Warn($"[PLOT] AskEndPreparePlotEvent invalid payload bytes={msg.Body.Length}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
conn.Log.Info($"[PLOT] AskEndPreparePlotEvent event={args.EventId} agent={args.AgentEntityId}");
|
||||
TryCompleteStaticNpcPlot(GetStateIfExists(conn.Session), args.AgentEntityId, args.EventId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskTriggerPlotEvent, HandlerPacketKind.Notify)]
|
||||
private Task AskTriggerPlotEvent(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.Body.Length != SceneMethods.AskTriggerPlotEventArgs4229938.WireSize
|
||||
|| !msg.TryGetArgs<SceneMethods.AskTriggerPlotEventArgs4229938>(out var args)
|
||||
|| args is null || !HasFinitePosition(args.Parameter.Source)
|
||||
|| !HasFinitePosition(args.Parameter.Source2))
|
||||
{
|
||||
conn.Log.Warn($"[PLOT] AskTriggerPlotEvent invalid payload bytes={msg.Body.Length}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
conn.Log.Info($"[PLOT] AskTriggerPlotEvent event={args.EventId} agent={args.AgentEntityId} "
|
||||
+ $"source=[{FormatPlotTarget(args.Parameter.Source)}] source2=[{FormatPlotTarget(args.Parameter.Source2)}] "
|
||||
+ $"clientDmemOverrideCfgId={args.ClientDmemOverrideCfgId}");
|
||||
// The native response has finished and entered its server-reaction phase.
|
||||
// These ambient NPCs have no plot continuation. Explicitly close it so
|
||||
// SelectStimOnline no longer uses the completed event's priority floor.
|
||||
if (TryCompleteStaticNpcPlot(GetStateIfExists(conn.Session), args.AgentEntityId, args.EventId))
|
||||
{
|
||||
conn.Log.Info($"[PLOT] AMBIENT_RESPONSE_FINISHED agent={args.AgentEntityId} event={args.EventId} currentEvent=0");
|
||||
return conn.NotifyAsync(MethodId.SyncPlotCurrentEvent,
|
||||
new SceneMethods.SyncPlotCurrentEvent4229938 { AgentEntityId = args.AgentEntityId });
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal static bool TryCompleteStaticNpcPlot(WorldEntryState? state, ulong agentId, uint eventId)
|
||||
{
|
||||
if (state is null || eventId == 0) return false;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (!state.StaticNpcPreparedPlotEvents.TryGetValue(agentId, out var pending) || pending != eventId)
|
||||
return false;
|
||||
state.StaticNpcPreparedPlotEvents[agentId] = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasFinitePosition(SceneMethods.PlotEventTarget4229938 target)
|
||||
=> float.IsFinite(target.Position.X) && float.IsFinite(target.Position.Y) && float.IsFinite(target.Position.Z);
|
||||
|
||||
private static string FormatPlotTarget(SceneMethods.PlotEventTarget4229938 target)
|
||||
=> $"id={target.Id} type={target.Type} pos=({target.Position.X:F2},{target.Position.Y:F2},{target.Position.Z:F2})";
|
||||
}
|
||||
@@ -77,7 +77,7 @@ internal sealed partial class GameRouter
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
|
||||
WorldCodec.PositionAndFacing(unitId, switchPosition, switchFacing));
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerCurrentSpirit,
|
||||
WorldCodec.CurrentSpirit(Profile.PlayerPid, templateId, unitId, isAgentSwitch: true));
|
||||
WorldCodec.CurrentSpirit(Profile.PlayerPid, templateId, unitId, isAgentSwitch: false));
|
||||
// CurrentSpirit can rebuild the controlled actor on the client. Re-assert the frozen transform
|
||||
// after ownership transfer so the new character cannot fall back to its template/default spawn.
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
|
||||
@@ -111,9 +111,8 @@ internal sealed partial class GameRouter
|
||||
await PublishSafeRuntimeBuffSnapshot4229938(ctx, unitId, templateId, "minimal-direct-switch");
|
||||
state.AllBuildBuffsPublished = true;
|
||||
|
||||
// Keep the previous character in the world scene so the player can interact with / carry them
|
||||
// if (oldUnitId != 0 && oldUnitId != unitId)
|
||||
// await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(oldUnitId));
|
||||
if (oldUnitId != 0 && oldUnitId != unitId)
|
||||
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(oldUnitId));
|
||||
await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false));
|
||||
|
||||
state.SwitchCount++;
|
||||
@@ -130,3 +129,4 @@ internal sealed partial class GameRouter
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
using Ananta.SDK.Serialization;
|
||||
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
|
||||
// IGameSceneToClient positional arguments; both nested values are native structs.
|
||||
[UxContract(Inline = true)]
|
||||
internal sealed class SyncNpcBelongings4229938
|
||||
{
|
||||
public ulong Id;
|
||||
public List<NpcBelongingItem4229938> Items = [];
|
||||
public uint DebugAgentId; // BelongingDebugInfo.AgentId
|
||||
}
|
||||
|
||||
[UxContract(Inline = true)]
|
||||
internal sealed class NpcBelongingItem4229938
|
||||
{
|
||||
public ulong InstanceId;
|
||||
public uint ConfigId;
|
||||
public float Hp;
|
||||
public uint State;
|
||||
}
|
||||
|
||||
[UxContract(Inline = true)]
|
||||
internal sealed class SyncNpcBelongingUsage4229938
|
||||
{
|
||||
public ulong Id;
|
||||
public List<uint> UsageIds = [];
|
||||
}
|
||||
|
||||
// RPCSerializeAuto.lua WriteClientNpcPoiActionData (4229938).
|
||||
[UxContract]
|
||||
internal sealed class ClientNpcPoiActionData4229938
|
||||
{
|
||||
public ulong Id;
|
||||
public uint SPoiActionId;
|
||||
public uint CPoiActionId;
|
||||
public bool IsActive;
|
||||
public float PlayPoiSpeed;
|
||||
}
|
||||
|
||||
// Complex argument, with ClientBaseEntityData's static-NPC discriminator.
|
||||
// SpawnNPC2's ClientStaticNpcInitData uses CustomFlagType=2, not onlyFields.
|
||||
// Field order: lua/LuaGen/AutoGen/RPCSerializeAuto.lua WriteClientStaticNpcInitData.
|
||||
[UxContract(TypeMark = 2)]
|
||||
internal sealed class ClientStaticNpcInitData4229938
|
||||
{
|
||||
public ulong StaticNpcInfoId;
|
||||
public uint NpcFormworkId;
|
||||
public uint AgentPersonaId;
|
||||
public uint SPoiActionId;
|
||||
public uint CPoiActionId;
|
||||
public uint UrbanDiversityId;
|
||||
public bool IgnoreAllStim;
|
||||
public bool TaskRelated;
|
||||
public bool EnableHack;
|
||||
public int NpcPid;
|
||||
public NpcAgentSyncClientInfo4229938? AgentSyncClientInfo;
|
||||
public uint LookAtDecisionRulesId;
|
||||
public bool ForceGo;
|
||||
public byte SourceType;
|
||||
public uint MartialArtistGossipConfigID;
|
||||
public ulong Id;
|
||||
public SceneMethods.UxVector3 Position;
|
||||
public float Facing;
|
||||
public SceneMethods.UxVector3 EulerAngles;
|
||||
}
|
||||
|
||||
// Full current-build AOI projection for an NPC AgentUnit. This contract is
|
||||
// consumed by UnitsManager.SyncAgentEnterAOI and initializes the BaseUnit
|
||||
// modules that own animation and physical reactions.
|
||||
// Field order: RPCSerializeAuto.lua WriteRaidBattleUnitAgent (build 4229938).
|
||||
[UxContract]
|
||||
internal sealed class RaidBattleUnitAgent4229938
|
||||
{
|
||||
public int SkillId;
|
||||
public int HSummonIndex;
|
||||
public int SpoonAgentId;
|
||||
public uint SuitId;
|
||||
[UxCollection(Count = UxCountEncoding.Int7)]
|
||||
public List<uint> FashionIdList = [];
|
||||
public ulong ParentId;
|
||||
public uint SpoonIndex;
|
||||
public uint AutoBackIndex;
|
||||
public ulong VehicleId;
|
||||
public int VehicleIndex;
|
||||
public ulong SourceWeaponId;
|
||||
public bool IsBorn;
|
||||
public bool BattleAiS;
|
||||
public NpcAgentSyncClientInfo4229938 AgentSyncClientInfo = new();
|
||||
public uint WeaponId;
|
||||
public byte SpawnType;
|
||||
public byte AnimateCullingMode;
|
||||
public NpcLinkAIAgentInfo4229938 AIAgentInfo = new();
|
||||
public ulong Id;
|
||||
public uint TemplateId;
|
||||
public SceneMethods.UxVector3 Position;
|
||||
public float FacingDirection;
|
||||
public ulong OwnerId;
|
||||
public ulong ManagedPid;
|
||||
public byte MoveId;
|
||||
public uint NavTags;
|
||||
public Auto.MoveActionGroundData GroundData = new();
|
||||
}
|
||||
|
||||
// WriteAgentSyncClientInfo order for 4229938. Do not copy the old build's
|
||||
// metroLineId/metroCarriageId layout: POI/platform fields replaced that section.
|
||||
[UxContract]
|
||||
internal sealed class NpcAgentSyncClientInfo4229938
|
||||
{
|
||||
public bool NeedFTF180DegreeInteract;
|
||||
public bool PlayerFTF180DegreeInteract;
|
||||
public uint IndoorId;
|
||||
public ulong chairId;
|
||||
public ulong gadgetId;
|
||||
public bool forbidAetherAI;
|
||||
public bool isApproachNpc;
|
||||
public bool TriggerLeaveEvent;
|
||||
public int approachDistance;
|
||||
public int LeaveDistance;
|
||||
public string? petPerformData = "";
|
||||
public List<int> stimIDList = [];
|
||||
public uint randomModelCfgId;
|
||||
public int layer;
|
||||
public float gpsOffsetY;
|
||||
public bool isTemp;
|
||||
public List<uint> spawnEffectId = [];
|
||||
public uint hideEffectId;
|
||||
public uint actionId;
|
||||
public uint actionGroupId;
|
||||
public uint initPoiActionId;
|
||||
public bool useDefaultPoiOnReturn;
|
||||
public List<uint> returnPoiActionIds = [];
|
||||
public NpcAdhereMovingPlatformInfo4229938? AdherePlatformInfo;
|
||||
public uint AgentDataSetsActivityCfgId;
|
||||
public uint GameplaySignalId;
|
||||
public string treeName = "";
|
||||
public int sitIndex;
|
||||
public List<uint> indoorList = [];
|
||||
public List<int> roomIds = [];
|
||||
public byte forbidStimulateType;
|
||||
public byte agentStimType;
|
||||
public byte beHitType;
|
||||
public int SpoonAgentId;
|
||||
public bool isAttackInSafeMode;
|
||||
public bool FeiSuo;
|
||||
public uint FashionSuitId;
|
||||
public bool CanBeExaminedByPolice;
|
||||
public bool IgnoreWanted;
|
||||
public bool BeAttackIgnorePolicePunish;
|
||||
public uint InteractId;
|
||||
public uint AISetting;
|
||||
public NpcLinkAIAgentInfo4229938 AIAgentInfo = new();
|
||||
public bool HackerBetray;
|
||||
}
|
||||
|
||||
[UxContract]
|
||||
internal sealed class NpcAdhereMovingPlatformInfo4229938
|
||||
{
|
||||
public byte PlatformType;
|
||||
public bool IsScene;
|
||||
public ulong PlatformId;
|
||||
public uint PartId;
|
||||
public ulong PlatformEid;
|
||||
}
|
||||
|
||||
[UxContract]
|
||||
internal sealed class NpcLinkAIAgentInfo4229938
|
||||
{
|
||||
public ulong Uid;
|
||||
public uint FightSpiritId;
|
||||
public NpcLinkAIFashionInfo4229938? Fashion;
|
||||
public string? Nickname;
|
||||
public uint NameId;
|
||||
public uint AvatarImageId;
|
||||
public uint VehicleId;
|
||||
}
|
||||
|
||||
[UxContract]
|
||||
internal sealed class NpcLinkAIFashionInfo4229938
|
||||
{
|
||||
public uint SuitId;
|
||||
public Auto.OtherPlayerSpiritWearFashionsInfo WearInfo = new();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Ananta.SDK.Serialization;
|
||||
|
||||
namespace Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
// IGameSceneToClient.SyncPlotCurrentEvent(ulong, uint, ClientActionTarget).
|
||||
// EventId=0 closes the client's current stimulus/plot reaction.
|
||||
[UxContract(Inline = true)]
|
||||
internal sealed class SyncPlotCurrentEvent4229938
|
||||
{
|
||||
public ulong AgentEntityId;
|
||||
public uint EventId;
|
||||
public PlotEventTarget4229938 Source = new();
|
||||
}
|
||||
|
||||
// ClientToGameSceneDelegate.AskPreparePlotEvent_Serializer (4229938).
|
||||
[UxContract(Inline = true)]
|
||||
internal sealed class AskPreparePlotEventArgs4229938
|
||||
{
|
||||
internal const int WireSize = 58;
|
||||
public uint EventId;
|
||||
public ulong AgentEntityId;
|
||||
public uint ResponseId;
|
||||
public StimEventParameter4229938 Source = new();
|
||||
}
|
||||
|
||||
// ClientToGameSceneDelegate.AskEndPreparePlotEvent_Serializer (4229938).
|
||||
[UxContract(Inline = true)]
|
||||
internal sealed class AskEndPreparePlotEventArgs4229938
|
||||
{
|
||||
internal const int WireSize = 12;
|
||||
public uint EventId;
|
||||
public ulong AgentEntityId;
|
||||
}
|
||||
|
||||
// ClientToGameSceneDelegate.AskTriggerPlotEvent_Serializer (4229938).
|
||||
// Both StimEventParameter and its targets are structs, without object markers.
|
||||
[UxContract(Inline = true)]
|
||||
internal sealed class AskTriggerPlotEventArgs4229938
|
||||
{
|
||||
internal const int WireSize = 58;
|
||||
public uint EventId;
|
||||
public ulong AgentEntityId;
|
||||
public StimEventParameter4229938 Parameter = new();
|
||||
public uint ClientDmemOverrideCfgId;
|
||||
}
|
||||
|
||||
[UxContract(Inline = true)]
|
||||
internal sealed class StimEventParameter4229938
|
||||
{
|
||||
public PlotEventTarget4229938 Source = new();
|
||||
public PlotEventTarget4229938 Source2 = new();
|
||||
}
|
||||
|
||||
[UxContract(Inline = true)]
|
||||
internal sealed class PlotEventTarget4229938
|
||||
{
|
||||
public ulong Id;
|
||||
public UxVector3 Position;
|
||||
public byte Type;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"_class": "WeatherConfig",
|
||||
"_namespace": "LT.ConfigGen",
|
||||
"_expectedCount": 5,
|
||||
"records": [
|
||||
{
|
||||
"Id": 1,
|
||||
"WindScale": 1,
|
||||
"Icon": 25600351,
|
||||
"TimerAppTitleIcon": 30300201,
|
||||
"Name": "晴天",
|
||||
"SoundEffect": 0,
|
||||
"TaWeatherIndex": 0,
|
||||
"PerformanceType": [
|
||||
1
|
||||
],
|
||||
"__IDX__Name": 4294967293,
|
||||
"__RAW__Name": "晴天"
|
||||
},
|
||||
{
|
||||
"Id": 2,
|
||||
"WindScale": 2,
|
||||
"Icon": 25600341,
|
||||
"TimerAppTitleIcon": 30300202,
|
||||
"Name": "阴天",
|
||||
"SoundEffect": 0,
|
||||
"TaWeatherIndex": 1,
|
||||
"PerformanceType": [
|
||||
1
|
||||
],
|
||||
"__IDX__Name": 4294967293,
|
||||
"__RAW__Name": "阴天"
|
||||
},
|
||||
{
|
||||
"Id": 3,
|
||||
"WindScale": 2,
|
||||
"Icon": 25600348,
|
||||
"TimerAppTitleIcon": 30300203,
|
||||
"Name": "雨天",
|
||||
"SoundEffect": 70300844,
|
||||
"TaWeatherIndex": 2,
|
||||
"PerformanceType": [
|
||||
0
|
||||
],
|
||||
"__IDX__Name": 4294967293,
|
||||
"__RAW__Name": "雨天"
|
||||
},
|
||||
{
|
||||
"Id": 4,
|
||||
"WindScale": 2,
|
||||
"Icon": 25600347,
|
||||
"TimerAppTitleIcon": 30300204,
|
||||
"Name": "风",
|
||||
"SoundEffect": 0,
|
||||
"TaWeatherIndex": 3,
|
||||
"PerformanceType": [],
|
||||
"__IDX__Name": 4294967293,
|
||||
"__RAW__Name": "风"
|
||||
},
|
||||
{
|
||||
"Id": 5,
|
||||
"WindScale": 1,
|
||||
"Icon": 25600341,
|
||||
"TimerAppTitleIcon": 30300202,
|
||||
"Name": "雨后",
|
||||
"SoundEffect": 0,
|
||||
"TaWeatherIndex": 4,
|
||||
"PerformanceType": [
|
||||
1
|
||||
],
|
||||
"__IDX__Name": 4294967293,
|
||||
"__RAW__Name": "雨后"
|
||||
}
|
||||
],
|
||||
"_dumpedCount": 5,
|
||||
"_loadFailures": 0,
|
||||
"_serializationErrors": 0
|
||||
}
|
||||
Reference in new issue
Block a user