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

605 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Binary;
using System.IO;
using System.Text.Json;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 钓鱼玩法(build 4229938)—— 从零接上服务端链路。
///
/// 原版链路(已用 dump.cs + 代理解码器 RPCDeserializeAuto.lua 坐实):
/// 走近钓点 → 客户端发 AskFishingSpotFullSync(fishGroupId) 拉鱼池
/// → 服务端回 SyncFishingSpotFull(Reader[335]) 推全量鱼池
/// → 交互跑 PlayFishingGameAction 小游戏
/// → 成功触发 OnFishingSuccess → 客户端发 AskFishingSuccess(fishGroupId, fishId) 记账
/// → 百科全书用 SyncFishAdd(Reader[277]) 登记。
///
/// 线格式铁律(UxContract.cs):复杂体只先写 TypeMark 字节(默认 0xFF)再按声明顺序写公共字段,
/// 客户端代理端 Auto.Reader[N] 的 N 只是解码索引、对线路字节无意义。所以下面每个 wire DTO 只要
/// 「字段顺序 / 类型」对齐代理的 Reader[335]/[338]/[277]/[347] 即可,类名无关。
///
/// 鱼池种子数据 100% 来自真实配置(FishingSpotConfig / FishingFishGroupConfig / FishingFishConfig),
/// 已复制进 ClientData/4229938/Configs/。客户端传什么 fishGroupId 都能接:
/// - 命中 FishingFishGroupConfig 的组号(1/2) → 用它的 InitGroup(短 id → 10105000+id)
/// - 命中 FishingSpotConfig 的 spot id(10105150…) → 用它的 InitGroup(已是全 id)
/// - 都不中 → 退回一套通用鱼,保证客户端永远有鱼可钓。
/// </summary>
internal sealed partial class GameRouter
{
// ── wire DTO:字段顺序严格对齐代理 Reader[335] / [338] / [277] / [347] ──────────────────────
[UxContract]
private sealed class FishingSpotFullSyncPayload4229938
{
public uint FishGroupId;
public List<FishingSpotFishWire4229938> Fishes = new();
public uint LastRefreshTime;
public uint LastResetTime;
public uint MaxCount;
}
[UxContract]
private sealed class FishingSpotFishWire4229938
{
public uint Id;
public uint FishConfigId;
public float Weight;
public float Length;
}
[UxContract]
private sealed class FishingFishAddWire4229938
{
public uint UniqueId;
public uint TemplateId;
public float Weight;
public float Length;
public bool IsPlaced;
}
[UxContract]
private sealed class FishingGearWire4229938
{
public uint UniqueId;
public uint TemplateId;
public uint RemainingDurability;
}
private static readonly Random Rng = new();
private const uint StarterRodConfigId = 10105100; // 入门鱼竿
// ═══════════════════════════ 常驻:拉鱼池 ═══════════════════════════
[Handler(MethodId.AskFishingSpotFullSync, HandlerPacketKind.Invoke)]
private async Task AskFishingSpotFullSync(Connection conn, UxRpcMessage msg)
{
var fishGroupId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u;
var state = GetWorldState(msg.Context);
var (_, snap) = FishingCatalog4229938.EnsurePool(state, fishGroupId);
var payload = new FishingSpotFullSyncPayload4229938
{
FishGroupId = fishGroupId,
LastRefreshTime = FishingCatalog4229938.NowUnix(),
LastResetTime = FishingCatalog4229938.NowUnix(),
MaxCount = (uint)snap.Count,
};
foreach (var (id, fid, w, l) in snap)
payload.Fishes.Add(new FishingSpotFishWire4229938 { Id = id, FishConfigId = fid, Weight = w, Length = l });
// 返回值和 S2C 通知都带同样的 Reader[335] 形态:客户端两种都可能消费,两个都发(幂等)。
await conn.ReturnAsync(msg, payload);
await conn.NotifyAsync(MethodId.IGameToClient_SyncFishingSpotFull, payload);
}
// ═══════════════════════════ 常驻:钓上鱼记账 ═══════════════════════════
[Handler(MethodId.AskFishingSuccess, HandlerPacketKind.Invoke)]
private async Task AskFishingSuccess(Connection conn, UxRpcMessage msg)
{
uint fishGroupId = 0, fishId = 0;
if (msg.Body.Length >= 8)
{
fishGroupId = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body);
fishId = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(4));
}
var state = GetWorldState(msg.Context);
if (TryRecordCatch(state, fishGroupId, fishId, out var uniqueId, out var templateId, out var weight, out var length))
{
await conn.NotifyAsync(MethodId.IGameToClient_SyncFishAdd, new FishingFishAddWire4229938
{
UniqueId = uniqueId,
TemplateId = templateId,
Weight = weight,
Length = length,
IsPlaced = false,
});
PlayerProgressStore.Save(state, conn.Log, quiet: true);
conn.Log.Info($"[FISHING] caught fishGroupId={fishGroupId} fishId={fishId} templateId={templateId} uniqueId={uniqueId}");
}
else
{
conn.Log.Warn($"[FISHING] AskFishingSuccess: no matching fish (group={fishGroupId}, fishId={fishId})");
}
await conn.ReturnEmptyOkAsync(msg);
}
private static bool TryRecordCatch(
WorldEntryState state, uint fishGroupId, uint fishId,
out uint uniqueId, out uint templateId, out float weight, out float length)
{
uniqueId = 0; templateId = 0; weight = 0; length = 0;
lock (state.SyncRoot)
{
if (state.Fishing.GameplayInfo.SpotPools.TryGetValue(fishGroupId, out var pool) &&
pool.Fishes.TryGetValue(fishId, out var fish))
{
uniqueId = state.Fishing.NextUniqueId++;
templateId = fish.FishConfigId;
weight = fish.Weight;
length = fish.Length;
state.Fishing.OrnamentalFishDict[uniqueId] = new Auto.FishInfo
{
UniqueId = uniqueId,
TemplateId = templateId,
Weight = weight,
Length = length,
};
pool.Fishes.Remove(fishId); // 钓走后从钓点鱼池移除(下次 SyncFishingSpotFull 反映)
return true;
}
}
return false;
}
// ═══════════════════════════ GM:发 / 收钓竿 ═══════════════════════════
[Handler(MethodId.GmAddFishingGear, HandlerPacketKind.Invoke)]
private async Task GmAddFishingGear(Connection conn, UxRpcMessage msg)
{
var gearConfigId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u;
if (gearConfigId == 0) gearConfigId = StarterRodConfigId;
var state = GetWorldState(msg.Context);
uint uniqueId;
lock (state.SyncRoot)
{
uniqueId = state.Fishing.NextUniqueId++;
state.Fishing.GearDict[uniqueId] = new Auto.FishingGearInfo
{
UniqueId = uniqueId,
TemplateId = gearConfigId,
RemainingDurability = 100,
};
}
await conn.NotifyAsync(MethodId.IGameToClient_SyncFishingGearAdd, new FishingGearWire4229938
{
UniqueId = uniqueId,
TemplateId = gearConfigId,
RemainingDurability = 100,
});
await conn.ReturnEmptyOkAsync(msg);
PlayerProgressStore.Save(state, conn.Log, quiet: true);
conn.Log.Info($"[FISHING] GmAddFishingGear granted gearConfigId={gearConfigId} uniqueId={uniqueId}");
}
[Handler(MethodId.GmRemoveFishingGear, HandlerPacketKind.Invoke)]
private async Task GmRemoveFishingGear(Connection conn, UxRpcMessage msg)
{
var uniqueId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u;
var state = GetWorldState(msg.Context);
lock (state.SyncRoot)
state.Fishing.GearDict.Remove(uniqueId);
// SyncFishingGearRemove 是裸 uint(代理 ReadUInt32),用 NotifyRawAsync 发原始 4 字节。
await conn.NotifyRawAsync(MethodId.IGameToClient_SyncFishingGearRemove, BitConverter.GetBytes(uniqueId));
await conn.ReturnEmptyOkAsync(msg);
PlayerProgressStore.Save(state, conn.Log, quiet: true);
}
// ═══════════════════════════ GM:钓点 / 鱼池维护 ═══════════════════════════
[Handler(MethodId.GmInitFishingSpot, HandlerPacketKind.Invoke)]
private async Task GmInitFishingSpot(Connection conn, UxRpcMessage msg)
{
var spotId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u;
var state = GetWorldState(msg.Context);
FishingCatalog4229938.EnsurePool(state, spotId);
await conn.ReturnEmptyOkAsync(msg);
conn.Log.Info($"[FISHING] GmInitFishingSpot seeded pool for spot/group {spotId}");
}
[Handler(MethodId.GmSpawnFishingSpotFish, HandlerPacketKind.Invoke)]
private async Task GmSpawnFishingSpotFish(Connection conn, UxRpcMessage msg)
{
var spotId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u;
var state = GetWorldState(msg.Context);
uint spawned = 0;
lock (state.SyncRoot)
{
var pool = FishingCatalog4229938.GetOrCreatePool(state, spotId);
uint instanceId = 1;
while (pool.Fishes.ContainsKey(instanceId)) instanceId++;
spawned = FishingCatalog4229938.PickFishConfigId();
pool.Fishes[instanceId] = new Auto.PlayerFishingSpotFish
{
Id = instanceId,
FishConfigId = spawned,
Weight = (float)Math.Round(0.3 + Rng.NextDouble() * 5.7, 2),
Length = (float)Math.Round(8 + Rng.NextDouble() * 82, 1),
SpawnTime = 0,
};
}
await conn.ReturnEmptyOkAsync(msg);
conn.Log.Info($"[FISHING] GmSpawnFishingSpotFish spot={spotId} fishConfigId={spawned} (client refreshes on next AskFishingSpotFullSync)");
}
[Handler(MethodId.GmResetAllFishingSpots, HandlerPacketKind.Invoke)]
private async Task GmResetAllFishingSpots(Connection conn, UxRpcMessage msg)
{
var state = GetWorldState(msg.Context);
lock (state.SyncRoot)
{
state.Fishing.GameplayInfo.SpotPools.Clear();
state.Fishing.GameplayInfo.SpotLastRefreshTime.Clear();
state.Fishing.GameplayInfo.SpotLastResetTime.Clear();
}
await conn.ReturnEmptyOkAsync(msg);
PlayerProgressStore.Save(state, conn.Log, quiet: true);
conn.Log.Info("[FISHING] GmResetAllFishingSpots cleared all spot pools");
}
[Handler(MethodId.GmClearFishing, HandlerPacketKind.Invoke)]
private async Task GmClearFishing(Connection conn, UxRpcMessage msg)
{
var state = GetWorldState(msg.Context);
lock (state.SyncRoot)
{
state.Fishing.GearDict.Clear();
state.Fishing.ConsumableFishDict.Clear();
state.Fishing.OrnamentalFishDict.Clear();
state.Fishing.NextUniqueId = 0;
state.Fishing.GameplayInfo.SpotPools.Clear();
state.Fishing.GameplayInfo.SpotLastRefreshTime.Clear();
state.Fishing.GameplayInfo.SpotLastResetTime.Clear();
}
await conn.ReturnEmptyOkAsync(msg);
PlayerProgressStore.Save(state, conn.Log, quiet: true);
conn.Log.Info("[FISHING] GmClearFishing wiped all fishing state");
}
// ═══════════════════════════ GM:百科全书灌全鱼 ═══════════════════════════
[Handler(MethodId.GmAddAllFishes, HandlerPacketKind.Invoke)]
private async Task GmAddAllFishes(Connection conn, UxRpcMessage msg)
{
var state = GetWorldState(msg.Context);
var fishIds = FishingCatalog4229938.AllFishIds;
uint pushed = 0;
foreach (var fishId in fishIds)
{
uint uid;
lock (state.SyncRoot) uid = state.Fishing.NextUniqueId++;
await conn.NotifyAsync(MethodId.IGameToClient_SyncFishAdd, new FishingFishAddWire4229938
{
UniqueId = uid,
TemplateId = fishId,
Weight = 0,
Length = 0,
IsPlaced = false,
});
pushed++;
}
await conn.ReturnEmptyOkAsync(msg);
conn.Log.Info($"[FISHING] GmAddAllFishes pushed {pushed} fish to encyclopedia");
}
// ═══════════════════════════ GM:强制记一笔钓获 ═══════════════════════════
[Handler(MethodId.GmOnFishingSuccess, HandlerPacketKind.Invoke)]
private async Task GmOnFishingSuccess(Connection conn, UxRpcMessage msg)
{
uint spotId = 0, fishId = 0;
if (msg.Body.Length >= 8)
{
spotId = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body);
fishId = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(4));
}
var state = GetWorldState(msg.Context);
if (TryRecordCatch(state, spotId, fishId, out var uniqueId, out var templateId, out var weight, out var length))
{
await conn.NotifyAsync(MethodId.IGameToClient_SyncFishAdd, new FishingFishAddWire4229938
{
UniqueId = uniqueId,
TemplateId = templateId,
Weight = weight,
Length = length,
IsPlaced = false,
});
PlayerProgressStore.Save(state, conn.Log, quiet: true);
conn.Log.Info($"[FISHING] GmOnFishingSuccess recorded spot={spotId} fishId={fishId} templateId={templateId}");
}
else
{
conn.Log.Warn($"[FISHING] GmOnFishingSuccess: no matching fish (spot={spotId}, fishId={fishId})");
}
await conn.ReturnEmptyOkAsync(msg);
}
// ═══════════════════════════ GM:调试查看(有 string 返回) ═══════════════════════════
// ⛑⛑ 2026-09-29 修正:这两条**不是 void**,客户端
// `midToReturnMessageReader[GmViewFishingSpot/GmViewFishingInfo]` 都是
// `reader.ReadString(reader)`(裸 string)。原来 `ReturnEmptyOkAsync` 是 **0 字节载荷**
// (Connection.cs:32 `Array.Empty<byte>()`)⇒ 客户端读空 buffer,GM 面板什么都看不到。
// 实测 [FISHING] 一轮 9 次(一直在点)。现在把已经拼好的 dump **真的回给客户端**。
// wire:`UxWriter.UxString` —— null 写 0x00,有值写 `VarInt(len+1)` + UTF8(空串 = 0x01)。
// ⛑ 回滚:把 return 换回 `await conn.ReturnEmptyOkAsync(msg)` + 签名改回 `async Task`。
[Handler(MethodId.GmViewFishingSpot, HandlerPacketKind.Invoke)]
private Task GmViewFishingSpot(Connection conn, UxRpcMessage msg)
{
var spotId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u;
var state = GetWorldState(msg.Context);
string dump;
lock (state.SyncRoot)
{
if (state.Fishing.GameplayInfo.SpotPools.TryGetValue(spotId, out var pool))
dump = $"spot/group {spotId}: {pool.Fishes.Count} fish -> " +
string.Join(",", pool.Fishes.Values.Select(f => f.FishConfigId.ToString()));
else
dump = $"spot/group {spotId}: no pool yet";
}
conn.Log.Info($"[FISHING] GmViewFishingSpot {dump}");
var w = new UxWriter();
w.UxString(dump);
return conn.ReturnRawAsync(msg, w.ToArray());
}
[Handler(MethodId.GmViewFishingInfo, HandlerPacketKind.Invoke)]
private Task GmViewFishingInfo(Connection conn, UxRpcMessage msg)
{
var state = GetWorldState(msg.Context);
string dump;
lock (state.SyncRoot)
dump = $"gear={state.Fishing.GearDict.Count} ornamental={state.Fishing.OrnamentalFishDict.Count} " +
$"consumable={state.Fishing.ConsumableFishDict.Count} spots={state.Fishing.GameplayInfo.SpotPools.Count} " +
$"nextUid={state.Fishing.NextUniqueId}";
conn.Log.Info($"[FISHING] GmViewFishingInfo {dump}");
var w = new UxWriter();
w.UxString(dump);
return conn.ReturnRawAsync(msg, w.ToArray());
}
}
/// <summary>
/// 钓鱼配置目录(懒加载一次)。读真实 JSON(FishingSpotConfig / FishingFishGroupConfig /
/// FishingFishConfig),找不到文件就退化为通用鱼池,绝不让钓鱼链路崩。
/// </summary>
internal static class FishingCatalog4229938
{
private sealed record FishingInitGroup(uint FishId, uint Num);
private sealed record FishingSpotRec(uint Id, List<FishingInitGroup> InitGroup);
private sealed record FishingGroupRec(uint Id, uint MaxCount, List<FishingInitGroup> InitGroup);
private static readonly Dictionary<uint, FishingSpotRec> Spots = new();
private static readonly Dictionary<uint, FishingGroupRec> Groups = new();
private static readonly List<uint> AllFishIdsField = new();
/// <summary> fishId → (NormalWeight 克, NormalLength 厘米),取自 FishingFishConfig,用于生成贴近原版的体重/体长。 </summary>
private static readonly Dictionary<uint, (float WeightKg, float LengthCm)> FishBody = new();
private static readonly Random Rng = new();
private static readonly object Gate = new();
static FishingCatalog4229938()
{
try
{
var root = PrivateServerConfigStore.ResolveProjectPath(
PrivateServerConfigStore.Current.Paths.ClientConfigs);
LoadSpots(Path.Combine(root, "FishingSpotConfig.json"));
LoadGroups(Path.Combine(root, "FishingFishGroupConfig.json"));
LoadFish(Path.Combine(root, "FishingFishConfig.json"));
Console.WriteLine(
$"[FISHING] catalog loaded: spots={Spots.Count} groups={Groups.Count} fish={AllFishIds.Count} bodies={FishBody.Count}");
}
catch (Exception ex)
{
Console.WriteLine($"[FISHING] config load failed ({ex.GetType().Name}: {ex.Message}) — falling back to generic pools");
}
}
internal static List<uint> AllFishIds => AllFishIdsField;
internal static uint NowUnix() => (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
internal static (Auto.PlayerFishingSpotPool Pool, List<(uint Id, uint FishConfigId, float Weight, float Length)> Snapshot)
EnsurePool(WorldEntryState state, uint fishGroupId)
{
lock (state.SyncRoot)
{
if (!state.Fishing.GameplayInfo.SpotPools.TryGetValue(fishGroupId, out var pool))
{
pool = BuildPool(fishGroupId);
state.Fishing.GameplayInfo.SpotPools[fishGroupId] = pool;
state.Fishing.GameplayInfo.SpotLastRefreshTime[fishGroupId] = NowUnix();
state.Fishing.GameplayInfo.SpotLastResetTime[fishGroupId] = NowUnix();
}
var snap = new List<(uint, uint, float, float)>();
foreach (var f in pool.Fishes.Values)
snap.Add((f.Id, f.FishConfigId, f.Weight, f.Length));
return (pool, snap);
}
}
internal static Auto.PlayerFishingSpotPool GetOrCreatePool(WorldEntryState state, uint fishGroupId)
{
lock (state.SyncRoot)
{
if (!state.Fishing.GameplayInfo.SpotPools.TryGetValue(fishGroupId, out var pool))
{
pool = BuildPool(fishGroupId);
state.Fishing.GameplayInfo.SpotPools[fishGroupId] = pool;
state.Fishing.GameplayInfo.SpotLastRefreshTime[fishGroupId] = NowUnix();
state.Fishing.GameplayInfo.SpotLastResetTime[fishGroupId] = NowUnix();
}
return pool;
}
}
internal static uint PickFishConfigId()
{
lock (Gate)
{
if (AllFishIds.Count == 0) return 10105001u;
return AllFishIds[Rng.Next(AllFishIds.Count)];
}
}
private static Auto.PlayerFishingSpotPool BuildPool(uint fishGroupId)
{
var pool = new Auto.PlayerFishingSpotPool();
var entries = new List<(uint FishId, uint Count)>();
uint maxCount = 20;
if (Groups.TryGetValue(fishGroupId, out var g))
{
foreach (var ig in g.InitGroup)
entries.Add((NormalizeFishId(ig.FishId), ig.Num));
maxCount = g.MaxCount;
}
else if (Spots.TryGetValue(fishGroupId, out var s))
{
foreach (var ig in s.InitGroup)
entries.Add((ig.FishId, ig.Num));
maxCount = (uint)entries.Sum(e => (int)e.Count);
}
else
{
// 兜底:客户端传了一个谁都不认的 id —— 给一套通用鱼,保证能钓。
foreach (var fid in new[] { 10105001u, 10105002u, 10105003u, 10105004u, 10105005u })
entries.Add((fid, 4));
maxCount = 20;
}
uint instanceId = 1;
foreach (var (fishId, count) in entries)
{
// 还原优先:体重/体长围绕 FishingFishConfig 的 NormalWeight(克)/NormalLength(厘米) ±20% 抖动,
// 没有该鱼配置时退回通用区间。
float baseW = 1f, baseL = 30f;
lock (Gate)
{
if (FishBody.TryGetValue(fishId, out var body)) { baseW = body.WeightKg; baseL = body.LengthCm; }
}
for (uint i = 0; i < count && instanceId <= 200; i++)
{
float weight, length;
lock (Gate)
{
weight = (float)Math.Round(baseW * (0.8 + Rng.NextDouble() * 0.4), 2);
length = (float)Math.Round(baseL * (0.8 + Rng.NextDouble() * 0.4), 1);
}
pool.Fishes[instanceId] = new Auto.PlayerFishingSpotFish
{
Id = instanceId,
FishConfigId = fishId,
Weight = weight,
Length = length,
SpawnTime = 0,
};
instanceId++;
}
}
return pool;
}
/// <summary>FishingFishGroupConfig 用的是短 id(2/10/3/7…),补成全 id(10105002…);已是全 id 的不动。</summary>
private static uint NormalizeFishId(uint raw) => raw < 1000 ? 10105000u + raw : raw;
/// <summary>
/// 宽容 uint 读取:客户端导出的 JSON 里数字字段混用 number / string(如 "FishId": "2"、
/// "Id": 10105001),直接 GetUInt32() 会在 string 上抛 InvalidOperationException。
/// </summary>
private static uint ReadUIntEl(System.Text.Json.JsonElement el)
{
return el.ValueKind switch
{
System.Text.Json.JsonValueKind.Number => el.GetUInt32(),
System.Text.Json.JsonValueKind.String => uint.TryParse(el.GetString(), out var v) ? v : 0u,
_ => 0u,
};
}
private static void LoadSpots(string path)
{
if (!File.Exists(path)) return;
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("records", out var records)) return;
foreach (var r in records.EnumerateArray())
{
if (!r.TryGetProperty("Id", out var idEl)) continue;
var id = ReadUIntEl(idEl);
var init = new List<FishingInitGroup>();
if (r.TryGetProperty("InitGroup", out var ig))
foreach (var e in ig.EnumerateArray())
{
var fid = NormalizeFishId(ReadUIntEl(e.GetProperty("FishId")));
var num = e.TryGetProperty("Num", out var n) ? Math.Max(1u, ReadUIntEl(n)) : 1u;
init.Add(new FishingInitGroup(fid, num));
}
Spots[id] = new FishingSpotRec(id, init);
}
}
private static void LoadGroups(string path)
{
if (!File.Exists(path)) return;
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("records", out var records)) return;
foreach (var r in records.EnumerateArray())
{
if (!r.TryGetProperty("Id", out var idEl)) continue;
var id = ReadUIntEl(idEl);
var max = r.TryGetProperty("MaxCount", out var m) ? Math.Max(1u, ReadUIntEl(m)) : 20u;
var init = new List<FishingInitGroup>();
if (r.TryGetProperty("InitGroup", out var ig))
foreach (var e in ig.EnumerateArray())
{
var fid = NormalizeFishId(ReadUIntEl(e.GetProperty("FishId")));
var num = e.TryGetProperty("Num", out var n) ? Math.Max(1u, ReadUIntEl(n)) : 1u;
init.Add(new FishingInitGroup(fid, num));
}
Groups[id] = new FishingGroupRec(id, max, init);
}
}
private static void LoadFish(string path)
{
if (!File.Exists(path)) return;
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("records", out var records)) return;
foreach (var r in records.EnumerateArray())
{
if (!r.TryGetProperty("Id", out var idEl)) continue;
var id = ReadUIntEl(idEl);
if (id == 0) continue;
AllFishIds.Add(id);
// NormalWeight 单位是克(草鱼 5000 ≈ 5kg)、NormalLength 是厘米(60)。
float wKg = 1f, lCm = 30f;
if (r.TryGetProperty("NormalWeight", out var wEl) && wEl.ValueKind is System.Text.Json.JsonValueKind.Number or System.Text.Json.JsonValueKind.String)
{
var g = ReadUIntEl(wEl);
if (g > 0) wKg = g / 1000f;
}
if (r.TryGetProperty("NormalLength", out var lEl) && lEl.ValueKind is System.Text.Json.JsonValueKind.Number or System.Text.Json.JsonValueKind.String)
{
var cm = ReadUIntEl(lEl);
if (cm > 0) lCm = cm;
}
FishBody[id] = (wKg, lCm);
}
}
}