diff --git a/README.md b/README.md index bf8889e..cd69e72 100644 --- a/README.md +++ b/README.md @@ -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 --deck # replay a recorded session +python -m tools.import_history # backfill history from old session logs python -m tests.test_replay # 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. diff --git a/README.tr.md b/README.tr.md index a3c2cb0..1569e74 100644 --- a/README.tr.md +++ b/README.tr.md @@ -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 --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 # 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. diff --git a/core/history.py b/core/history.py new file mode 100644 index 0000000..845ead5 --- /dev/null +++ b/core/history.py @@ -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"]] diff --git a/data/cards.py b/data/cards.py index 4eda30b..1ff8659 100644 --- a/data/cards.py +++ b/data/cards.py @@ -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" diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..9e8a3a1 --- /dev/null +++ b/tests/test_history.py @@ -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:])) diff --git a/tools/import_history.py b/tools/import_history.py new file mode 100644 index 0000000..29e1869 --- /dev/null +++ b/tools/import_history.py @@ -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 # 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:])) diff --git a/ui/history_window.py b/ui/history_window.py new file mode 100644 index 0000000..8adf03e --- /dev/null +++ b/ui/history_window.py @@ -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) diff --git a/ui/i18n.py b/ui/i18n.py index 9f0a828..5ad53f0 100644 --- a/ui/i18n.py +++ b/ui/i18n.py @@ -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 diff --git a/ui/theme.py b/ui/theme.py index 0bf3177..0d172b8 100644 --- a/ui/theme.py +++ b/ui/theme.py @@ -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; }} +""" diff --git a/ui/widgets.py b/ui/widgets.py index 97f1944..eeb3356 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -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. diff --git a/ui/window.py b/ui/window.py index 50ee940..d9f76ce 100644 --- a/ui/window.py +++ b/ui/window.py @@ -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} {mark}' ) - 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: