feat: Implement missions 1-5 (combat fluidity, urban crowd locomotion, smart traffic signals, dynamic gacha pity, universal item spawner & armory)

This commit is contained in:
CAtlas0784 committed 2026-09-18 19:05:47 +07:00
1 parent 5e333d7678
commit ce1f471eab
27 files changed
+321970 -135

No files matched your search

Binary file not shown.
+179
View File
@@ -105,6 +105,8 @@ internal sealed class DebugApiServer(PrivateServerConfig config, GameSessionHub
await WriteJsonAsync(ctx, await GarageUnlockAllAsync(token), token);
else if (method == "POST" && path == "/api/player/teleport")
await WriteJsonAsync(ctx, await TeleportAsync(await ReadBodyAsync(ctx.Request, token), token), token);
else if (method == "POST" && path == "/api/subway/travel")
await WriteJsonAsync(ctx, await SubwayTravelAsync(await ReadBodyAsync(ctx.Request, token), token), token);
else if (method == "POST" && path == "/api/vehicle/spawn")
await WriteJsonAsync(ctx, await VehicleSpawnAsync(await ReadBodyAsync(ctx.Request, token), token), token);
else if (method == "POST" && path == "/api/vehicle/to-me")
@@ -165,6 +167,16 @@ internal sealed class DebugApiServer(PrivateServerConfig config, GameSessionHub
await WriteJsonAsync(ctx, CrowdStatus(), token);
else if (method == "POST" && path == "/api/crowd/config")
await WriteJsonAsync(ctx, await CrowdConfigAsync(await ReadBodyAsync(ctx.Request, token), token), token);
else if (method == "GET" && path == "/api/catalog/spirits")
await WriteJsonAsync(ctx, SpiritCatalogList(), token);
else if (method == "GET" && path == "/api/catalog/weapons")
await WriteJsonAsync(ctx, WeaponCatalogList(), token);
else if (method == "GET" && path == "/api/catalog/items")
await WriteJsonAsync(ctx, ItemCatalogList(ctx.Request.QueryString), token);
else if (method == "POST" && (path == "/api/item/give" || path == "/api/player/give-item"))
await WriteJsonAsync(ctx, await GiveItemAsync(await ReadBodyAsync(ctx.Request, token), token), token);
else if (method == "POST" && (path == "/api/armory/equip" || path == "/api/weapon/equip"))
await WriteJsonAsync(ctx, await WeaponEquipAsync(await ReadBodyAsync(ctx.Request, token), token), token);
else
await WriteJsonAsync(ctx, new { ok = false, error = "unknown route" }, token, 404);
}
@@ -580,6 +592,80 @@ internal sealed class DebugApiServer(PrivateServerConfig config, GameSessionHub
return await SendPlayerTeleportAsync(session, unitId, x, y, z, facing, "teleport", token);
}
private async Task<object> SubwayTravelAsync(string json, CancellationToken token)
{
var session = hub.Current;
if (session is null)
return new { ok = false, error = "no live game session (is the client in the world?)" };
float x, y, z, facing;
string stationName = "Subway Station";
string startTl = "loading_metro_normal01_m";
string endTl = "loading_metro_end_1";
bool withCutscene = true;
try
{
using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json);
var root = doc.RootElement;
x = root.TryGetProperty("x", out var px) ? (float)px.GetDouble() : throw new InvalidDataException("missing numeric 'x'");
y = root.TryGetProperty("y", out var py) ? (float)py.GetDouble() : throw new InvalidDataException("missing numeric 'y'");
z = root.TryGetProperty("z", out var pz) ? (float)pz.GetDouble() : throw new InvalidDataException("missing numeric 'z'");
facing = root.TryGetProperty("facing", out var pf) ? (float)pf.GetDouble() : Profile.WorldFacing;
if (root.TryGetProperty("name", out var pn)) stationName = pn.GetString() ?? stationName;
if (root.TryGetProperty("startTl", out var ps)) startTl = ps.GetString() ?? startTl;
if (root.TryGetProperty("endTl", out var pe)) endTl = pe.GetString() ?? endTl;
if (root.TryGetProperty("cutscene", out var pc)) withCutscene = pc.GetBoolean();
if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z) || !float.IsFinite(facing))
throw new InvalidDataException("coordinates must be finite numbers");
}
catch (Exception ex)
{
return new { ok = false, error = $"bad request: {ex.Message}" };
}
ulong unitId = Profile.InitialUnitId;
if (session.Items.TryGetValue(GameRouter.WorldStateKey, out var raw) && raw is WorldEntryState state)
{
lock (state.SyncRoot)
{
if (state.ActiveSpiritUnitId != 0)
unitId = state.ActiveSpiritUnitId;
state.LastReportedPlayerPosition = new Vec3(x, y, z);
state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f);
state.HasLastReportedPlayerTransform = true;
}
}
if (withCutscene)
{
session.Log.Info($"[SUBWAY] Boarding train towards {stationName}... playing {startTl}");
var cmdStart = $"CMD:PLAY_TIMELINE:{startTl}";
await session.NotifyAsync(MethodId.SyncNotice, UxSerializer.Serialize(cmdStart), token);
// Wait for carriage transit simulation (2.2s)
await Task.Delay(2200, token);
// Teleport player to destination station tunnel exit
await SendPlayerTeleportAsync(session, unitId, x, y, z, facing, "subway-teleport", token);
await Task.Delay(300, token);
// Play destination station tunnel exit timeline
if (!string.IsNullOrEmpty(endTl))
{
var cmdEnd = $"CMD:PLAY_TIMELINE:{endTl}";
await session.NotifyAsync(MethodId.SyncNotice, UxSerializer.Serialize(cmdEnd), token);
}
session.Log.Info($"[SUBWAY] Exited tunnel at {stationName} ({x}, {y}, {z}): played {endTl}");
return new { ok = true, station = stationName, x, y, z, facing, cutscene = true };
}
else
{
return await SendPlayerTeleportAsync(session, unitId, x, y, z, facing, "subway-teleport", token);
}
}
private static object Scenes()
{
return new
@@ -1526,6 +1612,99 @@ internal sealed class DebugApiServer(PrivateServerConfig config, GameSessionHub
return await reader.ReadToEndAsync(token);
}
private object SpiritCatalogList()
=> ClientConfigRepository.Characters()
.Select(c => new { templateId = c.TemplateId, unitId = c.UnitId, name = c.Name })
.ToList();
private object WeaponCatalogList()
=> CombatCatalogRepository.AccountWeapons
.Select(w => new { templateId = w.TemplateId, instanceId = w.InstanceId, name = w.Name, isShoot = w.IsShootWeapon, isPrivate = w.IsPrivate, fightStyleTypeId = w.WeaponFightSkillTypeId })
.ToList();
private object ItemCatalogList(System.Collections.Specialized.NameValueCollection query)
{
var limit = 200;
if (uint.TryParse(query["limit"], out var parsed) && parsed is > 0 and <= 5000)
limit = (int)parsed;
var items = EconomyConfigRepository.SearchConsumableItems(query["q"], limit);
return new
{
hasItemTable = EconomyConfigRepository.HasConsumableItems,
count = items.Count,
items = items
.Select(x => new { templateId = x.Id, name = x.Name, subType = x.SubType, quality = x.Quality })
.ToList(),
};
}
private async Task<object> GiveItemAsync(string json, CancellationToken token)
{
var session = hub.Current;
if (session is null)
return new { ok = false, error = "no live game session (is the client in the world?)" };
uint templateId;
var count = 1u;
var bind = false;
try
{
using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json);
var root = doc.RootElement;
if (!root.TryGetProperty("templateId", out var pt) || !pt.TryGetUInt32(out templateId) || templateId == 0)
{
if (!root.TryGetProperty("itemId", out var pi) || !pi.TryGetUInt32(out templateId) || templateId == 0)
return new { ok = false, error = "missing numeric 'templateId' or 'itemId' (Item ID in 36xxxxxx)" };
}
if (root.TryGetProperty("count", out var pc) && pc.TryGetUInt32(out var parsedCount))
count = Math.Clamp(parsedCount == 0 ? 1u : parsedCount, 1u, 99999u);
if (root.TryGetProperty("bind", out var pb) && pb.ValueKind is JsonValueKind.True or JsonValueKind.False)
bind = pb.GetBoolean();
}
catch (Exception ex)
{
return new { ok = false, error = $"bad request: {ex.Message}" };
}
var result = await GameRouter.PanelGiveItemAsync(session, templateId, count, bind);
return result.Ok ? new { ok = true, message = result.Message } : new { ok = false, error = result.Message };
}
private async Task<object> WeaponEquipAsync(string json, CancellationToken token)
{
var session = hub.Current;
if (session is null)
return new { ok = false, error = "no live game session (is the client in the world?)" };
uint? spiritId = null;
uint? templateId = null;
ulong? instanceId = null;
int slotIndex;
try
{
using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json);
var root = doc.RootElement;
if (root.TryGetProperty("spiritId", out var ps) && ps.TryGetUInt32(out var sid))
spiritId = sid;
if (root.TryGetProperty("templateId", out var pt) && pt.TryGetUInt32(out var tid))
templateId = tid;
if (root.TryGetProperty("instanceId", out var pi) && pi.TryGetUInt64(out var iid))
instanceId = iid;
if (!root.TryGetProperty("slotIndex", out var psl) || !psl.TryGetInt32(out slotIndex))
return new { ok = false, error = "missing numeric 'slotIndex' (1..15)" };
}
catch (Exception ex)
{
return new { ok = false, error = $"bad request: {ex.Message}" };
}
var result = await GameRouter.PanelEquipWeaponAsync(session, spiritId, slotIndex, templateId, instanceId);
if (result.Ok)
return new { ok = true, message = result.Message };
return new { ok = false, error = result.Message };
}
private static async Task WriteHtmlAsync(HttpListenerContext ctx, CancellationToken token)
{
var bytes = PanelHtml.Value;
+174 -26
View File
@@ -32,6 +32,8 @@
.badge-police { background: #3e2723; color: #ffcc80; border: 1px solid #795548; }
.badge-animal { background: #1b4332; color: #95d5b2; border: 1px solid #2d6a4f; }
.badge-ally { background: #4a148c; color: #e1bee7; border: 1px solid #8e24aa; }
.picklist { max-height: 180px; overflow-y: auto; border: 1px solid #333; border-radius: 6px; padding: 6px; margin-top: 6px; background: #101215; }
.picklist label { display: block; padding: 3px 0; font-size: 12px; cursor: pointer; }
</style>
</head>
<body>
@@ -236,6 +238,13 @@
<div class="card" style="border-color:#3b82f6">
<h2 style="color:#60a5fa">🚇 ระบบขนส่งมวลชน & รถไฟใต้ดิน (Metro Transit & Live Train Tracker)</h2>
<div style="margin-bottom:12px;padding:8px 12px;background:#1e293b;border-radius:6px;display:flex;align-items:center;gap:10px;border:1px solid #3b82f6">
<input id="autoSubwayCutscene" type="checkbox" checked style="width:18px;height:18px;accent-color:#38bdf8;cursor:pointer">
<label for="autoSubwayCutscene" style="font-size:13px;color:#f0f9ff;cursor:pointer;font-weight:600">
🎬 เล่นคัทซีนรถไฟลอดอุโมงค์อัตโนมัติ (เล่นฉากนั่งรถไฟ -> ลอดอุโมงค์ -> โผล่ออกที่อุโมงค์สถานีปลายทาง)
</label>
</div>
<!-- Sub-Tabs Navigation -->
<div style="display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap">
<button id="tabBtn_live" class="npc-tab active" onclick="switchTransitTab('live')">🚊 ติดตามขบวนรถไฟสด & สายรถไฟ (Live Train Lines)</button>
@@ -270,41 +279,41 @@
<div style="font-weight:bold;margin-bottom:6px;color:#60a5fa;font-size:13px">🚉 เส้นทางสถานี & จุดขึ้นขบวนรถ (Station Route & Boarding):</div>
<div id="routeStationsContainer" style="background:#1e222b;border:1px solid #334155;border-radius:6px;padding:8px;max-height:300px;overflow-y:auto">
</div>
<div class="muted" style="margin-top:6px">💡 คลิก <b>⚡ วาร์ปด่วน</b> เพื่อไปที่ชานชาลาทันที หรือคลิก <b>🎬 ขึ้นรถไฟ</b> เพื่อเล่นคัทซีนนั่งในตู้โดยสารก่อนวาร์ปไปถึง</div>
<div class="muted" style="margin-top:6px">💡 เมื่อเปิดเช็คบ็อกซ์ด้านบน ระบบจะเล่นคัทซีนนั่งรถไฟลอดอุโมงค์และไปโผล่ออกที่อุโมงค์สถานีปลายทางให้อัตโนมัติ</div>
</div>
<!-- TAB 2: All Stations Grid -->
<div id="transitPane_stations" style="display:none">
<div style="font-weight:bold;margin-bottom:6px;color:#93c5fd">🏙️ Nova City Metro (12 สถานี):</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px">
<button onclick="warpSubway(2376.0, -1.1, 2729.6, 104.5)">Sunny Bay</button>
<button onclick="warpSubway(898.4, -1.1, 1558.2, 51)">Freshville</button>
<button onclick="warpSubway(1878.8, -1.1, 2250.5, 0)">Glimmergold</button>
<button onclick="warpSubway(2748.3, -1.1, 2046.6, 0)">Memorial Park</button>
<button onclick="warpSubway(3159.0, -1.1, 2643.0, 0)">Azure North</button>
<button onclick="warpSubway(3661.7, -1.0, 1286.9, 0)">Future Port</button>
<button onclick="warpSubway(2243.6, -1.1, 1616.9, 0)">Coastal Station</button>
<button onclick="warpSubway(3941.7, -1.1, 1974.1, 0)">Vista Station</button>
<button onclick="warpSubway(1232.9, -1.1, 2213.1, 0)">Veluxity Station</button>
<button onclick="warpSubway(620.2, -1.0, 2072.1, 0)">Paradox Plaza</button>
<button onclick="warpSubway(1383.1, -1.1, 1267.7, 0)">Marine MechWorks</button>
<button onclick="warpSubway(2947.4, -1.1, 1030.3, 0)">Rixxy Wonderland</button>
<button onclick="warpSubway(2376.0, -1.1, 2729.6, 104.5, 'Sunny Bay Station', 'loading_metro_end_1')">Sunny Bay</button>
<button onclick="warpSubway(898.4, -1.1, 1558.2, 51, 'Freshville Station', 'loading_metro_end_1')">Freshville</button>
<button onclick="warpSubway(1878.8, -1.1, 2250.5, 0, 'Glimmergold Station', 'loading_metro_end_1')">Glimmergold</button>
<button onclick="warpSubway(2748.3, -1.1, 2046.6, 0, 'Memorial Park Station', 'loading_metro_end_1')">Memorial Park</button>
<button onclick="warpSubway(3159.0, -1.1, 2643.0, 0, 'Azure North Station', 'loading_metro_end_1')">Azure North</button>
<button onclick="warpSubway(3661.7, -1.0, 1286.9, 0, 'Future Port Station', 'loading_metro_end_1')">Future Port</button>
<button onclick="warpSubway(2243.6, -1.1, 1616.9, 0, 'Coastal Station', 'loading_metro_end_1')">Coastal Station</button>
<button onclick="warpSubway(3941.7, -1.1, 1974.1, 0, 'Vista Station', 'loading_metro_end_1')">Vista Station</button>
<button onclick="warpSubway(1232.9, -1.1, 2213.1, 0, 'Veluxity Station', 'loading_metro_end_1')">Veluxity Station</button>
<button onclick="warpSubway(620.2, -1.0, 2072.1, 0, 'Paradox Plaza Station', 'loading_metro_end_1')">Paradox Plaza</button>
<button onclick="warpSubway(1383.1, -1.1, 1267.7, 0, 'Marine MechWorks Station', 'loading_metro_end_1')">Marine MechWorks</button>
<button onclick="warpSubway(2947.4, -1.1, 1030.3, 0, 'Rixxy Wonderland Station', 'loading_metro_end_1')">Rixxy Wonderland</button>
</div>
<div style="font-weight:bold;margin-bottom:6px;color:#a78bfa">🏮 Longqi Metro (13 สถานี):</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="ghost" onclick="warpSubway(-1638.0, 18.0, 1540.5, 0)">Dinglan North</button>
<button class="ghost" onclick="warpSubway(-1492.5, 17.6, 1107.9, 0)">Zhaohua Road</button>
<button class="ghost" onclick="warpSubway(-1431.7, 18.0, 565.0, 0)">Dinglan Road</button>
<button class="ghost" onclick="warpSubway(-1725.5, 17.5, 12.1, 0)">Lakeside Pedestrian</button>
<button class="ghost" onclick="warpSubway(-1002.5, 17.7, 115.3, 0)">Zenith Center</button>
<button class="ghost" onclick="warpSubway(-1788.4, 17.5, -574.6, 0)">Lakeside South</button>
<button class="ghost" onclick="warpSubway(-844.0, 17.5, 1133.7, 0)">Great Ferry Port</button>
<button class="ghost" onclick="warpSubway(-624.4, 17.6, 660.4, 0)">Xinfeng Road</button>
<button class="ghost" onclick="warpSubway(61.6, 17.5, 778.3, 0)">Yaojin Road</button>
<button class="ghost" onclick="warpSubway(375.8, 17.5, 98.4, 0)">Yaojin Pedestrian</button>
<button class="ghost" onclick="warpSubway(-427.6, 17.5, 354.0, 0)">Mingchun North</button>
<button class="ghost" onclick="warpSubway(-2442.7, 17.5, 1015.6, 0)">Lakefront Institute</button>
<button class="ghost" onclick="warpSubway(-1138.2, 17.7, -602.3, 0)">Cloudstream West</button>
<button class="ghost" onclick="warpSubway(-1638.0, 18.0, 1540.5, 0, 'Dinglan North', 'loading_general_end')">Dinglan North</button>
<button class="ghost" onclick="warpSubway(-1492.5, 17.6, 1107.9, 0, 'Zhaohua Road', 'loading_metro_end_1')">Zhaohua Road</button>
<button class="ghost" onclick="warpSubway(-1431.7, 18.0, 565.0, 0, 'Dinglan Road', 'loading_general_end')">Dinglan Road</button>
<button class="ghost" onclick="warpSubway(-1725.5, 17.5, 12.1, 0, 'Lakeside Pedestrian', 'loading_metro_end_1')">Lakeside Pedestrian</button>
<button class="ghost" onclick="warpSubway(-1002.5, 17.7, 115.3, 0, 'Zenith Center', 'loading_metro_end_1')">Zenith Center</button>
<button class="ghost" onclick="warpSubway(-1788.4, 17.5, -574.6, 0, 'Lakeside South', 'loading_metro_end_1')">Lakeside South</button>
<button class="ghost" onclick="warpSubway(-844.0, 17.5, 1133.7, 0, 'Great Ferry Port', 'loading_metro_end_1')">Great Ferry Port</button>
<button class="ghost" onclick="warpSubway(-624.4, 17.6, 660.4, 0, 'Xinfeng Road', 'loading_metro_end_1')">Xinfeng Road</button>
<button class="ghost" onclick="warpSubway(61.6, 17.5, 778.3, 0, 'Yaojin Road', 'loading_metro_end_1')">Yaojin Road</button>
<button class="ghost" onclick="warpSubway(375.8, 17.5, 98.4, 0, 'Yaojin Pedestrian', 'loading_metro_end_1')">Yaojin Pedestrian</button>
<button class="ghost" onclick="warpSubway(-427.6, 17.5, 354.0, 0, 'Mingchun North', 'loading_metro_end_1')">Mingchun North</button>
<button class="ghost" onclick="warpSubway(-2442.7, 17.5, 1015.6, 0, 'Lakefront Institute', 'loading_metro_end_1')">Lakefront Institute</button>
<button class="ghost" onclick="warpSubway(-1138.2, 17.7, -602.3, 0, 'Cloudstream West', 'loading_metro_end_1')">Cloudstream West</button>
</div>
</div>
@@ -447,6 +456,40 @@
</div>
</div>
<div class="card" id="card-weapon" style="border-left: 4px solid #00bcd4;">
<h2 style="color:#4dd0e1">⚔️ Armory Wheel Customizer (ปรับแต่งอาวุธในวงล้อช่อง 1–15)</h2>
<div class="muted">ติดตั้งอาวุธ 634 ชิ้นในเกมลงในช่องวงล้ออาวุธ 1–15 ของตัวละครใดก็ได้ (ช่อง 0 คือหมัด) ซิงค์สไตล์การต่อสู้ แอนิเมชัน และกระสุนเข้าเกมทันที</div>
<div style="margin-top:8px">
ตัวละคร: <select id="wpnSpirit" style="min-width:200px"></select>
ช่องวงล้อ (Slot 1–15): <input id="wpnSlot" type="number" min="1" max="15" value="1" style="width:70px">
</div>
<div style="margin-top:6px">
ค้นหาอาวุธ: <input id="wpnFilter" placeholder="พิมพ์ชื่อ หรือ templateId เช่น 98003001" style="width:250px" onkeydown="if(event.key==='Enter')renderWeapons()">
<button class="ghost" onclick="renderWeapons()">ค้นหา</button>
</div>
<div class="picklist" id="wpnList">กำลังโหลดรายชื่ออาวุธ...</div>
<div style="margin-top:8px">
<button style="background:#00838f;color:#fff;font-weight:bold" onclick="equipSelected()">⚔️ สวมใส่อาวุธลงในช่อง (Equip Weapon)</button>
</div>
</div>
<div class="card" id="card-give-item" style="border-left: 4px solid #ffca28;">
<h2 style="color:#ffd54f">🎒 Universal Item Spawner (ระบบเสกไอเทมเข้ากระเป๋าเป้)</h2>
<div class="muted">ส่งไอเทมตรงเข้ากระเป๋าตัวละครผู้เล่นแบบ Real-time ผ่าน MethodId.SyncBackpackItemChanged โดยไม่ต้องเปิด GM คอนโซลหรือรีเซ็ตเกม</div>
<div style="margin-top:8px">
Item Template ID: <input id="giveId" type="number" placeholder="36012003" style="width:140px">
จำนวน (Count): <input id="giveCount" type="number" value="1" min="1" max="99999" style="width:90px">
<label class="muted" style="margin-left:6px"><input id="giveBind" type="checkbox"> ผูกมัด (Bind)</label>
<button style="background:#f57f17;color:#fff;font-weight:bold" onclick="giveItem()">🎁 เสกไอเทม / Grant</button>
</div>
<div style="margin-top:6px">
ค้นหาไอเทม: <input id="itemFilter" placeholder="ชื่อไอเทม หรือ ID" style="width:250px" onkeydown="if(event.key==='Enter')renderItems()">
<button class="ghost" onclick="renderItems()">ค้นหา</button>
</div>
<div class="picklist" id="itemList">กำลังโหลดรายการไอเทม...</div>
<div class="muted" style="margin-top:4px">คลิกที่รายการเพื่อใส่ ID ลงในช่อง หรือคลิกปุ่ม "ส่ง" เพื่อเสกไอเทมทันที 1 ชิ้น</div>
</div>
<div class="card" style="border-color:#e53935">
<h2 style="color:#ff8a80">Emergency Unstuck & Black Screen Recovery</h2>
<div>
@@ -1298,10 +1341,115 @@ async function refreshTrafficStatus() {
} catch (e) { }
}
let weaponData = [];
let itemData = [];
async function loadCatalogs() {
try {
const roster = await api('/api/catalog/spirits');
if (Array.isArray(roster)) {
document.getElementById('wpnSpirit').innerHTML =
roster.map(s => `<option value="${s.templateId}">${s.templateId} - ${s.name}</option>`).join('');
}
weaponData = await api('/api/catalog/weapons');
if (Array.isArray(weaponData)) renderWeapons();
const itemsRes = await api('/api/catalog/items?limit=5000');
itemData = (itemsRes && itemsRes.items) ? itemsRes.items : [];
renderItems();
} catch (e) {
log('catalog load error: ' + e);
}
}
function renderWeapons() {
const q = (document.getElementById('wpnFilter').value || '').toLowerCase();
const list = (weaponData || []).filter(w => !q || (w.name || '').toLowerCase().includes(q) || String(w.templateId).includes(q)).slice(0, 400);
document.getElementById('wpnList').innerHTML = list.length
? list.map(w => `<label><input type="radio" name="wpn" value="${w.templateId}"> ` +
`${w.templateId} - ${w.name}` +
(w.isPrivate ? ' <span class="muted">[Exclusive]</span>' : '') +
(w.isShoot ? ' <span class="muted">[Firearm]</span>' : '') + `</label>`).join('')
: '<span class="muted">ไม่พบอาวุธที่ค้นหา</span>';
}
function itemLabel(i) {
const n = (i.name || '').trim();
return (n.length ? n : '(Unnamed Item)').replace(/</g, '&lt;');
}
function filterItemCache(q) {
const k = (q || '').toLowerCase();
return (itemData || []).filter(i =>
!k || (i.name || '').toLowerCase().includes(k) || String(i.templateId).includes(k)).slice(0, 300);
}
function renderItems() {
const q = (document.getElementById('itemFilter').value || '').trim();
const box = document.getElementById('itemList');
const paint = list => {
box.innerHTML = (list && list.length)
? list.map(i => `<label style="cursor:pointer">` +
`<button class="ghost" style="padding:1px 7px;margin-right:6px" ` +
`onclick="event.preventDefault();giveItem(${i.templateId})">ส่ง</button>` +
`<span onclick="pickItemId(${i.templateId})">${i.templateId} - ${itemLabel(i)}</span></label>`).join('')
: '<span class="muted">ไม่พบไอเทมที่ค้นหา</span>';
};
if (q) {
api('/api/catalog/items?limit=300&q=' + encodeURIComponent(q))
.then(r => paint((r && r.items) ? r.items : []))
.catch(() => paint(filterItemCache(q)));
return;
}
paint(filterItemCache(''));
}
function pickItemId(id) {
document.getElementById('giveId').value = id;
log('เลือกไอเทม ID: ' + id);
}
async function giveItem(id) {
const templateId = id || parseInt(document.getElementById('giveId').value || '0', 10);
if (!templateId) { log('กรุณาระบุ Item Template ID'); return; }
const count = Math.max(1, parseInt(document.getElementById('giveCount').value || '1', 10) || 1);
const bind = document.getElementById('giveBind').checked;
try {
const r = await api('/api/item/give', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ templateId, count, bind })
});
log('🎁 เสกไอเทม ' + templateId + ' x' + count + ': ' + (r.ok ? r.message : 'ล้มเหลว - ' + (r.error || '')));
} catch (e) {
log('เสกไอเทมล้มเหลว: ' + e);
}
}
async function equipSelected() {
const pick = document.querySelector('input[name=wpn]:checked');
if (!pick) { log('กรุณาเลือกอาวุธก่อนกดสวมใส่'); return; }
const body = {
spiritId: parseInt(document.getElementById('wpnSpirit').value || '0', 10),
slotIndex: parseInt(document.getElementById('wpnSlot').value || '1', 10),
templateId: parseInt(pick.value, 10)
};
try {
const r = await api('/api/armory/equip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
log('⚔️ สวมใส่อาวุธ: ' + (r.ok ? r.message : 'ล้มเหลว - ' + (r.error || '')));
} catch (e) {
log('สวมใส่อาวุธล้มเหลว: ' + e);
}
}
loadFleet();
loadScenes();
loadNpcCatalog();
loadCutsceneCatalog();
loadCatalogs();
refresh();
setInterval(refresh, 2000);
refreshTrafficStatus();
@@ -0,0 +1,603 @@
using System.Text.Json;
using Ananta.Server.Configuration;
namespace Ananta.Server.ClientData.Client4229938;
/// <summary>
/// Client economy tables (backpack items, mall commodities, drops, gacha pools/contents/tier rules)
/// imported from the 4229938 config dump into <c>paths.clientConfigs</c>.
///
/// Unlike ClientConfigRepository these tables are optional: a missing or malformed file only disables
/// the matching feature (mall purchase / gacha draw) instead of refusing to start, because the rest of
/// the server must keep working when a dump is incomplete.
/// </summary>
internal static class EconomyConfigRepository
{
private static readonly Lazy<Data> Cache = new(Load);
/// <summary>Forces the (optional) tables to load so the startup log reports what was imported.</summary>
internal static void Warmup()
=> _ = Cache.Value;
internal static bool TryMallCommodity(uint id, out MallCommodity commodity)
=> Cache.Value.Mall.TryGetValue(id, out commodity!);
internal static bool TryDrop(uint id, out DropRow drop)
=> Cache.Value.Drops.TryGetValue(id, out drop!);
internal static bool TryPool(uint id, out GachaPool pool)
=> Cache.Value.Pools.TryGetValue(id, out pool!);
internal static bool TryTierRule(uint id, out GachaTierRule rule)
=> Cache.Value.TierRules.TryGetValue(id, out rule!);
internal static bool TryTradeItem(uint id, out TradeItem item)
=> Cache.Value.TradeItems.TryGetValue(id, out item!);
/// <summary>
/// Backpack items this build actually ships (<c>ConsumableConfig</c>). The client resolves a pack
/// item's name, icon and bag page through the same table and silently drops rows it cannot find
/// (CommonItemManager_Package.SetPackItem), so a grant has to be checked against it before it is
/// pushed; item ids are 36xxxxxx and never overlap weapons (98xxxxxx), fashion (111xxxxx),
/// suits (1119xxxx) or vehicles (81xxxxxx).
/// </summary>
internal static bool TryConsumableItem(uint id, out ConsumableItem item)
=> Cache.Value.Items.TryGetValue(id, out item!);
/// <summary>False when the table was not imported; grants then fall back to no validation.</summary>
internal static bool HasConsumableItems => Cache.Value.Items.Count > 0;
internal static IReadOnlyCollection<ConsumableItem> ConsumableItems => Cache.Value.Items.Values;
/// <summary>Id-or-name lookup for the debug panel: matches on any id digit and on a name substring.</summary>
internal static IReadOnlyList<ConsumableItem> SearchConsumableItems(string? query, int limit)
{
IEnumerable<ConsumableItem> matches = Cache.Value.Items.Values;
if (!string.IsNullOrWhiteSpace(query))
{
var text = query.Trim();
matches = matches.Where(x =>
x.Id.ToString().Contains(text, StringComparison.Ordinal) ||
x.Name.Contains(text, StringComparison.OrdinalIgnoreCase));
}
return matches.OrderBy(x => x.Id).Take(limit).ToArray();
}
internal static IReadOnlyList<GachaContent> PoolContents(uint poolId)
=> Cache.Value.ContentsByPool.TryGetValue(poolId, out var contents) ? contents : [];
/// <summary>
/// Wardrobe pieces a fashion suit is made of (<c>FashionSuitConfig.FashionIdList</c>). The client
/// answers "do I own this suit?" by asking about every piece (MallManager.CheckFashionSuitOwned),
/// so both the shelf check and the purchase need the suit -&gt; pieces mapping.
/// </summary>
internal static bool TryFashionSuit(uint suitId, out uint[] fashionIds)
=> Cache.Value.FashionSuits.TryGetValue(suitId, out fashionIds!);
/// <summary>
/// Wardrobe pieces the direct-sale shelves stock. They have to stay locked in the login payload:
/// their commodities are authored <c>OwnedCanNotBuy</c>, and MallManager.IsCommodityBuyable() reads
/// that flag as "hide the button once the player owns it" - a free unlock therefore greys the row
/// out permanently instead of selling it.
/// </summary>
internal static IReadOnlySet<uint> MallSoldFashionIds => Cache.Value.MallSoldFashions;
/// <summary>Vehicle counterpart of <see cref="MallSoldFashionIds"/> (ApplyCarManager.CheckPlayerAlreadyHasVehicle).</summary>
internal static IReadOnlySet<uint> MallSoldVehicleIds => Cache.Value.MallSoldVehicles;
// MallCommodityConfig.Type: 0 stocks a wardrobe suit, 1 a vehicle, 2 a fashion suit that retail
// only trades through the order book, 5/6/7 plain item rows.
internal const uint CommodityTypeFashion4229938 = 0u;
internal const uint CommodityTypeVehicle4229938 = 1u;
// The direct-sale group of MallConfig (Tab.Main = 2): sub-tabs Outfits (2/1), Vehicles (2/3),
// 14 and 15. Only these shelves are held back from the free unlock; every other tab keeps
// shipping unlocked goods because nothing there is sold on ownership.
private static readonly uint[] DirectSaleMallIds4229938 = [2u, 3u, 14u, 15u];
/// <summary>One backpack item row. SubType selects the client's bag page via ConsumableTypeConfig.</summary>
internal sealed record ConsumableItem(uint Id, string Name, uint SubType, uint Quality);
/// <summary>One mall shelf row. Price is the list price, DiscountPrice the current charge (0 = none).</summary>
internal sealed record MallCommodity(
uint Id,
uint MallId,
uint Type,
uint BindId,
uint ConsumeItemId,
double Price,
double DiscountPrice,
uint DropId,
uint LimitNum,
IReadOnlyList<uint> CommodityBindIds)
{
internal double UnitPrice => DiscountPrice > 0 ? DiscountPrice : Price;
/// <summary>
/// Suit ids the client derives ownership from: MallManager.GetCommodityBindId() picks one entry
/// of <c>CommodityBindId</c> (index 2 for male spirits, index 1 otherwise), so the whole list has
/// to be honoured to cover both sexes. The row's own <c>BindId</c> is deliberately left out - the
/// client never consults it for ownership.
/// </summary>
internal IEnumerable<uint> OwnershipBindIds
{
get
{
foreach (var bindId in CommodityBindIds)
{
if (bindId != 0)
yield return bindId;
}
}
}
}
/// <summary>
/// One drop row. <paramref name="Guaranteed"/> items are always granted, <paramref name="Ranged"/>
/// picks one entry and a random count in [Min, Max], <paramref name="Weighted"/> picks one entry by
/// relative weight (Item3 carries fractional weights, so they are doubles).
/// </summary>
internal sealed record DropRow(
uint Id,
double Money,
double BindingGold,
IReadOnlyList<(uint Id, uint Count)> Guaranteed,
IReadOnlyList<(uint Id, uint Min, uint Max)> Ranged,
IReadOnlyList<(uint Id, double Weight)> Weighted)
{
internal bool IsEmpty => Guaranteed.Count == 0 && Ranged.Count == 0 && Weighted.Count == 0
&& Money == 0 && BindingGold == 0;
}
internal sealed record GachaPool(
uint Id,
bool IsDrop,
uint MoneyId,
uint CommodityId,
uint GrandTierRuleId,
IReadOnlyList<(uint DrawCount, uint Cost)> Costs)
{
/// <summary>Authored cost for the requested draw count, falling back to per-draw cost × count.</summary>
internal double CostFor(uint drawCount)
{
foreach (var (count, cost) in Costs)
{
if (count == drawCount)
return cost;
}
foreach (var (count, cost) in Costs)
{
if (count == 1)
return cost * drawCount;
}
return Costs.Count > 0 ? Costs[0].Cost : 0;
}
}
internal sealed record GachaContent(
uint Id,
uint PoolId,
uint TierRarity,
uint DropId,
double Weight,
uint Quantity,
uint Quality,
uint DuplicateReturnDropId,
uint DuplicateReturnCount)
{
/// <summary>SS-tier row: the UI plays the extra grand-prize video for these draws.</summary>
internal bool IsGrandPrize => TierRarity >= 4 || Quality >= 5;
}
/// <summary>
/// Pity ramp for one tier rule. <paramref name="RuleType"/> 1 pools ramp their grand-prize chance
/// from <paramref name="BaseProbability"/> by <paramref name="ProbabilityIncrement"/> every draw
/// once <paramref name="RampStartDraw"/> is reached and guarantee it at <paramref name="PityThreshold"/>.
/// <paramref name="RuleType"/> 0 pools instead pick a tier from the cumulative
/// <paramref name="GrandPrizeProbability"/> table (scale 100000) with no pity at all.
/// </summary>
internal sealed record GachaTierRule(
uint Id,
uint RuleType,
uint PityThreshold,
uint RampStartDraw,
uint BaseProbability,
uint ProbabilityIncrement,
IReadOnlyList<uint> GrandPrizeProbability);
/// <summary>
/// One trading-post row. <paramref name="ItemType"/> 0 hands out <paramref name="ItemId"/> as an
/// ordinary backpack item, 1 unlocks the wardrobe piece <paramref name="FashionId"/>, 3 is a
/// household collectible (also <paramref name="ItemId"/>) and 2 is a fashion suit that retail only
/// trades through the order book.
/// </summary>
internal sealed record TradeItem(
uint Id,
uint ItemType,
uint ItemId,
uint Tab,
uint FashionId,
uint FashionSuitId,
uint MinPrice,
uint MaxPrice,
IReadOnlyList<uint> BucketRanges)
{
/// <summary>Cheapest price the row is offered at - the floor of its authored bucket ranges.</summary>
internal uint MarketPrice
{
get
{
if (MinPrice > 0)
return MinPrice;
foreach (var bucket in BucketRanges)
{
if (bucket > 0)
return bucket;
}
return 0;
}
}
}
private sealed class Data
{
internal Dictionary<uint, ConsumableItem> Items { get; init; } = [];
internal Dictionary<uint, MallCommodity> Mall { get; init; } = [];
internal Dictionary<uint, DropRow> Drops { get; init; } = [];
internal Dictionary<uint, GachaPool> Pools { get; init; } = [];
internal Dictionary<uint, GachaTierRule> TierRules { get; init; } = [];
internal Dictionary<uint, List<GachaContent>> ContentsByPool { get; init; } = [];
internal Dictionary<uint, TradeItem> TradeItems { get; init; } = [];
internal Dictionary<uint, uint[]> FashionSuits { get; init; } = [];
internal IReadOnlySet<uint> MallSoldFashions { get; init; } = new HashSet<uint>();
internal IReadOnlySet<uint> MallSoldVehicles { get; init; } = new HashSet<uint>();
}
private static Data Load()
{
var cfg = PrivateServerConfigStore.Current;
var root = PrivateServerConfigStore.ResolveProjectPath(cfg.Paths.ClientConfigs);
var warnings = new List<string>();
var mall = new Dictionary<uint, MallCommodity>();
foreach (var row in ReadRecords(Path.Combine(root, "MallCommodityConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var id) || id == 0)
continue;
TryUInt32(row, "BelongMallId", out var mallId);
TryUInt32(row, "Type", out var type);
TryUInt32(row, "BindId", out var bindId);
TryUInt32(row, "ConsumeItemId", out var consumeItemId);
TryDouble(row, "Price", out var price);
TryDouble(row, "DiscountPrice", out var discountPrice);
TryUInt32(row, "DropId", out var dropId);
TryUInt32(row, "LimitNum", out var limitNum);
mall[id] = new MallCommodity(
id, mallId, type, bindId, consumeItemId, price, discountPrice, dropId, limitNum,
ReadUInt32Array(row, "CommodityBindId"));
}
var items = new Dictionary<uint, ConsumableItem>();
foreach (var row in ReadRecords(Path.Combine(root, "ConsumableConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var itemId) || itemId == 0)
continue;
TryUInt32(row, "SubType", out var subType);
TryUInt32(row, "Quality", out var quality);
items[itemId] = new ConsumableItem(itemId, ReadString(row, "Name"), subType, quality);
}
var drops = new Dictionary<uint, DropRow>();
foreach (var row in ReadRecords(Path.Combine(root, "DropConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var id) || id == 0)
continue;
TryDouble(row, "Money", out var money);
TryDouble(row, "BindingGold", out var bindingGold);
drops[id] = new DropRow(
id, money, bindingGold, ReadItemList(row, "Item1"), ReadItemRangeList(row, "Item2"),
ReadItemWeightList(row, "Item3"));
}
var pools = new Dictionary<uint, GachaPool>();
foreach (var row in ReadRecords(Path.Combine(root, "GachaPoolConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var id) || id == 0)
continue;
TryUInt32(row, "MoneyId", out var moneyId);
TryUInt32(row, "CommodityId", out var commodityId);
TryUInt32(row, "SS_TierRuleId", out var grandTierRuleId);
pools[id] = new GachaPool(
id, ReadBool(row, "isDrop"), moneyId, commodityId, grandTierRuleId, ReadCostList(row, "CostCount"));
}
var tierRules = new Dictionary<uint, GachaTierRule>();
foreach (var row in ReadRecords(Path.Combine(root, "GachaPoolTierRuleConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var id) || id == 0)
continue;
TryUInt32(row, "RuleType", out var ruleType);
TryUInt32(row, "PityThreshold", out var pityThreshold);
TryUInt32(row, "RampStartDraw", out var rampStartDraw);
TryUInt32(row, "BaseProbability", out var baseProbability);
TryUInt32(row, "ProbabilityIncrement", out var probabilityIncrement);
tierRules[id] = new GachaTierRule(
id, ruleType, pityThreshold, rampStartDraw, baseProbability, probabilityIncrement,
ReadUInt32Array(row, "GrandPrizeProbability"));
}
var contentsByPool = new Dictionary<uint, List<GachaContent>>();
foreach (var row in ReadRecords(Path.Combine(root, "GachaPoolContentConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var id) || id == 0)
continue;
if (!TryUInt32(row, "PoolId", out var poolId) || poolId == 0)
continue;
TryUInt32(row, "PoolTierRarity", out var tierRarity);
TryUInt32(row, "dropId", out var dropId);
TryDouble(row, "weight", out var weight);
TryUInt32(row, "Quantity", out var quantity);
TryUInt32(row, "Quality", out var quality);
TryUInt32(row, "duplicateReturnDropId", out var duplicateDropId);
TryUInt32(row, "duplicateReturnCount", out var duplicateCount);
if (!contentsByPool.TryGetValue(poolId, out var list))
{
list = [];
contentsByPool[poolId] = list;
}
list.Add(new GachaContent(
id, poolId, tierRarity, dropId, weight < 0 ? 0 : weight, quantity, quality,
duplicateDropId, duplicateCount));
}
var tradeItems = new Dictionary<uint, TradeItem>();
foreach (var row in ReadRecords(Path.Combine(root, "TradeItemConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var id) || id == 0)
continue;
TryUInt32(row, "ItemType", out var itemType);
TryUInt32(row, "ItemId", out var itemId);
TryUInt32(row, "Tab", out var tab);
TryUInt32(row, "FashionId", out var fashionId);
TryUInt32(row, "FashionSuitId", out var fashionSuitId);
TryUInt32(row, "MinPrice", out var minPrice);
TryUInt32(row, "MaxPrice", out var maxPrice);
tradeItems[id] = new TradeItem(
id, itemType, itemId, tab, fashionId, fashionSuitId, minPrice, maxPrice,
ReadUInt32Array(row, "BucketRanges"));
}
var fashionSuits = new Dictionary<uint, uint[]>();
foreach (var row in ReadRecords(Path.Combine(root, "FashionSuitConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var id) || id == 0)
continue;
var pieces = ReadUInt32Array(row, "FashionIdList").Where(x => x != 0).Distinct().ToArray();
if (pieces.Length > 0)
fashionSuits[id] = pieces;
}
// Everything the direct-sale shelves stock has to stay unowned at login, otherwise the client
// hides the buy button behind its own "already owned" rule. See MallSoldFashionIds.
var mallSoldFashions = new HashSet<uint>();
var mallSoldVehicles = new HashSet<uint>();
foreach (var commodity in mall.Values)
{
if (Array.IndexOf(DirectSaleMallIds4229938, commodity.MallId) < 0)
continue;
foreach (var bindId in commodity.OwnershipBindIds)
{
if (commodity.Type == CommodityTypeFashion4229938
&& fashionSuits.TryGetValue(bindId, out var pieces))
{
mallSoldFashions.UnionWith(pieces);
}
else if (commodity.Type == CommodityTypeVehicle4229938)
{
mallSoldVehicles.Add(bindId);
}
}
}
foreach (var message in warnings)
Console.WriteLine($"[ECONOMY] {message}");
Console.WriteLine(
$"[ECONOMY] items={items.Count} mall={mall.Count} drops={drops.Count} pools={pools.Count} " +
$"gachaContents={contentsByPool.Values.Sum(x => x.Count)} tierRules={tierRules.Count} " +
$"tradeItems={tradeItems.Count} fashionSuits={fashionSuits.Count} " +
$"directSaleFashions={mallSoldFashions.Count} directSaleVehicles={mallSoldVehicles.Count} " +
$"source={root}");
return new Data
{
Items = items,
Mall = mall,
Drops = drops,
Pools = pools,
TierRules = tierRules,
ContentsByPool = contentsByPool,
TradeItems = tradeItems,
FashionSuits = fashionSuits,
MallSoldFashions = mallSoldFashions,
MallSoldVehicles = mallSoldVehicles,
};
}
private static JsonElement[] ReadRecords(string path, List<string> warnings)
{
if (!File.Exists(path))
{
warnings.Add($"optional client config is missing: {path}");
return [];
}
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array)
{
warnings.Add($"client config has no 'records' array: {path}");
return [];
}
return records.EnumerateArray().Select(x => x.Clone()).ToArray();
}
catch (Exception ex)
{
warnings.Add($"client config failed to parse ({ex.GetType().Name}: {ex.Message}): {path}");
return [];
}
}
// Dump records may carry fully qualified keys ("LT.ConfigGen.IFoo.Id"), so match on the leaf name.
private static bool TryProperty(JsonElement row, string name, out JsonElement value)
{
if (row.TryGetProperty(name, out value))
return true;
foreach (var property in row.EnumerateObject())
{
if (property.Name.AsSpan()[(property.Name.LastIndexOf('.') + 1)..].SequenceEqual(name))
{
value = property.Value;
return true;
}
}
value = default;
return false;
}
private static bool TryUInt32(JsonElement row, string name, out uint result)
{
result = 0;
return TryProperty(row, name, out var value) && value.TryGetUInt32(out result);
}
private static bool TryDouble(JsonElement row, string name, out double result)
{
result = 0;
return TryProperty(row, name, out var value) && value.TryGetDouble(out result);
}
private static bool ReadBool(JsonElement row, string name)
=> TryProperty(row, name, out var value) && value.ValueKind == JsonValueKind.True;
private static string ReadString(JsonElement row, string name)
=> TryProperty(row, name, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString() ?? string.Empty
: string.Empty;
private static IReadOnlyList<uint> ReadUInt32Array(JsonElement row, string name)
{
if (!TryProperty(row, name, out var value) || value.ValueKind != JsonValueKind.Array)
return [];
return value.EnumerateArray()
.Where(x => x.TryGetUInt32(out _))
.Select(x => x.GetUInt32())
.ToArray();
}
// Item1: [{ "id1": 36211010, "count": 10 }, ...] - every entry is granted.
private static IReadOnlyList<(uint Id, uint Count)> ReadItemList(JsonElement row, string name)
{
if (!TryProperty(row, name, out var value) || value.ValueKind != JsonValueKind.Array)
return [];
var result = new List<(uint, uint)>();
foreach (var entry in value.EnumerateArray())
{
if (!TryUInt32(entry, "id1", out var id) || id == 0)
continue;
TryUInt32(entry, "count", out var count);
result.Add((id, count == 0 ? 1u : count));
}
return result;
}
// Item2: [{ "id2": .., "min": .., "max": .. }] - one entry is chosen and rolled inside [min, max].
private static IReadOnlyList<(uint Id, uint Min, uint Max)> ReadItemRangeList(JsonElement row, string name)
{
if (!TryProperty(row, name, out var value) || value.ValueKind != JsonValueKind.Array)
return [];
var result = new List<(uint, uint, uint)>();
foreach (var entry in value.EnumerateArray())
{
if (!TryUInt32(entry, "id2", out var id) || id == 0)
continue;
TryUInt32(entry, "min", out var min);
TryUInt32(entry, "max", out var max);
if (max < min)
max = min;
result.Add((id, min, max));
}
return result;
}
// Item3: [{ "id3": .., "count": 0.00075 }] - 'count' is a relative probability weight.
private static IReadOnlyList<(uint Id, double Weight)> ReadItemWeightList(JsonElement row, string name)
{
if (!TryProperty(row, name, out var value) || value.ValueKind != JsonValueKind.Array)
return [];
var result = new List<(uint, double)>();
foreach (var entry in value.EnumerateArray())
{
if (!TryUInt32(entry, "id3", out var id) || id == 0)
continue;
TryDouble(entry, "count", out var weight);
if (weight <= 0)
continue;
result.Add((id, weight));
}
return result;
}
// CostCount: [{ "drawCount": 1, "cost": 100 }, ...]
private static IReadOnlyList<(uint DrawCount, uint Cost)> ReadCostList(JsonElement row, string name)
{
if (!TryProperty(row, name, out var value) || value.ValueKind != JsonValueKind.Array)
return [];
var result = new List<(uint, uint)>();
foreach (var entry in value.EnumerateArray())
{
if (!TryUInt32(entry, "drawCount", out var drawCount) || drawCount == 0)
continue;
TryUInt32(entry, "cost", out var cost);
result.Add((drawCount, cost));
}
return result;
}
}
@@ -0,0 +1,194 @@
using System.Text.Json;
using Ananta.SDK.Logging;
using Ananta.Server.Configuration;
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>Wallet + backpack + gacha counters kept in memory for the lifetime of a login.</summary>
internal sealed class PlayerProgress
{
internal double Money { get; set; }
internal double Gold { get; set; }
internal double BindingGold { get; set; }
internal List<PlayerProgressItem> Items { get; } = [];
internal Dictionary<uint, uint> GachaDrawsSinceGrand { get; } = [];
internal HashSet<uint> Fashions { get; } = [];
internal HashSet<uint> Vehicles { get; } = [];
internal bool RestoredFromDisk { get; set; }
}
internal sealed record PlayerProgressItem(uint TemplateId, uint Count, bool IsBind, ulong UniqueId);
/// <summary>
/// Minimal character economy persistence for build 4229938. The retail server keeps this in the
/// account database; this private server writes one JSON snapshot per player next to
/// <c>config/private-server.json</c> and replays it through PlayerInfo at login.
/// </summary>
internal static class PlayerProgressStore
{
private static readonly object Gate = new();
private static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
internal static string FilePath { get; } =
PrivateServerConfigStore.ResolveProjectPath(Path.Combine("config", "player-progress.json"));
internal static void LoadInto(PlayerProgress progress, ServerLogger log)
{
var path = FilePath;
if (!File.Exists(path))
{
log.Info($"[ECONOMY] no saved character economy yet ({path})");
return;
}
try
{
SaveFile? file;
lock (Gate)
file = JsonSerializer.Deserialize<SaveFile>(File.ReadAllText(path));
if (file is null)
{
log.Warn($"[ECONOMY] saved character economy is empty: {path}");
return;
}
progress.Money = file.Money;
progress.Gold = file.Gold;
progress.BindingGold = file.BindingGold;
progress.Items.Clear();
progress.GachaDrawsSinceGrand.Clear();
progress.Fashions.Clear();
progress.Vehicles.Clear();
foreach (var item in file.Items)
{
if (item.TemplateId == 0 || item.Count == 0)
continue;
progress.Items.RemoveAll(x => x.TemplateId == item.TemplateId);
progress.Items.Add(new PlayerProgressItem(item.TemplateId, item.Count, item.IsBind, item.UniqueId));
}
foreach (var (poolId, draws) in file.GachaDraws)
{
if (poolId != 0)
progress.GachaDrawsSinceGrand[poolId] = draws;
}
foreach (var fashionId in file.Fashions)
{
if (fashionId != 0)
progress.Fashions.Add(fashionId);
}
foreach (var vehicleId in file.Vehicles)
{
if (vehicleId != 0)
progress.Vehicles.Add(vehicleId);
}
progress.RestoredFromDisk = true;
log.Info(
$"[ECONOMY] restored pid={file.Pid} account={file.AccountId} " +
$"money={progress.Money} gold={progress.Gold} bindingGold={progress.BindingGold} " +
$"items={progress.Items.Count} gachaPools={progress.GachaDrawsSinceGrand.Count} " +
$"fashions={progress.Fashions.Count} vehicles={progress.Vehicles.Count} ({path})");
}
catch (Exception ex)
{
log.Warn($"[ECONOMY] saved character economy could not be read ({ex.GetType().Name}: {ex.Message}): {path}");
}
}
/// <summary>Snapshots the live session state (wallet, GM/granted backpack, gacha counters) to disk.</summary>
internal static void Save(WorldEntryState state, ServerLogger log)
{
var file = new SaveFile
{
Pid = PrivateServerConfigStore.Current.Player.Pid,
AccountId = PrivateServerConfigStore.Current.Player.AccountId,
Money = state.WalletMoney,
Gold = state.WalletGold,
BindingGold = state.WalletBindingGold,
SavedAtUtc = DateTime.UtcNow.ToString("O"),
};
lock (state.SyncRoot)
{
foreach (var stack in state.GmBackpackItems.Values)
{
if (stack.TemplateId == 0 || stack.Count == 0)
continue;
file.Items.Add(new SaveItem
{
TemplateId = stack.TemplateId,
Count = stack.Count,
IsBind = stack.IsBind,
UniqueId = stack.UniqueId,
});
}
file.Fashions.AddRange(state.Progress.Fashions.Where(x => x != 0).OrderBy(x => x));
file.Vehicles.AddRange(state.Progress.Vehicles.Where(x => x != 0).OrderBy(x => x));
}
foreach (var (poolId, draws) in state.Progress.GachaDrawsSinceGrand)
file.GachaDraws[poolId] = draws;
var path = FilePath;
var temp = path + ".tmp";
try
{
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
Directory.CreateDirectory(directory);
lock (Gate)
{
File.WriteAllText(temp, JsonSerializer.Serialize(file, Options));
File.Move(temp, path, overwrite: true);
}
log.Info(
$"[ECONOMY] saved character economy money={file.Money} gold={file.Gold} " +
$"bindingGold={file.BindingGold} items={file.Items.Count} " +
$"fashions={file.Fashions.Count} vehicles={file.Vehicles.Count} -> {path}");
}
catch (Exception ex)
{
log.Warn($"[ECONOMY] character economy could not be saved ({ex.GetType().Name}: {ex.Message}): {path}");
try
{
if (File.Exists(temp))
File.Delete(temp);
}
catch
{
}
}
}
private sealed class SaveFile
{
public ulong Pid { get; set; }
public string AccountId { get; set; } = string.Empty;
public double Money { get; set; }
public double Gold { get; set; }
public double BindingGold { get; set; }
public string SavedAtUtc { get; set; } = string.Empty;
public List<SaveItem> Items { get; set; } = [];
public Dictionary<uint, uint> GachaDraws { get; set; } = [];
public List<uint> Fashions { get; set; } = [];
public List<uint> Vehicles { get; set; } = [];
}
private sealed class SaveItem
{
public uint TemplateId { get; set; }
public uint Count { get; set; }
public bool IsBind { get; set; }
public ulong UniqueId { get; set; }
}
}
@@ -488,4 +488,20 @@ internal static class RuntimePayloadFactory
private static Auto.UXVector3 Vector(Vec3 value)
=> new() { X = value.X, Y = value.Y, Z = value.Z };
internal static Auto.PlayerPackItem GmPackItem4229938(uint templateId, uint count, bool isBind, ulong uniqueId)
=> new()
{
UniqueId = uniqueId,
TemplateId = templateId,
Count = count,
IsNew = false,
ExpiryTime = 0,
RemindState = 0,
Quality = 0,
Tags = 0,
IsBind = isBind,
Components = null,
CDFinishTime = 0,
};
}
@@ -53,19 +53,29 @@ internal static class WorldCodec
internal static SceneMethods.SyncSceneLoadCompleted SceneLoadCompleted(ulong sceneId)
=> new() { sceneId = sceneId };
internal static SceneMethods.SyncUnitPositionAndFacing PositionAndFacing(ulong unitId, Vec3 position, float facing, byte setPositionType = 3)
internal static SceneMethods.SyncUnitPositionAndFacing PositionAndFacing(
ulong unitId, Vec3 position, float facing, byte setPositionType, bool continueMove, byte moveId)
=> new()
{
unitId = unitId,
position = new SceneMethods.UxVector3(position.X, position.Y, position.Z),
facing = facing,
moveId = 0,
continueMove = false,
moveId = moveId,
continueMove = continueMove,
setPositionType = setPositionType,
moveGroundInfo = null,
loadingInfo = null,
};
internal static SceneMethods.SyncUnitPositionAndFacing PositionAndFacing(
ulong unitId, Vec3 position, float facing, byte setPositionType = 3)
=> PositionAndFacing(unitId, position, facing, setPositionType, continueMove: false, moveId: MoveAction.Unknown);
internal static SceneMethods.SyncUnitPositionAndFacing Walking(
ulong unitId, Vec3 position, float facing, byte moveId = MoveAction.WalkFront,
byte setPositionType = SetPositionType.Gm, bool continueMove = true)
=> PositionAndFacing(unitId, position, facing, setPositionType, continueMove, moveId);
internal static SceneMethods.SyncUnitPositionAndFacing PositionAndFacing(Vec3 position, float facing, byte setPositionType = 3)
=> PositionAndFacing(Profile.InitialUnitId, position, facing, setPositionType);
@@ -161,4 +171,29 @@ internal static class WorldCodec
internal static SceneMethods.SyncRemoveUnitState RemoveUnitState(uint state)
=> RemoveUnitState(Profile.InitialUnitId, state);
internal static class MoveAction
{
internal const byte Unknown = 0;
internal const byte Idle = 1;
internal const byte WalkFront = 2;
internal const byte WalkBack = 3;
internal const byte WalkLeft = 4;
internal const byte WalkRight = 5;
internal const byte Run = 6;
}
internal static class SetPositionType
{
internal const byte Force = 0;
internal const byte RejectSync = 1;
internal const byte Revive = 2;
internal const byte SwitchSpirit = 3;
internal const byte SpoonNoLoading = 4;
internal const byte FallGround = 5;
internal const byte Teleport = 6;
internal const byte Gm = 7;
internal const byte Portal = 8;
internal const byte OutOfStuck = 9;
}
}
@@ -86,7 +86,17 @@ internal sealed class WorldEntryState
// (loaded magazine + reserve). Keep it instance-persistent for the same reason as the magazine.
internal Dictionary<ulong, int> WeaponDurabilityAmmo { get; } = [];
internal Dictionary<ulong, uint> WeaponBulletByInstance { get; } = [];
internal Dictionary<int, uint> SkillByInstanceId { get; } = [];
internal Dictionary<uint, uint> BackpackItemCounts { get; } = [];
internal Dictionary<uint, RpcTypes.Client4229938.Auto.PlayerPackItem> GmBackpackItems { get; } = [];
internal double WalletMoney { get; set; }
internal double WalletGold { get; set; }
internal double WalletBindingGold { get; set; }
internal PlayerProgress Progress { get; } = new();
internal bool ProgressRestored { get; set; }
internal bool HasSessionEconomy4229938()
=> WalletMoney != 0 || WalletGold != 0 || WalletBindingGold != 0 || GmBackpackItems.Count > 0;
internal HashSet<ulong> ArmoryWeaponsAnnounced { get; } = [];
internal Dictionary<(uint SpiritId, uint FightStyleTypeId), uint> SpiritStyleOverrides { get; } = [];
internal int CombatUseCount { get; set; }
internal int CombatEndCount { get; set; }
@@ -7,5 +7,9 @@ public interface IUrbanCrowdDriver
Task<(bool Ok, ulong[] EntityIds)> SpawnCrowdPedestriansAsync(
TcpSession session, uint npcFormworkId, uint poiActionId, float x, float y, float z, float facing);
Task DespawnCrowdPedestrianAsync(TcpSession session, ulong entityId);
Task SendPedestrianMoveAsync(TcpSession session, ulong entityId, float x, float y, float z, float facing, byte moveId);
(bool HasPlayer, float X, float Y, float Z, float Yaw, uint RaidId) GetPlayerPosition(TcpSession session);
}
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using Ananta.SDK.Network;
using Ananta.Server.Protocol.Client4229938;
namespace Ananta.Server.Gameplay.Crowd;
@@ -94,12 +95,16 @@ public sealed class UrbanCrowdEngine
private Task? _crowdLoopTask;
public bool Enabled { get; set; } = true;
public int TargetPedestrianDensity { get; set; } = 15;
public int TargetPedestrianDensity { get; set; } = 25;
public bool PopulateShops { get; set; } = true;
// Verified authentic civilian, shopkeeper, barista, and bar patron NPCs
// Verified authentic civilian models from AgentConfig (4013xxxx)
private static readonly uint[] CitizenFormworkPool =
[
40130003, // Generic Young Woman (少女)
40130017, // Generic Young Boy (少男)
40130020, // Generic Adult Female (成女)
40130035, // Generic Adult Male (成男)
40130101, // Emily Sato - Lively female shopkeeper
40130102, // Sato Saori - Gentle female shopkeeper
40130117, // Emma - Store greeter
@@ -128,6 +133,12 @@ public sealed class UrbanCrowdEngine
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
public float Facing { get; set; }
public float Speed { get; set; } = 1.3f;
public bool IsWalking { get; set; }
public List<(float X, float Y, float Z)> Waypoints { get; } = [];
public int CurrentWpIndex { get; set; }
public bool WalkForwardDirection { get; set; } = true;
public DateTime SpawnedAt { get; set; }
}
@@ -163,6 +174,8 @@ public sealed class UrbanCrowdEngine
Start();
}
private Task? _motionLoopTask;
public void Start()
{
if (_crowdLoopTask is not null && !_crowdLoopTask.IsCompleted)
@@ -170,7 +183,8 @@ public sealed class UrbanCrowdEngine
_cts = new CancellationTokenSource();
_crowdLoopTask = Task.Run(() => CrowdLoopAsync(_cts.Token));
Console.WriteLine("[CROWD] Living Urban Crowd Engine started.");
_motionLoopTask = Task.Run(() => CrowdMotionLoopAsync(_cts.Token));
Console.WriteLine("[CROWD] Living Urban Crowd Engine started (Spawn & Locomotion loops).");
}
public void Stop()
@@ -200,7 +214,6 @@ public sealed class UrbanCrowdEngine
try
{
// Run crowd checks every 2.5 seconds (crowd spawns do not require high frequency updates)
await Task.Delay(2500, token);
}
catch (OperationCanceledException)
@@ -210,6 +223,95 @@ public sealed class UrbanCrowdEngine
}
}
private async Task CrowdMotionLoopAsync(CancellationToken token)
{
const int motionIntervalMs = 125; // 8 Hz locomotion push
const float dt = motionIntervalMs / 1000f;
while (!token.IsCancellationRequested)
{
try
{
if (Enabled && _driver is not null && _sessionProvider is not null)
{
var sessions = _sessionProvider().ToList();
foreach (var session in sessions)
{
await TickSessionCrowdMotionAsync(session, dt);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[CROWD-MOTION-ERROR] {ex.Message}");
}
try
{
await Task.Delay(motionIntervalMs, token);
}
catch (OperationCanceledException)
{
break;
}
}
}
private async Task TickSessionCrowdMotionAsync(TcpSession session, float dt)
{
if (_driver is null) return;
var (hasPlayer, px, py, pz, _, _) = _driver.GetPlayerPosition(session);
if (!hasPlayer) return;
foreach (var npc in _activeCrowd.Values)
{
if (!npc.IsWalking || npc.Waypoints.Count < 2)
continue;
var dx = npc.X - px;
var dz = npc.Z - pz;
if (dx * dx + dz * dz > 90f * 90f)
continue;
var targetIdx = npc.CurrentWpIndex;
if (targetIdx >= npc.Waypoints.Count)
{
npc.WalkForwardDirection = false;
npc.CurrentWpIndex = Math.Max(0, npc.Waypoints.Count - 2);
targetIdx = npc.CurrentWpIndex;
}
else if (targetIdx < 0)
{
npc.WalkForwardDirection = true;
npc.CurrentWpIndex = Math.Min(1, npc.Waypoints.Count - 1);
targetIdx = npc.CurrentWpIndex;
}
var target = npc.Waypoints[targetIdx];
var toTargetX = target.X - npc.X;
var toTargetZ = target.Z - npc.Z;
var distToTarget = MathF.Sqrt(toTargetX * toTargetX + toTargetZ * toTargetZ);
if (distToTarget < 0.6f)
{
npc.CurrentWpIndex += npc.WalkForwardDirection ? 1 : -1;
continue;
}
var step = MathF.Min(distToTarget, npc.Speed * dt);
var dirX = toTargetX / distToTarget;
var dirZ = toTargetZ / distToTarget;
npc.X += dirX * step;
npc.Z += dirZ * step;
var yawDeg = MathF.Atan2(dirX, dirZ) * (180f / MathF.PI);
npc.Facing = yawDeg;
await _driver.SendPedestrianMoveAsync(
session, npc.EntityId, npc.X, npc.Y, npc.Z, npc.Facing, WorldCodec.MoveAction.WalkFront);
}
}
private async Task TickSessionCrowdAsync(TcpSession session)
{
if (_driver is null)
@@ -219,6 +321,45 @@ public sealed class UrbanCrowdEngine
if (!hasPlayer || !float.IsFinite(px) || !float.IsFinite(pz))
return;
// 0. Prune distant pedestrians (> 120m) to continuously recycle population
var toRemove = new List<ulong>();
foreach (var npc in _activeCrowd.Values)
{
var dx = npc.X - px;
var dz = npc.Z - pz;
if (dx * dx + dz * dz > 120f * 120f)
{
toRemove.Add(npc.EntityId);
}
}
foreach (var id in toRemove)
{
if (_activeCrowd.TryRemove(id, out _))
{
await _driver.DespawnCrowdPedestrianAsync(session, id);
}
}
// Also prune distant interior records (> 100m)
if (_interiorsData is not null && _populatedInteriors.Count > 0)
{
var distantShops = new List<int>();
foreach (var (shopId, _) in _populatedInteriors)
{
var shop = _interiorsData.Interiors.FirstOrDefault(s => s.Id == shopId);
if (shop is not null && shop.Pos.Count >= 3)
{
var sdx = shop.Pos[0] - px;
var sdz = shop.Pos[2] - pz;
if (sdx * sdx + sdz * sdz > 100f * 100f)
distantShops.Add(shopId);
}
}
foreach (var sId in distantShops)
_populatedInteriors.TryRemove(sId, out _);
}
// 1. Populate nearby shops and cafes (within 60m)
if (PopulateShops && _interiorsData is not null)
{
@@ -274,6 +415,8 @@ public sealed class UrbanCrowdEngine
X = sx,
Y = sy,
Z = sz,
Facing = facing,
IsWalking = false,
SpawnedAt = DateTime.UtcNow
};
Console.WriteLine($"[CROWD] Populated shop '{shop.Name}' with citizen NPC {formworkId}!");
@@ -294,7 +437,7 @@ public sealed class UrbanCrowdEngine
var centerCx = (int)MathF.Floor(px / pGridSize);
var centerCz = (int)MathF.Floor(pz / pGridSize);
var candidateWaypoints = new List<(float X, float Y, float Z)>();
var candidateLanes = new List<PedestrianLaneEntry>();
for (var cx = centerCx - 1; cx <= centerCx + 1; cx++)
{
@@ -308,15 +451,15 @@ public sealed class UrbanCrowdEngine
if (laneId >= 0 && laneId < _pedestrianData.Lanes.Count)
{
var lane = _pedestrianData.Lanes[laneId];
foreach (var pt in lane.Pts)
if (lane.Pts.Count >= 2)
{
if (pt.Count < 3) continue;
var ldx = pt[0] - px;
var ldz = pt[2] - pz;
var pt0 = lane.Pts[0];
var ldx = pt0[0] - px;
var ldz = pt0[2] - pz;
var dSq = ldx * ldx + ldz * ldz;
if (dSq >= 25f * 25f && dSq <= 80f * 80f)
if (dSq >= 20f * 20f && dSq <= 85f * 85f)
{
candidateWaypoints.Add((pt[0], pt[1], pt[2]));
candidateLanes.Add(lane);
}
}
}
@@ -325,29 +468,45 @@ public sealed class UrbanCrowdEngine
}
}
if (candidateWaypoints.Count > 0)
if (candidateLanes.Count > 0)
{
var wp = candidateWaypoints[_rng.Next(candidateWaypoints.Count)];
// Ground elevation clamping: ensure sidewalk pedestrian Y is on mesh floor
var spawnY = MathF.Abs(wp.Y - py) > 2.5f ? py : wp.Y;
var chosenLane = candidateLanes[_rng.Next(candidateLanes.Count)];
var pt0 = chosenLane.Pts[0];
var spawnY = MathF.Abs(pt0[1] - py) > 2.5f ? py : pt0[1];
var formworkId = CitizenFormworkPool[_rng.Next(CitizenFormworkPool.Length)];
var poiAction = AmbientPoiActions[_rng.Next(AmbientPoiActions.Length)];
var isWalker = _rng.Next(100) < 60; // 60% walkers, 40% ambient idle/talkers
var poiAction = isWalker ? 0u : AmbientPoiActions[_rng.Next(AmbientPoiActions.Length)];
var facing = _rng.Next(0, 360);
var (ok, ids) = await _driver.SpawnCrowdPedestriansAsync(
session, formworkId, poiAction, wp.X, spawnY, wp.Z, facing);
session, formworkId, poiAction, pt0[0], spawnY, pt0[2], facing);
if (ok && ids.Length > 0)
{
_activeCrowd[ids[0]] = new ActiveCrowdNpc
var npc = new ActiveCrowdNpc
{
EntityId = ids[0],
FormworkId = formworkId,
X = wp.X,
X = pt0[0],
Y = spawnY,
Z = wp.Z,
Z = pt0[2],
Facing = facing,
IsWalking = isWalker,
CurrentWpIndex = 1,
WalkForwardDirection = true,
SpawnedAt = DateTime.UtcNow
};
if (isWalker)
{
foreach (var pt in chosenLane.Pts)
{
if (pt.Count >= 3)
npc.Waypoints.Add((pt[0], MathF.Abs(pt[1] - py) > 2.5f ? py : pt[1], pt[2]));
}
}
_activeCrowd[ids[0]] = npc;
}
}
}
@@ -13,6 +13,7 @@ public sealed class CityTrafficEngine
private readonly ConcurrentDictionary<ulong, SimulatedVehicle> _simulatedVehicles = new();
private RoadNetworkFile? _roadNetwork;
private TrafficSignalFile? _signalData;
private readonly Dictionary<int, RoadLaneData> _laneLookup = new();
private CancellationTokenSource? _cts;
@@ -69,30 +70,46 @@ public sealed class CityTrafficEngine
_sessionProvider = sessionProvider;
var roadPath = Path.Combine(clientDataPath, "World", "RoadNetwork.json");
if (!File.Exists(roadPath))
if (File.Exists(roadPath))
{
Console.WriteLine($"[TRAFFIC] RoadNetwork.json not found at: {roadPath}");
return;
}
try
{
Console.WriteLine($"[TRAFFIC] Loading road network from: {roadPath}...");
var json = File.ReadAllText(roadPath);
_roadNetwork = JsonSerializer.Deserialize<RoadNetworkFile>(json);
if (_roadNetwork is not null)
try
{
foreach (var lane in _roadNetwork.Lanes)
Console.WriteLine($"[TRAFFIC] Loading road network from: {roadPath}...");
var json = File.ReadAllText(roadPath);
_roadNetwork = JsonSerializer.Deserialize<RoadNetworkFile>(json);
if (_roadNetwork is not null)
{
lane.Precompute();
_laneLookup[lane.Id] = lane;
foreach (var lane in _roadNetwork.Lanes)
{
lane.Precompute();
_laneLookup[lane.Id] = lane;
}
Console.WriteLine($"[TRAFFIC] Road network loaded: {_roadNetwork.Lanes.Count} lanes precomputed across {_roadNetwork.SpatialGrid.Count} spatial cells.");
}
Console.WriteLine($"[TRAFFIC] Road network loaded: {_roadNetwork.Lanes.Count} lanes precomputed across {_roadNetwork.SpatialGrid.Count} spatial cells.");
}
catch (Exception ex)
{
Console.WriteLine($"[TRAFFIC] Failed to load road network: {ex.Message}");
}
}
catch (Exception ex)
else
{
Console.WriteLine($"[TRAFFIC] Failed to load road network: {ex.Message}");
Console.WriteLine($"[TRAFFIC] RoadNetwork.json not found at: {roadPath}");
}
var signalPath = Path.Combine(clientDataPath, "World", "TrafficSignals.json");
if (File.Exists(signalPath))
{
try
{
var sJson = File.ReadAllText(signalPath);
_signalData = JsonSerializer.Deserialize<TrafficSignalFile>(sJson);
Console.WriteLine($"[TRAFFIC] Loaded {_signalData?.Lights.Count ?? 0} traffic signals from {signalPath}.");
}
catch (Exception ex)
{
Console.WriteLine($"[TRAFFIC] Error loading traffic signals: {ex.Message}");
}
}
Start();
@@ -194,7 +211,48 @@ public sealed class CityTrafficEngine
continue;
}
v.DistanceOnLane += v.Speed * SpeedScale * dt;
// 1. Car-Following: Check if there is another vehicle ahead on the same lane
var effectiveSpeed = v.Speed;
SimulatedVehicle? leadingVehicle = null;
var minGap = float.MaxValue;
foreach (var other in vehicleEntries)
{
if (other.EntityId == v.EntityId || other.HijackedByPlayer)
continue;
if (other.CurrentLaneId == v.CurrentLaneId && other.DistanceOnLane > v.DistanceOnLane)
{
var gap = other.DistanceOnLane - v.DistanceOnLane;
if (gap < minGap)
{
minGap = gap;
leadingVehicle = other;
}
}
}
if (leadingVehicle is not null)
{
if (minGap < 5f)
{
// Emergency stop to prevent rear-end collision
effectiveSpeed = 0f;
}
else if (minGap < 14f)
{
// Smooth deceleration matching leader
effectiveSpeed = MathF.Min(effectiveSpeed, leadingVehicle.Speed * ((minGap - 4f) / 10f));
}
}
// 2. Traffic Light check: if approaching a red signal, decelerate/stop
if (effectiveSpeed > 0f && IsTrafficLightRedForVehicle(v.X, v.Y, v.Z, v.Yaw))
{
effectiveSpeed = MathF.Max(0f, effectiveSpeed - 6f * dt);
}
v.DistanceOnLane += effectiveSpeed * SpeedScale * dt;
// Check if vehicle reached the end of the lane
if (v.DistanceOnLane >= currentLane.TotalLength)
@@ -228,7 +286,7 @@ public sealed class CityTrafficEngine
}
// Calculate new spline position & velocity
var (nx, ny, nz, nyaw, vx, vz) = currentLane.Evaluate(v.DistanceOnLane, v.Speed * SpeedScale);
var (nx, ny, nz, nyaw, vx, vz) = currentLane.Evaluate(v.DistanceOnLane, effectiveSpeed * SpeedScale);
v.X = nx;
v.Y = ny;
v.Z = nz;
@@ -364,6 +422,34 @@ public sealed class CityTrafficEngine
Console.WriteLine($"[TRAFFIC] Cleared {ids.Count} traffic vehicles.");
}
private bool IsTrafficLightRedForVehicle(float vx, float vy, float vz, float vyaw)
{
if (_signalData is null || _signalData.Lights.Count == 0)
return false;
var cycleSecond = (DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond) % 30; // 30s cycle
foreach (var light in _signalData.Lights)
{
if (light.Pos.Count < 3) continue;
var ldx = light.Pos[0] - vx;
var ldz = light.Pos[2] - vz;
var distSq = ldx * ldx + ldz * ldz;
// Approaching intersection light within 6m to 24m
if (distSq >= 6f * 6f && distSq <= 24f * 24f)
{
var isPhaseA = (light.Zbr % 2) == 0;
var isRed = isPhaseA ? (cycleSecond >= 14) : (cycleSecond < 15 || cycleSecond == 29);
if (isRed)
return true;
}
}
return false;
}
public TrafficStatus GetStatus() => new()
{
Enabled = Enabled,
@@ -4,6 +4,30 @@ using Ananta.Server.RpcTypes.Client4229938;
namespace Ananta.Server.Gameplay.Traffic;
public sealed class TrafficSignalFile
{
[JsonPropertyName("lights")]
public List<TrafficLightEntry> Lights { get; set; } = [];
}
public sealed class TrafficLightEntry
{
[JsonPropertyName("handle")]
public long Handle { get; set; }
[JsonPropertyName("inter")]
public int Inter { get; set; }
[JsonPropertyName("zbr")]
public int Zbr { get; set; }
[JsonPropertyName("pos")]
public List<float> Pos { get; set; } = [];
[JsonPropertyName("fwd")]
public List<float> Fwd { get; set; } = [];
}
public sealed class RoadNetworkFile
{
[JsonPropertyName("metadata")]
@@ -27,10 +27,12 @@ internal sealed partial class GameRouter
return;
}
state.SkillByInstanceId[req.SkillInstanceId] = req.SkillId;
var style = ActiveStyle(state);
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
if (weapon.IsReloadSkill(req.SkillId))
if (weapon.IsReloadSkill(req.SkillId) || CombatCatalogRepository.SkillCastTag(req.SkillId) == 19u)
await ReloadWeapon4229938(ctx, state, weapon);
if (!weapon.AllowsSkill(style, req.SkillId))
{
@@ -406,25 +408,68 @@ internal sealed partial class GameRouter
?? throw new InvalidDataException($"Missing runtime view for spirit {spiritId}, weapon {instanceId}."))
.ToArray();
Task OnSkillUseWeaponDurability(RpcContext ctx, int skillInstanceId, int triggerIndex)
async Task OnSkillUseWeaponDurability(RpcContext ctx, int skillInstanceId, int triggerIndex)
{
var state = GetWorldState(ctx);
if (!state.Ready)
return Task.CompletedTask;
return;
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
if (!weapon.IsShootWeapon || weapon.MagazineAmmo == 0)
return Task.CompletedTask;
return;
uint skillId = 0;
if (state.SkillByInstanceId.TryGetValue(skillInstanceId, out var mappedSkillId))
skillId = mappedSkillId;
else if (skillInstanceId > 0)
skillId = (uint)skillInstanceId;
else if (state.ActiveSkillId != 0)
skillId = state.ActiveSkillId;
// If this durability event belongs to a reload skill, it represents the reload trigger in the animation,
// not a bullet shot. Never decrement ammunition for a reload event!
if (weapon.IsReloadSkill(skillId) || CombatCatalogRepository.SkillCastTag(skillId) == 19u)
{
ctx.Session.Log.Info($"[AMMO] reload-trigger detected token={skillInstanceId} skill={skillId} weapon={weapon.TemplateId}/{weapon.InstanceId}");
await ReloadWeapon4229938(ctx, state, weapon);
return;
}
var current = EnsureMagazine4229938(state, weapon);
if (current > 0)
var infiniteAmmo = InfiniteAmmoEnabled4229938() && weapon.MagazineAmmo > 0;
if (infiniteAmmo)
{
// Infinite ammo: restate the magazine at full capacity so the client never reaches
// the dry state (the one that stops firing and waits for an incomplete reload).
current = weapon.MagazineAmmo;
state.WeaponMagazineAmmo[weapon.InstanceId] = current;
}
else if (current > 0)
{
state.WeaponMagazineAmmo[weapon.InstanceId] = --current;
}
// Separate-bullet firearms consume only the loaded magazine; their reserve is an ordinary
// backpack item. Other firearms use Durability as total remaining ammunition, so decrement
// total and magazine together. Infinite (-1) values stay infinite.
var durability = CurrentDurability4229938(state, weapon, current);
if (!weapon.SeparateBullets && durability > 0)
if (infiniteAmmo)
{
// Infinite ammo: freeze the total pool for non-separate guns as well.
if (!weapon.SeparateBullets)
{
if (durability < current)
{
durability = current;
state.WeaponDurabilityAmmo[weapon.InstanceId] = durability;
}
}
else
{
durability = current;
}
}
else if (!weapon.SeparateBullets && durability > 0)
{
durability--;
state.WeaponDurabilityAmmo[weapon.InstanceId] = durability;
@@ -439,7 +484,7 @@ internal sealed partial class GameRouter
? EnsureReserve4229938(state, bulletId)
: NonSeparateReserve4229938(durability, current);
ctx.Session.Log.Info($"[AMMO] shot weapon={weapon.TemplateId}/{weapon.InstanceId} token={skillInstanceId} trigger={triggerIndex} mag={current}/{weapon.MagazineAmmo} reserve={reserve} bullet={bulletId} total={durability}");
return ctx.NotifyAsync(MethodId.SyncSpiritWeaponDurabilityChangedAction,
await ctx.NotifyAsync(MethodId.SyncSpiritWeaponDurabilityChangedAction,
CombatCodec.WeaponDurabilityChanged(state.ActiveSpiritTemplateId, state.ActiveSpiritUnitId, weapon, durability, current, bulletId));
}
@@ -602,5 +647,26 @@ internal sealed partial class GameRouter
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
return ResolveWeaponStyle(state, weapon);
}
private static long _infiniteAmmoCheckTicks4229938;
private static bool _infiniteAmmoCached4229938 = true;
internal static bool InfiniteAmmoEnabled4229938()
{
var now = System.Environment.TickCount64;
if (now - _infiniteAmmoCheckTicks4229938 < 2000)
return _infiniteAmmoCached4229938;
_infiniteAmmoCheckTicks4229938 = now;
try
{
_infiniteAmmoCached4229938 = !System.IO.File.Exists(
System.IO.Path.Combine(System.AppContext.BaseDirectory, "infinite-ammo.disabled"));
}
catch
{
_infiniteAmmoCached4229938 = true;
}
return _infiniteAmmoCached4229938;
}
}
@@ -0,0 +1,427 @@
using Ananta.SDK.Logging;
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Protocol.Client4229938;
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Shared economy plumbing for build 4229938 mall purchases and gacha draws: currency/wallet mapping,
/// backpack grants, the pushes the client expects, and the snapshot that lets money and items survive
/// a re-login. Retail keeps all of this in its account database; here the session state is the single
/// authority and PlayerProgressStore mirrors it to one JSON file per player.
/// </summary>
internal sealed partial class GameRouter
{
private const uint CurrencyRewardGold4229938 = 36920001u;
private const uint CurrencyRewardMoney4229938 = 36920002u;
private const uint CurrencyRewardBindingGold4229938 = 36920003u;
private static long _gmItemSeq = 920000000000L;
internal readonly record struct ItemGrant4229938(uint TemplateId, uint Count, bool IsBind);
/// <summary>A pending economy mutation: what to take away, what to add, how the wallet moves.</summary>
private sealed class EconomyChanges4229938
{
internal Dictionary<uint, uint> Consumed { get; } = [];
internal Dictionary<uint, ItemGrant4229938> Granted { get; } = [];
internal double MoneyDelta { get; set; }
internal double GoldDelta { get; set; }
internal double BindingGoldDelta { get; set; }
}
/// <summary>Loads the saved character economy into the session state at login.</summary>
internal static void RestorePlayerProgress4229938(WorldEntryState state, ServerLogger log)
{
lock (state.SyncRoot)
{
if (state.ProgressRestored)
return;
}
PlayerProgressStore.LoadInto(state.Progress, log);
lock (state.SyncRoot)
{
state.ProgressRestored = true;
state.WalletMoney = state.Progress.Money;
state.WalletGold = state.Progress.Gold;
state.WalletBindingGold = state.Progress.BindingGold;
state.GmBackpackItems.Clear();
foreach (var item in state.Progress.Items)
{
if (EconomyConfigRepository.HasConsumableItems &&
!EconomyConfigRepository.TryConsumableItem(item.TemplateId, out _))
{
log.Warn(
$"[GM] dropping saved backpack item templateId={item.TemplateId} count={item.Count}: " +
"not in ConsumableConfig for this build");
continue;
}
var uniqueId = item.UniqueId;
if (uniqueId == 0)
uniqueId = NextGmItemUniqueId4229938();
else
ReserveGmItemUniqueId4229938(uniqueId);
state.GmBackpackItems[item.TemplateId] = RuntimePayloadFactory.GmPackItem4229938(
item.TemplateId, item.Count, item.IsBind, uniqueId);
}
}
}
private static ulong NextGmItemUniqueId4229938()
=> (ulong)Interlocked.Increment(ref _gmItemSeq);
private static void ReserveGmItemUniqueId4229938(ulong uniqueId)
{
if (uniqueId > long.MaxValue)
return;
while (true)
{
var current = Interlocked.Read(ref _gmItemSeq);
if (uniqueId <= (ulong)current)
return;
if (Interlocked.CompareExchange(ref _gmItemSeq, (long)uniqueId, current) == current)
return;
}
}
/// <summary>
/// Charges a price in currency units. Wallet currencies debit the wallet,
/// everything else debits the granted stack.
/// </summary>
private static void ChargeCurrency4229938(
WorldEntryState state,
EconomyChanges4229938 changes,
uint currencyItemId,
double amount,
ServerLogger log,
string context)
{
if (currencyItemId == 0 || amount <= 0)
return;
switch (currencyItemId)
{
case CurrencyRewardMoney4229938:
changes.MoneyDelta -= amount;
return;
case CurrencyRewardGold4229938:
changes.GoldDelta -= amount;
return;
case CurrencyRewardBindingGold4229938:
changes.BindingGoldDelta -= amount;
return;
}
uint available;
lock (state.SyncRoot)
available = state.GmBackpackItems.TryGetValue(currencyItemId, out var stack) ? stack.Count : 0u;
var taken = Math.Min(amount, available);
if (taken < amount)
log.Warn($"[ECONOMY] {context}: missing {currencyItemId} to pay {amount} with (have {available}), granting remainder");
if (taken <= 0)
return;
var count = (uint)Math.Min(taken, uint.MaxValue);
changes.Consumed[currencyItemId] = changes.Consumed.TryGetValue(currencyItemId, out var previous)
? SaturatingAdd4229938(previous, count)
: count;
}
/// <summary>Rolls one drop row into changes.</summary>
private static void GrantDrop4229938(
EconomyChanges4229938 changes,
EconomyConfigRepository.DropRow drop,
uint rolls,
string context,
ServerLogger log)
{
if (rolls == 0)
return;
if (drop.Money != 0)
changes.MoneyDelta += drop.Money * rolls;
if (drop.BindingGold != 0)
changes.BindingGoldDelta += drop.BindingGold * rolls;
foreach (var (id, count) in drop.Guaranteed)
AddGrant4229938(changes, id, SaturatingMultiply4229938(count, rolls), isBind: false);
for (var roll = 0u; roll < rolls; roll++)
{
if (drop.Ranged.Count > 0)
{
var (id, min, max) = drop.Ranged[Random.Shared.Next(drop.Ranged.Count)];
AddGrant4229938(changes, id, RollCount4229938(min, max), isBind: false);
}
if (drop.Weighted.Count > 0)
{
var index = PickWeightedIndex4229938(drop.Weighted.Count, i => drop.Weighted[i].Weight);
if (index >= 0)
AddGrant4229938(changes, drop.Weighted[index].Id, 1, isBind: false);
}
}
log.Info(
$"[ECONOMY] {context}: drop={drop.Id} rolls={rolls} money={drop.Money * rolls} " +
$"bindingGold={drop.BindingGold * rolls} guaranteed={drop.Guaranteed.Count} " +
$"ranged={drop.Ranged.Count} weighted={drop.Weighted.Count}");
}
private static void AddGrant4229938(EconomyChanges4229938 changes, uint templateId, uint count, bool isBind)
{
if (templateId == 0 || count == 0)
return;
if (changes.Granted.TryGetValue(templateId, out var existing))
{
changes.Granted[templateId] = existing with
{
Count = SaturatingAdd4229938(existing.Count, count),
IsBind = existing.IsBind || isBind,
};
return;
}
changes.Granted[templateId] = new ItemGrant4229938(templateId, count, isBind);
}
private static Task<string> ApplyEconomy4229938Async(
Connection conn,
WorldEntryState state,
EconomyChanges4229938 changes,
string context)
=> ApplyEconomy4229938Async(conn.Session, conn.Log, state, changes, context);
private static async Task<string> ApplyEconomy4229938Async(
TcpSession session,
ServerLogger log,
WorldEntryState state,
EconomyChanges4229938 changes,
string context)
{
var adds = new List<Auto.PlayerPackItem>();
var updates = new List<Auto.PlayerPackItem>();
var deletes = new List<Auto.PlayerPackItem>();
double money;
double gold;
double bindingGold;
lock (state.SyncRoot)
{
var templates = new HashSet<uint>(changes.Consumed.Keys);
foreach (var templateId in changes.Granted.Keys)
templates.Add(templateId);
foreach (var templateId in templates)
{
changes.Consumed.TryGetValue(templateId, out var consumed);
changes.Granted.TryGetValue(templateId, out var grant);
if (state.GmBackpackItems.TryGetValue(templateId, out var stack))
{
var remaining = stack.Count > consumed ? stack.Count - consumed : 0u;
var final = SaturatingAdd4229938(remaining, grant.Count);
if (final == 0)
{
state.GmBackpackItems.Remove(templateId);
deletes.Add(stack);
continue;
}
stack.Count = final;
stack.IsBind = stack.IsBind || grant.IsBind;
updates.Add(stack);
continue;
}
if (grant.Count == 0)
continue;
var item = RuntimePayloadFactory.GmPackItem4229938(
grant.TemplateId, grant.Count, grant.IsBind, NextGmItemUniqueId4229938());
state.GmBackpackItems[templateId] = item;
adds.Add(item);
}
money = state.WalletMoney + changes.MoneyDelta;
gold = state.WalletGold + changes.GoldDelta;
bindingGold = state.WalletBindingGold + changes.BindingGoldDelta;
if (money < 0 || gold < 0 || bindingGold < 0)
{
log.Warn(
$"[ECONOMY] {context}: wallet went negative " +
$"(money={money} gold={gold} bindingGold={bindingGold}), clamped to zero");
money = Math.Max(money, 0);
gold = Math.Max(gold, 0);
bindingGold = Math.Max(bindingGold, 0);
}
state.WalletMoney = money;
state.WalletGold = gold;
state.WalletBindingGold = bindingGold;
}
if (changes.MoneyDelta != 0 || changes.GoldDelta != 0 || changes.BindingGoldDelta != 0)
{
await NotifySession4229938Async(session, MethodId.SyncMoney, new GameMethods.SyncMoney4229938
{
money = money,
gold = gold,
bindingGold = bindingGold,
});
if (changes.MoneyDelta > 0)
{
await NotifySession4229938Async(session, MethodId.SyncMoneyAdd, new GameMethods.SyncMoneyAdd4229938
{
value = changes.MoneyDelta,
reason = 0,
silence = false,
});
}
}
if (adds.Count > 0 || updates.Count > 0 || deletes.Count > 0)
{
await NotifySession4229938Async(session, MethodId.SyncBackpackItemChanged, new GameMethods.SyncBackpackItemChanged4229938
{
addItemList = adds,
updateItemList = updates,
deleteItemList = deletes,
});
}
PlayerProgressStore.Save(state, log);
return $"wallet=({money},{gold},{bindingGold}) add={adds.Count} update={updates.Count} delete={deletes.Count}";
}
private static async Task NotifySession4229938Async<T>(TcpSession session, uint methodId, T body)
{
var bytes = UxSerializer.Serialize(body);
RuntimeLogs.Decoded(session.Log.Scope, "S2C", methodId, typeof(T), body);
await session.NotifyAsync(methodId, bytes, CancellationToken.None);
}
/// <summary>
/// Debug panel: hands out a backpack stack through the mall pipeline, syncing SyncBackpackItemChanged.
/// </summary>
internal static async Task<(bool Ok, string Message)> PanelGiveItemAsync(
TcpSession session,
uint templateId,
uint count,
bool isBind)
{
var state = GetStateIfExists(session);
if (state is null)
return (false, "no live game session (is the client in the world?)");
if (templateId == 0)
return (false, "missing templateId");
if (!TryResolveGmItem4229938(templateId, out var name))
return (false, $"Item Id {templateId} not found in ConsumableConfig{DescribeItemNamespace4229938(templateId)}");
if (count == 0)
count = 1;
var changes = new EconomyChanges4229938();
changes.Granted[templateId] = new ItemGrant4229938(templateId, count, isBind);
var summary = await ApplyEconomy4229938Async(
session, session.Log, state, changes, $"panel give-item {templateId}x{count}");
uint total;
lock (state.SyncRoot)
total = state.GmBackpackItems.TryGetValue(templateId, out var stack) ? stack.Count : 0u;
session.Log.Info($"[PANEL] give-item id={templateId} name={name} count={count} bind={isBind} total={total} {summary}");
return (true, $"Granted {name} (Id={templateId}) x{count}, backpack now has {total}; {summary}");
}
internal static uint SaturatingAdd4229938(uint left, uint right)
=> left > uint.MaxValue - right ? uint.MaxValue : left + right;
private static uint SaturatingMultiply4229938(uint value, uint factor)
=> factor == 0 ? 0u : (value > uint.MaxValue / factor ? uint.MaxValue : value * factor);
private static uint RollCount4229938(uint min, uint max)
{
if (max < min)
(min, max) = (max, min);
return max == min ? min : (uint)Random.Shared.NextInt64(min, (long)max + 1);
}
private static int PickWeightedIndex4229938(int count, Func<int, double> weightAt)
{
var total = 0d;
for (var i = 0; i < count; i++)
{
var weight = weightAt(i);
if (weight > 0)
total += weight;
}
if (total <= 0)
return -1;
var roll = Random.Shared.NextDouble() * total;
for (var i = 0; i < count; i++)
{
var weight = weightAt(i);
if (weight <= 0)
continue;
roll -= weight;
if (roll <= 0)
return i;
}
return count - 1;
}
private readonly record struct EconomyRequest4229938(uint Id, uint Count, bool Flag);
private static EconomyRequest4229938 ParseEconomyRequest4229938(byte[] body)
=> new(
body.Length >= 4 ? BitConverter.ToUInt32(body, 0) : 0u,
body.Length >= 8 ? BitConverter.ToUInt32(body, 4) : 0u,
body.Length > 8 && body[8] != 0);
private static bool TryResolveGmItem4229938(uint templateId, out string name)
{
name = string.Empty;
if (!EconomyConfigRepository.HasConsumableItems)
return true;
if (!EconomyConfigRepository.TryConsumableItem(templateId, out var item))
return false;
name = item.Name;
return true;
}
private static string DescribeItemNamespace4229938(uint templateId) => templateId switch
{
>= 36000000u and < 37000000u => " (36xxxxxx Item ID exists in range but not in rows)",
>= 98000000u and < 99000000u => " (Weapon ID, please equip via Armory Wheel)",
>= 81000000u and < 82000000u => " (Vehicle ID)",
>= 11190000u and < 11200000u => " (Fashion Suit ID)",
>= 11100000u and < 11190000u => " (Fashion Piece ID)",
_ => " (Unknown Item ID range)",
};
}
@@ -1,97 +1,220 @@
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938.Auto;
using Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Gacha draws for build 4229938 (GachaManager.DoDrawGacha -> AskDrawGacha(poolId, drawCount, isAutoExchange)).
///
/// Odds follow GachaPoolTierRuleConfig: RuleType 1 pools ramp the grand-prize chance from
/// BaseProbability by ProbabilityIncrement per draw past RampStartDraw and hand out a guaranteed
/// grand prize at PityThreshold; these pools also pick the prize row itself by relative weight within
/// the "grand" or "ordinary" group. RuleType 0 pools have no pity ramp and simply pick by weight.
/// Duplicate grand prizes are converted to the row's duplicateReturn drop, which is also how the
/// client labels the result (IsConverted).
/// </summary>
internal sealed partial class GameRouter
{
private const int GachaProbabilityScale4229938 = 100000;
[Handler(MethodId.AskDrawGacha, HandlerPacketKind.Invoke)]
private async Task AskDrawGacha(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<AskDrawGachaArgs>();
uint poolId = args.gachaPoolId == 0 ? 1001u : args.gachaPoolId;
uint count = args.drawCount == 0 ? 1u : args.drawCount;
var state = GetWorldState(msg.Context);
var request = ParseEconomyRequest4229938(msg.Body);
var drawCount = Math.Clamp(request.Count == 0 ? 1u : request.Count, 1u, 10u);
conn.Session.Log.Info($"[GACHA] AskDrawGacha: poolId={poolId} count={count} autoEx={args.isAutoExchange}");
var fiveStarSpirits = new uint[] { 15020992, 15020991, 15020997, 15021024, 15021025 };
var fourStarSpirits = new uint[] { 15020989, 15020990, 15021016, 15021017, 15021020, 15021021, 15021022, 15021023 };
var topWeapons = new uint[] { 98003001, 98003023, 98003131, 98003184, 98003185 };
var sync = new SyncGachaDrawInfo
if (!EconomyConfigRepository.TryPool(request.Id, out var pool))
{
isGrandPrizeWithAllFillers = false,
drawDetails = new List<GachaDrawDetail>()
};
conn.Log.Warn($"[GACHA] unknown pool {request.Id} (body={Convert.ToHexString(msg.Body)})");
await ReplyDrawGacha4229938Async(conn, msg);
return;
}
var records = new List<GachaDrawRecord>();
var rng = Random.Shared;
for (uint i = 0; i < count; i++)
var contents = EconomyConfigRepository.PoolContents(pool.Id);
if (contents.Count == 0)
{
bool isGuaranteedFiveStar = (count >= 10 && i == count - 1) || (rng.Next(100) < 15);
bool isFourStar = !isGuaranteedFiveStar && (rng.Next(100) < 45);
conn.Log.Warn($"[GACHA] pool {pool.Id} has no contents, nothing can be drawn");
await ReplyDrawGacha4229938Async(conn, msg);
return;
}
uint prizeId;
if (isGuaranteedFiveStar)
EconomyConfigRepository.GachaTierRule? tierRule = null;
if (pool.GrandTierRuleId != 0 && EconomyConfigRepository.TryTierRule(pool.GrandTierRuleId, out var foundRule))
tierRule = foundRule;
var changes = new EconomyChanges4229938();
ChargeCurrency4229938(
state, changes, pool.MoneyId, pool.CostFor(drawCount), conn.Log, $"gacha {pool.Id}");
var details = new List<GameMethods.GachaDrawDetail4229938>((int)drawCount);
var grandCount = 0;
var skipped = 0;
for (var draw = 0u; draw < drawCount; draw++)
{
uint drawsSinceGrand;
lock (state.SyncRoot)
{
prizeId = fiveStarSpirits[rng.Next(fiveStarSpirits.Length)];
state.Progress.GachaDrawsSinceGrand.TryGetValue(pool.Id, out var previous);
drawsSinceGrand = previous + 1;
}
else if (isFourStar)
var isGrand = RollGrandPrize4229938(tierRule, drawsSinceGrand);
var content = PickGachaContent4229938(contents, isGrand)
?? PickGachaContent4229938(contents, grandOnly: null);
if (content is null)
{
prizeId = fourStarSpirits[rng.Next(fourStarSpirits.Length)];
skipped++;
lock (state.SyncRoot)
state.Progress.GachaDrawsSinceGrand[pool.Id] = drawsSinceGrand;
continue;
}
isGrand = content.IsGrandPrize;
lock (state.SyncRoot)
state.Progress.GachaDrawsSinceGrand[pool.Id] = isGrand ? 0u : drawsSinceGrand;
if (isGrand)
grandCount++;
var converted = false;
var isNew = false;
var dropId = content.DropId;
var rolls = 1u;
if (TryPrizeTemplateId4229938(content.DropId, out var prizeTemplateId))
{
var owned = IsItemOwned4229938(state, prizeTemplateId);
isNew = !owned;
if (owned && content.DuplicateReturnDropId != 0 && content.DuplicateReturnCount != 0)
{
converted = true;
dropId = content.DuplicateReturnDropId;
rolls = content.DuplicateReturnCount;
}
}
if (dropId != 0 && EconomyConfigRepository.TryDrop(dropId, out var drop) && !drop.IsEmpty)
GrantDrop4229938(changes, drop, rolls, $"gacha {pool.Id} content {content.Id}", conn.Log);
else
{
prizeId = topWeapons[rng.Next(topWeapons.Length)];
}
conn.Log.Warn($"[GACHA] content {content.Id} grants nothing (drop={dropId})");
sync.drawDetails.Add(new GachaDrawDetail
details.Add(new GameMethods.GachaDrawDetail4229938
{
PoolContentId = prizeId,
IsGrandPrize = isGuaranteedFiveStar,
IsConverted = false,
IsNew = true
});
records.Add(new GachaDrawRecord
{
GachaId = poolId,
PoolContentId = prizeId,
DropId = prizeId,
DrawTimeUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
PoolContentId = content.Id,
IsGrandPrize = isGrand,
IsConverted = converted,
IsNew = isNew,
});
}
await conn.NotifyAsync(MethodId.SyncGachaDrawInfo, sync);
var summary = await ApplyEconomy4229938Async(conn, state, changes, $"gacha {pool.Id}");
var gachaInfos = new PlayerGachaInfos();
var poolInfo = new PlayerGachaPoolInfo
if (details.Count > 0)
{
DrawCount = count,
DrawRecords = records
};
gachaInfos.PoolInfos[poolId] = poolInfo;
gachaInfos.GroupInfos[poolId] = new PlayerGachaGroupInfo
{
TotalDrawCount = count
};
gachaInfos.PityInfos[poolId] = new PlayerGachaPityInfo
{
TotalDrawCount = count,
DrawsSinceLastReset = count % 80
};
await conn.NotifyAsync(MethodId.IGameToClient_SyncGachaDrawInfo, new GameMethods.SyncGachaDrawInfo4229938
{
drawDetails = details,
isGrandPrizeWithAllFillers = false,
});
}
await conn.ReturnAsync(msg, gachaInfos);
conn.Log.Info(
$"[GACHA] pool={pool.Id} system={pool.MoneyId} count={drawCount} cost={pool.CostFor(drawCount)} " +
$"grand={grandCount} skipped={skipped} rule={tierRule?.Id ?? 0} ruleType={tierRule?.RuleType ?? 0} " +
$"-> {summary}");
await ReplyDrawGacha4229938Async(conn, msg);
}
private static bool RollGrandPrize4229938(EconomyConfigRepository.GachaTierRule? rule, uint drawsSinceGrand)
{
if (rule is null)
return false;
if (rule.PityThreshold > 0 && drawsSinceGrand >= rule.PityThreshold)
return true;
if (rule.RuleType != 1)
return false;
var probability = (double)rule.BaseProbability;
if (rule.RampStartDraw > 0 && drawsSinceGrand > rule.RampStartDraw)
probability += (double)(drawsSinceGrand - rule.RampStartDraw) * rule.ProbabilityIncrement;
if (probability <= 0)
return false;
if (probability >= GachaProbabilityScale4229938)
return true;
return Random.Shared.Next(GachaProbabilityScale4229938) < probability;
}
private static EconomyConfigRepository.GachaContent? PickGachaContent4229938(
IReadOnlyList<EconomyConfigRepository.GachaContent> contents,
bool? grandOnly)
{
var candidates = new List<EconomyConfigRepository.GachaContent>();
foreach (var content in contents)
{
if (content.Weight <= 0)
continue;
if (grandOnly is { } wanted && content.IsGrandPrize != wanted)
continue;
candidates.Add(content);
}
if (candidates.Count == 0)
return null;
var index = PickWeightedIndex4229938(candidates.Count, i => candidates[i].Weight);
return index < 0 ? null : candidates[index];
}
private static bool TryPrizeTemplateId4229938(uint dropId, out uint templateId)
{
templateId = 0;
if (dropId == 0 || !EconomyConfigRepository.TryDrop(dropId, out var drop))
return false;
if (drop.Guaranteed.Count > 0)
templateId = drop.Guaranteed[0].Id;
else if (drop.Ranged.Count > 0)
templateId = drop.Ranged[0].Id;
else if (drop.Weighted.Count > 0)
templateId = drop.Weighted[0].Id;
return templateId != 0;
}
private static bool IsItemOwned4229938(WorldEntryState state, uint templateId)
{
lock (state.SyncRoot)
return state.GmBackpackItems.TryGetValue(templateId, out var stack) && stack.Count > 0;
}
private static Task ReplyDrawGacha4229938Async(Connection conn, UxRpcMessage msg)
{
if (DefaultReturnCatalog4229938.TryGet(msg.MethodId, out var body, out var shape))
{
conn.Log.Info($"[GACHA] reply typed-default {shape} {body.Length}b");
return msg.Context.ReturnAsync(body);
}
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskClaimGachaMilestone, HandlerPacketKind.Invoke)]
private Task AskClaimGachaMilestone(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<AskClaimGachaMilestoneArgs>();
var args = msg.GetArgs<GameMethods.AskClaimGachaMilestoneArgs>();
conn.Session.Log.Info($"[GACHA] AskClaimGachaMilestone: groupId={args.groupId} count={args.milestoneCount}");
return conn.ReturnEmptyOkAsync(msg);
}
@@ -0,0 +1,150 @@
using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Debug-panel armory operations: install any armory weapon into any spirit editable wheel
/// slot (1..15) and republish the proven weapon-detail / fight-style / switch shapes.
/// </summary>
internal sealed partial class GameRouter
{
internal static async Task<(bool Ok, string Message)> PanelEquipWeaponAsync(
TcpSession session, uint? spiritIdOption, int slotIndex, uint? templateIdOption, ulong? instanceIdOption)
{
var state = GetStateIfExists(session);
if (state is null)
return (false, "no live game session (is the client in the world?)");
var spiritId = spiritIdOption is uint requested && requested != 0
? requested
: state.ActiveSpiritTemplateId;
if (!ClientConfigRepository.Characters().Any(character => character.TemplateId == spiritId))
return (false, $"spirit {spiritId} is not in the roster");
if (!IsEditableWeaponSlot(slotIndex))
return (false, "slotIndex must be 1..15 (slot 0 is the authored fists slot)");
ulong instanceId;
if (instanceIdOption is ulong requestedInstance && requestedInstance != 0)
{
instanceId = requestedInstance;
}
else if (templateIdOption is uint templateId && templateId != 0)
{
var match = CombatCatalogRepository.AccountWeapons.FirstOrDefault(weapon => weapon.TemplateId == templateId);
if (match is null)
return (false, $"template {templateId} is not an armory weapon (see GET /api/catalog/weapons)");
instanceId = match.InstanceId;
}
else
{
return (false, "provide templateId or instanceId");
}
var target = CombatCatalogRepository.Weapon(spiritId, instanceId);
if (target is null)
return (false, $"weapon instance {instanceId} has no runtime view for spirit {spiritId}");
if (state.ArmoryWeaponsAnnounced.Add(instanceId))
{
await session.NotifyAsync(MethodId.SyncArmoryAddWeapon,
UxSerializer.Serialize(RuntimePayloadFactory.WeaponData(target)), CancellationToken.None);
session.Log.Info($"[ARMORY] announce-to-client weapon={target.TemplateId}/{instanceId}");
}
var slots = WeaponSlotIds(state, spiritId);
var existingIndex = slots.IndexOf(instanceId);
if (existingIndex >= 0 && existingIndex != slotIndex)
(slots[existingIndex], slots[slotIndex]) = (slots[slotIndex], slots[existingIndex]);
else
slots[slotIndex] = instanceId;
await PublishPanelWeaponSlotsAsync(session, state, spiritId, $"panel-equip:{instanceId}@{slotIndex}");
session.Log.Info($"[ARMORY] panel equip spirit={spiritId} slot={slotIndex} weapon={target.TemplateId}/{instanceId} name={target.Name}");
return (true, $"spirit {spiritId} slot {slotIndex} = {target.Name} ({target.TemplateId})");
}
private static async Task PublishPanelWeaponSlotsAsync(TcpSession session, WorldEntryState state, uint spiritId, string operation)
{
var slots = WeaponSlotIds(state, spiritId);
var isActive = spiritId == state.ActiveSpiritTemplateId;
var currentWeaponId = isActive
? state.ActiveWeaponInstanceId
: state.LastWeaponBySpirit.GetValueOrDefault(spiritId);
if (currentWeaponId == 0 || !slots.Contains(currentWeaponId))
currentWeaponId = slots.FirstOrDefault(id => id != 0);
if (currentWeaponId == 0)
currentWeaponId = CombatCodec.Loadout(spiritId).DefaultWeapon.InstanceId;
var unitId = isActive
? state.ActiveSpiritUnitId
: ClientConfigRepository.Characters().First(x => x.TemplateId == spiritId).UnitId;
var currentWeapon = CombatCatalogRepository.Weapon(spiritId, currentWeaponId)
?? throw new InvalidDataException($"Missing runtime view for spirit {spiritId}, weapon {currentWeaponId}.");
var currentChanged = isActive && state.ActiveWeaponInstanceId != currentWeaponId;
var style = ResolveWeaponStyle(state, currentWeapon);
state.LastWeaponBySpirit[spiritId] = currentWeaponId;
if (isActive)
{
state.ActiveWeaponInstanceId = currentWeaponId;
state.ActiveFightStyleId = style.Id;
state.ActiveSkillId = 0;
state.RestoreResourcesAfterActiveSkill = false;
state.ActiveClientSkillInstanceId = 0;
state.ActiveSkillStartedTicks = 0;
}
var snapshot = CombatCodec.SpiritWeaponSnapshot(unitId, spiritId, currentWeaponId, WeaponSlotDefinitions(state, spiritId));
foreach (var detail in snapshot.WeaponSlots)
{
if (detail is null)
continue;
if (CombatCatalogRepository.Weapon(spiritId, detail.InstanceId) is not { } definition)
continue;
detail.FightStyleId = ResolveWeaponStyle(state, definition).Id;
detail.MagazineAmmo = EnsureMagazine4229938(state, definition);
detail.Durability = CurrentDurability4229938(state, definition, detail.MagazineAmmo);
detail.BulletDatas.BulletId = EnsureBullet4229938(state, definition);
}
await session.NotifyAsync(MethodId.SyncSpiritWeaponDetail, UxSerializer.Serialize(snapshot), CancellationToken.None);
foreach (var detail in snapshot.WeaponSlots.Where(x => x is not null).Select(x => x!))
{
await session.NotifyAsync(MethodId.SyncWeaponFightStyleChange, UxSerializer.Serialize(
new GameMethods.SyncWeaponFightStyleChange
{
weaponInstanceId = detail.InstanceId,
fightStyleId = detail.FightStyleId,
}), CancellationToken.None);
}
await session.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon, UxSerializer.Serialize(
CombatCodec.SpiritLastUsedWeapon(spiritId, currentWeaponId)), CancellationToken.None);
if (!currentChanged)
return;
await session.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction, UxSerializer.Serialize(
CombatCodec.SpiritSwitchWeapon(unitId, currentWeaponId)), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncPlayerAllSkillChargeData, UxSerializer.Serialize(
CombatCodec.AllSkillCharges(unitId, currentWeapon, style)), CancellationToken.None);
foreach (var (resourceId, maximum) in CombatCodec.AllResourceMaximums)
{
await session.NotifyAsync(MethodId.SyncFightResource, UxSerializer.Serialize(
CombatCodec.FightResource(unitId, resourceId, maximum)), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncFightResourceFreeState, UxSerializer.Serialize(
CombatCodec.FightResourceFreeState(unitId, resourceId, true)), CancellationToken.None);
}
await session.NotifyAsync(MethodId.SyncChangeCommonSkill, UxSerializer.Serialize(CombatCodec.CommonBinding(unitId, style)), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncChangeHeavyAttack, UxSerializer.Serialize(CombatCodec.HeavyAttackBinding(unitId, style)), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncChangeDodgeSkill, UxSerializer.Serialize(CombatCodec.DodgeBinding(unitId, style)), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncChangeControlSkill, UxSerializer.Serialize(CombatCodec.ControlBinding(unitId, style)), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncChangeActiveSkill, UxSerializer.Serialize(CombatCodec.ActiveBinding(unitId, currentWeapon, style)), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncChangeUniqueSkill, UxSerializer.Serialize(CombatCodec.UniqueBinding(unitId, currentWeapon, style)), CancellationToken.None);
session.Log.Info($"[ARMORY] panel {operation} spirit={spiritId} active={isActive} current={currentWeaponId} style={style.Id}");
}
}
@@ -73,4 +73,19 @@ internal sealed class TrafficAndCrowdDriver : ITrafficVehicleDriver, IUrbanCrowd
var result = await GameRouter.SpawnStaticNpcAtAsync(session, npcFormworkId, poiActionId, x, y, z, facing);
return (result.Ok, result.Ids);
}
public async Task DespawnCrowdPedestrianAsync(TcpSession session, ulong entityId)
{
await session.NotifyAsync(MethodId.SyncLogicAgentLeave,
UxSerializer.Serialize(WorldCodec.LogicAgentLeave(entityId)), CancellationToken.None);
session.Log.Info($"[CROWD] despawn pedestrian entity={entityId}");
}
public async Task SendPedestrianMoveAsync(
TcpSession session, ulong entityId, float x, float y, float z, float facing, byte moveId)
{
var packet = WorldCodec.PositionAndFacing(
entityId, new Vec3(x, y, z), facing, WorldCodec.SetPositionType.Gm, continueMove: true, moveId: moveId);
await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing, UxSerializer.Serialize(packet), CancellationToken.None);
}
}
@@ -16,3 +16,47 @@ internal sealed class SyncBackpackItemChanged4229938
[UxCollection(Count = UxCountEncoding.Int7, ItemObjectEncoding = UxObjectEncoding.Complex)]
public List<Auto.PlayerPackItem> deleteItemList = [];
}
/// <summary>Authoritative wallet totals. The client stores these verbatim and refreshes the HUD.</summary>
[UxContract(Inline = true)]
internal sealed class SyncMoney4229938
{
public double money;
public double gold;
public double bindingGold;
}
/// <summary>Visual-only money grant used for the drop/reward popup (no client state change).</summary>
[UxContract(Inline = true)]
internal sealed class SyncMoneyAdd4229938
{
public double value;
public int reason;
public bool silence;
}
/// <summary>
/// One drawn entry of <c>SyncGachaDrawInfo</c>. The client resolves every id through
/// <c>GachaPoolContentConfig</c> for the artwork/name, so only the row id and the three flags travel.
/// </summary>
[UxContract(Inline = true)]
internal sealed class GachaDrawDetail4229938
{
public uint PoolContentId;
public bool IsGrandPrize;
public bool IsConverted;
public bool IsNew;
}
/// <summary>
/// Result push for <c>AskDrawGacha</c>: the reply itself carries no rewards, the client only plays the
/// animation from this notify (GameToClientImpl.SyncGachaDrawInfo -> GachaManager.ShowGachaResult).
/// </summary>
[UxContract(Inline = true)]
internal sealed class SyncGachaDrawInfo4229938
{
[UxCollection(Count = UxCountEncoding.Int7, ItemObjectEncoding = UxObjectEncoding.Struct)]
public List<GachaDrawDetail4229938> drawDetails = [];
public bool isGrandPrizeWithAllFillers;
}
@@ -0,0 +1 @@
{"_class":"ConsumableConfig","_namespace":"LT.ConfigGen","records":[{"Id":36000000,"Name":"Street Tabloid","SubType":18,"Quality":0},{"Id":36000001,"Name":"City Periodical","SubType":18,"Quality":2},{"Id":36000002,"Name":"Photography Album","SubType":18,"Quality":3},{"Id":36000003,"Name":"Hardcover Biography","SubType":18,"Quality":4},{"Id":36001000,"Name":"巡卫官天赋点","SubType":109,"Quality":4},{"Id":36001001,"Name":"货车司机天赋点","SubType":109,"Quality":4},{"Id":36001002,"Name":"黑客天赋点","SubType":109,"Quality":4},{"Id":36001003,"Name":"街头领袖天赋点","SubType":109,"Quality":4},{"Id":36001004,"Name":"赛车手天赋点","SubType":109,"Quality":4},{"Id":36001005,"Name":"武师天赋点","SubType":109,"Quality":4},{"Id":36001006,"Name":"厨师天赋点","SubType":109,"Quality":4},{"Id":36001007,"Name":"种田天赋点","SubType":109,"Quality":4},{"Id":36001008,"Name":"发明家天赋点","SubType":109,"Quality":4},{"Id":36001009,"Name":"清洁工天赋点","SubType":109,"Quality":4},{"Id":36001010,"Name":"占卜师天赋点","SubType":109,"Quality":4},{"Id":36001011,"Name":"调饮师天赋点","SubType":109,"Quality":4},{"Id":36001012,"Name":"街头艺术家天赋点","SubType":109,"Quality":4},{"Id":36001013,"Name":"医生天赋点","SubType":109,"Quality":4},{"Id":36001014,"Name":"混厄召唤师天赋点","SubType":109,"Quality":4},{"Id":36001015,"Name":"通用角色战斗天赋点","SubType":109,"Quality":4},{"Id":36001016,"Name":"倒观天天赋点","SubType":109,"Quality":4},{"Id":36001017,"Name":"通用全角色战斗天赋点","SubType":109,"Quality":5},{"Id":36002000,"Name":"纪念奖杯","SubType":110,"Quality":2},{"Id":36002001,"Name":"荣誉奖杯","SubType":110,"Quality":3},{"Id":36002002,"Name":"巅峰奖杯","SubType":110,"Quality":4},{"Id":36002003,"Name":"纪念奖牌","SubType":110,"Quality":2},{"Id":36002004,"Name":"荣誉奖牌","SubType":110,"Quality":3},{"Id":36002005,"Name":"巅峰奖牌","SubType":110,"Quality":4},{"Id":36002006,"Name":"赛车纪念奖杯","SubType":110,"Quality":3},{"Id":36002007,"Name":"赛车荣誉奖杯","SubType":110,"Quality":4},{"Id":36002008,"Name":"赛车巅峰奖杯","SubType":110,"Quality":5},{"Id":36002009,"Name":"篮球纪念奖杯","SubType":110,"Quality":3},{"Id":36002010,"Name":"篮球荣誉奖杯","SubType":110,"Quality":4},{"Id":36002011,"Name":"篮球巅峰奖杯","SubType":110,"Quality":5},{"Id":36002012,"Name":"足球赛事奖杯1","SubType":110,"Quality":3},{"Id":36002013,"Name":"足球赛事奖杯2","SubType":110,"Quality":4},{"Id":36002014,"Name":"足球赛事奖杯3","SubType":110,"Quality":5},{"Id":36003000,"Name":"Pistol Manual","SubType":20,"Quality":5},{"Id":36003001,"Name":"Submachine Gun Manual","SubType":20,"Quality":5},{"Id":36003002,"Name":"Rifle Manual","SubType":20,"Quality":5},{"Id":36003003,"Name":"Shotgun Manual","SubType":20,"Quality":5},{"Id":36003004,"Name":"Sniper Rifle Manual","SubType":20,"Quality":5},{"Id":36003005,"Name":"Stun Gun Manual","SubType":20,"Quality":5},{"Id":36003006,"Name":"Heavy Weapon Manual","SubType":20,"Quality":5},{"Id":36003007,"Name":"Throwables Manual","SubType":20,"Quality":5},{"Id":36003008,"Name":"One-Handed Blunt Weapon Manual","SubType":20,"Quality":5},{"Id":36003009,"Name":"One-Handed Short Weapon Manual","SubType":20,"Quality":5},{"Id":36003010,"Name":"Two-Handed Heavy Weapon Manual","SubType":20,"Quality":5},{"Id":36003011,"Name":"Two-Handed Polearm Manual","SubType":20,"Quality":5},{"Id":36003012,"Name":"Maintenance Theory Manual","SubType":20,"Quality":5},{"Id":36003013,"Name":"Throwing Theory Manual","SubType":20,"Quality":5},{"Id":36003014,"Name":"Knuckles Manual","SubType":20,"Quality":5},{"Id":36004000,"Name":"抗性弱化合成蓝图","SubType":84,"Quality":6},{"Id":36004001,"Name":"异常循环合成蓝图","SubType":84,"Quality":6},{"Id":36004002,"Name":"痛楚暴击合成蓝图","SubType":84,"Quality":6},{"Id":36004003,"Name":"破防极化合成蓝图","SubType":84,"Quality":6},{"Id":36004004,"Name":"伤痛叠加合成蓝图","SubType":84,"Quality":6},{"Id":36004005,"Name":"制胜崩解合成蓝图","SubType":84,"Quality":6},{"Id":36004006,"Name":"内在崩解合成蓝图","SubType":84,"Quality":6},{"Id":36004007,"Name":"虚弱崩解合成蓝图","SubType":84,"Quality":6},{"Id":36004008,"Name":"异常崩溃合成蓝图","SubType":84,"Quality":6},{"Id":36004009,"Name":"异常盛宴合成蓝图","SubType":84,"Quality":6},{"Id":36004010,"Name":"蓝图:特遣防护装置","SubType":92,"Quality":3},{"Id":36004011,"Name":"蓝图:防护装置·应急反应","SubType":92,"Quality":3},{"Id":36004012,"Name":"蓝图:防护装置·决战","SubType":92,"Quality":3},{"Id":36004013,"Name":"蓝图:远征防护装置","SubType":92,"Quality":3},{"Id":36004014,"Name":"蓝图:防护装置·隐秘","SubType":92,"Quality":3},{"Id":36004015,"Name":"蓝图:特遣扩容组件","SubType":92,"Quality":3},{"Id":36004016,"Name":"蓝图:特遣扩容组件·改","SLine truncated
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,317 @@
{
"_class": "GachaConfig",
"_namespace": "LT.ConfigGen",
"_expectedCount": 4,
"records": [
{
"Id": 1,
"Name": "Ember Weave",
"GachaType": 1,
"PriorityDisplay": 1,
"RuleText": 73977008,
"PanelTexts": [
73977000,
73977001
],
"ShopId": 0,
"BackgroundImage": 28002238,
"GrandPrizeCommodityId": 89020000,
"ButtonVideo": 0,
"DrawVideo": [
24104360
],
"SingleTimeline": "",
"tenxTimeline": "",
"PreviewVideoTitle": [
"测试1"
],
"PreviewVedio": [
24105003
],
"GachaSoundid": 0,
"PanelSoundid": 0,
"StartVersion": "V1.1.0",
"EndVersion": "",
"TagList": [
0,
1
],
"__IDX__Name": 129131,
"__RAW__Name": "",
"__IDX__PreviewVideoTitle": [
4294967293
],
"__RAW__PreviewVideoTitle": [
"测试1"
],
"PrizePoolIds": [
{
"level": 1,
"id": 101
}
],
"StartTime": {
"year": 2025,
"month": 10,
"day": 12,
"hour": 4,
"minute": 0,
"second": 0
},
"EndTime": {
"year": 2026,
"month": 10,
"day": 28,
"hour": 3,
"minute": 59,
"second": 59
},
"Milestone": []
},
{
"Id": 3,
"Name": "Moonlit Serenade",
"GachaType": 2,
"PriorityDisplay": 1,
"RuleText": 73977009,
"PanelTexts": [
73977041
],
"ShopId": 73970116,
"BackgroundImage": 28002226,
"GrandPrizeCommodityId": 89030000,
"ButtonVideo": 24104354,
"DrawVideo": [
24104353
],
"SingleTimeline": "commercial_blindbox_common",
"tenxTimeline": "commercial_blindbox_common",
"PreviewVideoTitle": [
"测试1",
"测试2"
],
"PreviewVedio": [
24105004,
24105005
],
"GachaSoundid": 0,
"PanelSoundid": 0,
"StartVersion": "V1.1.0",
"EndVersion": "",
"TagList": [
0,
1
],
"__IDX__Name": 129132,
"__RAW__Name": "",
"__IDX__PreviewVideoTitle": [
4294967293,
4294967293
],
"__RAW__PreviewVideoTitle": [
"测试1",
"测试2"
],
"PrizePoolIds": [
{
"level": 1,
"id": 201
}
],
"StartTime": {
"year": 2025,
"month": 10,
"day": 12,
"hour": 4,
"minute": 0,
"second": 0
},
"EndTime": {
"year": 2026,
"month": 10,
"day": 28,
"hour": 3,
"minute": 59,
"second": 59
},
"Milestone": []
},
{
"Id": 5,
"Name": "Center Stage",
"GachaType": 3,
"PriorityDisplay": 1,
"RuleText": 73977044,
"PanelTexts": [
73977042
],
"ShopId": 73970116,
"BackgroundImage": 28201645,
"GrandPrizeCommodityId": 89030003,
"ButtonVideo": 24104355,
"DrawVideo": [
24104351
],
"SingleTimeline": "commercial_fashion_common_one",
"tenxTimeline": "commercial_fashion_common",
"PreviewVideoTitle": [
"测试1",
"测试2"
],
"PreviewVedio": [
24105004,
24105005
],
"GachaSoundid": 0,
"PanelSoundid": 0,
"StartVersion": "V1.1.0",
"EndVersion": "",
"TagList": [
0,
1
],
"__IDX__Name": 150394,
"__RAW__Name": "",
"__IDX__PreviewVideoTitle": [
4294967293,
4294967293
],
"__RAW__PreviewVideoTitle": [
"测试1",
"测试2"
],
"PrizePoolIds": [
{
"level": 1,
"id": 203
}
],
"StartTime": {
"year": 2025,
"month": 10,
"day": 12,
"hour": 4,
"minute": 0,
"second": 0
},
"EndTime": {
"year": 2026,
"month": 10,
"day": 28,
"hour": 3,
"minute": 59,
"second": 59
},
"Milestone": [
{
"count": 10,
"dropId": 14476335
},
{
"count": 20,
"dropId": 14476319
},
{
"count": 30,
"dropId": 14476321
},
{
"count": 50,
"dropId": 14476317
},
{
"count": 70,
"dropId": 14360000
}
]
},
{
"Id": 6,
"Name": "逐日计划",
"GachaType": 3,
"PriorityDisplay": 2,
"RuleText": 73977044,
"PanelTexts": [
73977042
],
"ShopId": 73970116,
"BackgroundImage": 28017694,
"GrandPrizeCommodityId": 89030004,
"ButtonVideo": 24104357,
"DrawVideo": [
24104352
],
"SingleTimeline": "commercial_car_common",
"tenxTimeline": "commercial_car_common",
"PreviewVideoTitle": [
"测试1"
],
"PreviewVedio": [
24105003
],
"GachaSoundid": 0,
"PanelSoundid": 0,
"StartVersion": "V1.1.0",
"EndVersion": "",
"TagList": [
0,
1
],
"__IDX__Name": 145595,
"__RAW__Name": "",
"__IDX__PreviewVideoTitle": [
4294967293
],
"__RAW__PreviewVideoTitle": [
"测试1"
],
"PrizePoolIds": [
{
"level": 1,
"id": 204
}
],
"StartTime": {
"year": 2025,
"month": 10,
"day": 12,
"hour": 4,
"minute": 0,
"second": 0
},
"EndTime": {
"year": 2026,
"month": 10,
"day": 28,
"hour": 3,
"minute": 59,
"second": 59
},
"Milestone": [
{
"count": 10,
"dropId": 14476335
},
{
"count": 20,
"dropId": 14476319
},
{
"count": 30,
"dropId": 14476321
},
{
"count": 50,
"dropId": 14476317
},
{
"count": 70,
"dropId": 14360000
}
]
}
],
"_dumpedCount": 4,
"_loadFailures": 0,
"_serializationErrors": 0
}
@@ -0,0 +1,302 @@
{
"_class": "GachaPoolConfig",
"_namespace": "LT.ConfigGen",
"_expectedCount": 13,
"records": [
{
"Id": 101,
"isDrop": false,
"SystemId": 334,
"MoneyId": 36789012,
"CommodityId": 89020001,
"LackMoneyTip": 65403513,
"SS_TierRuleId": 10001,
"SS_SamplingMethod": 2,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 1,
"CostCount": [
{
"drawCount": 1,
"cost": 100
},
{
"drawCount": 2,
"cost": 300
},
{
"drawCount": 3,
"cost": 1000
},
{
"drawCount": 4,
"cost": 2000
},
{
"drawCount": 5,
"cost": 3500
},
{
"drawCount": 6,
"cost": 4500
}
]
},
{
"Id": 201,
"isDrop": false,
"SystemId": 333,
"MoneyId": 36789003,
"CommodityId": 89030001,
"LackMoneyTip": 65403514,
"SS_TierRuleId": 20001,
"SS_SamplingMethod": 0,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 3,
"CostCount": [
{
"drawCount": 1,
"cost": 1
}
]
},
{
"Id": 202,
"isDrop": false,
"SystemId": 333,
"MoneyId": 36789003,
"CommodityId": 89030001,
"LackMoneyTip": 65403514,
"SS_TierRuleId": 20002,
"SS_SamplingMethod": 0,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 3,
"CostCount": [
{
"drawCount": 1,
"cost": 1
}
]
},
{
"Id": 203,
"isDrop": false,
"SystemId": 333,
"MoneyId": 36789017,
"CommodityId": 89030002,
"LackMoneyTip": 65403518,
"SS_TierRuleId": 20002,
"SS_SamplingMethod": 0,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 3,
"CostCount": [
{
"drawCount": 1,
"cost": 1
}
]
},
{
"Id": 204,
"isDrop": false,
"SystemId": 333,
"MoneyId": 36789017,
"CommodityId": 89030002,
"LackMoneyTip": 65403518,
"SS_TierRuleId": 20003,
"SS_SamplingMethod": 0,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 3,
"CostCount": [
{
"drawCount": 1,
"cost": 1
}
]
},
{
"Id": 13500000,
"isDrop": true,
"SystemId": 0,
"MoneyId": 0,
"CommodityId": 0,
"LackMoneyTip": 0,
"SS_TierRuleId": 0,
"SS_SamplingMethod": 0,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 0,
"CostCount": []
},
{
"Id": 13500001,
"isDrop": true,
"SystemId": 0,
"MoneyId": 0,
"CommodityId": 0,
"LackMoneyTip": 0,
"SS_TierRuleId": 0,
"SS_SamplingMethod": 0,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 0,
"CostCount": []
},
{
"Id": 13500002,
"isDrop": true,
"SystemId": 0,
"MoneyId": 0,
"CommodityId": 0,
"LackMoneyTip": 0,
"SS_TierRuleId": 0,
"SS_SamplingMethod": 0,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 0,
"CostCount": []
},
{
"Id": 13500003,
"isDrop": true,
"SystemId": 0,
"MoneyId": 0,
"CommodityId": 0,
"LackMoneyTip": 0,
"SS_TierRuleId": 0,
"SS_SamplingMethod": 0,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 0,
"CostCount": []
},
{
"Id": 13500004,
"isDrop": true,
"SystemId": 0,
"MoneyId": 0,
"CommodityId": 0,
"LackMoneyTip": 0,
"SS_TierRuleId": 0,
"SS_SamplingMethod": 0,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 0,
"CostCount": []
},
{
"Id": 13500005,
"isDrop": true,
"SystemId": 0,
"MoneyId": 0,
"CommodityId": 0,
"LackMoneyTip": 0,
"SS_TierRuleId": 0,
"SS_SamplingMethod": 0,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 0,
"CostCount": []
},
{
"Id": 13500006,
"isDrop": true,
"SystemId": 0,
"MoneyId": 0,
"CommodityId": 0,
"LackMoneyTip": 0,
"SS_TierRuleId": 0,
"SS_SamplingMethod": 0,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 0,
"CostCount": []
},
{
"Id": 13500007,
"isDrop": true,
"SystemId": 0,
"MoneyId": 0,
"CommodityId": 0,
"LackMoneyTip": 0,
"SS_TierRuleId": 0,
"SS_SamplingMethod": 0,
"S_TierRuleId": 0,
"S_SamplingMethod": 0,
"A_TierRuleId": 0,
"A_SamplingMethod": 0,
"B_TierRuleId": 0,
"B_SamplingMethod": 0,
"C_TierRuleId": 0,
"C_SamplingMethod": 0,
"CostCount": []
}
],
"_dumpedCount": 13,
"_loadFailures": 0,
"_serializationErrors": 0
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,83 @@
{
"_class": "GachaPoolTierRuleConfig",
"_namespace": "LT.ConfigGen",
"_expectedCount": 6,
"records": [
{
"Id": 10001,
"RuleType": 0,
"PityThreshold": 0,
"RampStartDraw": 1,
"BaseProbability": 0,
"ProbabilityIncrement": 0,
"GrandPrizeProbability": [
800,
1120,
1410,
4820,
16300,
100000
]
},
{
"Id": 10002,
"RuleType": 0,
"PityThreshold": 0,
"RampStartDraw": 1,
"BaseProbability": 0,
"ProbabilityIncrement": 0,
"GrandPrizeProbability": [
80,
320,
1410,
1610,
100000
]
},
{
"Id": 10003,
"RuleType": 0,
"PityThreshold": 0,
"RampStartDraw": 1,
"BaseProbability": 0,
"ProbabilityIncrement": 0,
"GrandPrizeProbability": [
80,
320,
1410,
1610,
100000
]
},
{
"Id": 20001,
"RuleType": 1,
"PityThreshold": 85,
"RampStartDraw": 66,
"BaseProbability": 300,
"ProbabilityIncrement": 2200,
"GrandPrizeProbability": []
},
{
"Id": 20002,
"RuleType": 1,
"PityThreshold": 85,
"RampStartDraw": 66,
"BaseProbability": 300,
"ProbabilityIncrement": 2200,
"GrandPrizeProbability": []
},
{
"Id": 20003,
"RuleType": 1,
"PityThreshold": 85,
"RampStartDraw": 66,
"BaseProbability": 300,
"ProbabilityIncrement": 2200,
"GrandPrizeProbability": []
}
],
"_dumpedCount": 6,
"_loadFailures": 0,
"_serializationErrors": 0
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff