1335 lines
52 KiB
C#
1335 lines
52 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,
|
||
|
|
};
|
||
|
|
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 fishId = NextFishId();
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 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;
|
||
|
|
|
||
|
|
spawned.Add(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()
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 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; }
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|