Server
This commit is contained in:
508
GameLogic.cs
Normal file
508
GameLogic.cs
Normal file
@@ -0,0 +1,508 @@
|
||||
namespace GeoSus.Server;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Threading.Channels;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
// Herní logika - kill, meeting, voting, tasks
|
||||
public class GameLogic
|
||||
{
|
||||
private readonly ServerConfig _config;
|
||||
private readonly ILogger _logger;
|
||||
private readonly OverpassService? _overpassService;
|
||||
private readonly Random _random = new Random(); // Sdílená instance pro lepší náhodnost
|
||||
|
||||
public GameLogic(ServerConfig config, ILogger logger, OverpassService? overpassService = null)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
_overpassService = overpassService;
|
||||
}
|
||||
|
||||
#region Zabíjení
|
||||
|
||||
public (bool Success, string? Error, Body? Body) TryKill(
|
||||
Player killer, Player victim, Dictionary<string, Player> players, List<Body> bodies)
|
||||
{
|
||||
// Validace - killer musí být impostor a alive
|
||||
if (killer.Role != PlayerRole.Impostor)
|
||||
return (false, "Nejsi impostor", null);
|
||||
|
||||
if (killer.State != PlayerState.Alive)
|
||||
return (false, "Jsi mrtvý", null);
|
||||
|
||||
if (victim.State != PlayerState.Alive)
|
||||
return (false, "Oběť je již mrtvá", null);
|
||||
|
||||
// Cooldown kontrola
|
||||
if (killer.LastKillTime.HasValue)
|
||||
{
|
||||
var elapsed = (DateTime.UtcNow - killer.LastKillTime.Value).TotalMilliseconds;
|
||||
if (elapsed < _config.KillCooldownMs)
|
||||
{
|
||||
var remaining = (_config.KillCooldownMs - elapsed) / 1000;
|
||||
return (false, $"Cooldown: {remaining:F1}s", null);
|
||||
}
|
||||
}
|
||||
|
||||
// Vzdálenost
|
||||
var distance = killer.Position.DistanceTo(victim.Position);
|
||||
if (distance > _config.KillDistanceM)
|
||||
return (false, $"Příliš daleko ({distance:F1}m)", null);
|
||||
|
||||
// Kill úspěšný
|
||||
victim.State = PlayerState.Dead;
|
||||
killer.LastKillTime = DateTime.UtcNow;
|
||||
|
||||
var body = new Body
|
||||
{
|
||||
BodyId = Guid.NewGuid().ToString("N")[..8],
|
||||
VictimId = victim.ClientUuid,
|
||||
KillerId = killer.ClientUuid,
|
||||
Location = victim.Position,
|
||||
KilledAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
bodies.Add(body);
|
||||
|
||||
_logger.LogInformation("Kill: {Killer} zabil {Victim}, body {BodyId}",
|
||||
killer.ClientUuid, victim.ClientUuid, body.BodyId);
|
||||
|
||||
return (true, null, body);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reporty a meetingy
|
||||
|
||||
public (bool Success, string? Error, Body? Body) TryReportBody(
|
||||
Player reporter, string bodyId, List<Body> bodies)
|
||||
{
|
||||
if (reporter.State != PlayerState.Alive)
|
||||
return (false, "Jsi mrtvý", null);
|
||||
|
||||
var body = bodies.FirstOrDefault(b => b.BodyId == bodyId);
|
||||
if (body == null)
|
||||
return (false, "Tělo neexistuje", null);
|
||||
|
||||
if (body.Reported)
|
||||
return (false, "Tělo již bylo reportnuto", null);
|
||||
|
||||
// Vzdálenost
|
||||
var distance = reporter.Position.DistanceTo(body.Location);
|
||||
if (distance > _config.ReportDistanceM)
|
||||
return (false, $"Příliš daleko ({distance:F1}m)", null);
|
||||
|
||||
body.Reported = true;
|
||||
body.ReportedBy = reporter.ClientUuid;
|
||||
|
||||
_logger.LogInformation("Report: {Reporter} reportnul tělo {BodyId}",
|
||||
reporter.ClientUuid, bodyId);
|
||||
|
||||
return (true, null, body);
|
||||
}
|
||||
|
||||
public (bool Success, string? Error) TryCallEmergencyMeeting(
|
||||
Player caller, int maxMeetings, int cooldownMs)
|
||||
{
|
||||
if (caller.State != PlayerState.Alive)
|
||||
return (false, "Jsi mrtvý");
|
||||
|
||||
// Limit na počet emergency meetings
|
||||
if (caller.EmergencyMeetingsUsed >= maxMeetings)
|
||||
return (false, $"Vyčerpal jsi emergency meetingy ({maxMeetings})");
|
||||
|
||||
// Cooldown
|
||||
if (caller.LastEmergencyMeetingTime.HasValue)
|
||||
{
|
||||
var elapsed = (DateTime.UtcNow - caller.LastEmergencyMeetingTime.Value).TotalMilliseconds;
|
||||
if (elapsed < cooldownMs)
|
||||
{
|
||||
var remaining = (cooldownMs - elapsed) / 1000;
|
||||
return (false, $"Cooldown: {remaining:F1}s");
|
||||
}
|
||||
}
|
||||
|
||||
caller.EmergencyMeetingsUsed++;
|
||||
caller.LastEmergencyMeetingTime = DateTime.UtcNow;
|
||||
|
||||
_logger.LogInformation("Emergency meeting: {Caller}", caller.ClientUuid);
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
public Meeting StartMeeting(
|
||||
MeetingType type, string callerId, Position meetingLocation,
|
||||
string? reportedBodyId, int arrivalBaseMs, int votingPhaseMs, int discussionPhaseMs)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
return new Meeting
|
||||
{
|
||||
MeetingId = Guid.NewGuid().ToString("N")[..8],
|
||||
Type = type,
|
||||
CallerId = callerId,
|
||||
ReportedBodyId = reportedBodyId,
|
||||
MeetingLocation = meetingLocation,
|
||||
StartTime = now,
|
||||
ArrivalDeadline = now.AddMilliseconds(arrivalBaseMs + _config.ArrivalSafetyMarginMs),
|
||||
DiscussionEndTime = discussionPhaseMs > 0 ? now.AddMilliseconds(arrivalBaseMs + discussionPhaseMs) : null,
|
||||
VotingEndTime = now.AddMilliseconds(arrivalBaseMs + discussionPhaseMs + votingPhaseMs)
|
||||
};
|
||||
}
|
||||
|
||||
public bool CheckPlayerArrival(Player player, Meeting meeting)
|
||||
{
|
||||
if (player.State != PlayerState.Alive)
|
||||
return false;
|
||||
|
||||
var distance = player.Position.DistanceTo(meeting.MeetingLocation);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Grace period
|
||||
var deadline = meeting.ArrivalDeadline.AddMilliseconds(_config.AllowedLateMs);
|
||||
|
||||
if (now <= deadline && distance <= _config.MeetingArrivalRadiusM)
|
||||
{
|
||||
meeting.ArrivedPlayers.Add(player.ClientUuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hlasování
|
||||
|
||||
public (bool Success, string? Error) TryCastVote(
|
||||
Player voter, string? targetId, Meeting meeting, Dictionary<string, Player> players)
|
||||
{
|
||||
if (voter.State != PlayerState.Alive)
|
||||
return (false, "Jsi mrtvý");
|
||||
|
||||
if (!meeting.ArrivedPlayers.Contains(voter.ClientUuid))
|
||||
return (false, "Nedorazil jsi včas na meeting");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Kontrola fáze - musíme být ve voting fázi
|
||||
if (meeting.DiscussionEndTime.HasValue && now < meeting.DiscussionEndTime.Value)
|
||||
return (false, "Ještě probíhá diskuze");
|
||||
|
||||
if (now > meeting.VotingEndTime)
|
||||
return (false, "Hlasování skončilo");
|
||||
|
||||
// Rate limit na změny hlasu
|
||||
if (meeting.Votes.ContainsKey(voter.ClientUuid) && meeting.LastVoteChangeTime.HasValue)
|
||||
{
|
||||
var sinceLastChange = (now - meeting.LastVoteChangeTime.Value).TotalMilliseconds;
|
||||
if (sinceLastChange < 2000)
|
||||
return (false, "Příliš rychlá změna hlasu");
|
||||
}
|
||||
|
||||
// Validace targetu
|
||||
if (targetId != null)
|
||||
{
|
||||
if (!players.TryGetValue(targetId, out var target))
|
||||
return (false, "Neplatný cíl hlasu");
|
||||
if (target.State != PlayerState.Alive)
|
||||
return (false, "Cíl je mrtvý");
|
||||
}
|
||||
|
||||
meeting.Votes[voter.ClientUuid] = targetId;
|
||||
meeting.LastVoteChangeTime = now;
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
public (string? EjectedId, bool WasTie, Dictionary<string, int> VoteCounts) ResolveVoting(
|
||||
Meeting meeting, Dictionary<string, Player> players, TiePolicy tiePolicy)
|
||||
{
|
||||
var voteCounts = new Dictionary<string, int>();
|
||||
var skipCount = 0;
|
||||
|
||||
foreach (var (voterId, targetId) in meeting.Votes)
|
||||
{
|
||||
if (targetId == null)
|
||||
{
|
||||
skipCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
voteCounts.TryAdd(targetId, 0);
|
||||
voteCounts[targetId]++;
|
||||
}
|
||||
}
|
||||
|
||||
voteCounts["__SKIP__"] = skipCount;
|
||||
|
||||
// Najdeme maximum
|
||||
var maxVotes = voteCounts.Values.Max();
|
||||
var topVoted = voteCounts.Where(kv => kv.Value == maxVotes).Select(kv => kv.Key).ToList();
|
||||
|
||||
// Kontrola remízy
|
||||
if (topVoted.Count > 1 || topVoted.Contains("__SKIP__"))
|
||||
{
|
||||
// Remíza nebo skip vyhrál
|
||||
switch (tiePolicy)
|
||||
{
|
||||
case TiePolicy.NoEject:
|
||||
return (null, topVoted.Count > 1, voteCounts);
|
||||
|
||||
case TiePolicy.Random:
|
||||
if (topVoted.Contains("__SKIP__"))
|
||||
topVoted.Remove("__SKIP__");
|
||||
if (topVoted.Count > 0)
|
||||
{
|
||||
var random = new Random();
|
||||
var ejected = topVoted[random.Next(topVoted.Count)];
|
||||
return (ejected, true, voteCounts);
|
||||
}
|
||||
return (null, true, voteCounts);
|
||||
|
||||
case TiePolicy.EjectLowestId:
|
||||
if (topVoted.Contains("__SKIP__"))
|
||||
topVoted.Remove("__SKIP__");
|
||||
if (topVoted.Count > 0)
|
||||
{
|
||||
var ejected = topVoted.OrderBy(id => id).First();
|
||||
return (ejected, true, voteCounts);
|
||||
}
|
||||
return (null, true, voteCounts);
|
||||
|
||||
default:
|
||||
return (null, topVoted.Count > 1, voteCounts);
|
||||
}
|
||||
}
|
||||
|
||||
var winner = topVoted[0];
|
||||
if (winner == "__SKIP__")
|
||||
return (null, false, voteCounts);
|
||||
|
||||
return (winner, false, voteCounts);
|
||||
}
|
||||
|
||||
public void EjectPlayer(Player player)
|
||||
{
|
||||
player.State = PlayerState.Dead;
|
||||
_logger.LogInformation("Ejected: {Player}", player.ClientUuid);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tasky
|
||||
|
||||
/// <summary>
|
||||
/// Pokusí se dokončit task. Kontroluje:
|
||||
/// - Hráč je naživu
|
||||
/// - Není impostor
|
||||
/// - Task existuje a patří hráči
|
||||
/// - Task ještě není dokončen
|
||||
/// - Hráč je dostatečně blízko
|
||||
/// Poznámka: Duchové (mrtví hráči) MOHOU plnit úkoly - to je důležité pro crew!
|
||||
/// </summary>
|
||||
public (bool Success, string? Error) TryCompleteTask(Player player, string taskId)
|
||||
{
|
||||
// Duchové mohou plnit úkoly - neblokujeme mrtvé hráče
|
||||
// Ale musí být crew role
|
||||
if (player.Role == PlayerRole.Impostor)
|
||||
return (false, "Impostoři nemohou dělat tasky");
|
||||
|
||||
var task = player.Tasks.FirstOrDefault(t => t.TaskId == taskId);
|
||||
if (task == null)
|
||||
return (false, "Tento task ti nepatří");
|
||||
|
||||
if (player.CompletedTaskIds.Contains(taskId))
|
||||
return (false, "Task již dokončen");
|
||||
|
||||
// Kontrola vzdálenosti
|
||||
var distance = player.Position.DistanceTo(task.Location);
|
||||
if (distance > _config.TaskStartDistanceM)
|
||||
return (false, $"Příliš daleko ({distance:F1}m, max {_config.TaskStartDistanceM}m)");
|
||||
|
||||
// Dokončit task
|
||||
player.CompletedTaskIds.Add(taskId);
|
||||
|
||||
_logger.LogInformation("Task completed: {Player} dokončil {Task} na pozici {Pos} (vzdálenost {Dist:F1}m)",
|
||||
player.ClientUuid, task.Name, task.Location, distance);
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Najde nejbližší nedokončený task hráče
|
||||
/// </summary>
|
||||
public GameTask? FindNearestTask(Player player, double maxDistance)
|
||||
{
|
||||
return player.Tasks
|
||||
.Where(t => !player.CompletedTaskIds.Contains(t.TaskId))
|
||||
.Where(t => player.Position.DistanceTo(t.Location) <= maxDistance)
|
||||
.OrderBy(t => player.Position.DistanceTo(t.Location))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Win conditions
|
||||
|
||||
public (bool GameOver, string? WinningFaction, string? Reason) CheckWinConditions(
|
||||
Dictionary<string, Player> players, List<GameTask> tasks)
|
||||
{
|
||||
var aliveCrew = players.Values.Count(p => p.State == PlayerState.Alive && p.Role == PlayerRole.Crew);
|
||||
var aliveImpostors = players.Values.Count(p => p.State == PlayerState.Alive && p.Role == PlayerRole.Impostor);
|
||||
|
||||
// Impostoři vyhráli - mají většinu nebo rovnost
|
||||
if (aliveImpostors >= aliveCrew && aliveCrew > 0)
|
||||
{
|
||||
return (true, "Impostor", "Impostoři mají převahu");
|
||||
}
|
||||
|
||||
// Všichni impostoři mrtví
|
||||
if (aliveImpostors == 0)
|
||||
{
|
||||
return (true, "Crew", "Všichni impostoři eliminováni");
|
||||
}
|
||||
|
||||
// Všechny tasky hotové (počítáme pouze crew tasky)
|
||||
var crewPlayers = players.Values.Where(p => p.Role == PlayerRole.Crew).ToList();
|
||||
var totalTasks = crewPlayers.Sum(p => p.Tasks.Count);
|
||||
var completedTasks = crewPlayers.Sum(p => p.CompletedTaskIds.Count);
|
||||
|
||||
if (totalTasks > 0 && completedTasks >= totalTasks)
|
||||
{
|
||||
return (true, "Crew", "Všechny tasky dokončeny");
|
||||
}
|
||||
|
||||
return (false, null, null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Role assignment
|
||||
|
||||
public void AssignRoles(List<Player> players, int impostorCount)
|
||||
{
|
||||
if (players.Count < 2)
|
||||
{
|
||||
// Minimálně 2 hráči
|
||||
foreach (var p in players)
|
||||
p.Role = PlayerRole.Crew;
|
||||
return;
|
||||
}
|
||||
|
||||
// Omezení počtu impostorů
|
||||
var maxImpostors = Math.Max(1, players.Count / 3);
|
||||
impostorCount = Math.Min(impostorCount, maxImpostors);
|
||||
impostorCount = Math.Min(impostorCount, players.Count - 1);
|
||||
|
||||
// Náhodně vybereme impostory
|
||||
var shuffled = players.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)).ToList();
|
||||
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
shuffled[i].Role = i < impostorCount ? PlayerRole.Impostor : PlayerRole.Crew;
|
||||
shuffled[i].State = PlayerState.Alive;
|
||||
shuffled[i].EmergencyMeetingsUsed = 0;
|
||||
shuffled[i].LastEmergencyMeetingTime = null;
|
||||
shuffled[i].LastKillTime = null;
|
||||
shuffled[i].CompletedTaskIds.Clear();
|
||||
shuffled[i].CurrentTaskId = null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Roles assigned: {ImpostorCount} impostorů z {TotalPlayers} hráčů",
|
||||
impostorCount, players.Count);
|
||||
}
|
||||
|
||||
public List<GameTask> GenerateTasks(int count, Position center, double radius, MapData? mapData = null, int startIndex = 0)
|
||||
{
|
||||
var tasks = new List<GameTask>();
|
||||
|
||||
var taskNames = new[] {
|
||||
"Opravit kabel", "Zkalibrovat senzor", "Stáhnout data",
|
||||
"Nabít baterii", "Vyčistit filtr", "Nastavit kompas",
|
||||
"Opravit antenu", "Zkontrolovat zásoby", "Otestovat reaktor"
|
||||
};
|
||||
|
||||
// Get task positions - use map data if available
|
||||
List<Position> positions;
|
||||
|
||||
if (mapData != null && _overpassService != null && mapData.ReachablePositions.Count >= count)
|
||||
{
|
||||
positions = _overpassService.GetPOIBasedPositions(mapData, count, _random);
|
||||
_logger.LogInformation("Generated {Count} positions for tasks using map data", positions.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
positions = GenerateRandomPositions(count, center, radius, _random);
|
||||
_logger.LogDebug("Generated {Count} positions for tasks using random fallback", positions.Count);
|
||||
}
|
||||
|
||||
for (int i = 0; i < count && i < positions.Count; i++)
|
||||
{
|
||||
string taskId = $"task_{startIndex + i}";
|
||||
string taskName = taskNames[(startIndex + i) % taskNames.Length];
|
||||
|
||||
tasks.Add(new GameTask
|
||||
{
|
||||
TaskId = taskId,
|
||||
Name = taskName,
|
||||
Location = positions[i],
|
||||
Type = TaskType.Instant
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private List<Position> GenerateRandomPositions(int count, Position center, double radius, Random random)
|
||||
{
|
||||
var positions = new List<Position>();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
// Náhodná pozice v kruhu
|
||||
var angle = random.NextDouble() * 2 * Math.PI;
|
||||
var distance = random.NextDouble() * radius * 0.8; // 80% radius aby nebyly na kraji
|
||||
var lat = center.Lat + (distance / 111000) * Math.Cos(angle);
|
||||
var lon = center.Lon + (distance / (111000 * Math.Cos(center.Lat * Math.PI / 180))) * Math.Sin(angle);
|
||||
positions.Add(new Position(lat, lon));
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate repair station positions using map data for reachability
|
||||
/// </summary>
|
||||
public List<Position> GenerateRepairStationPositions(int count, Position center, double radius, MapData? mapData = null)
|
||||
{
|
||||
if (mapData != null && _overpassService != null && mapData.ReachablePositions.Count >= count)
|
||||
{
|
||||
// Get well-distributed reachable positions
|
||||
var positions = _overpassService.GetDistributedReachablePositions(
|
||||
mapData, count, _random, _config.MinTaskSpacingMeters * 2);
|
||||
|
||||
if (positions.Count >= count)
|
||||
{
|
||||
_logger.LogInformation("Generated {Count} repair station positions using map data", positions.Count);
|
||||
return positions;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: opposite ends of play area
|
||||
var result = new List<Position>();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var angle = (2 * Math.PI * i) / count;
|
||||
var dist = radius * 0.7;
|
||||
var lat = center.Lat + (dist * Math.Cos(angle)) / 111000.0;
|
||||
var lon = center.Lon + (dist * Math.Sin(angle)) / (111000.0 * Math.Cos(center.Lat * Math.PI / 180));
|
||||
result.Add(new Position(lat, lon));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user