Files
BuYuHouDuan/FishGameSrc/FishGameService/Services/FishSpawnService.cs
T
2026-10-13 21:52:02 +08:00

1525 lines
60 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Common;
using CommonDomain.BaseModel;
using CommonService;
using FishGameService.GameService;
using FishGameService.Hubs;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using RedisHelper;
using RedisService;
namespace FishGameService.Services
{
public class FishSpawnService : BackgroundService
{
private readonly FishSpawnConfigStore _configStore;
private readonly IHubSend<FishGameHub> _hubGame;
private readonly IRedisService _redisService;
private readonly ICache _cache;
private readonly string _instanceId;
private readonly ILogger<FishSpawnService> _logger;
private readonly ConcurrentDictionary<string, RoomFishState> _roomStates = new ConcurrentDictionary<string, RoomFishState>();
private const string GlobalFishIdKey = "fish:spawn:global:id";
private const int TickIntervalMs = 1000;
private const int SystemFactorSyncIntervalMs = 5000;
private const double SystemFactorChangeThreshold = 0.1;
private const int SpawnOwnerLeaseSeconds = 10;
private const string SpawnOwnerKeyPrefix = "fish:spawn:owner:";
private const string ActiveFishSnapshotKeyPrefix = "fish:spawn:snapshot:";
private const string FishKilledKeyPrefix = "fish:spawn:killed:";
private const string SpawnStopKeyPrefix = "fish:spawn:stop:";
private const int ActiveFishSnapshotSeconds = 60;
private const int RestrictedFishRespawnBufferSeconds = 5;
private const int ExpiredHitBufferSeconds = 15;
public FishSpawnService(
FishSpawnConfigStore configStore,
IHubSend<FishGameHub> hubGame,
IRedisService redisService,
ICache cache,
AppInstanceId appInstanceId,
ILogger<FishSpawnService> logger)
{
_configStore = configStore;
_hubGame = hubGame;
_redisService = redisService;
_cache = cache;
_instanceId = string.IsNullOrWhiteSpace(appInstanceId?.Id) ? "default" : appInstanceId.Id;
_logger = logger;
}
public void RegisterRoom(string roomId, int gameType, int roomLevel)
{
var stopKey = GetSpawnStopKey(roomId);
if (_cache.Exists(stopKey))
{
_roomStates.TryRemove(roomId, out _);
_cache.Remove(GetActiveFishSnapshotKey(roomId));
}
_cache.Remove(stopKey);
if (_roomStates.ContainsKey(roomId))
{
return;
}
if (!TryClaimSpawnOwnership(roomId))
{
var owner = _cache.Get<string>(GetSpawnOwnerKey(roomId));
return;
}
var config = _configStore.GetOrLoad(gameType, roomLevel);
if (config == null)
{
ReleaseSpawnOwnership(roomId);
return;
}
var state = new RoomFishState
{
RoomId = roomId,
GameType = gameType,
RoomLevel = roomLevel,
Config = config,
};
ResetBossSpawnState(state);
var now = DateTime.UtcNow;
lock (state.Lock)
{
SpawnFishBatch(state, now);
}
_roomStates[roomId] = state;
PersistActiveFishSnapshot(state);
}
public void ReloadAllRoomConfigs()
{
var now = DateTimeOffset.UtcNow;
var nowMs = now.ToUnixTimeMilliseconds();
foreach (var kv in _roomStates)
{
var state = kv.Value;
var newConfig = _configStore.GetOrLoad(state.GameType, state.RoomLevel);
if (newConfig != null)
{
lock (state.Lock)
{
string oldProbs = string.Join(";",
(state.Config?.Probabilities ?? new List<FishSpawnProbability>())
.Select(p => $"type={p.Type} fishType={p.FishType} time={p.TimeMin}-{p.TimeMax} count={p.CountMin}-{p.CountMax} max={p.MaxCountMin}-{p.MaxCountMax}"));
string newProbs = string.Join(";",
newConfig.Probabilities.Select(p => $"type={p.Type} fishType={p.FishType} time={p.TimeMin}-{p.TimeMax} count={p.CountMin}-{p.CountMax} max={p.MaxCountMin}-{p.MaxCountMax}"));
state.Config = newConfig;
state.NextSpawnByType.Clear();
ResetBossSpawnState(state);
}
}
else
{
}
}
}
public void UnregisterRoom(string roomId)
{
var latestRoom = _redisService.GetGameRoom(roomId);
if (latestRoom?.Players?.Any(p => !p.IsBot && p.State == 0) == true)
{
return;
}
var recheckRoom = _redisService.GetGameRoom(roomId);
if (recheckRoom?.Players?.Any(p => !p.IsBot && p.State == 0) == true)
{
return;
}
int fishCount = 0;
if (_roomStates.TryGetValue(roomId, out var st))
{
lock (st.Lock) { fishCount = st.ActiveFishes.Count; }
}
_roomStates.TryRemove(roomId, out _);
_cache.Insert(GetSpawnStopKey(roomId), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), ActiveFishSnapshotSeconds);
_cache.Remove(GetActiveFishSnapshotKey(roomId));
_cache.Remove(GetSpawnOwnerKey(roomId));
}
public List<ActiveFish> GetActiveFishes(string roomId)
{
if (_roomStates.TryGetValue(roomId, out var state))
{
lock (state.Lock)
{
var localNow = DateTime.UtcNow;
return state.ActiveFishes.Values
.Where(f => !_cache.Exists(GetFishKilledKey(roomId, f.FishId)) && IsFishWithinHitWindow(f, localNow))
.ToList();
}
}
var shared = _cache.Get<List<ActiveFish>>(GetActiveFishSnapshotKey(roomId));
if (shared == null && _cache.Exists(GetSpawnOwnerKey(roomId)) && !_cache.Exists(GetSpawnStopKey(roomId)))
{
for (var attempt = 0; attempt < 5 && shared == null; attempt++)
{
Thread.Sleep(10);
shared = _cache.Get<List<ActiveFish>>(GetActiveFishSnapshotKey(roomId));
}
}
shared ??= new List<ActiveFish>();
var sharedNow = DateTime.UtcNow;
return shared.Where(f => f != null && !f.IsKilled && IsFishWithinHitWindow(f, sharedNow)).ToList();
}
public bool TryGetFishNumber(string roomId, long fishId, out string fishNumber)
{
fishNumber = string.Empty;
if (!_roomStates.TryGetValue(roomId, out var state))
{
var sharedFish = GetActiveFishes(roomId).FirstOrDefault(f => f.FishId == fishId);
if (sharedFish == null || string.IsNullOrWhiteSpace(sharedFish.FishNumber))
return false;
fishNumber = sharedFish.FishNumber;
return true;
}
lock (state.Lock)
{
if (!state.ActiveFishes.TryGetValue(fishId, out var fish) || string.IsNullOrWhiteSpace(fish.FishNumber))
return false;
fishNumber = fish.FishNumber;
return true;
}
}
public bool SpawnTemporaryFish(string roomId, string fishNumber, int count, int? routeId = null, int? seconds = null)
{
return SpawnTemporaryFish(roomId, fishNumber, count, out _, routeId, seconds);
}
public bool SpawnTemporaryFish(string roomId, string fishNumber, int count, out List<long> fishIds,
int? routeId = null, int? seconds = null)
{
return SpawnTemporaryFishCore(roomId, fishNumber, count, out fishIds, routeId, seconds, false);
}
private bool SpawnTemporaryFishCore(string roomId, string fishNumber, int count, out List<long> fishIds,
int? routeId, int? seconds, bool isSummonCard)
{
fishIds = new List<long>();
if (string.IsNullOrWhiteSpace(roomId) || string.IsNullOrWhiteSpace(fishNumber))
return false;
if (!_roomStates.TryGetValue(roomId, out var state))
{
var owner = _cache.Get<string>(GetSpawnOwnerKey(roomId));
return false;
}
var spawned = new List<SpawnFishItem>();
DateTime now;
int times;
lock (state.Lock)
{
var fishCfg = (state.Config?.Fishes ?? new List<FishSpawnFish>())
.FirstOrDefault(f => string.Equals(f.FishNumber, fishNumber, StringComparison.OrdinalIgnoreCase));
if (fishCfg == null)
{
return false;
}
var isRestricted = fishCfg.FishType == 2 || fishCfg.FishType == 3 ||
fishCfg.FishType == 4 || fishCfg.FishType == 5;
var alternateRestrictedBySide = isRestricted;
if (!isSummonCard && isRestricted && state.ActiveFishes.Values.Any(f =>
!f.IsKilled && f.ExpireAt > DateTime.UtcNow &&
string.Equals(f.FishNumber, fishNumber, StringComparison.Ordinal)))
{
return false;
}
times = (seconds.HasValue && seconds.Value > 0) ? seconds.Value
: (fishCfg.Times > 0 ? fishCfg.Times : 8);
now = DateTime.UtcNow;
var maxCount = isRestricted ? 1 : Math.Clamp(count, 1, 2000);
var random = new Random();
bool preferOdd = true;
for (var i = 0; i < maxCount; i++)
{
var fishId = NextFishId();
var routePreference = alternateRestrictedBySide
? (fishCfg.FishType == 3 ? state.PreferOddGoldenRoute : state.PreferOddRestrictedRoute)
: preferOdd;
if (!isSummonCard && !routeId.HasValue && alternateRestrictedBySide && !HasRouteForSide(fishCfg, routePreference))
break;
var route = routeId.HasValue ? routeId.Value : PickRoute(fishCfg, random, routePreference);
preferOdd = !preferOdd;
if (alternateRestrictedBySide)
{
if (fishCfg.FishType == 3)
state.PreferOddGoldenRoute = (route & 1) == 0;
else
state.PreferOddRestrictedRoute = (route & 1) == 0;
}
var activeFish = new ActiveFish
{
FishId = fishId,
FishNumber = fishCfg.FishNumber,
FishType = fishCfg.FishType,
RouteId = route,
CreatedAt = now,
ExpireAt = now.AddSeconds(times),
IsKilled = false,
FishArrayNumber = null,
ArrayNumber = null
};
state.ActiveFishes[fishId] = activeFish;
state.FishCountByType.TryGetValue(fishCfg.FishType, out var fc);
state.FishCountByType[fishCfg.FishType] = fc + 1;
spawned.Add(new SpawnFishItem
{
FishId = fishId,
FishIds = new List<long> { fishId },
FishNumber = fishCfg.FishNumber,
FishType = fishCfg.FishType,
RouteId = route,
CreatedAtMs = new DateTimeOffset(now).ToUnixTimeMilliseconds(),
ExpireAtMs = new DateTimeOffset(activeFish.ExpireAt).ToUnixTimeMilliseconds()
});
}
}
if (spawned.Count == 0)
return false;
var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var payload = new
{
type = "spawn",
timestamp = nowMs,
serverTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"),
fish = spawned.Select(s => new
{
fishId = s.FishId,
fishNumber = s.FishNumber,
routeId = s.RouteId,
fishArrayNumber = string.Empty,
fishIds = s.FishIds,
createdAt = s.CreatedAtMs,
createdTime = DateTimeOffset.FromUnixTimeMilliseconds(s.CreatedAtMs).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff"),
expireAt = s.ExpireAtMs,
expireTime = DateTimeOffset.FromUnixTimeMilliseconds(s.ExpireAtMs).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff"),
remainingSeconds = (int)Math.Max(0, (s.ExpireAtMs - nowMs) / 1000)
}).ToList()
};
_hubGame.SendGroup(roomId, "OnFishSpawn", payload);
PersistActiveFishSnapshot(state);
fishIds = spawned.Select(s => s.FishId).ToList();
return true;
}
public bool SpawnSummonCardFish(string roomId, int fishType, out string fishNumber)
{
return SpawnSummonCardFish(roomId, fishType, out fishNumber, out _);
}
public bool SpawnSummonCardFish(string roomId, int fishType, out string fishNumber, out long fishId)
{
fishNumber = string.Empty;
fishId = 0;
if (!_roomStates.TryGetValue(roomId, out var state))
return false;
List<string> candidates;
lock (state.Lock)
{
candidates = (state.Config?.Fishes ?? new List<FishSpawnFish>())
.Where(f => f.FishType == fishType && f.FishWeight > 0 && !string.IsNullOrWhiteSpace(f.FishNumber))
.OrderBy(_ => Random.Shared.Next())
.Select(f => f.FishNumber)
.Distinct()
.ToList();
}
foreach (var candidate in candidates)
{
if (!SpawnTemporaryFishCore(roomId, candidate, 1, out var fishIds, null, null, true) || fishIds.Count == 0)
{
continue;
}
fishNumber = candidate;
fishId = fishIds[0];
return true;
}
return false;
}
public void SendSummonCardFishNotice(string roomId, string userId, int fishType, string fishNumber, long fishId)
{
var room = _redisService.GetGameRoom(roomId);
var player = room?.Players?.FirstOrDefault(p => p.UserId == userId);
_hubGame.SendGroup(roomId, "SummonCardFishNotice", new
{
roomId,
userId,
userNumber = player?.UserNumber ?? string.Empty,
userName = player?.UserName ?? string.Empty,
fishType,
fishTypeName = Enum.IsDefined(typeof(CommonDomain.Enum.FishTypeEnum), fishType)
? ((CommonDomain.Enum.FishTypeEnum)fishType).ToString()
: string.Empty,
fishNumber,
fishId
});
}
public bool ValidateFishHit(string roomId, long fishId)
{
if (_cache.Exists(GetFishKilledKey(roomId, fishId)))
return false;
if (!_roomStates.TryGetValue(roomId, out var state))
{
var sharedFish = GetActiveFishes(roomId).FirstOrDefault(f => f.FishId == fishId);
return sharedFish != null && IsFishWithinHitWindow(sharedFish, DateTime.UtcNow);
}
lock (state.Lock)
{
if (!state.ActiveFishes.TryGetValue(fishId, out var fish))
return false;
if (fish.IsKilled)
return false;
if (DateTime.UtcNow > fish.ExpireAt)
{
if (fish.FishType == (int)CommonDomain.Enum.FishTypeEnum.Boss鱼 ||
fish.FishType == (int)CommonDomain.Enum.FishTypeEnum.特殊Boss ||
fish.FishType == (int)CommonDomain.Enum.FishTypeEnum.特殊鱼)
{
return DateTime.UtcNow <= fish.ExpireAt.AddSeconds(ExpiredHitBufferSeconds);
}
return false;
}
return true;
}
}
public bool TryMarkFishKilled(string roomId, long fishId)
{
var killedKey = GetFishKilledKey(roomId, fishId);
if (!_cache.GetFullRedis().Add(killedKey, _instanceId, ActiveFishSnapshotSeconds))
return false;
if (!_roomStates.TryGetValue(roomId, out var state))
{
var shared = _cache.Get<List<ActiveFish>>(GetActiveFishSnapshotKey(roomId)) ?? new List<ActiveFish>();
var fish = shared.FirstOrDefault(f => f.FishId == fishId);
if (fish == null || fish.IsKilled || !IsFishWithinHitWindow(fish, DateTime.UtcNow))
{
_cache.Remove(killedKey);
return false;
}
fish.IsKilled = true;
_cache.Insert(GetActiveFishSnapshotKey(roomId), shared, ActiveFishSnapshotSeconds);
return true;
}
lock (state.Lock)
{
if (!state.ActiveFishes.TryGetValue(fishId, out var fish))
{
_cache.Remove(killedKey);
return false;
}
if (fish.IsKilled)
{
_cache.Remove(killedKey);
return false;
}
if (DateTime.UtcNow > fish.ExpireAt)
{
if (fish.FishType == (int)CommonDomain.Enum.FishTypeEnum.Boss鱼 ||
fish.FishType == (int)CommonDomain.Enum.FishTypeEnum.特殊Boss ||
fish.FishType == (int)CommonDomain.Enum.FishTypeEnum.特殊鱼)
{
if (DateTime.UtcNow > fish.ExpireAt.AddSeconds(ExpiredHitBufferSeconds))
{
_cache.Remove(killedKey);
return false;
}
}
else
{
_cache.Remove(killedKey);
return false;
}
}
fish.IsKilled = true;
state.FishCountByType.TryGetValue(fish.FishType, out var count);
if (count > 0)
state.FishCountByType[fish.FishType] = count - 1;
if (!string.IsNullOrEmpty(fish.FishArrayNumber))
DecrementArrayFishCount(state, fish.FishArrayNumber);
PersistActiveFishSnapshot(state);
return true;
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
int tickCount = 0;
while (!stoppingToken.IsCancellationRequested)
{
try
{
Tick();
tickCount++;
if (tickCount % 5 == 0)
SyncRoomSystemFactors();
}
catch (Exception ex)
{
_logger.LogError(ex, "[FishSpawn] Tick 异常");
}
await Task.Delay(TickIntervalMs, stoppingToken);
}
}
private void Tick()
{
var now = DateTime.UtcNow;
var expiredRoomIds = new List<string>();
foreach (var kv in _roomStates)
{
var roomId = kv.Key;
var state = kv.Value;
if (_cache.Exists(GetSpawnStopKey(roomId)))
{
expiredRoomIds.Add(roomId);
continue;
}
if (!RenewSpawnOwnership(roomId))
{
_logger.LogError("[FishSpawn] 房间出鱼权已转移,停止本机出鱼 roomId={RoomId} instanceId={InstanceId}", roomId, _instanceId);
expiredRoomIds.Add(roomId);
continue;
}
try
{
lock (state.Lock)
{
CleanupExpiredFishes(state, now);
SpawnFishBatch(state, now);
PersistActiveFishSnapshot(state);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[FishSpawn] 房间出鱼失败 roomId={RoomId} activeCount={Count}", roomId, state.ActiveFishes.Count);
}
}
foreach (var roomId in expiredRoomIds)
{
_roomStates.TryRemove(roomId, out _);
}
}
private static string GetSpawnOwnerKey(string roomId) => $"{SpawnOwnerKeyPrefix}{roomId}";
private static string GetActiveFishSnapshotKey(string roomId) => $"{ActiveFishSnapshotKeyPrefix}{roomId}";
private static string GetFishKilledKey(string roomId, long fishId) => $"{FishKilledKeyPrefix}{roomId}:{fishId}";
private static string GetSpawnStopKey(string roomId) => $"{SpawnStopKeyPrefix}{roomId}";
private long NextFishId() => _cache.IncrBy(GlobalFishIdKey, 1);
private void PersistActiveFishSnapshot(RoomFishState state)
{
var snapshot = state.ActiveFishes.Values
.Where(f => IsFishWithinHitWindow(f, DateTime.UtcNow) && !_cache.Exists(GetFishKilledKey(state.RoomId, f.FishId)))
.Select(f => new ActiveFish
{
FishId = f.FishId,
FishNumber = f.FishNumber,
FishType = f.FishType,
RouteId = f.RouteId,
FishArrayNumber = f.FishArrayNumber,
ArrayNumber = f.ArrayNumber,
CreatedAt = f.CreatedAt,
ExpireAt = f.ExpireAt,
IsKilled = f.IsKilled
}).ToList();
_cache.Insert(GetActiveFishSnapshotKey(state.RoomId), snapshot, ActiveFishSnapshotSeconds);
}
private static bool IsFishWithinHitWindow(ActiveFish fish, DateTime now)
{
if (fish == null || fish.IsKilled)
return false;
if (now <= fish.ExpireAt)
return true;
var buffered = fish.FishType == (int)CommonDomain.Enum.FishTypeEnum.Boss鱼 ||
fish.FishType == (int)CommonDomain.Enum.FishTypeEnum.特殊Boss ||
fish.FishType == (int)CommonDomain.Enum.FishTypeEnum.特殊鱼;
return buffered && now <= fish.ExpireAt.AddSeconds(ExpiredHitBufferSeconds);
}
private bool TryClaimSpawnOwnership(string roomId)
{
var key = GetSpawnOwnerKey(roomId);
if (_cache.GetFullRedis().Add(key, _instanceId, SpawnOwnerLeaseSeconds))
return true;
var owner = _cache.Get<string>(key);
if (string.IsNullOrWhiteSpace(owner))
return _cache.GetFullRedis().Add(key, _instanceId, SpawnOwnerLeaseSeconds);
if (!string.Equals(owner, _instanceId, StringComparison.Ordinal))
return false;
return _cache.Insert(key, _instanceId, SpawnOwnerLeaseSeconds);
}
private bool RenewSpawnOwnership(string roomId)
{
var key = GetSpawnOwnerKey(roomId);
var owner = _cache.Get<string>(key);
if (string.IsNullOrWhiteSpace(owner))
return _cache.GetFullRedis().Add(key, _instanceId, SpawnOwnerLeaseSeconds);
if (!string.Equals(owner, _instanceId, StringComparison.Ordinal))
return false;
return _cache.Insert(key, _instanceId, SpawnOwnerLeaseSeconds);
}
private void ReleaseSpawnOwnership(string roomId)
{
var key = GetSpawnOwnerKey(roomId);
var owner = _cache.Get<string>(key);
if (string.Equals(owner, _instanceId, StringComparison.Ordinal))
_cache.Remove(key);
}
private void SyncRoomSystemFactors()
{
try
{
var systemGold = _redisService.GetSystemGoldDay();
var gameConfig = _redisService.GetGameConfig();
if (gameConfig?.KillConfig == null)
return;
var dayTarget = gameConfig.KillConfig.DaySystemGold;
var normFactor = gameConfig.KillConfig.NormalizationFactorSystem;
var minMod = gameConfig.KillConfig.MinSystemModifier;
var maxMod = gameConfig.KillConfig.MaxSystemModifier;
double systemFactor = 1.0 + (systemGold - dayTarget) / (normFactor * 1.0);
systemFactor = Math.Clamp(systemFactor, minMod, maxMod);
foreach (var kv in _roomStates)
{
var room = _redisService.GetGameRoom(kv.Key);
if (room == null) continue;
double oldFactor = room.SystemFactor;
room.SystemFactor = systemFactor;
if (Math.Abs(systemFactor - oldFactor) >= SystemFactorChangeThreshold)
{
_redisService.SetGameRoom(room);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[FishSpawn] 同步系统因子异常");
}
}
private int CleanupExpiredFishes(RoomFishState state, DateTime now)
{
var expiredIds = new List<long>();
foreach (var kv in state.ActiveFishes)
{
var fish = kv.Value;
if (fish.IsKilled)
{
expiredIds.Add(kv.Key);
continue;
}
if (!_cache.Exists(GetFishKilledKey(state.RoomId, fish.FishId)) && now <= fish.ExpireAt)
continue;
bool isBufferedType = fish.FishType == (int)CommonDomain.Enum.FishTypeEnum.Boss鱼 ||
fish.FishType == (int)CommonDomain.Enum.FishTypeEnum.特殊Boss ||
fish.FishType == (int)CommonDomain.Enum.FishTypeEnum.特殊鱼;
if (isBufferedType && !_cache.Exists(GetFishKilledKey(state.RoomId, fish.FishId)) && now <= fish.ExpireAt.AddSeconds(ExpiredHitBufferSeconds))
continue;
expiredIds.Add(kv.Key);
}
foreach (var fishId in expiredIds)
{
if (state.ActiveFishes.Remove(fishId, out var fish))
{
if (fish.IsKilled)
continue;
state.FishCountByType.TryGetValue(fish.FishType, out var count);
if (count > 0)
state.FishCountByType[fish.FishType] = count - 1;
if (!string.IsNullOrEmpty(fish.FishArrayNumber))
DecrementArrayFishCount(state, fish.FishArrayNumber);
}
}
return expiredIds.Count;
}
private void SpawnFishBatch(RoomFishState state, DateTime now)
{
var config = state.Config;
if (config == null || config.Probabilities == null) return;
var random = new Random();
var allSpawned = new List<SpawnFishItem>();
bool isFirst = !state.IsFirstBatchSpawned;
foreach (var prob in config.Probabilities)
{
if (prob == null) continue;
var typeKey = prob.Type == 2 ? -1 : prob.FishType;
if (!isFirst)
{
if (!state.NextSpawnByType.TryGetValue(typeKey, out var nextTime))
nextTime = DateTime.MinValue;
if (now < nextTime)
continue;
}
int currentCount;
if (prob.Type == 2)
currentCount = state.ActiveArrayFishCount.Count;
else
state.FishCountByType.TryGetValue(prob.FishType, out currentCount);
if (currentCount >= prob.MaxCountMax) continue;
var remainingCapacity = Math.Max(0, prob.MaxCountMax - currentCount);
var configuredMin = isFirst ? prob.FirstFishCountMin : prob.CountMin;
var configuredMax = isFirst ? prob.FirstFishCountMax : prob.CountMax;
configuredMin = Math.Max(0, configuredMin);
configuredMax = Math.Max(configuredMin, configuredMax);
var requiredForFieldMinimum = !isFirst && currentCount < prob.MaxCountMin
? prob.MaxCountMin - currentCount
: 0;
var actualMin = Math.Max(configuredMin, requiredForFieldMinimum);
var actualMax = Math.Min(configuredMax, remainingCapacity);
if (actualMin > actualMax && currentCount < prob.MaxCountMin)
{
actualMin = requiredForFieldMinimum;
actualMax = remainingCapacity;
}
if (actualMin > actualMax)
{
var nextDelay = random.Next(
prob.TimeMin,
Math.Max(prob.TimeMin + 1, prob.TimeMax + 1));
state.NextSpawnByType[typeKey] = now.AddSeconds(nextDelay);
continue;
}
var spawnCount = random.Next(
actualMin,
Math.Max(actualMin + 1, actualMax + 1));
if (spawnCount <= 0)
{
if (!state.NextSpawnByType.ContainsKey(typeKey))
{
var nextDelay = random.Next(prob.TimeMin, Math.Max(prob.TimeMin + 1, prob.TimeMax + 1));
state.NextSpawnByType[typeKey] = now.AddSeconds(nextDelay);
}
continue;
}
var spawned = SpawnFishOfType(state, config, prob, spawnCount, now, random, isFirst);
allSpawned.AddRange(spawned);
var delay = random.Next(prob.TimeMin, Math.Max(prob.TimeMin + 1, prob.TimeMax + 1));
state.NextSpawnByType[typeKey] = now.AddSeconds(delay);
}
if (!state.IsFirstBatchSpawned)
state.IsFirstBatchSpawned = true;
if (allSpawned.Count > 0)
{
var spawnType = isFirst ? "init" : "spawn";
foreach (var spawnedFish in allSpawned.Where(f =>
string.IsNullOrWhiteSpace(f.FishArrayNumber) &&
(f.FishType == 2 || f.FishType == 5)))
{
var fishIds = spawnedFish.FishIds != null && spawnedFish.FishIds.Count > 0
? spawnedFish.FishIds
: new List<long> { spawnedFish.FishId };
var spawnTime = DateTimeOffset
.FromUnixTimeMilliseconds(spawnedFish.CreatedAtMs)
.ToLocalTime()
.ToString("yyyy-MM-dd HH:mm:ss.fff");
}
var payload = new
{
type = spawnType,
timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
serverTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"),
fish = allSpawned.Select(s => new
{
fishId = s.FishId,
fishNumber = string.IsNullOrWhiteSpace(s.FishArrayNumber) ? s.FishNumber : string.Empty,
routeId = s.RouteId,
fishArrayNumber = s.FishArrayNumber ?? string.Empty,
fishIds = s.FishIds,
createdAt = s.CreatedAtMs,
createdTime = DateTimeOffset.FromUnixTimeMilliseconds(s.CreatedAtMs).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff"),
expireAt = s.ExpireAtMs,
expireTime = DateTimeOffset.FromUnixTimeMilliseconds(s.ExpireAtMs).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff"),
remainingSeconds = (int)Math.Max(0, (s.ExpireAtMs - new DateTimeOffset(now).ToUnixTimeMilliseconds()) / 1000)
}).ToList()
};
if (!isFirst)
_hubGame.SendGroup(state.RoomId, "OnFishSpawn", payload);
if (isFirst)
{
var fishList = string.Join(", ", allSpawned.Select(s =>
{
var created = DateTimeOffset.FromUnixTimeMilliseconds(s.CreatedAtMs).ToLocalTime().ToString("HH:mm:ss.fff");
var expire = DateTimeOffset.FromUnixTimeMilliseconds(s.ExpireAtMs).ToLocalTime().ToString("HH:mm:ss.fff");
return $"{s.FishNumber}(id={s.FishId},c={created},e={expire})";
}));
}
}
}
private List<SpawnFishItem> SpawnFishOfType(RoomFishState state, FishSpawnLevelConfig config,
FishSpawnProbability prob, int spawnCount, DateTime now, Random random, bool isFirst)
{
var spawned = new List<SpawnFishItem>();
var activeRestrictedFish = new HashSet<string>();
foreach (var af in state.ActiveFishes.Values)
{
if (!af.IsKilled && af.ExpireAt > now &&
(af.FishType == 2 || af.FishType == 3 || af.FishType == 4 || af.FishType == 5))
{
activeRestrictedFish.Add(af.FishNumber);
}
}
var batchRouteSet = new HashSet<(string FishNumber, int RouteId)>();
bool preferOdd = true;
if (prob.Type == 1)
{
var candidates = config.Fishes
.Where(f => f.FishType == prob.FishType && f.FishWeight > 0)
.ToList();
if (candidates.Count == 0)
{
return spawned;
}
if (prob.FishType == (int)CommonDomain.Enum.FishTypeEnum.Boss鱼)
return SpawnBossFishByQueue(state, candidates, spawnCount, now, random, isFirst);
if (prob.FishType == (int)CommonDomain.Enum.FishTypeEnum.特殊Boss)
return SpawnSpecialBossByCycle(state, candidates, spawnCount, now, random, isFirst);
var maxAttempts = Math.Max(20, spawnCount * 10);
for (int attempt = 0; spawned.Count < spawnCount && attempt < maxAttempts; attempt++)
{
var alternateBySide = prob.FishType == 2 || prob.FishType == 3 || prob.FishType == 4 || prob.FishType == 5;
var requestedOdd = alternateBySide
? (prob.FishType == 3 ? state.PreferOddGoldenRoute : state.PreferOddRestrictedRoute)
: preferOdd;
var eligibleCandidates = alternateBySide
? candidates.Where(f => HasRouteForSide(f, requestedOdd)).ToList()
: candidates;
if (eligibleCandidates.Count == 0)
{
break;
}
var fishCfg = PickFishByWeight(eligibleCandidates, random);
if (fishCfg == null) continue;
if ((fishCfg.FishType == 2 || fishCfg.FishType == 3 || fishCfg.FishType == 4 || fishCfg.FishType == 5) &&
activeRestrictedFish.Contains(fishCfg.FishNumber))
continue;
if ((fishCfg.FishType == 2 || fishCfg.FishType == 5) &&
state.NextAllowedSpawnByFishNumber.TryGetValue(fishCfg.FishNumber, out var nextAllowedAt) &&
now < nextAllowedAt)
continue;
var routeId = PickRoute(fishCfg, random, requestedOdd);
if (alternateBySide && ((routeId & 1) == 1) != requestedOdd)
continue;
if ((fishCfg.FishType == 2 || fishCfg.FishType == 3 ||
fishCfg.FishType == 4 || fishCfg.FishType == 5) &&
state.ActiveFishes.Values.Any(f =>
!f.IsKilled && f.ExpireAt > now &&
string.Equals(f.FishNumber, fishCfg.FishNumber, StringComparison.Ordinal)))
continue;
var routeKey = (fishCfg.FishNumber, routeId);
if (!batchRouteSet.Add(routeKey))
{
if (alternateBySide)
continue;
routeId = PickRoute(fishCfg, random, !requestedOdd);
routeKey = (fishCfg.FishNumber, routeId);
if (!batchRouteSet.Add(routeKey))
continue;
}
if (fishCfg.FishType == 2 || fishCfg.FishType == 3 || fishCfg.FishType == 4 || fishCfg.FishType == 5)
activeRestrictedFish.Add(fishCfg.FishNumber);
if (alternateBySide)
{
var isLeft = (routeId & 1) == 1;
if (fishCfg.FishType == 3)
state.PreferOddGoldenRoute = !isLeft;
else
state.PreferOddRestrictedRoute = !isLeft;
}
else
{
preferOdd = !preferOdd;
}
spawned.Add(AddActiveFish(state, fishCfg, routeId, isFirst, now, random));
}
}
else if (prob.Type == 2 && config.FishArrays != null)
{
var arrayCandidates = config.FishArrays
.Where(a => a != null)
.OrderBy(_ => random.Next())
.OrderByDescending(a =>
int.TryParse(a.ArrayNumber, out var number) &&
((number & 1) == (state.PreferOddArrayNumber ? 1 : 0)))
.ToList();
foreach (var arrayCfg in arrayCandidates)
{
if (!int.TryParse(arrayCfg.ArrayNumber, out var arrayRouteId))
{
continue;
}
if (random.NextDouble() >= arrayCfg.GenerateFrequency) continue;
if (!string.IsNullOrEmpty(arrayCfg.BossFishNumber) &&
activeRestrictedFish.Contains(arrayCfg.BossFishNumber))
continue;
if (!string.IsNullOrEmpty(arrayCfg.BossFishNumber) &&
state.NextAllowedSpawnByFishNumber.TryGetValue(arrayCfg.BossFishNumber, out var arrayBossNextAllowedAt) &&
now < arrayBossNextAllowedAt)
continue;
var arrayCreatedAt = isFirst
? now.AddSeconds(-15 * (0.2 + random.NextDouble() * 0.6))
: now;
var arrayExpireAt = arrayCreatedAt.AddSeconds(15);
var arrayItem = new SpawnFishItem
{
RouteId = arrayRouteId,
FishArrayNumber = arrayCfg.FishArrayNumber,
FishNumber = arrayCfg.FishNumber,
BossFishNumber = arrayCfg.BossFishNumber,
FishCount = arrayCfg.FishCount,
BossCount = arrayCfg.BossCount,
CreatedAtMs = new DateTimeOffset(arrayCreatedAt).ToUnixTimeMilliseconds(),
ExpireAtMs = new DateTimeOffset(arrayExpireAt).ToUnixTimeMilliseconds()
};
var normalFishCount = Math.Max(0, arrayCfg.FishCount);
var configuredBossFishCount = Math.Max(0, arrayCfg.BossCount);
var bossFishCount = string.IsNullOrWhiteSpace(arrayCfg.BossFishNumber)
? 0
: Math.Min(1, configuredBossFishCount);
if (configuredBossFishCount > bossFishCount)
{
}
var expectedFishIdCount = normalFishCount + bossFishCount;
if (expectedFishIdCount == 0)
{
continue;
}
for (int fi = 0; fi < normalFishCount; fi++)
{
var fishId = NextFishId();
var routeId = PickRouteValue(random, 1, 9, preferOdd);
var activeFish = new ActiveFish
{
FishId = fishId,
FishNumber = arrayCfg.FishNumber,
FishType = (int)CommonDomain.Enum.FishTypeEnum.普通鱼,
RouteId = routeId,
FishArrayNumber = arrayCfg.FishArrayNumber,
ArrayNumber = arrayCfg.ArrayNumber,
CreatedAt = arrayCreatedAt,
ExpireAt = arrayExpireAt,
IsKilled = false
};
state.ActiveFishes[fishId] = activeFish;
if (fi == 0)
arrayItem.FishId = fishId;
arrayItem.FishIds.Add(fishId);
preferOdd = !preferOdd;
}
for (int bi = 0; bi < bossFishCount; bi++)
{
var routeId = PickRouteValue(random, 1, 9, preferOdd);
if (!string.IsNullOrEmpty(arrayCfg.BossFishNumber))
activeRestrictedFish.Add(arrayCfg.BossFishNumber);
var fishId = NextFishId();
var activeFish = new ActiveFish
{
FishId = fishId,
FishNumber = arrayCfg.BossFishNumber,
FishType = (int)CommonDomain.Enum.FishTypeEnum.Boss鱼,
RouteId = routeId,
FishArrayNumber = arrayCfg.FishArrayNumber,
ArrayNumber = arrayCfg.ArrayNumber,
CreatedAt = arrayCreatedAt,
ExpireAt = arrayExpireAt,
IsKilled = false
};
state.ActiveFishes[fishId] = activeFish;
state.NextAllowedSpawnByFishNumber[arrayCfg.BossFishNumber] =
activeFish.ExpireAt.AddSeconds(RestrictedFishRespawnBufferSeconds);
arrayItem.FishIds.Add(fishId);
preferOdd = !preferOdd;
}
if (arrayItem.FishIds.Count != expectedFishIdCount)
{
continue;
}
state.ActiveArrayFishCount[arrayCfg.FishArrayNumber] = arrayItem.FishIds.Count;
spawned.Add(arrayItem);
var isLeft = (arrayRouteId & 1) == 1;
state.PreferOddArrayNumber = !isLeft;
break;
}
}
return spawned;
}
/// <summary>
/// 重置 Boss 排队状态:Boss鱼(FishType=2)队列、回合计数、特殊Boss(FishType=5)轮询列表
/// </summary>
private static void ResetBossSpawnState(RoomFishState state)
{
var fishes = state.Config?.Fishes ?? new List<FishSpawnFish>();
state.BossQueue = fishes
.Where(f => f.FishType == (int)CommonDomain.Enum.FishTypeEnum.Boss鱼 &&
f.FishWeight > 0 && !string.IsNullOrWhiteSpace(f.FishNumber))
.Select(f => f.FishNumber)
.Distinct()
.OrderBy(_ => Random.Shared.Next())
.ToList();
state.BossQueueTotal = state.BossQueue.Count;
state.BossSpawnedThisRound = new List<string>();
state.BossRoundBatchCount = 0;
state.SpecialBossCycle = fishes
.Where(f => f.FishType == (int)CommonDomain.Enum.FishTypeEnum.特殊Boss &&
f.FishWeight > 0 && !string.IsNullOrWhiteSpace(f.FishNumber))
.Select(f => f.FishNumber)
.Distinct()
.OrderBy(_ => Random.Shared.Next())
.ToList();
}
/// <summary>
/// Boss鱼排队出鱼:按队列依次轮流出、不重复;每 maxBosscount/2 轮(批次)为一个回合,
/// 回合结束后本回合出过的鱼重新添加到随机出 Boss 的列表中。
/// </summary>
private List<SpawnFishItem> SpawnBossFishByQueue(RoomFishState state, List<FishSpawnFish> candidates,
int spawnCount, DateTime now, Random random, bool isFirst)
{
var spawned = new List<SpawnFishItem>();
if (state.BossQueueTotal == 0 && state.BossQueue.Count == 0 && state.BossSpawnedThisRound.Count == 0)
ResetBossSpawnState(state);
while (spawned.Count < spawnCount)
{
var requestedOdd = state.PreferOddRestrictedRoute;
var fishCfg = TakeNextEligibleBoss(state, candidates, requestedOdd, now);
if (fishCfg == null)
break;
var routeId = PickRoute(fishCfg, random, requestedOdd);
state.PreferOddRestrictedRoute = (routeId & 1) == 0;
spawned.Add(AddActiveFish(state, fishCfg, routeId, isFirst, now, random));
}
state.BossRoundBatchCount++;
var roundLength = Math.Max(1, state.BossQueueTotal / 2);
if (state.BossRoundBatchCount >= roundLength)
{
if (state.BossSpawnedThisRound.Count > 0)
state.BossQueue.AddRange(state.BossSpawnedThisRound);
state.BossSpawnedThisRound.Clear();
state.BossRoundBatchCount = 0;
}
return spawned;
}
/// <summary>
/// 特殊Boss轮询出鱼:每轮按随机顺序把每个特殊Boss各出一次,出完一轮重新随机排序。
/// </summary>
private List<SpawnFishItem> SpawnSpecialBossByCycle(RoomFishState state, List<FishSpawnFish> candidates,
int spawnCount, DateTime now, Random random, bool isFirst)
{
var spawned = new List<SpawnFishItem>();
while (spawned.Count < spawnCount)
{
var requestedOdd = state.PreferOddRestrictedRoute;
var fishCfg = TakeNextEligibleSpecialBoss(state, candidates, requestedOdd, now);
if (fishCfg == null)
break;
var routeId = PickRoute(fishCfg, random, requestedOdd);
state.PreferOddRestrictedRoute = (routeId & 1) == 0;
spawned.Add(AddActiveFish(state, fishCfg, routeId, isFirst, now, random));
}
return spawned;
}
/// <summary>
/// 从 Boss 队列依次取鱼:取队头,仍活跃或处于冷却期的鱼暂缓到队尾,取出的鱼记录到本回合已出列表。
/// </summary>
private FishSpawnFish? TakeNextEligibleBoss(RoomFishState state, List<FishSpawnFish> candidates,
bool requestedOdd, DateTime now)
{
var scanLimit = state.BossQueue.Count;
for (var i = 0; i < scanLimit && state.BossQueue.Count > 0; i++)
{
var fishNumber = state.BossQueue[0];
state.BossQueue.RemoveAt(0);
var fishCfg = candidates.FirstOrDefault(f =>
string.Equals(f.FishNumber, fishNumber, StringComparison.OrdinalIgnoreCase));
if (fishCfg == null)
continue;
if (!HasRouteForSide(fishCfg, requestedOdd) || IsFishNumberActiveOrCooling(state, fishNumber, now))
{
state.BossQueue.Add(fishNumber);
continue;
}
state.BossSpawnedThisRound.Add(fishNumber);
return fishCfg;
}
return null;
}
/// <summary>
/// 从特殊Boss轮询列表依次取鱼:取队头,仍活跃或处于冷却期的鱼暂缓到队尾;列表取空后重新随机排序。
/// </summary>
private FishSpawnFish? TakeNextEligibleSpecialBoss(RoomFishState state, List<FishSpawnFish> candidates,
bool requestedOdd, DateTime now)
{
if (state.SpecialBossCycle.Count == 0)
{
state.SpecialBossCycle = candidates
.Select(f => f.FishNumber)
.Distinct()
.OrderBy(_ => Random.Shared.Next())
.ToList();
}
var scanLimit = state.SpecialBossCycle.Count;
for (var i = 0; i < scanLimit && state.SpecialBossCycle.Count > 0; i++)
{
var fishNumber = state.SpecialBossCycle[0];
state.SpecialBossCycle.RemoveAt(0);
var fishCfg = candidates.FirstOrDefault(f =>
string.Equals(f.FishNumber, fishNumber, StringComparison.OrdinalIgnoreCase));
if (fishCfg == null)
continue;
if (!HasRouteForSide(fishCfg, requestedOdd) || IsFishNumberActiveOrCooling(state, fishNumber, now))
{
state.SpecialBossCycle.Add(fishNumber);
continue;
}
return fishCfg;
}
return null;
}
private static bool IsFishNumberActiveOrCooling(RoomFishState state, string fishNumber, DateTime now)
{
if (state.ActiveFishes.Values.Any(f => !f.IsKilled && f.ExpireAt > now &&
string.Equals(f.FishNumber, fishNumber, StringComparison.Ordinal)))
return true;
return state.NextAllowedSpawnByFishNumber.TryGetValue(fishNumber, out var nextAllowedAt) && now < nextAllowedAt;
}
/// <summary>
/// 将鱼写入房间活跃鱼状态并返回出鱼项(首批鱼向前偏移创建时间,模拟已游动一段时间)。
/// </summary>
private SpawnFishItem AddActiveFish(RoomFishState state, FishSpawnFish fishCfg, int routeId,
bool isFirst, DateTime now, Random random)
{
var times = fishCfg.Times > 0 ? fishCfg.Times : 8;
DateTime createdAt;
if (isFirst)
{
var pastOffset = times * (0.2 + random.NextDouble() * 0.6);
createdAt = now.AddSeconds(-pastOffset);
}
else
{
createdAt = now;
}
var fishId = NextFishId();
var activeFish = new ActiveFish
{
FishId = fishId,
FishNumber = fishCfg.FishNumber,
FishType = fishCfg.FishType,
RouteId = routeId,
CreatedAt = createdAt,
ExpireAt = createdAt.AddSeconds(times),
IsKilled = false
};
state.ActiveFishes[fishId] = activeFish;
if (fishCfg.FishType == 2 || fishCfg.FishType == 5)
{
state.NextAllowedSpawnByFishNumber[fishCfg.FishNumber] =
activeFish.ExpireAt.AddSeconds(RestrictedFishRespawnBufferSeconds);
}
state.FishCountByType.TryGetValue(fishCfg.FishType, out var fc);
state.FishCountByType[fishCfg.FishType] = fc + 1;
return new SpawnFishItem
{
FishId = fishId,
FishIds = new List<long> { fishId },
FishNumber = fishCfg.FishNumber,
FishType = fishCfg.FishType,
RouteId = routeId,
CreatedAtMs = new DateTimeOffset(createdAt).ToUnixTimeMilliseconds(),
ExpireAtMs = new DateTimeOffset(activeFish.ExpireAt).ToUnixTimeMilliseconds()
};
}
private static string GetSpawnLogTypeName(int fishType)
{
return fishType switch
{
1 => "普通鱼",
2 => "Boss鱼",
3 => "黄金鱼",
4 => "特殊鱼",
5 => "特殊Boss",
_ => $"未知类型({fishType})"
};
}
private static string GetFishTypeName(int fishType)
{
return fishType switch
{
1 => "普通鱼",
2 => "Boss鱼",
3 => "黄金鱼",
4 => "特殊鱼",
5 => "特殊Boss",
_ => $"未知鱼类({fishType})"
};
}
public void ExtendFishLifespan(string roomId, int extraSeconds)
{
if (!_roomStates.TryGetValue(roomId, out var state))
return;
lock (state.Lock)
{
int count = 0;
foreach (var fish in state.ActiveFishes.Values)
{
if (!fish.IsKilled)
{
fish.ExpireAt = fish.ExpireAt.AddSeconds(extraSeconds);
count++;
}
}
PersistActiveFishSnapshot(state);
}
}
private void DecrementArrayFishCount(RoomFishState state, string arrayNumber)
{
if (!state.ActiveArrayFishCount.TryGetValue(arrayNumber, out var remaining))
return;
remaining--;
if (remaining <= 0)
state.ActiveArrayFishCount.Remove(arrayNumber);
else
state.ActiveArrayFishCount[arrayNumber] = remaining;
}
private FishSpawnFish PickFishByWeight(List<FishSpawnFish> candidates, Random random)
{
var totalWeight = candidates.Sum(f => f.FishWeight);
if (totalWeight <= 0) return candidates[random.Next(candidates.Count)];
var roll = random.Next(0, totalWeight);
long cumulative = 0;
foreach (var fish in candidates)
{
cumulative += fish.FishWeight;
if (roll < cumulative)
return fish;
}
return candidates.Last();
}
private int PickRoute(FishSpawnFish fishCfg, Random random, bool? preferOdd = null)
{
var routes = fishCfg.PathList;
if (routes == null || routes.Count == 0)
return PickRouteValue(random, 1, 8, preferOdd);
if (preferOdd.HasValue)
{
var filtered = routes.Where(r => (r & 1) == (preferOdd.Value ? 1 : 0)).ToList();
if (filtered.Count > 0)
return filtered[random.Next(filtered.Count)];
}
return routes[random.Next(routes.Count)];
}
private static bool HasRouteForSide(FishSpawnFish fishCfg, bool preferOdd)
{
var routes = fishCfg.PathList;
return routes == null || routes.Count == 0 ||
routes.Any(routeId => ((routeId & 1) == 1) == preferOdd);
}
private int PickRouteValue(Random random, int min, int max, bool? preferOdd)
{
var candidates = Enumerable.Range(min, max - min).ToList();
if (preferOdd.HasValue)
{
var filtered = candidates.Where(r => (r & 1) == (preferOdd.Value ? 1 : 0)).ToList();
if (filtered.Count > 0)
return filtered[random.Next(filtered.Count)];
}
return candidates[random.Next(candidates.Count)];
}
}
public class RoomFishState
{
public int GameType { get; set; }
public string RoomId { get; set; }
public int RoomLevel { get; set; }
public FishSpawnLevelConfig Config { get; set; }
public Dictionary<int, DateTime> NextSpawnByType { get; set; } = new Dictionary<int, DateTime>();
public Dictionary<string, DateTime> NextAllowedSpawnByFishNumber { get; set; } = new Dictionary<string, DateTime>();
public bool IsFirstBatchSpawned { get; set; }
public Dictionary<long, ActiveFish> ActiveFishes { get; set; } = new Dictionary<long, ActiveFish>();
public Dictionary<int, int> FishCountByType { get; set; } = new Dictionary<int, int>();
public Dictionary<string, int> ActiveArrayFishCount { get; set; } = new Dictionary<string, int>();
public List<string> BossQueue { get; set; } = new List<string>();
public List<string> BossSpawnedThisRound { get; set; } = new List<string>();
public int BossQueueTotal { get; set; }
public int BossRoundBatchCount { get; set; }
public List<string> SpecialBossCycle { get; set; } = new List<string>();
public bool PreferOddArrayNumber { get; set; } = true;
public bool PreferOddRestrictedRoute { get; set; } = true;
public bool PreferOddGoldenRoute { get; set; } = true;
public object Lock { get; set; } = new object();
}
public class ActiveFish
{
public long FishId { get; set; }
public string FishNumber { get; set; }
public int FishType { get; set; }
public int RouteId { get; set; }
public string FishArrayNumber { get; set; }
public string ArrayNumber { get; set; }
public int ClientRouteId => !string.IsNullOrWhiteSpace(FishArrayNumber)
&& int.TryParse(ArrayNumber, out var arrayRouteId)
? arrayRouteId
: RouteId;
public DateTime CreatedAt { get; set; }
public DateTime ExpireAt { get; set; }
public bool IsKilled { get; set; }
}
public class SpawnFishItem
{
public long FishId { get; set; }
public string FishNumber { get; set; }
public int FishType { get; set; }
public int RouteId { get; set; }
public string FishArrayNumber { get; set; }
public string BossFishNumber { get; set; }
public int FishCount { get; set; }
public int BossCount { get; set; }
public List<long> FishIds { get; set; } = new List<long>();
public long ExpireAtMs { get; set; }
public long CreatedAtMs { get; set; }
}
}