Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров (Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
133 lines
4.1 KiB
Python
133 lines
4.1 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""История запусков и статистика игры.
|
||
|
||
Всё хранится в .launcher/stats.json рядом с кэшем Java:
|
||
plays — {версия: {count, seconds, last, crashes}}
|
||
launches — последние запуски (кап 300): {ts, ver, ok, code, seconds}
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import threading
|
||
import time
|
||
|
||
_FILE = None # путь к stats.json (задаётся set_path)
|
||
_LOCK = threading.Lock()
|
||
_MAX_LAUNCHES = 300
|
||
|
||
|
||
def set_path(launcher_dir):
|
||
"""Каталог .launcher — тут лежат stats.json и java_cache.json."""
|
||
global _FILE
|
||
_FILE = os.path.join(str(launcher_dir), "stats.json")
|
||
|
||
|
||
def _load():
|
||
try:
|
||
with open(_FILE, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
if not isinstance(data, dict):
|
||
raise ValueError
|
||
return data
|
||
except (OSError, ValueError):
|
||
return {"plays": {}, "launches": []}
|
||
|
||
|
||
def _save(data):
|
||
tmp = _FILE + ".tmp"
|
||
try:
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False)
|
||
os.replace(tmp, _FILE)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def record_start(version):
|
||
"""Вызывается при запуске игры: +1 к счётчику запусков версии."""
|
||
if not _FILE:
|
||
return
|
||
with _LOCK:
|
||
data = _load()
|
||
plays = data.setdefault("plays", {})
|
||
rec = plays.setdefault(version, {"count": 0, "seconds": 0,
|
||
"last": 0.0, "crashes": 0})
|
||
rec["count"] += 1
|
||
rec["last"] = time.time()
|
||
_save(data)
|
||
|
||
|
||
def record_finish(version, seconds, ok, code):
|
||
"""Вызывается при завершении игры: длительность, код, вылет."""
|
||
if not _FILE:
|
||
return
|
||
with _LOCK:
|
||
data = _load()
|
||
plays = data.setdefault("plays", {})
|
||
rec = plays.setdefault(version, {"count": 0, "seconds": 0,
|
||
"last": 0.0, "crashes": 0})
|
||
rec["seconds"] = max(0, int(seconds))
|
||
if not ok:
|
||
rec["crashes"] = rec.get("crashes", 0) + 1
|
||
launches = data.setdefault("launches", [])
|
||
launches.append({"ts": time.time(), "ver": version, "ok": bool(ok),
|
||
"code": code, "seconds": max(0, int(seconds))})
|
||
del launches[:-_MAX_LAUNCHES]
|
||
_save(data)
|
||
|
||
|
||
def totals():
|
||
"""Агрегаты для окна статистики."""
|
||
if not _FILE:
|
||
return {"total_seconds": 0, "total_launches": 0,
|
||
"per_version": [], "recent": []}
|
||
with _LOCK:
|
||
data = _load()
|
||
plays = data.get("plays") or {}
|
||
per = []
|
||
total_s = 0
|
||
for ver, rec in plays.items():
|
||
sec = int(rec.get("seconds") or 0)
|
||
total_s += sec
|
||
per.append({"ver": ver, "count": int(rec.get("count") or 0),
|
||
"seconds": sec, "last": float(rec.get("last") or 0),
|
||
"crashes": int(rec.get("crashes") or 0)})
|
||
per.sort(key=lambda r: r["last"], reverse=True)
|
||
recent = list(data.get("launches") or [])[-20:]
|
||
recent.reverse()
|
||
return {"total_seconds": total_s,
|
||
"total_launches": sum(r["count"] for r in per),
|
||
"per_version": per, "recent": recent}
|
||
|
||
|
||
def fmt_duration(seconds):
|
||
"""1800 → «30 мин»; 30 → «30 с»; 3700 → «1 ч 1 мин»."""
|
||
seconds = int(seconds or 0)
|
||
if seconds < 60:
|
||
return f"{seconds} с"
|
||
if seconds < 3600:
|
||
return f"{seconds // 60} мин"
|
||
h, m = seconds // 3600, (seconds % 3600) // 60
|
||
return f"{h} ч {m} мин" if m else f"{h} ч"
|
||
|
||
|
||
def fmt_time(ts):
|
||
"""Unix-время → «дд.мм.гггг чч:мм»."""
|
||
if not ts:
|
||
return "—"
|
||
try:
|
||
return time.strftime("%d.%m.%Y %H:%M", time.localtime(ts))
|
||
except (ValueError, OSError):
|
||
return "—"
|
||
|
||
|
||
def clear():
|
||
"""Полная очистка истории и статистики."""
|
||
if not _FILE:
|
||
return
|
||
with _LOCK:
|
||
try:
|
||
os.remove(_FILE)
|
||
except OSError:
|
||
pass
|