Danro/Assets/Scripts/Controls/Inputs/ComSystem/PrizeStandController.cs
Danil Iarosh 2963e342c2
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
refactor: improve COM flow and light timing
Архитектура COM и роли
- ComMultiManager переименован в ComHub и переведен на фабричный паттерн обработчиков по ROLE.
- Добавлен единый контракт обработчиков COM-контроллеров:
  - IComControllerHandler
  - DefaultComControllerHandler
- Ответственность за оркестрацию перенесена в ComSystemManager, чтобы инициализация COM и жизненный цикл контроллеров управлялись централизованно.
- ComWrapper сохранен как слой совместимости на время миграции логики в новую схему.
- PrizeStandController отрефакторен под новый role-handler pipeline вместо старой прямой привязки.
- Добавлен удобный доступ к PrizeStandController из менеджера системы там, где это требуется игровому коду.

Handshake/авторизация платы (ComDeviceClient)
- Авторизация платы переработана в строгую поэтапную последовательность:
  1) ПК отправляет 07
  2) ожидает CHAL
  3) отправляет 08 в ответ на CHAL
  4) ожидает RAES
- Парсер стадий теперь принимает только ожидаемые токены на каждой стадии:
  - WaitingChal -> только CHAL
  - WaitingRaes -> только RAES
- Любой посторонний вывод из serial во время handshake игнорируется (без немедленного падения), что устраняет ложные ошибки на нерелевантных COM-устройствах.
- Если ожидаемый ответ после 07 не приходит, порт быстро пропускается как нерелевантный, без эскалации в критическую ошибку.
- Для случаев с неподходящими/сторонними устройствами уровень логирования снижен с ошибок до предупреждений там, где это уместно.
- Добавлена подробная диагностика авторизации (переходы стадий, принятые значимые кадры, контекст таймаутов) для упрощения отладки.

Тестовая утилита handshake
- Добавлен Assets/Temp/Handshake_Test.cs как изолированный диагностический скрипт.
- Включены очень подробные логи по каждому шагу handshake и по каждому решению парсера.
- Позволяет отдельно гонять тестовый сценарий и сравнивать поведение платы, не ломая production-поток.

Стабильность COM и поведение reconnect
- Подправлена логика keep-alive, чтобы исключить агрессивные реконнекты.
- Reconnect теперь срабатывает только после нескольких последовательных пропусков INPUT-ответа, что снижает случайные обрывы COM и прерывания подсветки.

Поведение подсветки во время выполнения
- Циклы подсветки сделаны независимыми от Time.timeScale:
  - LightController: WaitForSeconds -> WaitForSecondsRealtime
  - LightSequence: WaitForSeconds -> WaitForSecondsRealtime
- Это устраняет случайные паузы/замедления световых циклов при изменении игрового времени (пауза, slow-mo, временные изменения scale).

Итоговый эффект
- COM-стек стал расширяемым по ролям и проще в сопровождении.
- Handshake стал детерминированным, устойчивым к serial-шуму и безопасным при работе со сторонними устройствами.
- Циклы подсветки стабилизированы для длительных сессий и больше не зависят от масштабирования игрового времени.
2026-02-24 11:34:25 +03:00

