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:
1 parent
5e333d7678
commit
ce1f471eab
27 files changed
+321970
-135
No files matched your search
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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, '<');
|
||||
}
|
||||
|
||||
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 -> 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
Reference in new issue
Block a user