- Добавлен асинхронный процесс аппаратной выдачи физических призов (хоппер и витрина) в GameplayHudController. - Реализована блокировка выдачи по 10-секундному таймауту датчика хоппера с гарантированной отправкой команды отключения двигателя '300'. - Реализован автоматический таймаут неактивности на экране QR-кода с последующим переходом в главное меню. - Расширен сервис статистики PrizeSessionStateService новыми долгосрочными счетчиками (сыгранные игры, очки, монеты, сбои связи COM, физ. выдачи и ошибки). - Обновлен построитель сводки статистики в меню настроек для отображения новых счетчиков и списка последних 5 сессий. - Написаны EditMode тесты для проверки инкремента счетчиков и запуска выдачи призов.
269 lines
9.6 KiB
C#
269 lines
9.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using UnityEngine;
|
|
|
|
namespace DanroJump.Prizes
|
|
{
|
|
/// <summary>
|
|
/// JSON-хранилище snapshot призовых сессий в Application.persistentDataPath.
|
|
/// </summary>
|
|
public sealed class JsonPrizeSessionStateRepository : IPrizeSessionStateRepository
|
|
{
|
|
private const string DefaultFileName = "prize-session-state.json";
|
|
|
|
public JsonPrizeSessionStateRepository(string fileName)
|
|
{
|
|
var safeFileName = GetSafeFileName(fileName);
|
|
Path = System.IO.Path.Combine(Application.persistentDataPath, safeFileName);
|
|
}
|
|
|
|
public string Path { get; }
|
|
|
|
private bool Exists => File.Exists(Path);
|
|
|
|
public bool TryLoad(out PrizeSessionStateSnapshot snapshot)
|
|
{
|
|
snapshot = null;
|
|
if (!Exists)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
var json = File.ReadAllText(Path);
|
|
var data = JsonUtility.FromJson<PrizeSessionStateSaveData>(json);
|
|
if (data == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var history = new List<PrizeSessionRecord>();
|
|
if (data.history != null)
|
|
{
|
|
foreach (var entry in data.history)
|
|
{
|
|
if (entry == null || string.IsNullOrWhiteSpace(entry.sessionId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var result = new PrizeFlowResult(
|
|
entry.rewardKind,
|
|
entry.score,
|
|
entry.coins,
|
|
entry.height,
|
|
entry.resultSessionId);
|
|
history.Add(new PrizeSessionRecord(
|
|
entry.sessionId,
|
|
result,
|
|
entry.status,
|
|
FromUtcTicks(entry.startedAtUtcTicks),
|
|
FromUtcTicks(entry.updatedAtUtcTicks),
|
|
entry.note));
|
|
}
|
|
}
|
|
|
|
snapshot = new PrizeSessionStateSnapshot(
|
|
history,
|
|
data.issuedSessionIds ?? new List<string>(),
|
|
data.activeSessionId,
|
|
data.startedCount,
|
|
data.completedCount,
|
|
data.failedCount,
|
|
data.duplicateBlockedCount,
|
|
data.fallbackSessionCounter,
|
|
data.totalGamesPlayed,
|
|
data.totalScore,
|
|
data.totalCoins,
|
|
data.qrShows,
|
|
data.physicalDispenses,
|
|
data.physicalDispenseFailures,
|
|
data.comErrors);
|
|
return true;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Debug.LogWarning($"[{nameof(JsonPrizeSessionStateRepository)}] Failed to load prize sessions from '{Path}': {exception.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public bool Save(PrizeSessionStateSnapshot snapshot)
|
|
{
|
|
if (snapshot == null)
|
|
{
|
|
Debug.LogWarning($"[{nameof(JsonPrizeSessionStateRepository)}] Snapshot is null. Save skipped.");
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
var directory = System.IO.Path.GetDirectoryName(Path);
|
|
if (!string.IsNullOrWhiteSpace(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
var data = new PrizeSessionStateSaveData
|
|
{
|
|
activeSessionId = snapshot.ActiveSessionId ?? string.Empty,
|
|
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
|
|
};
|
|
|
|
foreach (var sessionId in snapshot.IssuedSessionIds)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(sessionId))
|
|
{
|
|
data.issuedSessionIds.Add(sessionId);
|
|
}
|
|
}
|
|
|
|
foreach (var record in snapshot.History)
|
|
{
|
|
if (record == null || string.IsNullOrWhiteSpace(record.SessionId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
data.history.Add(new PrizeSessionStateSaveData.Entry
|
|
{
|
|
sessionId = record.SessionId,
|
|
rewardKind = record.Result.RewardKind,
|
|
score = record.Result.Score,
|
|
coins = record.Result.Coins,
|
|
height = record.Result.Height,
|
|
resultSessionId = record.Result.SessionId,
|
|
status = record.Status,
|
|
startedAtUtcTicks = record.StartedAtUtc.Ticks,
|
|
updatedAtUtcTicks = record.UpdatedAtUtc.Ticks,
|
|
note = record.Note ?? string.Empty
|
|
});
|
|
}
|
|
|
|
WriteAllTextSafely(Path, JsonUtility.ToJson(data, true));
|
|
return true;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Debug.LogWarning($"[{nameof(JsonPrizeSessionStateRepository)}] Failed to save prize sessions to '{Path}': {exception.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static DateTime FromUtcTicks(long ticks)
|
|
{
|
|
if (ticks <= 0)
|
|
{
|
|
return DateTime.UtcNow;
|
|
}
|
|
|
|
try
|
|
{
|
|
return new DateTime(ticks, DateTimeKind.Utc);
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
return DateTime.UtcNow;
|
|
}
|
|
}
|
|
|
|
private static void WriteAllTextSafely(string path, string contents)
|
|
{
|
|
var tempPath = path + ".tmp";
|
|
var backupPath = path + ".bak";
|
|
|
|
try
|
|
{
|
|
File.WriteAllText(tempPath, contents);
|
|
|
|
if (File.Exists(path))
|
|
{
|
|
File.Replace(tempPath, path, backupPath, true);
|
|
return;
|
|
}
|
|
|
|
File.Move(tempPath, path);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(tempPath))
|
|
{
|
|
File.Delete(tempPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string GetSafeFileName(string fileName)
|
|
{
|
|
var requestedFileName = string.IsNullOrWhiteSpace(fileName) ? DefaultFileName : fileName.Trim();
|
|
var safeFileName = System.IO.Path.GetFileName(requestedFileName);
|
|
|
|
if (string.IsNullOrWhiteSpace(safeFileName) ||
|
|
safeFileName == "." ||
|
|
safeFileName == ".." ||
|
|
safeFileName.IndexOfAny(System.IO.Path.GetInvalidFileNameChars()) >= 0)
|
|
{
|
|
Debug.LogWarning(
|
|
$"[{nameof(JsonPrizeSessionStateRepository)}] Unsafe file name '{fileName}' was replaced with '{DefaultFileName}'.");
|
|
return DefaultFileName;
|
|
}
|
|
|
|
if (!string.Equals(requestedFileName, safeFileName, StringComparison.Ordinal))
|
|
{
|
|
Debug.LogWarning(
|
|
$"[{nameof(JsonPrizeSessionStateRepository)}] Prize session path '{fileName}' was reduced to safe file name '{safeFileName}'.");
|
|
}
|
|
|
|
return safeFileName;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class PrizeSessionStateSaveData
|
|
{
|
|
public string activeSessionId = string.Empty;
|
|
public int startedCount;
|
|
public int completedCount;
|
|
public int failedCount;
|
|
public int duplicateBlockedCount;
|
|
public int fallbackSessionCounter;
|
|
public int totalGamesPlayed;
|
|
public int totalScore;
|
|
public int totalCoins;
|
|
public int qrShows;
|
|
public int physicalDispenses;
|
|
public int physicalDispenseFailures;
|
|
public int comErrors;
|
|
public List<string> issuedSessionIds = new();
|
|
public List<Entry> history = new();
|
|
|
|
[Serializable]
|
|
public sealed class Entry
|
|
{
|
|
public string sessionId = string.Empty;
|
|
public PrizeRewardKind rewardKind;
|
|
public int score;
|
|
public int coins;
|
|
public float height;
|
|
public string resultSessionId = string.Empty;
|
|
public PrizeSessionStatus status;
|
|
public long startedAtUtcTicks;
|
|
public long updatedAtUtcTicks;
|
|
public string note = string.Empty;
|
|
}
|
|
}
|
|
}
|
|
}
|