295 lines
9.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace RR.Common.Controls
{
// Зависит от ComHub и обрабатывает контроллеры витрины по ROLE.
public sealed class PrizeStandController : IComControllerHandler
{
public static readonly string[] SupportedRoles = { "prize_stand", "prize_stand_controller" };
/// <summary>
/// По завершении операции открытия.
/// bool - успех/неуспех.
/// string - сообщение в случае неуспеха
/// </summary>
public event Action<bool, string> OpeningFinished;
private const string RequestStandStatus = "25";
private const string OpenBoxPrefix = "2501";
private const float StatusRequestTimeout = 3f;
private const float WaitOpenStatusTimeout = 5f;
private static readonly IReadOnlyList<BoxStatus> ReadyToOpenStatuses =
new[] { BoxStatus.OK, BoxStatus.OKplusCard };
private readonly string _deviceId;
private readonly Func<string, string, bool> _sendToDevice;
private readonly Action<string> _log;
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly ConcurrentQueue<BoxStatusEntry> _statusQueue = new();
private int _boxesCount;
public PrizeStandController(string deviceId, Func<string, string, bool> sendToDevice, Action<string> log)
{
_deviceId = deviceId;
_sendToDevice = sendToDevice;
_log = log;
}
public void SetBoxesCount(int boxesCount)
{
_boxesCount = Math.Max(0, boxesCount);
}
public async Task OpenBoxAsync(int boxNumber)
{
if (!await _lock.WaitAsync(0))
{
OpeningFinished?.Invoke(false, "Выдача уже в процессе");
return;
}
try
{
var validationResult = await CanExecuteOpenAsync(boxNumber);
if (!validationResult.OK)
{
OpeningFinished?.Invoke(false, validationResult.Message);
return;
}
ClearStatusQueue();
SendCommand($"{OpenBoxPrefix}{boxNumber:D2}");
BoxStatus openStatus = await ReadBoxStatusAsync(boxNumber, 1, WaitOpenStatusTimeout);
if (openStatus == BoxStatus.OPENED)
{
OpeningFinished?.Invoke(true, $"Ячейка {boxNumber} успешно открыта");
}
else
{
_log?.Invoke($"[Модуль витрины] Ошибка при открытии ячейки {boxNumber}");
OpeningFinished?.Invoke(false, $"Ошибка при открытии ячейки номер {boxNumber}");
}
}
catch (Exception ex)
{
_log?.Invoke($"[Модуль витрины] Ошибка при открытии ячейки {boxNumber}: {ex.Message}");
OpeningFinished?.Invoke(false, ex.Message);
}
finally
{
_lock.Release();
}
}
public void HandleCoinAccepted()
{
// Для витрины монеты не обрабатываются.
}
public void HandleInputReceived(string inputBits)
{
// Для витрины INPUT-пакеты не используются.
}
public void HandleRawLine(string line)
{
var parseResult = ParseBoxStatus(line);
if (parseResult.BoxNumber < 0)
{
return;
}
_statusQueue.Enqueue(new BoxStatusEntry(parseResult.BoxNumber, parseResult.Status));
}
public void Dispose()
{
_lock.Dispose();
ClearStatusQueue();
}
public static bool IsSupportedRole(string role)
{
if (string.IsNullOrWhiteSpace(role))
{
return false;
}
return SupportedRoles.Any(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
}
private async Task<(bool OK, string Message)> CanExecuteOpenAsync(int boxNumber)
{
bool isOk = true;
StringBuilder sb = new();
sb.AppendLine("[Модуль витрины. Проверка перед открытием]");
if (boxNumber <= 0)
{
isOk = false;
sb.AppendLine("- Номер коробки должен быть больше 0.");
}
if (_boxesCount > 0 && boxNumber > _boxesCount)
{
isOk = false;
sb.AppendLine($"- Номер коробки={boxNumber} невалидный. Требуется от 1 до {_boxesCount}.");
}
if (isOk)
{
ClearStatusQueue();
SendCommand(RequestStandStatus);
int expectedEntriesCount = _boxesCount > 0 ? _boxesCount : 1;
BoxStatus boxStatus = await ReadBoxStatusAsync(boxNumber, expectedEntriesCount, StatusRequestTimeout);
if (!ReadyToOpenStatuses.Contains(boxStatus))
{
isOk = false;
sb.AppendLine($"- Статус коробки {boxNumber}: {boxStatus} не позволяет провести открытие.");
}
}
else
{
sb.AppendLine($"- Невозможно проверить статус коробки {boxNumber} из-за ошибок выше.");
}
return (isOk, sb.ToString());
}
private async Task<BoxStatus> ReadBoxStatusAsync(int expectedBoxNumber, int expectedEntriesCount, float timeout)
{
BoxStatus status = BoxStatus.NotFound;
int boxesReceived = 0;
Stopwatch sw = Stopwatch.StartNew();
while (sw.Elapsed.TotalSeconds < timeout && boxesReceived < expectedEntriesCount)
{
while (_statusQueue.TryDequeue(out BoxStatusEntry entry))
{
boxesReceived++;
if (entry.BoxNumber == expectedBoxNumber)
{
status = entry.Status;
}
if (boxesReceived >= expectedEntriesCount)
{
break;
}
}
if (boxesReceived >= expectedEntriesCount)
{
break;
}
await Task.Delay(10);
}
if (status == BoxStatus.NotFound)
{
_log?.Invoke("[Модуль витрины. Не дождался ответа по статусу ячейки]\n" +
$"Ожидаемый номер: {expectedBoxNumber}\n" +
$"Получено статусов ячеек: {boxesReceived}/{expectedEntriesCount}");
}
return status;
}
private void SendCommand(string command)
{
if (!_sendToDevice(_deviceId, command))
{
throw new InvalidOperationException($"Не удалось отправить команду '{command}' в устройство {_deviceId}");
}
}
private void ClearStatusQueue()
{
while (_statusQueue.TryDequeue(out _))
{
}
}
private static (int BoxNumber, BoxStatus Status) ParseBoxStatus(string entryString)
{
if (string.IsNullOrWhiteSpace(entryString) || !entryString.StartsWith("BOX", StringComparison.OrdinalIgnoreCase))
{
return (-1, BoxStatus.ParsingError);
}
string noPrefixEntry = entryString.Substring(3);
string[] parts = noPrefixEntry.Split('_');
if (parts.Length != 2)
{
return (-1, BoxStatus.ParsingError);
}
if (!int.TryParse(parts[0], out int parsedNumber))
{
return (-1, BoxStatus.ParsingError);
}
return (parsedNumber, MapStatusFromString(parts[1]));
}
private static BoxStatus MapStatusFromString(string statusString)
{
return statusString switch
{
"OK" => BoxStatus.OK,
"OK+CARD" => BoxStatus.OKplusCard,
"EMPTY" => BoxStatus.Empty,
"OPEN" => BoxStatus.Open,
"OPENED" => BoxStatus.OPENED,
"OPEN_ERROR" => BoxStatus.OPEN_ERROR,
"ERROR" => BoxStatus.Error,
_ => BoxStatus.ParsingError
};
}
private readonly struct BoxStatusEntry
{
public readonly int BoxNumber;
public readonly BoxStatus Status;
public BoxStatusEntry(int boxNumber, BoxStatus status)
{
BoxNumber = boxNumber;
Status = status;
}
}
}
public enum BoxStatus
{
// Статусы ДО открытия
OK,
OKplusCard,
Empty,
Open,
// Статусы ПОСЛЕ открытия
OPENED,
OPEN_ERROR,
// Служебные статусы
ParsingError,
NotFound,
Error
}
}