YD_TrainingCompetition_Api/Competition/CompetitionSessionService.cs

655 lines
23 KiB
C#
Raw Normal View History

2026-06-10 11:16:46 +08:00
using YD_TrainingCompetition_Api.Models;
using YD_TrainingCompetition_Api.Serial;
using YD_TrainingCompetition_Api.Services;
using System.Net.Http;
using System.Text;
using System.Text.Json;
namespace YD_TrainingCompetition_Api.Competition;
public sealed class CompetitionSessionService : ICompetitionSessionService
{
private static readonly HttpClient DebugClient = new();
private readonly object _gate = new();
private readonly IParticipantDirectory _directory;
private readonly ISerialGateway _serial;
private readonly IRealtimeNotifier _notifier;
private MutableCompetitionSession _session = MutableCompetitionSession.Empty();
public CompetitionSessionService(
IParticipantDirectory directory,
ISerialGateway serial,
IRealtimeNotifier notifier)
{
_directory = directory;
_serial = serial;
_notifier = notifier;
}
public CompetitionSessionState GetState()
{
lock (_gate)
{
return _session.ToState();
}
}
public async Task<CompetitionSessionState> CreateAsync(CreateCompetitionSessionRequest request, CancellationToken cancellationToken = default)
{
if (request.Participants is null || request.Participants.Count == 0)
{
throw new InvalidOperationException("participants is required");
}
MutableCompetitionSession next;
lock (_gate)
{
next = BuildSession(request);
_session = next;
ApplyCurrentRoundDirectoryUnsafe();
}
var state = next.ToState();
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public async Task<CompetitionSessionState> SelectRoundAsync(int roundIndex, CancellationToken cancellationToken = default)
{
CompetitionSessionState state;
lock (_gate)
{
EnsureLoadedUnsafe();
if (roundIndex < 0 || roundIndex >= _session.RoundParticipantCounts.Count)
{
throw new ArgumentOutOfRangeException(nameof(roundIndex));
}
_session.CurrentRoundIndex = roundIndex;
_session.IsTesting = false;
_session.UpdatedAt = DateTimeOffset.UtcNow;
ApplyCurrentRoundDirectoryUnsafe();
state = _session.ToState();
}
await SendSelectRoundCommandAsync(roundIndex, cancellationToken);
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public Task<CompetitionSessionState> SelectNextRoundAsync(CancellationToken cancellationToken = default)
{
var current = GetState();
var next = Math.Min(current.CurrentRoundIndex + 1, Math.Max(current.TotalRounds - 1, 0));
return SelectRoundAsync(next, cancellationToken);
}
public Task<CompetitionSessionState> SelectPreviousRoundAsync(CancellationToken cancellationToken = default)
{
var current = GetState();
var next = Math.Max(current.CurrentRoundIndex - 1, 0);
return SelectRoundAsync(next, cancellationToken);
}
public async Task<CompetitionSessionState> DownloadCurrentRoundAsync(CancellationToken cancellationToken = default)
{
CompetitionSessionState state;
IReadOnlyList<CompetitionParticipantState> roundParticipants;
int durationSeconds;
int roundIndex;
lock (_gate)
{
EnsureLoadedUnsafe();
roundIndex = _session.CurrentRoundIndex;
roundParticipants = _session.GetRoundParticipants(roundIndex).Select(CloneParticipant).ToArray();
durationSeconds = Math.Max(_session.TestDurationSeconds ?? 0, 0);
state = _session.ToState();
}
#region debug-point D:download-round
_ = DebugReportAsync("D", "CompetitionSessionService.DownloadCurrentRoundAsync", "[DEBUG] download current round requested", new
{
roundIndex,
durationSeconds,
participantCount = roundParticipants.Count,
participants = roundParticipants.Select(x => new
{
x.Id,
x.Name,
x.DeviceId,
x.Rfid,
x.RoundIndex,
x.SlotIndex
}).ToArray()
});
#endregion
await SendFrameWithLegacyDelayAsync(
BuildFrame(0x06, 0x06, (byte)(durationSeconds / 256), (byte)(durationSeconds % 256)),
100,
cancellationToken);
await SendFrameWithLegacyDelayAsync(BuildLegacyDownloadPrepareFrame(), 100, cancellationToken);
await SendFrameWithLegacyDelayAsync(BuildFrame(0x0D, 0x0D, 0x00, (byte)roundParticipants.Count), 100, cancellationToken);
var addressBase = (roundIndex % 10) * 20;
foreach (var participant in roundParticipants.OrderBy(x => x.SlotIndex))
{
var slot = addressBase + participant.SlotIndex;
#region debug-point D:participant-download
_ = DebugReportAsync("D", "CompetitionSessionService.DownloadCurrentRoundAsync", "[DEBUG] participant download prepared", new
{
roundIndex,
addressBase,
slot,
participant.Id,
participant.Name,
participant.DeviceId,
participant.Rfid,
participant.SlotIndex,
nameHex = Convert.ToHexString(BuildNameFrame(slot, participant.Name)),
rfidHex = Convert.ToHexString(BuildRfidFrame(slot, participant.Rfid))
});
#endregion
await SendFrameWithLegacyDelayAsync(BuildNameFrame(slot, participant.Name), 50, cancellationToken);
await SendFrameWithLegacyDelayAsync(BuildRfidFrame(slot, participant.Rfid), 50, cancellationToken);
}
await SendFrameWithLegacyDelayAsync(BuildFrame(0x30), 100, cancellationToken);
await SendFrameWithLegacyDelayAsync(BuildFrame(0x0A, 0x0A), 100, cancellationToken);
lock (_gate)
{
foreach (var participant in _session.GetRoundParticipants(_session.CurrentRoundIndex))
{
participant.Count = 0;
participant.Mode = 0;
participant.RemainingSeconds = _session.TestDurationSeconds;
}
_session.IsTesting = false;
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public async Task<CompetitionSessionState> StartAsync(CancellationToken cancellationToken = default)
{
await SendFrameAsync(BuildFrame(0x20), cancellationToken);
CompetitionSessionState state;
lock (_gate)
{
EnsureLoadedUnsafe();
_session.IsTesting = true;
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public async Task<CompetitionSessionState> StopAsync(CancellationToken cancellationToken = default)
{
await SendFrameAsync(BuildFrame(0x30), cancellationToken);
CompetitionSessionState state;
lock (_gate)
{
EnsureLoadedUnsafe();
_session.IsTesting = false;
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public async Task<CompetitionSessionState> ResetCurrentRoundAsync(CancellationToken cancellationToken = default)
{
await SendFrameAsync(BuildFrame(0x0A, 0x0A), cancellationToken);
CompetitionSessionState state;
lock (_gate)
{
EnsureLoadedUnsafe();
foreach (var participant in _session.GetRoundParticipants(_session.CurrentRoundIndex))
{
participant.Count = 0;
participant.Mode = 0;
participant.RemainingSeconds = _session.TestDurationSeconds;
}
_session.IsTesting = false;
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public async Task<CompetitionSessionState> SetParticipantCountAsync(int participantId, int count, CancellationToken cancellationToken = default)
{
CompetitionSessionState state;
lock (_gate)
{
var participant = FindParticipantUnsafe(participantId);
participant.Count = Math.Max(count, 0);
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public Task<CompetitionSessionState> ChangeParticipantCountAsync(int participantId, int delta, CancellationToken cancellationToken = default)
{
var state = GetState();
var participant = state.Participants.FirstOrDefault(x => x.Id == participantId)
?? throw new KeyNotFoundException($"participant {participantId} not found");
return SetParticipantCountAsync(participantId, participant.Count + delta, cancellationToken);
}
public async Task ApplyDeviceSnapshotAsync(DeviceSnapshot snapshot, CancellationToken cancellationToken = default)
{
CompetitionSessionState? state = null;
lock (_gate)
{
if (!_session.Loaded)
{
return;
}
var participant = _session.GetRoundParticipants(_session.CurrentRoundIndex)
.FirstOrDefault(x => string.Equals(x.DeviceId, snapshot.Telemetry.DeviceId, StringComparison.OrdinalIgnoreCase));
if (participant is null)
{
return;
}
participant.Count = Math.Max(snapshot.Telemetry.Counter, 0);
participant.Battery = snapshot.Telemetry.Battery;
participant.Mode = snapshot.Telemetry.Mode;
participant.RemainingSeconds = snapshot.Telemetry.RemainingSeconds;
participant.Online = snapshot.Online;
participant.LastSeen = snapshot.LastSeen;
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
}
private void EnsureLoadedUnsafe()
{
if (!_session.Loaded)
{
throw new InvalidOperationException("competition session is not loaded");
}
}
private MutableCompetitionParticipant FindParticipantUnsafe(int participantId)
{
EnsureLoadedUnsafe();
return _session.Participants.FirstOrDefault(x => x.Id == participantId)
?? throw new KeyNotFoundException($"participant {participantId} not found");
}
private void ApplyCurrentRoundDirectoryUnsafe()
{
var currentRound = _session.GetRoundParticipants(_session.CurrentRoundIndex)
.Select(x => new RosterEntry
{
DeviceId = x.DeviceId,
Name = x.Name,
School = x.School,
GroupName = _session.RoundNames.ElementAtOrDefault(x.RoundIndex)
})
.ToArray();
_directory.ApplyRoster(currentRound);
}
private MutableCompetitionSession BuildSession(CreateCompetitionSessionRequest request)
{
var requests = request.Participants!
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
.ToArray();
if (requests.Length == 0)
{
throw new InvalidOperationException("participants is empty");
}
var counts = NormalizeRoundCounts(request, requests.Length);
var names = NormalizeRoundNames(request.RoundNames, counts.Count);
var participants = new List<MutableCompetitionParticipant>(requests.Length);
var roundIndex = 0;
var slotIndex = 0;
var remainingInRound = counts[0];
for (var i = 0; i < requests.Length; i++)
{
while (remainingInRound == 0 && roundIndex < counts.Count - 1)
{
roundIndex++;
slotIndex = 0;
remainingInRound = counts[roundIndex];
}
var item = requests[i];
var name = item.Name!.Trim();
participants.Add(new MutableCompetitionParticipant
{
Id = item.Id ?? (i + 1),
Name = name,
ProjectName = string.IsNullOrWhiteSpace(item.ProjectName) ? null : item.ProjectName.Trim(),
School = string.IsNullOrWhiteSpace(item.School) ? null : item.School.Trim(),
// Jump_window uses ReceivedByte[2] as a zero-based slot index in the current round.
DeviceId = string.IsNullOrWhiteSpace(item.DeviceId) ? slotIndex.ToString() : item.DeviceId.Trim(),
Rfid = string.IsNullOrWhiteSpace(item.Rfid) ? null : item.Rfid.Trim(),
RoundIndex = item.RoundIndex ?? roundIndex,
SlotIndex = item.SlotIndex ?? slotIndex,
Count = Math.Max(item.Count ?? 0, 0),
RemainingSeconds = request.TestDurationSeconds
});
slotIndex++;
if (remainingInRound > 0)
{
remainingInRound--;
}
}
return new MutableCompetitionSession
{
Loaded = true,
EventId = string.IsNullOrWhiteSpace(request.EventId) ? Guid.NewGuid().ToString("N") : request.EventId.Trim(),
EventName = string.IsNullOrWhiteSpace(request.EventName) ? "跳绳比赛" : request.EventName.Trim(),
RosterPath = request.RosterPath,
TotalRounds = counts.Count,
CurrentRoundIndex = 0,
IsTesting = false,
TestDurationSeconds = request.TestDurationSeconds,
UpdatedAt = DateTimeOffset.UtcNow,
RoundParticipantCounts = counts,
RoundNames = names,
Participants = participants
};
}
private static List<int> NormalizeRoundCounts(CreateCompetitionSessionRequest request, int participantCount)
{
var counts = request.RoundParticipantCounts?
.Where(x => x > 0)
.ToList()
?? [];
if (counts.Count == 0)
{
if (request.TotalRounds is > 1)
{
var perRound = (int)Math.Ceiling(participantCount / (double)request.TotalRounds.Value);
counts = Enumerable.Range(0, request.TotalRounds.Value)
.Select(_ => perRound)
.ToList();
}
else
{
counts = [participantCount];
}
}
var total = counts.Sum();
if (total < participantCount)
{
counts[^1] += participantCount - total;
}
else if (total > participantCount)
{
var overflow = total - participantCount;
for (var i = counts.Count - 1; i >= 0 && overflow > 0; i--)
{
var cut = Math.Min(counts[i] - 1, overflow);
counts[i] -= cut;
overflow -= cut;
}
counts = counts.Where(x => x > 0).ToList();
}
if (counts.Count == 0)
{
counts = [participantCount];
}
return counts;
}
private static List<string> NormalizeRoundNames(IReadOnlyList<string>? requested, int roundCount)
{
var result = requested?
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.ToList()
?? [];
while (result.Count < roundCount)
{
result.Add($"第 {result.Count + 1} 场");
}
if (result.Count > roundCount)
{
result = result.Take(roundCount).ToList();
}
return result;
}
private async Task SendSelectRoundCommandAsync(int roundIndex, CancellationToken cancellationToken)
{
await SendFrameAsync(BuildFrame(0x0C, 0x0C, 0x00, (byte)(roundIndex % 10)), cancellationToken);
}
private Task SendFrameAsync(byte[] frame, CancellationToken cancellationToken)
{
return _serial.SendAsync(frame, cancellationToken);
}
private async Task SendFrameWithLegacyDelayAsync(byte[] frame, int delayMilliseconds, CancellationToken cancellationToken)
{
_serial.ClearInputBuffer();
await SendFrameAsync(frame, cancellationToken);
if (delayMilliseconds > 0)
{
await Task.Delay(delayMilliseconds, cancellationToken);
}
}
private static byte[] BuildNameFrame(int address, string name)
{
var frame = BuildFrame(0x01, (byte)address);
var bytes = System.Text.Encoding.Default.GetBytes(name ?? string.Empty);
var take = Math.Min(bytes.Length, 7);
Array.Copy(bytes, 0, frame, 3, take);
return frame;
}
private static byte[] BuildRfidFrame(int address, string? rfid)
{
var frame = BuildFrame(0x02, (byte)address);
var parts = (rfid ?? string.Empty)
.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Take(5)
.ToArray();
for (var i = 0; i < parts.Length; i++)
{
if (byte.TryParse(parts[i], out var value))
{
frame[3 + i] = value;
}
}
return frame;
}
private static byte[] BuildFrame(byte command, byte subCommand = 0x00, byte data3 = 0x00, byte data4 = 0x00)
{
var frame = new byte[12];
frame[0] = 0x55;
frame[1] = command;
frame[2] = subCommand;
frame[3] = data3;
frame[4] = data4;
frame[11] = 0xAA;
return frame;
}
private static byte[] BuildLegacyDownloadPrepareFrame()
{
var frame = BuildFrame(0xFF, 0x13, 0x04, 0x00);
frame[5] = 0x03;
return frame;
}
private static CompetitionParticipantState CloneParticipant(MutableCompetitionParticipant participant) => participant.ToState();
private static async Task DebugReportAsync(string hypothesisId, string location, string msg, object data)
{
try
{
var url = "http://127.0.0.1:7777/event";
var sessionId = "serial-gateway-download";
var envPath = Path.Combine(Directory.GetCurrentDirectory(), ".dbg", "serial-gateway-download.env");
if (File.Exists(envPath))
{
foreach (var line in await File.ReadAllLinesAsync(envPath))
{
if (line.StartsWith("DEBUG_SERVER_URL=", StringComparison.Ordinal))
{
url = line["DEBUG_SERVER_URL=".Length..].Trim();
}
else if (line.StartsWith("DEBUG_SESSION_ID=", StringComparison.Ordinal))
{
sessionId = line["DEBUG_SESSION_ID=".Length..].Trim();
}
}
}
var payload = JsonSerializer.Serialize(new
{
sessionId,
runId = "pre-fix",
hypothesisId,
location,
msg,
data,
ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
await DebugClient.PostAsync(url, content);
}
catch
{
}
}
private sealed class MutableCompetitionSession
{
public bool Loaded { get; init; }
public string EventId { get; init; } = string.Empty;
public string EventName { get; init; } = string.Empty;
public string? RosterPath { get; init; }
public int TotalRounds { get; init; }
public int CurrentRoundIndex { get; set; }
public bool IsTesting { get; set; }
public int? TestDurationSeconds { get; init; }
public DateTimeOffset UpdatedAt { get; set; }
public List<int> RoundParticipantCounts { get; init; } = [];
public List<string> RoundNames { get; init; } = [];
public List<MutableCompetitionParticipant> Participants { get; init; } = [];
public static MutableCompetitionSession Empty() => new()
{
Loaded = false,
UpdatedAt = DateTimeOffset.UtcNow
};
public IEnumerable<MutableCompetitionParticipant> GetRoundParticipants(int roundIndex)
{
return Participants.Where(x => x.RoundIndex == roundIndex).OrderBy(x => x.SlotIndex);
}
public CompetitionSessionState ToState()
{
return new CompetitionSessionState
{
Loaded = Loaded,
EventId = EventId,
EventName = EventName,
RosterPath = RosterPath,
TotalRounds = TotalRounds,
CurrentRoundIndex = CurrentRoundIndex,
IsTesting = IsTesting,
TestDurationSeconds = TestDurationSeconds,
UpdatedAt = UpdatedAt,
RoundParticipantCounts = RoundParticipantCounts.ToArray(),
RoundNames = RoundNames.ToArray(),
Participants = Participants.Select(x => x.ToState()).ToArray()
};
}
}
private sealed class MutableCompetitionParticipant
{
public int Id { get; init; }
public string Name { get; set; } = string.Empty;
public string? ProjectName { get; init; }
public string? School { get; init; }
public string DeviceId { get; init; } = string.Empty;
public string? Rfid { get; init; }
public int RoundIndex { get; init; }
public int SlotIndex { get; init; }
public int Count { get; set; }
public int? Battery { get; set; }
public int? Mode { get; set; }
public int? RemainingSeconds { get; set; }
public bool Online { get; set; }
public DateTimeOffset? LastSeen { get; set; }
public CompetitionParticipantState ToState()
{
return new CompetitionParticipantState
{
Id = Id,
Name = Name,
ProjectName = ProjectName,
School = School,
DeviceId = DeviceId,
Rfid = Rfid,
RoundIndex = RoundIndex,
SlotIndex = SlotIndex,
Count = Count,
Battery = Battery,
Mode = Mode,
RemainingSeconds = RemainingSeconds,
Online = Online,
LastSeen = LastSeen
};
}
}
}