Danro-Jump/Assets/Scripts/Runtime/Prizes/PrizeSessionStateService.cs
Danil Iarosh 839a68aef1 Improve VFX, UI overlays, session statistics, COM reliability, audio mixer, and expand architecture guides
Подробный список изменений:
- Перенесены эффекты частиц из C#-кода в готовые префабы-ассеты (Vfx Prefabs) в папке Assets/Resources/Vfx/ с автоматической генерацией материалов в Editor.
- Исправлены ошибки duration на играющих системах частиц и розовые текстуры в GameVfxService.cs.
- Исправлена навигация и фокус кнопок на оверлеях ContinueGameOverlay и PrizeResultOverlay с автоматическим подсвечиванием кнопок лимонно-зеленым цветом при фокусе и проигрыванием звука.
- Добавлена проверка настроек в GameplayHudController.cs для скрытия ContinueGameOverlay, если продолжение игры отключено.
- Расширен сбор статистики: создана структура GameplaySessionRecord, добавлена история до 50 раундов в PrizeSessionStateService и JsonPrizeSessionStateRepository, выведен расчет средних показателей и список 5 последних раундов в сервисной статистике.
- Повышена надежность COM-транспорта: добавлен sleep (80мс) в Dispose для graceful shutdown потоков, реализован цикл переподключения до 10 попыток при обрыве связи.
- Интегрированы настройки громкости с микшером: изменен шаг изменения громкости на 5% (0.05) для удобства, добавлен статус-чек и логи в ApplyServiceVolumes, расширены юнит-тесты на GetFloat параметры.
- Созданы новые архитектурные гайды по аппаратному COM-транспорту и призовой экономике автомата в папке GUIDES/architecture/.
- Оптимизирован и обновлен RAG-индекс проекта.
2026-07-03 15:04:08 +03:00

