Match history and win rate

Every finished match goes into a small sqlite database: mode, deck, both
classes, result, turn count and who started. The result tag arrives in the
last turn of the match, so the record is written then, not when the game
object is closed (that only happens once the next match starts). The unique
key is the session directory plus the match start time, so importing old
session logs into the same database can be repeated without duplicates.

The new window (from the menu) shows the totals, the win rate by deck and by
opponent class, and the recent matches. Classes are drawn as hero portraits
cut from the card render, with the class colour as a placeholder until the
image is downloaded. Every table sorts by any column: the sort key is hidden
data, not the visible text, so dates, turn counts and results sort correctly,
and numeric columns start descending.

tools/import_history.py backfills the database from the session logs already
on disk, tests/test_history.py covers the date handling, the idempotency and
the win rate maths.
This commit is contained in:
yusufipk
2026-07-28 00:04:24 +07:00
parent 4e67b34977
commit fa45c19d3b
11 changed files with 1442 additions and 12 deletions
+14 -3
View File
@@ -23,6 +23,9 @@ Turkish for "deck".
- **No deck codes needed:** decks are read from the game's own offline cache and
the one matching the match is picked by class and by the cards you draw. You
can also pick one by hand from the menu.
- **Match history and win rate:** every finished match is written to a small
sqlite file (mode, deck, both classes, result, turn count) and the `⋮` menu
shows the win rate by deck and by opponent class.
- **Card art** as the background of each row, and the full card image (rules
text included) when you hover over it.
- **Adjustable transparency**, an overlay mode (frameless, always on top) and a
@@ -54,7 +57,8 @@ knowing about Qt, which is why the same engine runs from the terminal
Card names, costs and rarities are downloaded once from HearthstoneJSON into
`~/.cache/deste/`. Card images go to the same place, lazily and in the
background. With no network the app keeps running off the cache.
background. With no network the app keeps running off the cache. Finished
matches go into `~/.local/share/deste/history.db`.
## Install
@@ -92,7 +96,9 @@ python main.py --full # start by replaying the current session log
python -m tools.live # live tracking in the terminal
python -m tools.replay <log_dir> --deck # replay a recorded session
python -m tools.import_history # backfill history from old session logs
python -m tests.test_replay <log_dir> # consistency tests
python -m tests.test_history # match history tests
```
Closing the window drops the app into the tray, clicking the tray icon brings it
@@ -139,6 +145,7 @@ core/ pure stdlib, knows nothing about UI or network, testable headless
state.py event -> game state (entities, zones, deck)
watcher.py live tracking loop (no Qt dependency)
deckstring.py deck code encode/decode
history.py match history and win rate (sqlite)
config.py user settings
data/ network and disk cache
cards.py HearthstoneJSON card data
@@ -147,6 +154,7 @@ data/ network and disk cache
images.py card images (tile and full render), downloaded in background
ui/ PyQt6 interface
window.py panel, tray icon, transparency, window modes
history_window.py match history, win rate by deck and by class
widgets.py card row (art strip) and hover preview
i18n.py interface strings, English and Turkish
theme.py colors and style
@@ -180,6 +188,11 @@ during the game are added to the list instead.
**Log files are never deleted.** They are only read.
**A match lands in the history the moment its result is known**, not when the
game object is closed (that only happens once the next match starts). The
session directory plus the match start time is the unique key, so importing old
logs into the same database can be repeated without producing duplicates.
## Verification
`tests/test_replay.py` uses no synthetic data, it reads real session logs from
@@ -203,8 +216,6 @@ exercised in a virtual KWin session: `kwin_wayland --virtual --width 1800
Next:
- **Match history and winrate** (sqlite, stdlib): mode, classes, result and turn
count per match; winrate by deck and by class.
- **Opponent archetype prediction:** match revealed cards against HSReplay
signature cards and show something like "Zee Shaman, 4/8 signature cards". If
the match is weak the panel hides itself instead of inventing a guess.
+14 -3
View File
@@ -21,6 +21,9 @@ hem de her yamada kırılıyordu.
- **Deste seçimi kendiliğinden:** deste kodu yapıştırmaya gerek yok, oyunun
çevrimdışı önbelleğindeki desteler okunuyor ve maçtaki sınıfa göre eşleşen
deste seçiliyor. Menüden elle de seçilebilir.
- **Maç geçmişi ve winrate:** biten her maç küçük bir sqlite dosyasına yazılıyor
(mod, deste, iki sınıf, sonuç, tur sayısı), `⋮` menüsündeki pencere desteye ve
rakip sınıfına göre galibiyet oranını gösteriyor.
- **Kart görselleri:** satırın arka planında kartın sanatı, üzerine gelince
kartın tam görseli (metni de görselin üstünde).
- **Ayarlanabilir saydamlık**, overlay (çerçevesiz, üstte) ve normal pencere
@@ -53,7 +56,8 @@ yüzden aynı motor terminalden (`tools/live.py`) veya kayıtlı loglar üstünd
Kart isimleri, maliyetleri ve nadirlikleri HearthstoneJSON'dan bir kere
indirilip `~/.cache/deste/` altına yazılır. Kart görselleri de aynı yere, ama
yalnızca ihtiyaç duyulduğunda ve arka planda. Ağ yoksa uygulama önbellekle
çalışmaya devam eder.
çalışmaya devam eder. Biten maçlar `~/.local/share/deste/history.db` içine
yazılıyor.
## Kurulum
@@ -90,7 +94,9 @@ python main.py --full # mevcut oturum logunu baştan okuyup başlat
python -m tools.live # terminalde canlı takip
python -m tools.replay <log_dizini> --deck # kayıtlı oturumu baştan oynat
python -m tools.import_history # eski oturum loglarını geçmişe aktar
python -m tests.test_replay <log_dizini> # tutarlılık testleri
python -m tests.test_history # maç geçmişi testleri
```
Pencere kapatıldığında uygulama tepsiye iner, tepsi ikonuna tıklayınca geri
@@ -136,6 +142,7 @@ core/ saf stdlib, UI ve ağ bilmez, headless test edilebilir
state.py olay -> oyun durumu (varlıklar, bölgeler, deste)
watcher.py canlı takip döngüsü (Qt'ye bağımlı değil)
deckstring.py deste kodu çöz/kodla
history.py maç geçmişi ve winrate (sqlite)
config.py kullanıcı ayarları
data/ ağ ve disk önbelleği
cards.py HearthstoneJSON kart verisi
@@ -144,6 +151,7 @@ data/ ağ ve disk önbelleği
images.py kart görselleri (tile ve tam render), arka planda indirir
ui/ PyQt6 arayüz
window.py panel, tepsi ikonu, saydamlık, mod yönetimi
history_window.py maç geçmişi, desteye ve sınıfa göre winrate
widgets.py kart satırı (sanat şeridi) ve hover önizlemesi
i18n.py arayüz metinleri, Türkçe ve İngilizce
theme.py renkler ve stil
@@ -176,6 +184,11 @@ karıştırılan kartlar listeye eklenir.
**Log dosyaları asla silinmez.** Sadece okunur.
**Maç, sonucu belli olduğu anda geçmişe yazılır**, maç nesnesi kapandığında
değil: kapanma ancak bir sonraki maç başlayınca oluyor. Benzersiz anahtar oturum
dizini ile maçın başlangıç saati, bu yüzden eski loglar aynı veritabanına
istendiği kadar tekrar aktarılabilir, kopya oluşmaz.
## Doğrulama
`tests/test_replay.py` uydurma veri kullanmaz, makinedeki gerçek oturum
@@ -199,8 +212,6 @@ pencere açmıyor.
Sırada:
- **Maç geçmişi ve winrate** (sqlite, stdlib): maç başına mod, sınıflar,
sonuç, tur sayısı; desteye ve sınıfa göre winrate ekranı.
- **Rakip arketip tahmini:** HSReplay imza kartlarıyla eşleştirip "Zee Shaman,
4/8 imza kartı" gibi bir tahmin. Eşleşme zayıfsa panel gizlenir, tahmin
uydurulmaz.
+299
View File
@@ -0,0 +1,299 @@
"""Maç geçmişi: sqlite, sadece stdlib.
Biten her maç tek bir satır: mod, sınıflar, deste, sonuç, tur sayısı. Amaç
"hangi deste hangi sınıfa karşı ne yapıyor" sorusunun tek sorguyla
cevaplanabilmesi.
Aynı maçın iki kez yazılmasını (kaynak oturum dizini, maçın başlangıç saati)
çifti engelliyor. Böylece canlı takip ile eski logların içe aktarımı aynı
veritabanında çakışmadan birleşiyor, içe aktarma istendiği kadar tekrarlanabilir.
Veritabanı erişilemezse (izin, bozuk dosya) sınıf sessizce devre dışı kalır:
geçmiş tutulamıyor diye maç takibi durmasın.
"""
from __future__ import annotations
import os
import re
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from .state import Game
DATA_DIR = Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() / "deste"
DB_PATH = DATA_DIR / "history.db"
# Kırılım alınabilen sütunlar. Sorgu metnine doğrudan gömüldüğü için whitelist.
GROUPS = ("deck", "my_class", "opponent_class", "mode", "format_type")
SOURCE_RE = re.compile(r"(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})$")
SCHEMA = """
CREATE TABLE IF NOT EXISTS matches (
id INTEGER PRIMARY KEY,
source TEXT NOT NULL,
started_ts TEXT NOT NULL,
played_at TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT '',
format_type TEXT NOT NULL DEFAULT '',
deck TEXT NOT NULL DEFAULT '',
my_class TEXT NOT NULL DEFAULT '',
opponent_class TEXT NOT NULL DEFAULT '',
result TEXT NOT NULL DEFAULT '',
turns INTEGER NOT NULL DEFAULT 0,
went_first INTEGER NOT NULL DEFAULT 0,
UNIQUE (source, started_ts)
);
CREATE INDEX IF NOT EXISTS matches_played_at ON matches (played_at);
"""
COLUMNS = (
"source",
"started_ts",
"played_at",
"mode",
"format_type",
"deck",
"my_class",
"opponent_class",
"result",
"turns",
"went_first",
)
INSERT = (
f"INSERT OR IGNORE INTO matches ({', '.join(COLUMNS)}) "
f"VALUES ({', '.join('?' * len(COLUMNS))})"
)
@dataclass(slots=True)
class MatchRecord:
source: str = "" # oturum log dizininin adı
started_ts: str = "" # log içi saat, maçın CREATE_GAME anı
played_at: str = "" # YYYY-MM-DD HH:MM:SS, dizin tarihi ile birleştirilmiş
mode: str = "" # RANKED, CASUAL, ARENA ...
format_type: str = "" # STANDARD, WILD
deck: str = "" # takip edilen deste adı, eşleşmediyse boş
my_class: str = ""
opponent_class: str = ""
result: str = "" # WON / LOST / TIED
turns: int = 0
went_first: bool = False
def as_row(self) -> tuple:
return (
self.source,
self.started_ts,
self.played_at,
self.mode,
self.format_type,
self.deck,
self.my_class,
self.opponent_class,
self.result,
int(self.turns),
int(self.went_first),
)
@classmethod
def from_row(cls, row: sqlite3.Row) -> "MatchRecord":
return cls(
source=row["source"],
started_ts=row["started_ts"],
played_at=row["played_at"],
mode=row["mode"],
format_type=row["format_type"],
deck=row["deck"],
my_class=row["my_class"],
opponent_class=row["opponent_class"],
result=row["result"],
turns=row["turns"],
went_first=bool(row["went_first"]),
)
@dataclass(slots=True)
class Stat:
"""Bir kırılımın (deste, sınıf, mod) galibiyet dökümü."""
key: str = ""
wins: int = 0
losses: int = 0
ties: int = 0
@property
def total(self) -> int:
return self.wins + self.losses + self.ties
@property
def decided(self) -> int:
return self.wins + self.losses
@property
def winrate(self) -> float | None:
"""Yüzde olarak galibiyet oranı, beraberlikler dışarıda.
Hiç sonuçlanmış maç yoksa None: sıfır göstermek "hep kaybetmiş" gibi
okunuyor, oysa bilinmiyor.
"""
return (100.0 * self.wins / self.decided) if self.decided else None
def played_at(source: str, started_ts: str) -> str:
"""Oturum dizininin tarihiyle log içi saati birleştirir.
Power.log satırları yalnızca saati yazıyor, tarih dizin adında duruyor
(Hearthstone_2026_07_25_20_29_50). Oturum gece yarısını geçtiyse saat
geriye sarar, o durumda bir gün ekliyoruz.
"""
match = SOURCE_RE.search(source)
if match is None or not started_ts:
return ""
parts = started_ts.split(":")
if len(parts) < 3:
return ""
try:
session = datetime(*(int(g) for g in match.groups()))
stamp = session.replace(
hour=int(parts[0]), minute=int(parts[1]), second=int(float(parts[2]))
)
except ValueError:
return ""
if stamp < session:
stamp += timedelta(days=1)
return stamp.strftime("%Y-%m-%d %H:%M:%S")
def from_game(
game: Game,
source: str,
my_class: str = "",
opponent_class: str = "",
deck: str = "",
) -> MatchRecord:
"""Biten maçtan kayıt üretir.
Sınıflar dışarıdan geliyor: kahraman kartından sınıfa çeviren kart
veritabanı data katmanında, core oraya bakmaz.
"""
return MatchRecord(
source=source,
started_ts=game.started_ts,
played_at=played_at(source, game.started_ts),
mode=game.meta.get("GameType", "").removeprefix("GT_"),
format_type=game.meta.get("FormatType", "").removeprefix("FT_"),
deck=deck,
my_class=my_class,
opponent_class=opponent_class,
result=game.result,
turns=game.game_turn,
went_first=game.first_player_id is not None
and game.first_player_id == game.local_player_id,
)
class History:
def __init__(self, path: Path | str = DB_PATH):
self.path = Path(path)
self.conn: sqlite3.Connection | None = None
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(str(self.path))
self.conn.row_factory = sqlite3.Row
self.conn.executescript(SCHEMA)
except (sqlite3.Error, OSError):
self.conn = None
@property
def available(self) -> bool:
return self.conn is not None
def close(self) -> None:
if self.conn is not None:
self.conn.close()
self.conn = None
# --- yazma ----------------------------------------------------------
def add(self, record: MatchRecord) -> bool:
"""Maçı yazar. Zaten kayıtlıysa dokunmaz ve False döner."""
if self.conn is None:
return False
try:
with self.conn:
cursor = self.conn.execute(INSERT, record.as_row())
except sqlite3.Error:
return False
return cursor.rowcount > 0
def add_many(self, records) -> int:
return sum(1 for record in records if self.add(record))
# --- okuma ----------------------------------------------------------
def _query(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
if self.conn is None:
return []
try:
return self.conn.execute(sql, params).fetchall()
except sqlite3.Error:
return []
@staticmethod
def _filter(mode: str) -> tuple[str, tuple]:
return (" WHERE mode = ?", (mode,)) if mode else ("", ())
def matches(self, limit: int = 200, mode: str = "") -> list[MatchRecord]:
"""En yeni maçtan başlayarak kayıtlar."""
where, params = self._filter(mode)
sql = f"SELECT * FROM matches{where} ORDER BY played_at DESC, id DESC LIMIT ?"
return [MatchRecord.from_row(row) for row in self._query(sql, params + (limit,))]
def summary(self, group: str, mode: str = "") -> list[Stat]:
"""Bir sütuna göre galibiyet dökümü, çok oynanan üstte."""
if group not in GROUPS:
raise ValueError(f"bilinmeyen kırılım: {group}")
where, params = self._filter(mode)
sql = (
f"SELECT {group} AS key, "
"SUM(result = 'WON') AS wins, "
"SUM(result = 'LOST') AS losses, "
"SUM(result = 'TIED') AS ties "
f"FROM matches{where} GROUP BY {group} ORDER BY COUNT(*) DESC, key ASC"
)
return [
Stat(row["key"] or "", row["wins"] or 0, row["losses"] or 0, row["ties"] or 0)
for row in self._query(sql, params)
]
def totals(self, mode: str = "") -> Stat:
where, params = self._filter(mode)
sql = (
"SELECT SUM(result = 'WON') AS wins, "
"SUM(result = 'LOST') AS losses, "
"SUM(result = 'TIED') AS ties "
f"FROM matches{where}"
)
rows = self._query(sql, params)
if not rows:
return Stat()
row = rows[0]
return Stat("", row["wins"] or 0, row["losses"] or 0, row["ties"] or 0)
def deck_classes(self) -> dict[str, str]:
"""Deste adı -> en çok oynandığı sınıf. Deste listesindeki portreler için."""
sql = (
"SELECT deck, my_class, COUNT(*) AS n FROM matches "
"GROUP BY deck, my_class ORDER BY n ASC"
)
# Artan sırada gezilince en çok oynanan sınıf en sona, yani sözlükte
# kalan değer olur.
return {row["deck"]: row["my_class"] for row in self._query(sql)}
def modes(self) -> list[str]:
sql = "SELECT mode, COUNT(*) AS n FROM matches GROUP BY mode ORDER BY n DESC"
return [row["mode"] for row in self._query(sql) if row["mode"]]
+17
View File
@@ -14,6 +14,23 @@ import urllib.request
from pathlib import Path
CARDS_URL = "https://api.hearthstonejson.com/v1/latest/enUS/cards.json"
# Sınıfın temel kahramanı. Sınıfı simgeleyen bir görsele ihtiyaç duyulduğunda
# (maç geçmişindeki portreler) bu kartın render'ı kullanılıyor.
CLASS_HEROES = {
"WARRIOR": "HERO_01",
"SHAMAN": "HERO_02",
"ROGUE": "HERO_03",
"PALADIN": "HERO_04",
"HUNTER": "HERO_05",
"DRUID": "HERO_06",
"WARLOCK": "HERO_07",
"MAGE": "HERO_08",
"PRIEST": "HERO_09",
"DEMONHUNTER": "HERO_10",
"DEATHKNIGHT": "HERO_11",
}
USER_AGENT = "deste/0.1 (kisisel hearthstone tracker; +local)"
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "deste"
CARDS_PATH = CACHE_DIR / "cards.json"
+217
View File
@@ -0,0 +1,217 @@
"""Maç geçmişi testleri.
İki parça var: veritabanı ve galibiyet matematiği elde kurulan kayıtlarla
sınanıyor (sonuçlar deterministik olsun diye), maçtan kayıt üretme yolu ise
makinedeki gerçek oturum loglarıyla. Log yoksa o bölüm atlanır.
Çalıştırma:
python -m tests.test_history [log_dizini]
"""
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from core import history, logdir, logtail
from core.parser_power import parse_lines
from core.state import Tracker
SOURCE = "Hearthstone_2026_07_25_20_29_50"
class Failure(Exception):
pass
def check(condition: bool, message: str) -> None:
if not condition:
raise Failure(message)
def sample(index: int, result: str, deck: str, opponent_class: str) -> history.MatchRecord:
return history.MatchRecord(
source=SOURCE,
started_ts=f"20:{index:02d}:00.0000000",
played_at=history.played_at(SOURCE, f"20:{index:02d}:00.0000000"),
mode="RANKED",
format_type="STANDARD",
deck=deck,
my_class="HUNTER",
opponent_class=opponent_class,
result=result,
turns=8 + index,
went_first=index % 2 == 0,
)
# 4 maç Face Hunter (3G 1M), 2 maç Tripwire (1G 1M), biri beraberlik.
SAMPLES = [
sample(31, "WON", "Face Hunter", "MAGE"),
sample(32, "WON", "Face Hunter", "MAGE"),
sample(33, "LOST", "Face Hunter", "PRIEST"),
sample(34, "WON", "Face Hunter", "WARRIOR"),
sample(35, "LOST", "Tripwire Hunter", "PRIEST"),
sample(36, "WON", "Tripwire Hunter", "MAGE"),
sample(37, "TIED", "Tripwire Hunter", "PRIEST"),
]
def load_games(log_dir: Path):
games = []
tracker = Tracker(on_game_end=games.append)
tracker.feed(parse_lines(logtail.read_file_lines(log_dir / "Power.log")))
tracker.close()
return games
def run(log_dir: Path | None) -> int:
failures: list[str] = []
def record(name: str, fn) -> None:
try:
fn()
except Failure as exc:
failures.append(f"{name}: {exc}")
print(f" BAŞARISIZ {name}: {exc}")
else:
print(f" tamam {name}")
def check_played_at() -> None:
check(
history.played_at(SOURCE, "20:31:02.1234567") == "2026-07-25 20:31:02",
"oturum tarihiyle saat birleşmiyor",
)
# Oturum gece yarısını geçtiyse saat geriye sarar, tarih bir gün ilerler.
check(
history.played_at(SOURCE, "00:14:00.0000000") == "2026-07-26 00:14:00",
"gece yarısı sarması yanlış",
)
check(history.played_at("baska_dizin", "20:31:02.1") == "", "bozuk kaynak boş dönmeli")
check(history.played_at(SOURCE, "") == "", "saatsiz kayıt boş dönmeli")
record("log dizini adı ile saat doğru tarihe çevriliyor", check_played_at)
with tempfile.TemporaryDirectory() as tmp:
store = history.History(Path(tmp) / "history.db")
check(store.available, "geçici veritabanı açılamadı")
record(
"kayıtlar yazılıyor",
lambda: check(store.add_many(SAMPLES) == len(SAMPLES), "hepsi yazılmadı"),
)
record(
"aynı maç ikinci kez yazılmıyor",
lambda: check(
store.add_many(SAMPLES) == 0 and store.totals().total == len(SAMPLES),
f"tekrar yazımdan sonra {store.totals().total} kayıt var",
),
)
def check_totals() -> None:
totals = store.totals()
check(
(totals.wins, totals.losses, totals.ties) == (4, 2, 1),
f"toplam döküm yanlış: {totals}",
)
# Beraberlik oranın dışında: 4 galibiyet / 6 sonuçlanmış maç.
check(
abs(totals.winrate - 100 * 4 / 6) < 1e-9,
f"galibiyet oranı yanlış: {totals.winrate}",
)
record("toplam döküm ve galibiyet oranı", check_totals)
def check_by_deck() -> None:
stats = {s.key: s for s in store.summary("deck")}
check(set(stats) == {"Face Hunter", "Tripwire Hunter"}, f"desteler: {list(stats)}")
face = stats["Face Hunter"]
check((face.wins, face.losses, face.total) == (3, 1, 4), f"Face Hunter: {face}")
check(abs(face.winrate - 75.0) < 1e-9, f"Face Hunter oranı: {face.winrate}")
tripwire = stats["Tripwire Hunter"]
check(
(tripwire.wins, tripwire.losses, tripwire.ties) == (1, 1, 1),
f"Tripwire Hunter: {tripwire}",
)
# Çok oynanan deste üstte olmalı, arayüz sıralamayı buradan alıyor.
check(store.summary("deck")[0].key == "Face Hunter", "sıralama maç sayısına göre değil")
record("desteye göre kırılım", check_by_deck)
def check_by_class() -> None:
stats = {s.key: s for s in store.summary("opponent_class")}
check(
(stats["MAGE"].wins, stats["MAGE"].losses) == (3, 0),
f"Mage eşleşmesi: {stats['MAGE']}",
)
check(
stats["PRIEST"].winrate == 0.0 and stats["PRIEST"].total == 3,
f"Priest eşleşmesi: {stats['PRIEST']}",
)
check(
sum(s.total for s in stats.values()) == len(SAMPLES),
"sınıf kırılımının toplamı maç sayısını tutmuyor",
)
record("rakip sınıfına göre kırılım", check_by_class)
def check_filter() -> None:
check(store.modes() == ["RANKED"], f"modlar: {store.modes()}")
check(store.totals("CASUAL").total == 0, "olmayan mod maç döndürdü")
check(store.totals("RANKED").total == len(SAMPLES), "mod süzgeci maç eledi")
record("mod süzgeci", check_filter)
def check_order() -> None:
listed = store.matches(3)
check(len(listed) == 3, f"limit çalışmıyor: {len(listed)}")
check(
[r.started_ts for r in listed]
== sorted((r.started_ts for r in SAMPLES), reverse=True)[:3],
"en yeni maç üstte değil",
)
record("son maçlar yeniden eskiye sıralı", check_order)
store.close()
if log_dir is None:
print(" atlandı gerçek maçlardan kayıt üretme (log dizini yok)")
else:
def check_from_game() -> None:
games = [g for g in load_games(log_dir) if g.result]
check(bool(games), f"{log_dir} içinde sonucu belli maç yok")
with tempfile.TemporaryDirectory() as tmp:
store = history.History(Path(tmp) / "history.db")
records = [history.from_game(g, log_dir.name) for g in games]
check(
store.add_many(records) == len(records),
"aynı oturumdaki maçlar birbirini eziyor (başlangıç saatleri çakışıyor)",
)
for index, (game, item) in enumerate(zip(games, records), start=1):
check(item.turns == game.game_turn >= 1, f"maç {index}: tur sayısı {item.turns}")
check(item.result in ("WON", "LOST", "TIED"), f"maç {index}: sonuç {item.result}")
check(bool(item.mode), f"maç {index}: mod boş")
check(bool(item.played_at), f"maç {index}: tarih çözülemedi")
check(
store.totals().total == len(records),
"veritabanındaki maç sayısı üretilen kayıt sayısını tutmuyor",
)
store.close()
record("gerçek maçlardan kayıt üretiliyor", check_from_game)
print()
if failures:
print(f"{len(failures)} test başarısız")
return 1
print("tüm testler geçti")
return 0
def main(argv: list[str]) -> int:
if argv:
return run(Path(argv[0]).expanduser())
install = logdir.detect()
found = logdir.latest_log_dir(install.logs_dir) if install else None
return run(found)
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+101
View File
@@ -0,0 +1,101 @@
"""Diskteki oturum loglarını maç geçmişine aktarır.
Uygulama yalnızca kendisi açıkken oynanan maçları kaydeder. Bu araç bütün
oturum dizinlerini baştan okuyup aynı veritabanına yazar. Daha önce yazılmış
maçlar atlanır, istendiği kadar tekrar çalıştırılabilir.
Kullanım:
python -m tools.import_history # bütün oturumlar
python -m tools.import_history <log_dizini> # tek oturum
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from core import history, logdir, logtail
from core.parser_power import parse_lines
from core.state import Game, Tracker
def games_in(log_dir: Path) -> list[Game]:
games: list[Game] = []
tracker = Tracker(on_game_end=games.append)
tracker.feed(parse_lines(logtail.read_file_lines(log_dir / "Power.log")))
tracker.close()
return games
def import_dir(log_dir: Path, store: history.History, cards, library) -> tuple[int, int]:
"""(yazılan, aday) maç sayısı. Sonucu bilinmeyen maçlar sayılmaz."""
if not (log_dir / "Power.log").exists():
return (0, 0)
added = candidates = 0
for game in games_in(log_dir):
# Yarım kalmış maçlar (bağlantı koptu, oyun kapandı) geçmişi kirletir.
if not game.result or game.local_player_id is None:
continue
candidates += 1
deck = library.match(game) if library is not None else None
record = history.from_game(
game,
log_dir.name,
my_class=cards.card_class(game.hero_card_id(game.local_player_id)),
opponent_class=cards.card_class(game.hero_card_id(game.opponent_player_id)),
deck=deck.name if deck is not None else "",
)
added += int(store.add(record))
return (added, candidates)
def main(argv: list[str]) -> int:
install = logdir.detect()
if argv:
log_dirs = [Path(argv[0]).expanduser()]
else:
if install is None:
print("Hearthstone kurulumu bulunamadı.", file=sys.stderr)
return 1
log_dirs = sorted(
d
for d in install.logs_dir.iterdir()
if d.is_dir() and logdir.LOG_DIR_RE.match(d.name)
)
if not log_dirs:
print(f"Oturum dizini yok: {install.logs_dir}", file=sys.stderr)
return 1
from data.cards import CardDB
from data.decks import DeckLibrary
cards = CardDB.load()
library = DeckLibrary(cards, install.game_dir if install else None).load()
store = history.History()
if not store.available:
print(f"Geçmiş veritabanı açılamadı: {store.path}", file=sys.stderr)
return 1
total_added = total_candidates = 0
for log_dir in log_dirs:
added, candidates = import_dir(log_dir, store, cards, library)
total_added += added
total_candidates += candidates
print(f"{log_dir.name} {added} yeni / {candidates} maç")
totals = store.totals()
rate = f"%{totals.winrate:.0f}" if totals.winrate is not None else "-"
print(
f"\n{total_added} maç eklendi ({total_candidates - total_added} zaten kayıtlıydı)."
f"\nVeritabanı: {store.path}"
f"\nToplam {totals.total} maç, {totals.wins}G {totals.losses}M, {rate}"
)
store.close()
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+600
View File
@@ -0,0 +1,600 @@
"""Maç geçmişi penceresi.
Üç bölüm: üstte toplam döküm, ortada deste ve rakip sınıfı kırılımları, altta
son maçlar. Sayı yerine çubuk çizmenin sebebi, "hangi eşleşme kötü gidiyor"
sorusunun listeye bakar bakmaz cevaplanması.
Panelin aksine bu pencere saydam değil ve üstte durmaz: oyunun üstünde değil,
oyun dışında okunuyor.
"""
from __future__ import annotations
from datetime import datetime
from PyQt6.QtCore import QRect, QSize, Qt, QTimer
from PyQt6.QtGui import QColor, QIcon, QPainter, QPainterPath, QPen
from PyQt6.QtWidgets import (
QAbstractItemView,
QComboBox,
QHBoxLayout,
QHeaderView,
QLabel,
QSizePolicy,
QStyledItemDelegate,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from core import config
from data.cards import CLASS_HEROES
from data.images import RENDER
from . import i18n, theme
from .i18n import t
from .icon import app_icon
from .widgets import color_dot, hero_portrait
RECENT_LIMIT = 300
ROW_HEIGHT = 24
ICON_SIZE = 18
# Portreler arka planda indiyor, geldiklerinde tabloyu yeniden kurmadan
# yalnızca ikonları değiştiriyoruz.
ART_POLL_MS = 700
WINRATE_WIDTH = 132
# Kırılım tabloları içeriği kadar yer kaplar, uzunsa kaydırılır. Böylece iki
# satırlık deste listesinin altında koca bir boşluk kalmıyor. Sınır oyundaki
# sınıf sayısı: rakip kırılımı hiç kaydırılmadan okunabilsin.
GROUP_MAX_ROWS = 11
# Hücrenin gizli verileri: sıralama anahtarı, sınıf (ikon için), oran (çubuk).
SORT_ROLE = Qt.ItemDataRole.UserRole + 1
RATE_ROLE = Qt.ItemDataRole.UserRole + 2
# Sonuç sütununun sıralaması işaretin harfine göre olmasın.
RESULT_ORDER = {"WON": 2, "TIED": 1, "LOST": 0}
def mode_label(mode: str) -> str:
"""GT_ öneki atılmış mod adını okunur biçime çevirir."""
key = f"mode_{mode}"
if i18n.has(key):
return t(key)
return mode.replace("_", " ").title() if mode else "?"
def date_label(played_at: str) -> str:
try:
stamp = datetime.strptime(played_at, "%Y-%m-%d %H:%M:%S")
except ValueError:
return played_at
return stamp.strftime(t("date_format"))
def class_label(player_class: str) -> str:
return theme.CLASS_NAMES.get(player_class, player_class or "?")
class ClassIcons:
"""Sınıf portrelerini üretir ve saklar.
Portre, sınıfın temel kahramanının kart görselinden kesiliyor; görsel henüz
inmediyse yerini sınıf renginde bir daire tutuyor. Böylece liste ilk açılışta
da ikonlu görünüyor, indikçe portreye dönüyor.
"""
def __init__(self, images=None):
self.images = images
self.generation = -1
self._cache: dict[str, QIcon] = {}
def refreshed(self) -> bool:
"""Yeni görsel indiyse önbelleği boşaltır ve True döner."""
generation = self.images.generation if self.images is not None else 0
if generation == self.generation:
return False
self.generation = generation
self._cache.clear()
return True
def get(self, player_class: str) -> QIcon:
icon = self._cache.get(player_class)
if icon is None:
icon = QIcon(self._pixmap(player_class))
self._cache[player_class] = icon
return icon
def _pixmap(self, player_class: str):
card_id = CLASS_HEROES.get(player_class, "")
path = self.images.get(card_id, RENDER) if self.images and card_id else None
# İkon iki katı boyutta üretiliyor, yoğun ekranlarda bulanıklaşmasın.
if path is not None:
portrait = hero_portrait(path, ICON_SIZE * 2)
if portrait is not None:
return portrait
return color_dot(theme.CLASS_COLORS.get(player_class, theme.TEXT_DIM), ICON_SIZE * 2)
class WinrateDelegate(QStyledItemDelegate):
"""Galibiyet oranını hücrenin içine çubuk olarak çizer.
Hücre widget'ı (setCellWidget) yerine delegate olmasının sebebi sıralama:
widget'lar satırla birlikte taşınmıyor, tablo sıralanınca çubuklar yanlış
satırda kalıyor. Delegate hücrenin verisinden çizdiği için bu sorun yok.
"""
def paint(self, painter, option, index) -> None:
rate = index.data(RATE_ROLE)
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = option.rect.adjusted(6, 0, -6, 0)
# Yazı alanı en uzun değere göre ölçülüyor: "%100" sığmazsa yüzde
# kırpılıyor ve %77 ile %7 aynı görünüyor.
text_width = painter.fontMetrics().horizontalAdvance(t("percent", value="100")) + 2
bar_width = float(max(rect.width() - text_width - 8, 10))
thickness = 6.0
top = rect.y() + (rect.height() - thickness) / 2
track = QPainterPath()
track.addRoundedRect(float(rect.x()), top, bar_width, thickness, 3.0, 3.0)
painter.fillPath(track, QColor(theme.BORDER))
if rate is not None:
fill = QPainterPath()
# En küçük dolgu bile görünsün: %0 da bir bilgidir.
fill.addRoundedRect(
float(rect.x()), top, max(bar_width * rate / 100.0, 3.0), thickness, 3.0, 3.0
)
painter.fillPath(fill, QColor(theme.WIN if rate >= 50 else theme.LOSS))
text = t("percent", value=f"{rate:.0f}")
else:
text = ""
painter.setPen(QPen(QColor(theme.TEXT if rate is not None else theme.TEXT_DIM)))
painter.drawText(
QRect(rect.x() + int(bar_width) + 8, rect.y(), text_width, rect.height()),
int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight),
text,
)
painter.restore()
def sizeHint(self, option, index) -> QSize: # noqa: N802
return QSize(WINRATE_WIDTH, ROW_HEIGHT)
class Cell(QTableWidgetItem):
"""Görünen metne değil, gizli anahtara göre sıralanan hücre.
Tarih "27.07 22:41" biçiminde yazılıyor, tur bir sayı, sonuç bir işaret:
üçünü de metne göre sıralamak yanlış sonuç verir.
"""
def __lt__(self, other) -> bool:
mine, theirs = self.data(SORT_ROLE), other.data(SORT_ROLE)
if mine is None or theirs is None:
return super().__lt__(other)
return mine < theirs
def _table(column_count: int, stretch: int = 0) -> QTableWidget:
table = QTableWidget(0, column_count)
table.verticalHeader().setVisible(False)
table.verticalHeader().setDefaultSectionSize(ROW_HEIGHT)
table.setShowGrid(False)
table.setWordWrap(False)
table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
table.setFocusPolicy(Qt.FocusPolicy.NoFocus)
table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
table.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
header = table.horizontalHeader()
header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
header.setSectionsClickable(True)
for column in range(column_count):
header.setSectionResizeMode(
column,
QHeaderView.ResizeMode.Stretch
if column == stretch
else QHeaderView.ResizeMode.ResizeToContents,
)
return table
def _fit(table: QTableWidget, max_rows: int) -> None:
"""Tabloyu satır sayısı kadar yükseltir, taşarsa kaydırma çubuğuna bırakır."""
rows = min(max(table.rowCount(), 1), max_rows)
table.setFixedHeight(table.horizontalHeader().height() + rows * ROW_HEIGHT + 2)
def _align_right(table: QTableWidget, columns) -> None:
"""Sayı sütunlarının başlığı da sağa yaslansın, veriyle hizalansın."""
for column in columns:
item = table.horizontalHeaderItem(column)
if item is not None:
item.setTextAlignment(
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
)
def _item(
text: str,
color: str = "",
align_right: bool = False,
player_class: str | None = None,
sort=None,
) -> Cell:
item = Cell(text)
if color:
item.setForeground(QColor(color))
if align_right:
item.setTextAlignment(
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
)
if player_class is not None:
# İkonun kendisi sonradan basılıyor: portre indiğinde satırı yeniden
# kurmadan değiştirebilmek için hücre hangi sınıfı gösterdiğini saklıyor.
item.setData(Qt.ItemDataRole.UserRole, player_class)
if sort is not None:
item.setData(SORT_ROLE, sort)
return item
def _section(title: str, table: QTableWidget, fill: bool = False) -> tuple[QWidget, QLabel]:
box = QWidget()
layout = QVBoxLayout(box)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
label = QLabel(title)
label.setObjectName("section")
layout.addWidget(label)
layout.addWidget(table)
if not fill:
# Boyu içeriğine sabitlenmiş tablo yukarı yaslansın.
layout.addStretch(1)
return box, label
class HistoryWindow(QWidget):
def __init__(self, store, settings: dict, images=None, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self.store = store
self.settings = settings
self.icons = ClassIcons(images)
self.mode_filter = ""
# Tablo -> (sütun, yön). Kullanıcının seçtiği sıralama, tablo yeniden
# dolduğunda (mod süzgeci, yeni maç, dil değişimi) korunuyor.
self._sort: dict[QTableWidget, tuple[int, Qt.SortOrder]] = {}
self._desc_first: dict[QTableWidget, set[int]] = {}
self._headers: dict[QTableWidget, list[str]] = {}
self.setObjectName("history")
self.setWindowIcon(app_icon())
self.setStyleSheet(theme.STYLESHEET + theme.TABLE_STYLESHEET)
self._art_timer = QTimer(self)
self._art_timer.timeout.connect(self._check_art)
self._build()
self._restore_geometry()
self.reload()
# --- kurulum --------------------------------------------------------
def _build(self) -> None:
outer = QVBoxLayout(self)
outer.setContentsMargins(14, 12, 14, 12)
outer.setSpacing(10)
top = QHBoxLayout()
top.setSpacing(8)
self.title_label = QLabel()
self.title_label.setObjectName("title")
top.addWidget(self.title_label)
self.totals_label = QLabel()
self.totals_label.setObjectName("total")
top.addWidget(self.totals_label, 1)
self.mode_box = QComboBox()
self.mode_box.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.mode_box.currentIndexChanged.connect(self._on_mode_changed)
top.addWidget(self.mode_box)
outer.addLayout(top)
self.empty_label = QLabel()
self.empty_label.setObjectName("empty")
self.empty_label.setWordWrap(True)
self.empty_label.setVisible(False)
outer.addWidget(self.empty_label)
groups = QHBoxLayout()
groups.setSpacing(14)
self.deck_table = _table(3)
self.deck_box, self.deck_title = _section("", self.deck_table)
groups.addWidget(self.deck_box, 1)
self.opponent_table = _table(3)
self.opponent_box, self.opponent_title = _section("", self.opponent_table)
groups.addWidget(self.opponent_box, 1)
for table in (self.deck_table, self.opponent_table):
table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
table.setColumnWidth(2, WINRATE_WIDTH)
table.setItemDelegateForColumn(2, WinrateDelegate(table))
# Varsayılan: en çok oynanan üstte.
self._init_sort(table, 1, Qt.SortOrder.DescendingOrder, desc_first=(1, 2))
outer.addLayout(groups)
# Esneyen sütun rakip: tarih, mod, deste ve rakip solda birbirine
# yapışık kalıyor, artan yer sonuçtan önceye düşüyor.
self.recent_table = _table(6, stretch=3)
self.recent_box, self.recent_title = _section("", self.recent_table, fill=True)
outer.addWidget(self.recent_box, 1)
self._init_sort(
self.recent_table, 0, Qt.SortOrder.DescendingOrder, desc_first=(0, 4, 5)
)
# Tablolar gizlendiğinde artan yeri bu boş dolgu yutar, yoksa Qt boşluğu
# başlık ile mesaj arasına dağıtıp ikisini pencerenin ortasına itiyor.
self._tail = QWidget()
outer.addWidget(self._tail)
# --- sıralama -------------------------------------------------------
def _init_sort(
self, table: QTableWidget, column: int, order: Qt.SortOrder, desc_first=()
) -> None:
"""Sıralamayı Qt'ye bırakmıyoruz.
Qt her sütuna artan sırayla başlıyor, oysa sayı sütunlarında beklenen
"önce en iyisi": galibiyete tıklayınca en iyi eşleşme, tura tıklayınca
en uzun maç üstte olmalı. Yön seçimi bu yüzden burada.
"""
self._sort[table] = (column, order)
self._desc_first[table] = set(desc_first)
table.horizontalHeader().sectionClicked.connect(
lambda index, target=table: self._on_header_clicked(target, index)
)
def _on_header_clicked(self, table: QTableWidget, column: int) -> None:
current, order = self._sort[table]
if column == current:
order = (
Qt.SortOrder.AscendingOrder
if order == Qt.SortOrder.DescendingOrder
else Qt.SortOrder.DescendingOrder
)
elif column in self._desc_first[table]:
order = Qt.SortOrder.DescendingOrder
else:
order = Qt.SortOrder.AscendingOrder
self._sort[table] = (column, order)
self._apply_sort(table)
def _apply_sort(self, table: QTableWidget) -> None:
column, order = self._sort[table]
table.sortItems(column, order)
self._mark_sorted_header(table)
def _mark_sorted_header(self, table: QTableWidget) -> None:
"""Sıralı sütunu başlıktaki okla gösterir.
Qt'nin kendi sıralama ibresi, başlığa stil verildiğinde çizilmiyor.
Oku metnin parçası yapmak hem her temada görünüyor hem de panelin deste
düğmesindeki dille aynı.
"""
column, order = self._sort[table]
arrow = "" if order == Qt.SortOrder.DescendingOrder else ""
for index, label in enumerate(self._headers.get(table, [])):
item = table.horizontalHeaderItem(index)
if item is not None:
item.setText(label + (arrow if index == column else ""))
def _set_headers(self, table: QTableWidget, labels: list[str], right=()) -> None:
self._headers[table] = labels
table.setHorizontalHeaderLabels(labels)
_align_right(table, right)
self._mark_sorted_header(table)
# --- veri -----------------------------------------------------------
def reload(self) -> None:
"""Metinleri ve verileri baştan kurar. Dil değişiminde de çağrılır."""
self.setWindowTitle(t("history_title"))
self.title_label.setText(t("history_title"))
self.deck_title.setText(t("history_by_deck"))
self.opponent_title.setText(t("history_by_opponent"))
self.recent_title.setText(t("history_recent"))
self.empty_label.setText(t("history_empty"))
self._set_headers(
self.deck_table,
[t("column_deck"), t("column_record"), t("column_winrate")],
right=(1,),
)
self._set_headers(
self.opponent_table,
[t("column_opponent"), t("column_record"), t("column_winrate")],
right=(1,),
)
self._set_headers(
self.recent_table,
[
t("column_date"),
t("column_mode"),
t("column_deck"),
t("column_opponent"),
t("column_result"),
t("column_turns"),
],
right=(4, 5),
)
self._fill_modes()
totals = self.store.totals(self.mode_filter)
self.totals_label.setText(
t("history_totals", total=totals.total, wins=totals.wins, losses=totals.losses)
+ (" " + t("percent", value=f"{totals.winrate:.0f}") if totals.winrate is not None else "")
)
# Hiç maç yoksa boş tablo iskeleti göstermek yerine tek cümle kalsın.
empty = totals.total == 0
self.empty_label.setVisible(empty)
for box in (self.deck_box, self.opponent_box, self.recent_box):
box.setVisible(not empty)
self.layout().setStretchFactor(self._tail, 1 if empty else 0)
deck_classes = self.store.deck_classes()
self._fill_groups(
self.deck_table,
self.store.summary("deck", self.mode_filter),
lambda key: key or t("history_no_deck"),
lambda key: deck_classes.get(key, ""),
)
self._fill_groups(
self.opponent_table,
self.store.summary("opponent_class", self.mode_filter),
class_label,
lambda key: key,
)
self._fill_recent()
self._apply_icons()
def _fill_modes(self) -> None:
modes = self.store.modes()
self.mode_box.blockSignals(True)
self.mode_box.clear()
self.mode_box.addItem(t("history_all_modes"), "")
for mode in modes:
self.mode_box.addItem(mode_label(mode), mode)
index = self.mode_box.findData(self.mode_filter)
# Süzgeçteki mod artık veride yoksa (mesela silinmişse) hepsine dön.
if index < 0:
index, self.mode_filter = 0, ""
self.mode_box.setCurrentIndex(index)
self.mode_box.blockSignals(False)
self.mode_box.setVisible(bool(modes))
def _on_mode_changed(self, _index: int) -> None:
self.mode_filter = self.mode_box.currentData() or ""
self.reload()
def _fill_groups(self, table, stats, label_for, class_for) -> None:
table.setRowCount(len(stats))
for row, stat in enumerate(stats):
player_class = class_for(stat.key)
table.setItem(
row,
0,
_item(
label_for(stat.key),
theme.CLASS_COLORS.get(player_class, ""),
player_class=player_class,
),
)
record = f"{stat.wins}-{stat.losses}"
if stat.ties:
record += f"-{stat.ties}"
# Sıralama anahtarları çift: G-M sütunu maç sayısına göre sıralanır
# (satırın ağırlığı bu), eşitlikte orana bakılır, oran sütununda tersi.
# Hiç sonuçlanmamış satırın oranı yok, en dibe insin diye -1.
rate_key = stat.winrate if stat.winrate is not None else -1.0
table.setItem(
row, 1, _item(record, theme.TEXT_DIM, True, sort=(stat.total, rate_key))
)
rate = _item("", align_right=True, sort=(rate_key, stat.total))
rate.setData(RATE_ROLE, stat.winrate)
table.setItem(row, 2, rate)
self._apply_sort(table)
_fit(table, GROUP_MAX_ROWS)
def _fill_recent(self) -> None:
records = self.store.matches(RECENT_LIMIT, self.mode_filter)
table = self.recent_table
table.setRowCount(len(records))
for row, record in enumerate(records):
deck = record.deck or class_label(record.my_class)
opponent = class_label(record.opponent_class)
# Her anahtarın ikinci parçası zaman damgası: aynı sınıfa karşı ya da
# aynı tur sayısında oynanan maçlar kendi içinde tarihe göre dizilsin,
# sıra tesadüfe kalmasın. Tarih sütununda ekrandaki kısa yazı değil
# tam damga sıralanıyor.
when = record.played_at
table.setItem(
row, 0, _item(date_label(when), theme.TEXT_DIM, sort=when)
)
mode = mode_label(record.mode)
table.setItem(row, 1, _item(mode, theme.TEXT_DIM, sort=(mode, when)))
table.setItem(
row, 2, _item(deck, player_class=record.my_class, sort=(deck, when))
)
table.setItem(
row,
3,
_item(
opponent,
theme.CLASS_COLORS.get(record.opponent_class, ""),
player_class=record.opponent_class,
sort=(opponent, when),
),
)
mark, color = theme.RESULT_MARKS.get(record.result, ("?", theme.TEXT_DIM))
result_item = _item(
mark, color, True, sort=(RESULT_ORDER.get(record.result, -1), when)
)
result_item.setToolTip(
t(i18n.RESULT_KEYS.get(record.result, "result_tied"))
+ " · "
+ t("coin_first" if record.went_first else "coin_second")
)
table.setItem(row, 4, result_item)
table.setItem(
row,
5,
_item(str(record.turns), theme.TEXT_DIM, True, sort=(record.turns, when)),
)
self._apply_sort(table)
# --- ikonlar --------------------------------------------------------
def _apply_icons(self) -> None:
"""Sınıf saklayan hücrelere portreyi basar.
Portre diskte yoksa ClassIcons indirmeye alıyor, o ana kadar sınıf
renginde daire duruyor.
"""
for table in (self.deck_table, self.opponent_table, self.recent_table):
for row in range(table.rowCount()):
for column in range(table.columnCount()):
item = table.item(row, column)
if item is None:
continue
player_class = item.data(Qt.ItemDataRole.UserRole)
if player_class:
item.setIcon(self.icons.get(player_class))
def _check_art(self) -> None:
"""Yeni portre indiyse yalnızca ikonları değiştir.
Tabloyu yeniden kurmuyoruz: kullanıcı listeyi kaydırmışken kendiliğinden
başa dönmesi rahatsız edici.
"""
if self.icons.refreshed():
self._apply_icons()
def showEvent(self, event) -> None: # noqa: N802
self._art_timer.start(ART_POLL_MS)
super().showEvent(event)
def hideEvent(self, event) -> None: # noqa: N802
self._art_timer.stop()
super().hideEvent(event)
# --- geometri -------------------------------------------------------
def _restore_geometry(self) -> None:
saved = self.settings.get("geometry", {}).get("history")
if saved and len(saved) == 2:
self.resize(max(saved[0], 520), max(saved[1], 380))
else:
self.resize(760, 620)
def closeEvent(self, event) -> None: # noqa: N802
geometry = self.settings.setdefault("geometry", {})
rect = self.geometry()
geometry["history"] = [rect.width(), rect.height()]
config.save(self.settings)
super().closeEvent(event)
+43
View File
@@ -37,6 +37,41 @@ STRINGS: dict[str, dict[str, str]] = {
"menu_draw_chance": {"tr": "Çekme yüzdesi", "en": "Draw chance"},
"menu_language": {"tr": "Dil", "en": "Language"},
"menu_reload_decks": {"tr": "Desteleri yeniden yükle", "en": "Reload decks"},
"menu_history": {"tr": "Maç geçmişi", "en": "Match history"},
"history_title": {"tr": "Maç geçmişi", "en": "Match history"},
"history_empty": {
"tr": "Henüz kayıtlı maç yok. Uygulama açıkken oynadığın maçlar buraya düşer.",
"en": "No matches recorded yet. Games you play with the app open land here.",
},
"history_totals": {
"tr": "{total} maç {wins}G {losses}M",
"en": "{total} matches {wins}W {losses}L",
},
"history_by_deck": {"tr": "DESTEYE GÖRE", "en": "BY DECK"},
"history_by_opponent": {"tr": "RAKİP SINIFINA GÖRE", "en": "BY OPPONENT CLASS"},
"history_recent": {"tr": "SON MAÇLAR", "en": "RECENT MATCHES"},
"history_all_modes": {"tr": "Tüm modlar", "en": "All modes"},
"history_no_deck": {"tr": "Deste yok", "en": "No deck"},
"column_date": {"tr": "Tarih", "en": "Date"},
"column_mode": {"tr": "Mod", "en": "Mode"},
"column_deck": {"tr": "Deste", "en": "Deck"},
"column_opponent": {"tr": "Rakip", "en": "Opponent"},
"column_result": {"tr": "Sonuç", "en": "Result"},
"column_turns": {"tr": "Tur", "en": "Turns"},
"column_record": {"tr": "G-M", "en": "W-L"},
"column_winrate": {"tr": "Galibiyet", "en": "Win rate"},
"percent": {"tr": "%{value}", "en": "{value}%"},
# strftime kalıbı. Ay adı yok: adlar sistem yerelinden geliyor, İngilizce
# arayüzde Türkçe ay kısaltması çıkıyordu.
"date_format": {"tr": "%d.%m %H:%M", "en": "%m-%d %H:%M"},
"coin_first": {"tr": "Önce başladın", "en": "You went first"},
"coin_second": {"tr": "Sonra başladın", "en": "You went second"},
"mode_RANKED": {"tr": "Dereceli", "en": "Ranked"},
"mode_CASUAL": {"tr": "Serbest", "en": "Casual"},
"mode_ARENA": {"tr": "Arena", "en": "Arena"},
"mode_VS_AI": {"tr": "Yapay zeka", "en": "vs AI"},
"mode_TAVERNBRAWL": {"tr": "Meyhane kapışması", "en": "Tavern Brawl"},
"mode_BATTLEGROUNDS": {"tr": "Battlegrounds", "en": "Battlegrounds"},
"menu_hide": {"tr": "Gizle (tepsiye)", "en": "Hide to tray"},
"menu_quit": {"tr": "Çıkış", "en": "Quit"},
"tray_toggle": {"tr": "Göster / Gizle", "en": "Show / Hide"},
@@ -62,6 +97,9 @@ STRINGS: dict[str, dict[str, str]] = {
},
}
# Maç sonucunun metin anahtarı. İşaretin kendisi theme.RESULT_MARKS içinde.
RESULT_KEYS = {"WON": "result_won", "LOST": "result_lost", "TIED": "result_tied"}
_current = "en"
@@ -82,6 +120,11 @@ def current() -> str:
return _current
def has(key: str) -> bool:
"""Anahtar sözlükte var mı? Çevirisi olmayan değerler ham gösterilsin diye."""
return key in STRINGS
def t(key: str, **kwargs) -> str:
text = STRINGS.get(key, {}).get(_current, key)
return text.format(**kwargs) if kwargs else text
+31
View File
@@ -14,6 +14,9 @@ MANA_BLUE = "#1f4e8c"
WIN = "#5fbf6a"
LOSS = "#d0605a"
# Maç sonucu: yazı yerine işaret. Hem panelde hem maç geçmişinde aynı dil.
RESULT_MARKS = {"WON": ("", WIN), "LOST": ("", LOSS), "TIED": ("=", TEXT_DIM)}
RARITY_COLORS = {
"COMMON": "#c9cedb",
"FREE": "#c9cedb",
@@ -131,3 +134,31 @@ QScrollBar::handle:vertical:hover {{ background: {TEXT_DIM}; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }}
"""
# Maç geçmişi penceresi. Panelin aksine saydam değil: oyunun üstünde değil,
# yanında okunan bir pencere.
TABLE_STYLESHEET = f"""
QWidget#history {{ background: {BACKGROUND}; }}
QLabel#title {{ font-size: 15px; font-weight: 600; }}
QLabel#total {{ color: {TEXT_DIM}; font-size: 12px; }}
QTableWidget {{
background: transparent;
border: none;
outline: none;
}}
QTableWidget::item {{ padding: 2px 6px; border: none; }}
QTableWidget::item:selected {{ background: {SURFACE_ALT}; color: {TEXT}; }}
QHeaderView::section {{
background: transparent;
color: {TEXT_DIM};
border: none;
border-bottom: 1px solid {BORDER};
padding: 4px 6px;
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
}}
/* Başlıklar tıklanınca sıralıyor, imleç üstüne gelince belli olsun. */
QHeaderView::section:hover {{ color: {TEXT}; }}
QHeaderView::down-arrow, QHeaderView::up-arrow {{ width: 9px; height: 9px; }}
"""
+52 -1
View File
@@ -8,7 +8,7 @@ görselle birlikte satır tanınabilir oluyor.
from __future__ import annotations
from PyQt6.QtCore import QPoint, QRect, QSize, Qt, QTimer
from PyQt6.QtCore import QPoint, QRect, QRectF, QSize, Qt, QTimer
from PyQt6.QtGui import (
QColor,
QFont,
@@ -31,6 +31,57 @@ PREVIEW_DELAY_MS = 200
HIDE_DELAY_MS = 140
PREVIEW_GAP = 8
# Kahraman kartının render'ında yüzün bulunduğu kare: sol kenar, üst kenar ve
# kenar uzunluğu, kart genişliğinin oranı olarak. Altın çerçeve dışarıda kalsın
# diye deneyerek bulundu.
PORTRAIT_CROP = (0.33, 0.18, 0.34)
def _circle(size: int) -> QPainterPath:
path = QPainterPath()
path.addEllipse(QRectF(0.0, 0.0, float(size), float(size)))
return path
def color_dot(color: str, size: int) -> QPixmap:
"""Sınıf rengiyle dolu daire. Portre inene kadarki yer tutucu."""
pixmap = QPixmap(size, size)
pixmap.fill(QColor(0, 0, 0, 0))
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.fillPath(_circle(size), QColor(color))
painter.end()
return pixmap
def hero_portrait(path, size: int) -> QPixmap | None:
"""Kahraman kartının render'ından daire portre keser."""
source = QPixmap(str(path))
if source.isNull():
return None
left, top, side = PORTRAIT_CROP
crop = source.copy(
QRect(
int(source.width() * left),
int(source.height() * top),
int(source.width() * side),
int(source.width() * side),
)
).scaled(
size,
size,
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation,
)
pixmap = QPixmap(size, size)
pixmap.fill(QColor(0, 0, 0, 0))
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setClipPath(_circle(size))
painter.drawPixmap(0, 0, crop)
painter.end()
return pixmap
class ElidedLabel(QLabel):
"""Metni kısaltarak gösteren, genişliği pencereye dayatmayan etiket.
+54 -5
View File
@@ -28,7 +28,7 @@ from PyQt6.QtWidgets import (
QWidget,
)
from core import config
from core import config, history
from core.state import Game
from data.images import TILE, ImageCache
@@ -40,8 +40,6 @@ from .widgets import CardList, CardPreview, ElidedLabel
POLL_INTERVAL_MS = 400
MIN_WIDTH = 210
MIN_HEIGHT = 260
RESULT_MARK = {"WON": ("", theme.WIN), "LOST": ("", theme.LOSS), "TIED": ("=", theme.TEXT_DIM)}
RESULT_TOOLTIP = {"WON": "result_won", "LOST": "result_lost", "TIED": "result_tied"}
OPACITY_STEPS = [1.00, 0.90, 0.75, 0.60, 0.45]
@@ -65,6 +63,11 @@ class TrackerWindow(QWidget):
self.settings = settings
self.mode = settings.get("window_mode", "overlay")
self.images = ImageCache()
self.history = history.History()
self.history_window = None
# En son geçmişe yazılan maç. Sonuç geldikten sonra panel daha birkaç
# kez tazeleniyor, aynı maçı her seferinde yazmaya kalkmayalım.
self._recorded_key: tuple[str, str] | None = None
# Önizleme pencereye bağlı: Wayland'da konumlandırılabilmesi için
# bir ebeveyne bağlı popup olması gerekiyor, başıboş pencere taşınamaz.
self.preview = CardPreview(self.images, self)
@@ -243,6 +246,9 @@ class TrackerWindow(QWidget):
language_menu.addAction(action)
menu.addSeparator()
history_action = QAction(t("menu_history"), self)
history_action.triggered.connect(self._show_history)
menu.addAction(history_action)
reload_action = QAction(t("menu_reload_decks"), self)
reload_action.triggered.connect(self._reload_decks)
menu.addAction(reload_action)
@@ -292,6 +298,8 @@ class TrackerWindow(QWidget):
self.opponent_list.set_empty_text(t("opponent_empty"))
self.opponent_title.setText(t("opponent_title"))
self._rebuild_deck_menu()
if self.history_window is not None:
self.history_window.reload()
self._refresh(force=True)
def _on_tray_activated(self, reason) -> None:
@@ -308,6 +316,9 @@ class TrackerWindow(QWidget):
def _quit(self) -> None:
self._save_geometry()
self.images.shutdown()
self.history.close()
if self.history_window is not None:
self.history_window.close()
self.tray.hide()
from PyQt6.QtWidgets import QApplication
@@ -343,12 +354,12 @@ class TrackerWindow(QWidget):
self.turn_label.setText("")
self.turn_label.setToolTip("")
return
mark, color = RESULT_MARK.get(game.result, ("", ""))
mark, color = theme.RESULT_MARKS.get(game.result, ("", ""))
if mark:
self.turn_label.setText(
f'T{game.game_turn} <span style="color:{color}">{mark}</span>'
)
self.turn_label.setToolTip(t(RESULT_TOOLTIP[game.result]))
self.turn_label.setToolTip(t(i18n.RESULT_KEYS[game.result]))
else:
self.turn_label.setText(f"T{game.game_turn}")
self.turn_label.setToolTip("")
@@ -365,6 +376,17 @@ class TrackerWindow(QWidget):
x = button.width() - width if align_right else 0
menu.popup(button.mapToGlobal(QPoint(x, button.height() + 3)))
def _show_history(self) -> None:
from .history_window import HistoryWindow
if self.history_window is None:
self.history_window = HistoryWindow(self.history, self.settings, self.images)
else:
self.history_window.reload()
self.history_window.show()
self.history_window.raise_()
self.history_window.activateWindow()
def _reload_decks(self) -> None:
self.library.load()
self._rebuild_deck_menu()
@@ -592,6 +614,33 @@ class TrackerWindow(QWidget):
self.opponent_list.set_opacity(float(self.settings.get("opacity", 0.90)))
self.images.prefetch([e["card_id"] for e in opponent_entries], TILE)
self._record_history(game, deck, my_class, opponent_class)
def _record_history(self, game: Game, deck, my_class: str, opponent_class: str) -> None:
"""Sonucu belli olan maçı geçmişe yazar.
Tracker bir maçı ancak bir sonraki maç başlayınca kapatıyor, oysa sonuç
etiketi maçın son anında geliyor; o yüzden maç bitişini beklemeden
sonucu görür görmez yazıyoruz. Aynı maçın ikinci kez yazılmasını hem
buradaki anahtar hem de veritabanındaki benzersizlik engelliyor.
"""
if not game.result:
return
source = self.watcher.log_dir.name if self.watcher.log_dir else ""
key = (source, game.started_ts)
if key == self._recorded_key:
return
self._recorded_key = key
record = history.from_game(
game,
source,
my_class=my_class,
opponent_class=opponent_class,
deck=deck.name if deck is not None else "",
)
if self.history.add(record) and self.history_window is not None:
self.history_window.reload()
@staticmethod
def _signature(game: Game | None):
if game is None: