519 lines
19 KiB
C#
519 lines
19 KiB
C#
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);
|
|
|
|
// Všichni impostoři mrtví -> crew vyhrál (kontrolujeme nejdřív, ošetří
|
|
// i edge case 0 impostorů + 0 crew - vrátí "crew vyhrál" místo aby
|
|
// padlo dál do impostor-win větve)
|
|
if (aliveImpostors == 0)
|
|
{
|
|
return (true, "Crew", "Všichni impostoři eliminováni");
|
|
}
|
|
|
|
// Žádní crewmati naživu -> impostoři vyhráli (kritický fix: P9 -
|
|
// při ejekci posledního crewmate stará podmínka `aliveCrew > 0`
|
|
// shodila tuto větev a hra pokračovala s 0 crew naživu).
|
|
if (aliveCrew == 0)
|
|
{
|
|
return (true, "Impostor", "Všichni crewmati eliminováni");
|
|
}
|
|
|
|
// Impostoři vyhráli - mají většinu nebo rovnost (oba > 0)
|
|
if (aliveImpostors >= aliveCrew)
|
|
{
|
|
return (true, "Impostor", "Impostoři mají převahu");
|
|
}
|
|
|
|
// 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
|
|
}
|