363 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using DanroJump.Hardware.Com;
namespace DanroJump.Prizes
{
/// <summary>
/// In-memory журнал призовых решений для сервисной статистики и защиты от повторной выдачи.
/// </summary>
public sealed class PrizeSessionStateService : IPrizeSessionStateService
{
private const string RecoveryFailureNote = "Сессия восстановлена после перезапуска до завершения выдачи.";
private const int MaxHistorySize = 50;
private readonly List<PrizeSessionRecord> history = new();
private readonly List<GameplaySessionRecord> gameplayHistory = new();
private readonly HashSet<string> issuedSessionIds = new(StringComparer.Ordinal);
private readonly IPrizeSessionStateRepository repository;
private int fallbackSessionCounter;
public PrizeSessionStateService(
[Zenject.InjectOptional] IPrizeSessionStateRepository repository = null,
[Zenject.InjectOptional] IComSystem comSystem = null)
{
this.repository = repository;
RestoreState();
if (comSystem != null)
{
comSystem.DeviceDisconnected.AddListener(HandleDeviceDisconnected);
}
}
private void HandleDeviceDisconnected(string deviceId)
{
RecordComError();
}
public event Action Changed;
public PrizeSessionRecord ActiveSession { get; private set; }
public IReadOnlyList<PrizeSessionRecord> History => history;
public IReadOnlyList<GameplaySessionRecord> GameplayHistory => gameplayHistory;
public int StartedCount { get; private set; }
public int CompletedCount { get; private set; }
public int FailedCount { get; private set; }
public int DuplicateBlockedCount { get; private set; }
public int TotalGamesPlayed { get; private set; }
public int TotalScore { get; private set; }
public int TotalCoins { get; private set; }
public int QrShows { get; private set; }
public int PhysicalDispenses { get; private set; }
public int PhysicalDispenseFailures { get; private set; }
public int ComErrors { get; private set; }
public bool TryBeginSession(PrizeFlowResult result, out PrizeSessionRecord record)
{
record = null;
if (!result.HasPrize)
{
return false;
}
var sessionId = ResolveSessionId(result);
if (ActiveSession != null)
{
DuplicateBlockedCount++;
record = new PrizeSessionRecord(
sessionId,
result,
PrizeSessionStatus.DuplicateBlocked,
DateTime.UtcNow,
"Новая выдача заблокирована: предыдущая призовая сессия ещё активна.");
history.Add(record);
PersistState();
Changed?.Invoke();
return false;
}
if (issuedSessionIds.Contains(sessionId))
{
DuplicateBlockedCount++;
record = new PrizeSessionRecord(
sessionId,
result,
PrizeSessionStatus.DuplicateBlocked,
DateTime.UtcNow,
"Повторная выдача этой игровой сессии заблокирована.");
history.Add(record);
PersistState();
Changed?.Invoke();
return false;
}
issuedSessionIds.Add(sessionId);
record = new PrizeSessionRecord(sessionId, result, PrizeSessionStatus.Pending, DateTime.UtcNow);
ActiveSession = record;
history.Add(record);
StartedCount++;
PersistState();
Changed?.Invoke();
return true;
}
public bool MarkActiveSessionShown()
{
return SetActiveStatus(PrizeSessionStatus.Shown, null, countTerminal: false);
}
public bool CompleteActiveSession(string note = null)
{
return SetActiveStatus(PrizeSessionStatus.Completed, note, countTerminal: true);
}
public bool FailActiveSession(string reason)
{
return SetActiveStatus(PrizeSessionStatus.Failed, reason, countTerminal: true);
}
public string BuildSummary()
{
var activeText = ActiveSession == null
? "Активной выдачи нет."
: $"Активная выдача: {FormatReward(ActiveSession.Result.RewardKind)}, статус {FormatStatus(ActiveSession.Status)}, очки {ActiveSession.Result.Score}.";
var last = history.LastOrDefault();
var lastText = last == null
? "История пуста."
: $"Последняя запись: {FormatReward(last.Result.RewardKind)}, {FormatStatus(last.Status)}, очки {last.Result.Score}.";
return $"Игр: {TotalGamesPlayed}. Очков всего: {TotalScore}. Монет всего: {TotalCoins}. QR показов: {QrShows}. Физич. выдач: {PhysicalDispenses} (ошибок: {PhysicalDispenseFailures}). COM-ошибок: {ComErrors}. Призовые сессии: начато {StartedCount}, завершено {CompletedCount}, ошибок {FailedCount}, повторов заблокировано {DuplicateBlockedCount}. {activeText} {lastText}";
}
public void RecordGameplaySession(
string sessionId,
int score,
int coins,
float maxHeight,
int deathCount,
PrizeRewardKind rewardKind)
{
var record = new GameplaySessionRecord(
sessionId,
score,
coins,
maxHeight,
deathCount,
rewardKind,
DateTime.UtcNow
);
gameplayHistory.Add(record);
if (gameplayHistory.Count > MaxHistorySize)
{
gameplayHistory.RemoveAt(0);
}
TotalGamesPlayed++;
TotalScore += score;
TotalCoins += coins;
PersistState();
Changed?.Invoke();
}
public void RecordQrShow()
{
QrShows++;
PersistState();
Changed?.Invoke();
}
public void RecordComError()
{
ComErrors++;
PersistState();
Changed?.Invoke();
}
public void RecordPhysicalDispense(bool success)
{
if (success)
{
PhysicalDispenses++;
}
else
{
PhysicalDispenseFailures++;
}
PersistState();
Changed?.Invoke();
}
private bool SetActiveStatus(PrizeSessionStatus status, string note, bool countTerminal)
{
if (ActiveSession == null)
{
return false;
}
ActiveSession.SetStatus(status, DateTime.UtcNow, note);
if (countTerminal)
{
if (status == PrizeSessionStatus.Completed)
{
CompletedCount++;
}
else if (status == PrizeSessionStatus.Failed)
{
FailedCount++;
}
ActiveSession = null;
}
PersistState();
Changed?.Invoke();
return true;
}
private void RestoreState()
{
if (repository == null || !repository.TryLoad(out var snapshot) || snapshot == null)
{
return;
}
history.Clear();
gameplayHistory.Clear();
issuedSessionIds.Clear();
if (snapshot.GameplayHistory != null)
{
foreach (var record in snapshot.GameplayHistory)
{
if (record != null)
{
gameplayHistory.Add(record);
}
}
}
foreach (var record in snapshot.History)
{
if (record != null)
{
history.Add(record);
}
}
foreach (var sessionId in snapshot.IssuedSessionIds)
{
if (!string.IsNullOrWhiteSpace(sessionId))
{
issuedSessionIds.Add(sessionId);
}
}
StartedCount = snapshot.StartedCount;
CompletedCount = snapshot.CompletedCount;
FailedCount = snapshot.FailedCount;
DuplicateBlockedCount = snapshot.DuplicateBlockedCount;
fallbackSessionCounter = snapshot.FallbackSessionCounter;
TotalGamesPlayed = snapshot.TotalGamesPlayed;
TotalScore = snapshot.TotalScore;
TotalCoins = snapshot.TotalCoins;
QrShows = snapshot.QrShows;
PhysicalDispenses = snapshot.PhysicalDispenses;
PhysicalDispenseFailures = snapshot.PhysicalDispenseFailures;
ComErrors = snapshot.ComErrors;
ActiveSession = null;
var restoredActive = FindHistoryRecord(snapshot.ActiveSessionId);
if (restoredActive != null &&
(restoredActive.Status == PrizeSessionStatus.Pending || restoredActive.Status == PrizeSessionStatus.Shown))
{
restoredActive.SetStatus(
PrizeSessionStatus.Failed,
DateTime.UtcNow,
AppendRecoveryNote(restoredActive.Note));
FailedCount++;
PersistState();
}
}
private void PersistState()
{
repository?.Save(CreateSnapshot());
}
private PrizeSessionStateSnapshot CreateSnapshot()
{
return new PrizeSessionStateSnapshot(
history.ToArray(),
issuedSessionIds.ToArray(),
ActiveSession?.SessionId,
StartedCount,
CompletedCount,
FailedCount,
DuplicateBlockedCount,
fallbackSessionCounter,
gameplayHistory.ToArray(),
TotalGamesPlayed,
TotalScore,
TotalCoins,
QrShows,
PhysicalDispenses,
PhysicalDispenseFailures,
ComErrors);
}
private PrizeSessionRecord FindHistoryRecord(string sessionId)
{
return string.IsNullOrWhiteSpace(sessionId)
? null
: history.LastOrDefault(record => string.Equals(record.SessionId, sessionId, StringComparison.Ordinal));
}
private static string AppendRecoveryNote(string existingNote)
{
return string.IsNullOrWhiteSpace(existingNote)
? RecoveryFailureNote
: existingNote + " " + RecoveryFailureNote;
}
private string ResolveSessionId(PrizeFlowResult result)
{
if (!string.IsNullOrWhiteSpace(result.SessionId))
{
return result.SessionId;
}
fallbackSessionCounter++;
return $"prize-session-{fallbackSessionCounter}";
}
private static string FormatReward(PrizeRewardKind rewardKind)
{
return rewardKind switch
{
PrizeRewardKind.QRCode => "QR",
PrizeRewardKind.Hopper => "хоппер",
PrizeRewardKind.PrizeStand => "витрина",
PrizeRewardKind.Big => "большой приз",
PrizeRewardKind.Small => "малый приз",
_ => "нет приза",
};
}
private static string FormatStatus(PrizeSessionStatus status)
{
return status switch
{
PrizeSessionStatus.Pending => "ожидает показа",
PrizeSessionStatus.Shown => "показана",
PrizeSessionStatus.Completed => "завершена",
PrizeSessionStatus.Failed => "ошибка",
PrizeSessionStatus.DuplicateBlocked => "повтор заблокирован",
_ => status.ToString(),
};
}
}
}