commit c2a5a2d4d2168d7f1a767091d4325f9eef6a2eb3 Author: yusufipk Date: Mon Jul 27 22:31:13 2026 +0700 Hearthstone deck tracker for Linux, first public version Reads the game's own Power.log: no screen capture, no image recognition, no memory reading. - deck tracking (remaining cards, draw chance, played cards fade in place) - opponent panel (revealed cards, hand and deck counts) - decks read from the game's offline cache, matched to the match - card art in rows, full card on hover - overlay and window modes, adjustable transparency, tray icon - English and Turkish interface - consistency tests over a real log corpus, cross-checked against Zone.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00f2d38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1560a35 --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +# deste + +English | [Türkçe](README.tr.md) + +A Hearthstone deck tracker for Linux (KDE Wayland). It reads the game's own log +files: no screen capture, no image recognition, no memory reading. When a new +set is released only the card data is updated, not the code. + +It exists because the one open source tracker on Linux, Arena Tracker, is +unmaintained and its Qt5 + OpenCV design breaks on every patch. "Deste" is +Turkish for "deck". + +## What it does today + +- **Your deck:** remaining cards sorted by mana cost, with counts. A card you + drew does not disappear from the list, it fades in place, so "I already played + this" stays visible. +- **Draw chance:** the odds of each card coming up on the next draw. +- **Opponent:** every card they played or revealed, plus how many cards are in + their hand and 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. +- **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 + normal window mode, a system tray icon and a desktop shortcut. +- **English and Turkish interface**, switchable from the menu. It follows your + system language by default. + +## How it works + +Every session the game writes a few log files under +`Logs/Hearthstone_YYYY_MM_DD_HH_MM_SS/`. `Power.log` contains the whole match: +entities being created, zone transitions (deck, hand, play, graveyard), tag +changes, turn boundaries and the result. + +The flow: + +``` +a line of Power.log + -> core/parser_power.py turns the line into an Event (one regex table) + -> core/state.py applies it to game state (entities, zones, deck) + -> data/decks.py matches a known deck using class and drawn cards + -> ui/window.py remaining list, draw chances, opponent panel +``` + +`core/logtail.py` follows the file by offset and switches over when the game +starts a new session directory. `core/watcher.py` drives all of this without +knowing about Qt, which is why the same engine runs from the terminal +(`tools/live.py`) or over recorded logs (`tools/replay.py`). + +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. + +## Install + +Requires Python 3.11+ and PyQt6. Everything else is the standard library. + +``` +./install.sh # shortcut, icon, KWin rules +./install.sh --no-kwin # shortcut only, do not touch the window manager +./install.sh --no-hs-rule # without the Hearthstone rule +./install.sh --uninstall # undo all of it +``` + +`install.sh` installs the desktop shortcut and the icon, writes a KWin rule that +keeps `wmclass=deste` above other windows, and asks KWin to reload its +configuration. All of it is reversible with `--uninstall`. + +If you use a tiling script (Krohnkite and friends), add the panel to that +script's floating list yourself. The installer does not edit other people's +settings. + +The game needs two files in place before it writes usable logs. If they are +already set up, nothing is touched: + +- `/users//AppData/Local/Blizzard/Hearthstone/log.config` with + `[Power] Verbose=1`, `[Zone]`, `[LoadingScreen]`, `[Arena]` +- `/client.config` with `[Log] FileSizeLimit.Int=-1` + +The app never edits them on its own, it warns if something is missing. + +## Usage + +``` +./run.sh # the interface (the desktop shortcut calls this) +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 tests.test_replay # consistency tests +``` + +Closing the window drops the app into the tray, clicking the tray icon brings it +back. Transparency, window mode, language and deck selection live in the `⋮` +menu. + +## Wayland notes + +**Staying above the game.** In KWin a fullscreen window is raised into the +active layer, which sits above the keep-above layer. No overlay can stay on top +of a fullscreen game, whatever rule you write. The fix is to stop the game from +going fullscreen: `install.sh` writes a rule with `fullscreen=false` and +`noborder=true` for Hearthstone, so the game still covers the screen as a +borderless window, looks identical, and the overlay can stay above it. Games +launched through umu/Proton use the window class `steam_app_default`, which is +shared with other games, so the rule matches on the title (`Hearthstone`) too. + +**Position.** On Wayland an application cannot know where its own window is on +screen, and cannot move itself. Dragging the panel goes through +`startSystemMove()` and lets the window manager do it. Calling `move()` does not +move the window, it only corrupts Qt's idea of where the window is, and after +that menus and the card preview end up at the bottom of the screen. For the same +reason only the size is persisted, never the position. Menu and preview +positions are computed relative to the window itself rather than to the screen +edges, so the unknown offset cancels out. + +**Window size.** The layout does not impose its minimum size on the window +(`SetNoConstraint`), the header text is elided and the turn label has a fixed +width. Otherwise the match result made the panel grow on its own and you had to +resize it after every game. + +**Icon.** The tray icon is deliberately not built with `QIcon.fromTheme`. Doing +that makes Qt send the icon's *name* instead of the icon itself, and Plasma +draws an empty square when a freshly installed icon is not in its cache yet. +`ui/icon.py` always builds the icon from files and ready-made PNGs. + +## Architecture + +``` +core/ pure stdlib, knows nothing about UI or network, testable headless + logdir.py find the installation and the log directory, check log.config + logtail.py offset based file following + parser_power.py a line of Power.log -> event + state.py event -> game state (entities, zones, deck) + watcher.py live tracking loop (no Qt dependency) + deckstring.py deck code encode/decode + config.py user settings +data/ network and disk cache + cards.py HearthstoneJSON card data + localdecks.py decks from the game's offline cache + decks.py deck library and match-to-deck matching + images.py card images (tile and full render), downloaded in background +ui/ PyQt6 interface + window.py panel, tray icon, transparency, window modes + widgets.py card row (art strip) and hover preview + i18n.py interface strings, English and Turkish + theme.py colors and style + icon.py application icon +tools/ replay and terminal live tracking +tests/ consistency tests over a real log corpus +``` + +### Design decisions + +**Power.log is the single source of truth.** Zone.log is easier to read, but +merging two files by timestamp is complexity for nothing. Power.log already has +the match boundaries, the metadata and every zone transition. Zone.log is used +only in the tests, as an independent source to check against. + +**Only `GameState.*` lines are processed.** `PowerTaskList.*` lines are the +client side copy of the same content; process both and every event is counted +twice. + +**State is tracked, not events.** Log lines can repeat, so "decrement the counter +on every DECK -> HAND line" gives wrong results. Each entity's zone is stored and +only real transitions are processed. + +**Deck tracking is independent from deck selection.** The tracker only records +what left your deck and what got shuffled in. The remaining list is computed +against a deck list when asked, so you can still pick the deck mid-match. + +**Generated cards do not come out of your deck.** Discovered, copied or randomly +generated cards never touch the deck counter. Cards shuffled into your deck +during the game are added to the list instead. + +**Log files are never deleted.** They are only read. + +## Verification + +`tests/test_replay.py` uses no synthetic data, it reads real session logs from +the machine. The two checks that matter most: + +1. **Cross-check against an independent source:** the set of "cards I drew" + derived from Power.log must match the `FRIENDLY DECK -> FRIENDLY HAND` + transitions in Zone.log, entity id by entity id. +2. **The remaining list must agree with the game's own counter:** the size of the + computed remaining deck must equal the number of cards in the game's DECK + zone. Miss a draw, or fail to count a shuffled-in card, and this breaks + immediately. + +Run over 6 sessions and 33 matches, all passing. + +Window manager behaviour (menu and preview placement, staying out of tiling) is +exercised in a virtual KWin session: `kwin_wayland --virtual --width 1800 +--height 1125`. That way the test never opens a window on your screen. + +## Roadmap + +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. +- **Opponent secret tracking:** candidates for a played secret get eliminated by + game events. The elimination rules live in `rules/secrets.json` as data, so a + new set means updating that file, not the code. + +Later: + +- Arena draft assistant. The only feature that needs screen capture: recognise + cards by a perceptual hash (pHash) of the art, score them from published arena + statistics. +- Battlegrounds panel +- Mulligan statistics, per-deck analysis +- Turn/rope timer, Twitch overlay, HSReplay upload + +## Notes + +Card data comes from HearthstoneJSON, card art belongs to Blizzard. This project +is not affiliated with Blizzard. The app only reads the log files the game +itself writes; it does not touch the game's memory or network traffic. diff --git a/README.tr.md b/README.tr.md new file mode 100644 index 0000000..6d74724 --- /dev/null +++ b/README.tr.md @@ -0,0 +1,221 @@ +# deste + +[English](README.md) | Türkçe + +Linux (KDE Wayland) için Hearthstone deck tracker. + +Oyunun kendi log dosyalarını okur: ekran yakalama yok, görüntü tanıma yok, +bellek okuma yok. Yeni set çıktığında kod değil sadece kart verisi güncellenir. +Kişisel ihtiyaçtan doğdu, Arena Tracker'ın Qt5 + OpenCV mimarisi hem bakımsız +hem de her yamada kırılıyordu. + +## Şu an ne yapıyor + +- **Kendi destem:** kalan kartlar mana sırasına göre, adetleriyle. Çekilen kart + listeden düşmez, solgunlaşır: "bunu oynadım" bilgisi görünür kalır. +- **Çekme yüzdesi:** her kartın bir sonraki çekişte gelme olasılığı. +- **Rakip:** oynadığı ve açığa çıkan kartlar, elindeki ve destesinde kalan kart + sayısı. +- **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. +- **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 + modu, sistem tepsisi ikonu, masaüstü kısayolu. +- **Türkçe ve İngilizce arayüz**, menüden anında değişiyor. Varsayılan olarak + sistemin diline bakılır. + +## Nasıl çalışıyor + +Oyun her oturumda `Logs/Hearthstone_YYYY_MM_DD_HH_MM_SS/` altına birkaç log +dosyası yazıyor. Bunlardan `Power.log` maçın tamamını içeriyor: varlıkların +yaratılması, bölge geçişleri (deste, el, oyun alanı, mezarlık), tag +değişimleri, tur sınırları ve maç sonucu. + +Akış şöyle: + +``` +Power.log satırı + -> core/parser_power.py satırı Event'e çevirir (regex tablosu tek yerde) + -> core/state.py olayı oyun durumuna işler (varlık tablosu, bölgeler) + -> data/decks.py durumdaki sınıf ve çekilen kartlarla desteyi eşler + -> ui/window.py kalan liste, yüzdeler, rakip paneli +``` + +`core/logtail.py` dosyayı offset tutarak takip eder, oyun yeni bir oturum +dizini açtığında oraya geçer. `core/watcher.py` bunu Qt bilmeden çevirir, o +yüzden aynı motor terminalden (`tools/live.py`) veya kayıtlı loglar üstünden +(`tools/replay.py`) da çalışır. + +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. + +## Kurulum + +Gereksinim: Python 3.11+, PyQt6. Geri kalan her şey stdlib. + +``` +./install.sh # kısayol, simge, KWin kuralları +./install.sh --no-kwin # sadece kısayol, pencere yöneticisine dokunma +./install.sh --no-hs-rule # Hearthstone kuralı olmadan +./install.sh --uninstall # hepsini geri al +``` + +`install.sh` masaüstü kısayolunu ve simgeyi kurar, `wmclass=deste` için +"her zaman üstte" KWin kuralı yazar ve KWin'e yapılandırmayı yeniden +okutur. Hepsi `--uninstall` ile geri alınır. + +Tiling betiği (Krohnkite gibi) kullanıyorsan paneli o betiğin yüzen pencere +listesine eklemen gerekir, kurulum betiği başkasının ayarlarına dokunmuyor. + +Oyunun logları yazması için iki dosya gerekiyor, ikisi de zaten kuruluysa +dokunulmaz: + +- `/users//AppData/Local/Blizzard/Hearthstone/log.config` + içinde `[Power] Verbose=1`, `[Zone]`, `[LoadingScreen]`, `[Arena]` +- `/client.config` içinde `[Log] FileSizeLimit.Int=-1` + +Uygulama bunları kendi başına değiştirmez, eksikse uyarır. + +## Kullanım + +``` +./run.sh # arayüz (masaüstü kısayolu da bunu çağırır) +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 tests.test_replay # tutarlılık testleri +``` + +Pencere kapatıldığında uygulama tepsiye iner, tepsi ikonuna tıklayınca geri +gelir. Saydamlık, mod değişimi, dil ve deste seçimi `⋮` menüsünde. + +## Wayland notları + +**Oyunun üstünde durmak.** KWin'de tam ekran pencereler "aktif katman"a çıkar +ve bu katman "her zaman üstte" katmanının da üstündedir. Yani oyun tam +ekrandayken hiçbir overlay üstünde duramaz, kural ne olursa olsun. Çözüm oyunun +tam ekrana geçmesini engellemek: `install.sh` Hearthstone için `fullscreen=false` +ve `noborder=true` kuralı yazar, oyun kenarlıksız pencere olarak ekranı +kaplamaya devam eder, görüntü aynıdır, overlay üstte kalabilir. Oyun umu/Proton +ile açıldığı için pencere sınıfı `steam_app_default`, bu sınıf başka oyunlarla +ortak olabileceğinden kural başlığa da bakar (`Hearthstone`). + +**Konum.** Wayland'da uygulama kendi penceresinin ekranda nerede olduğunu +bilmez ve kendini taşıyamaz. Panel sürüklenirken `startSystemMove()` ile +taşımayı pencere yöneticisine bırakıyoruz; `move()` çağırmak pencereyi +kıpırdatmadığı gibi Qt'nin konum bilgisini de bozuyor, sonra menüler ve kart +önizlemesi ekranın dibine düşüyor. Aynı sebeple ayarlarda yalnızca boyut +saklanıyor, konum saklanmıyor. Menü ve önizleme konumları ekran kenarına göre +değil pencerenin kendi koordinatlarına göre hesaplanıyor, böylece bilinmeyen +kayma sadeleşiyor. + +**Pencere boyutu.** Yerleşimin minimum boyutu pencereye dayatılmıyor +(`SetNoConstraint`), başlık metni kısaltılarak çiziliyor ve tur etiketi sabit +genişlikte. Yoksa maç sonucu yazısı geldiğinde panel kendiliğinden genişliyor, +kullanıcı her maçtan sonra boyutu düzeltmek zorunda kalıyor. + +**İkon.** Tepsi ikonu bilerek `QIcon.fromTheme` ile kurulmuyor. Öyle kurulursa +Qt sisteme ikonun kendisini değil *adını* gönderiyor ve Plasma yeni kurulmuş +bir ikonu önbelleğinde bulamayınca tepside boş kare çıkıyor. `ui/icon.py` ikonu +her zaman dosyadan ve hazır PNG'lerle kuruyor. + +## Mimari + +``` +core/ saf stdlib, UI ve ağ bilmez, headless test edilebilir + logdir.py kurulumu ve log dizinini bul, log.config doğrula + logtail.py offset tabanlı dosya takibi + parser_power.py Power.log satırı -> olay + 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 + config.py kullanıcı ayarları +data/ ağ ve disk önbelleği + cards.py HearthstoneJSON kart verisi + localdecks.py oyunun çevrimdışı önbelleğinden desteler + decks.py deste kütüphanesi ve maça deste eşleştirme + 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 + 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 + icon.py uygulama simgesi +tools/ replay ve terminal canlı takip +tests/ gerçek log korpusu üstünde tutarlılık testleri +``` + +### Tasarım kararları + +**Power.log tek doğruluk kaynağı.** Zone.log daha okunaklı ama iki dosyayı +zaman damgasına göre birleştirmek gereksiz karmaşıklık. Power.log maç +sınırlarını, metadatayı ve tüm bölge geçişlerini zaten içeriyor. Zone.log +yalnızca testlerde bağımsız doğrulama kaynağı olarak kullanılıyor. + +**Yalnızca `GameState.*` satırları işlenir.** `PowerTaskList.*` satırları aynı +içeriğin istemci kopyası, ikisi de işlenirse her olay iki kere sayılır. + +**Olay değil durum sayılır.** Log satırları tekrar edebildiği için "her +DECK -> HAND satırında sayacı azalt" yaklaşımı yanlış sonuç verir. Her varlığın +bölgesi saklanır, sadece gerçek değişimler işlenir. + +**Deste takibi deste seçiminden bağımsız.** Tracker sadece "destemden ne çıktı, +desteme ne karıştı" kaydını tutar. Kalan liste istenen an bir deste listesine +göre hesaplanır, böylece deste maç ortasında da seçilebilir. + +**Üretilen kartlar destemden düşmez.** Discover, kopyalama veya rastgele +üretilen kartlar deste sayacına dokunmaz. Tersine, oyun içinde desteye +karıştırılan kartlar listeye eklenir. + +**Log dosyaları asla silinmez.** Sadece okunur. + +## Doğrulama + +`tests/test_replay.py` uydurma veri kullanmaz, makinedeki gerçek oturum +loglarını okur. En kıymetli iki kontrol: + +1. **Bağımsız kaynakla çapraz doğrulama:** Power.log'dan çıkarılan "çektiğim + kartlar" kümesi, Zone.log'daki `FRIENDLY DECK -> FRIENDLY HAND` geçişleriyle + varlık kimliği düzeyinde birebir eşleşmeli. +2. **Kalan liste oyunun sayacıyla tutmalı:** hesaplanan kalan deste listesinin + boyutu, oyunun kendi DECK bölgesindeki kart sayısına eşit olmalı. Bir çekiş + kaçarsa ya da karıştırılan kart sayılmazsa bu test anında kırılır. + +6 oturum, 33 maç üstünde çalıştırıldı, hepsi geçiyor. + +Pencere yönetimiyle ilgili davranışlar (menü ve önizleme konumu, tiling'e +girmemesi) sanal bir KWin oturumunda deneniyor: +`kwin_wayland --virtual --width 1800 --height 1125`. Böylece test ekranda +pencere açmıyor. + +## Yol haritası + +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. +- **Rakip secret takibi:** oynanan secret'ın adayları oyun olaylarıyla elenir. + Eleme kuralları `rules/secrets.json` içinde veri olarak durur, yeni set + gelince kod değil o dosya güncellenir. + +Sonraya bıraktıklarım: + +- Arena draft asistanı. Ekran yakalama gerektiren tek özellik: kart sanatının + algısal hash'i (pHash) ile tanıma, skorlar için hazır arena istatistikleri. +- Battlegrounds paneli +- Mulligan istatistikleri, deste bazlı detaylı analiz +- Tur/rope zamanlayıcısı, Twitch overlay, HSReplay yükleme + +## Notlar + +Kart verisi HearthstoneJSON, kart görselleri Blizzard'a ait. Bu depo Blizzard +ile ilişkili değil. Uygulama yalnızca oyunun kendi yazdığı log dosyalarını +okur, oyunun belleğine veya ağ trafiğine dokunmaz. diff --git a/assets/deste.svg b/assets/deste.svg new file mode 100644 index 0000000..61c8d26 --- /dev/null +++ b/assets/deste.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/deste-128.png b/assets/icons/deste-128.png new file mode 100644 index 0000000..66bc2bb Binary files /dev/null and b/assets/icons/deste-128.png differ diff --git a/assets/icons/deste-16.png b/assets/icons/deste-16.png new file mode 100644 index 0000000..b100865 Binary files /dev/null and b/assets/icons/deste-16.png differ diff --git a/assets/icons/deste-22.png b/assets/icons/deste-22.png new file mode 100644 index 0000000..8eeb562 Binary files /dev/null and b/assets/icons/deste-22.png differ diff --git a/assets/icons/deste-24.png b/assets/icons/deste-24.png new file mode 100644 index 0000000..daed81e Binary files /dev/null and b/assets/icons/deste-24.png differ diff --git a/assets/icons/deste-256.png b/assets/icons/deste-256.png new file mode 100644 index 0000000..8b0cbcf Binary files /dev/null and b/assets/icons/deste-256.png differ diff --git a/assets/icons/deste-32.png b/assets/icons/deste-32.png new file mode 100644 index 0000000..348a518 Binary files /dev/null and b/assets/icons/deste-32.png differ diff --git a/assets/icons/deste-48.png b/assets/icons/deste-48.png new file mode 100644 index 0000000..259c72c Binary files /dev/null and b/assets/icons/deste-48.png differ diff --git a/assets/icons/deste-64.png b/assets/icons/deste-64.png new file mode 100644 index 0000000..830e5e4 Binary files /dev/null and b/assets/icons/deste-64.png differ diff --git a/assets/icons/deste-96.png b/assets/icons/deste-96.png new file mode 100644 index 0000000..01576ed Binary files /dev/null and b/assets/icons/deste-96.png differ diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..f757232 --- /dev/null +++ b/core/config.py @@ -0,0 +1,37 @@ +"""Kullanıcı ayarları. ~/.config/deste/config.json""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "deste" +CONFIG_PATH = CONFIG_DIR / "config.json" + +DEFAULTS = { + "game_dir": "", + "window_mode": "overlay", # overlay veya window + "opacity": 0.92, + "geometry": {}, # mod -> [genişlik, yükseklik] + "show_draw_chance": True, + "selected_deck": "", # boş ise otomatik eşleştirme + "language": "auto", # auto, tr, en +} + + +def load() -> dict: + data = dict(DEFAULTS) + if CONFIG_PATH.exists(): + try: + data.update(json.loads(CONFIG_PATH.read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError): + pass + return data + + +def save(data: dict) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + tmp = CONFIG_PATH.with_suffix(".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(CONFIG_PATH) diff --git a/core/deckstring.py b/core/deckstring.py new file mode 100644 index 0000000..329eef4 --- /dev/null +++ b/core/deckstring.py @@ -0,0 +1,202 @@ +"""Hearthstone deste kodu (deckstring) çözücü ve kodlayıcı. + +Biçim: base64 içinde varint akışı. + 0x00, sürüm(1), format, kahraman sayısı + kahramanlar, + tek kopyalı kart sayısı + kartlar, + çift kopyalı kart sayısı + kartlar, + n kopyalı kart sayısı + (kart, adet) çiftleri, + isteğe bağlı sideboard bölümü (ETC gibi kartlar için). + +Kartlar dbfId ile tutulur, kart kimliğine çevirmek için data.cards gerekir. +Tamamen stdlib. +""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass, field + +DECKSTRING_VERSION = 1 + +FORMAT_UNKNOWN = 0 +FORMAT_WILD = 1 +FORMAT_STANDARD = 2 +FORMAT_CLASSIC = 3 +FORMAT_TWIST = 4 + +FORMAT_NAMES = { + FORMAT_UNKNOWN: "UNKNOWN", + FORMAT_WILD: "WILD", + FORMAT_STANDARD: "STANDARD", + FORMAT_CLASSIC: "CLASSIC", + FORMAT_TWIST: "TWIST", +} + + +class DeckstringError(ValueError): + """Geçersiz ya da bozuk deste kodu.""" + + +@dataclass(slots=True) +class Deck: + cards: list[tuple[int, int]] = field(default_factory=list) # (dbfId, adet) + heroes: list[int] = field(default_factory=list) + format_id: int = FORMAT_UNKNOWN + # (dbfId, adet, sahip kartın dbfId'si) + sideboards: list[tuple[int, int, int]] = field(default_factory=list) + + @property + def format_name(self) -> str: + return FORMAT_NAMES.get(self.format_id, "UNKNOWN") + + @property + def card_count(self) -> int: + return sum(count for _, count in self.cards) + + def as_counter(self) -> dict[int, int]: + result: dict[int, int] = {} + for dbf_id, count in self.cards: + result[dbf_id] = result.get(dbf_id, 0) + count + return result + + +class _Reader: + def __init__(self, data: bytes): + self.data = data + self.index = 0 + + def byte(self) -> int: + if self.index >= len(self.data): + raise DeckstringError("deste kodu beklenenden kısa") + value = self.data[self.index] + self.index += 1 + return value + + def varint(self) -> int: + value = 0 + shift = 0 + while True: + byte = self.byte() + value |= (byte & 0x7F) << shift + if not byte & 0x80: + return value + shift += 7 + if shift > 63: + raise DeckstringError("bozuk varint") + + @property + def at_end(self) -> bool: + return self.index >= len(self.data) + + +def _write_varint(chunks: list[bytes], value: int) -> None: + while True: + piece = value & 0x7F + value >>= 7 + if value: + chunks.append(bytes((piece | 0x80,))) + else: + chunks.append(bytes((piece,))) + return + + +def parse(deckstring: str) -> Deck: + """Deste kodunu çözer. Geçersizse DeckstringError fırlatır.""" + text = deckstring.strip() + if not text: + raise DeckstringError("boş deste kodu") + # Oyundan kopyalanan kodlarda başta yorum satırları olabiliyor. + for line in text.splitlines(): + line = line.strip() + if line and not line.startswith("#"): + text = line + break + try: + raw = base64.b64decode(text + "=" * (-len(text) % 4)) + except (ValueError, TypeError) as exc: + raise DeckstringError("base64 çözülemedi") from exc + + reader = _Reader(raw) + if reader.byte() != 0: + raise DeckstringError("geçersiz deste kodu başlığı") + version = reader.varint() + if version != DECKSTRING_VERSION: + raise DeckstringError(f"desteklenmeyen sürüm: {version}") + + deck = Deck(format_id=reader.varint()) + + for _ in range(reader.varint()): + deck.heroes.append(reader.varint()) + deck.heroes.sort() + + for _ in range(reader.varint()): + deck.cards.append((reader.varint(), 1)) + for _ in range(reader.varint()): + deck.cards.append((reader.varint(), 2)) + for _ in range(reader.varint()): + card = reader.varint() + deck.cards.append((card, reader.varint())) + deck.cards.sort() + + # Sideboard bölümü isteğe bağlı, eski kodlarda hiç yok. + if not reader.at_end and reader.byte() == 1: + for _ in range(reader.varint()): + deck.sideboards.append((reader.varint(), 1, reader.varint())) + for _ in range(reader.varint()): + deck.sideboards.append((reader.varint(), 2, reader.varint())) + for _ in range(reader.varint()): + card = reader.varint() + count = reader.varint() + deck.sideboards.append((card, count, reader.varint())) + deck.sideboards.sort(key=lambda item: (item[2], item[0])) + + return deck + + +def write(deck: Deck) -> str: + """Desteyi tekrar deste koduna çevirir.""" + chunks: list[bytes] = [b"\0"] + _write_varint(chunks, DECKSTRING_VERSION) + _write_varint(chunks, deck.format_id) + + _write_varint(chunks, len(deck.heroes)) + for hero in sorted(deck.heroes): + _write_varint(chunks, hero) + + singles = sorted(c for c, n in deck.cards if n == 1) + doubles = sorted(c for c, n in deck.cards if n == 2) + others = sorted((c, n) for c, n in deck.cards if n not in (1, 2)) + + _write_varint(chunks, len(singles)) + for card in singles: + _write_varint(chunks, card) + _write_varint(chunks, len(doubles)) + for card in doubles: + _write_varint(chunks, card) + _write_varint(chunks, len(others)) + for card, count in others: + _write_varint(chunks, card) + _write_varint(chunks, count) + + if deck.sideboards: + chunks.append(b"\1") + for wanted in (1, 2): + group = sorted( + (card, owner) for card, count, owner in deck.sideboards if count == wanted + ) + _write_varint(chunks, len(group)) + for card, owner in group: + _write_varint(chunks, card) + _write_varint(chunks, owner) + group_n = sorted( + (card, count, owner) + for card, count, owner in deck.sideboards + if count not in (1, 2) + ) + _write_varint(chunks, len(group_n)) + for card, count, owner in group_n: + _write_varint(chunks, card) + _write_varint(chunks, count) + _write_varint(chunks, owner) + + return base64.b64encode(b"".join(chunks)).decode("ascii") diff --git a/core/logdir.py b/core/logdir.py new file mode 100644 index 0000000..fc60de0 --- /dev/null +++ b/core/logdir.py @@ -0,0 +1,161 @@ +"""Hearthstone kurulumunu, log dizinini ve log.config durumunu bulur. + +Oyun Wine/Proton prefix'i içinde çalıştığı için yol sabit değil. Önce +yapılandırma dosyasına, sonra bilinen prefix konumlarına bakılır. +""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path + +LOG_DIR_RE = re.compile(r"^Hearthstone_\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2}$") + +# Gerekli log bileşenleri. Power için Verbose=1 şart, yoksa oyun durumu eksik gelir. +REQUIRED_COMPONENTS = { + "Power": {"LogLevel": "1", "FilePrinting": "true", "Verbose": "1"}, + "Zone": {"LogLevel": "1", "FilePrinting": "true"}, + "LoadingScreen": {"LogLevel": "1", "FilePrinting": "true"}, + "Arena": {"LogLevel": "1", "FilePrinting": "true"}, +} + +# Prefix içindeki oyun dizini, kullanıcıya göre değişen kısımlar glob ile. +PREFIX_GLOBS = ( + "~/Games/*/drive_c/Program Files (x86)/Hearthstone", + "~/Games/*/*/drive_c/Program Files (x86)/Hearthstone", + "~/.wine/drive_c/Program Files (x86)/Hearthstone", + "~/.local/share/lutris/prefixes/*/drive_c/Program Files (x86)/Hearthstone", + "~/.steam/steam/steamapps/compatdata/*/pfx/drive_c/Program Files (x86)/Hearthstone", +) + +CONFIG_PATH = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "deste" / "config.json" + + +@dataclass(slots=True) +class Installation: + game_dir: Path + logs_dir: Path + log_config: Path | None + client_config: Path + + +def _expand(pattern: str) -> list[Path]: + pattern = os.path.expanduser(pattern) + root = Path("/") + parts = Path(pattern).parts[1:] + try: + return sorted(root.glob(str(Path(*parts)))) + except (OSError, ValueError): + return [] + + +def find_game_dir(configured: str | None = None) -> Path | None: + """Hearthstone kurulum dizinini bulur.""" + if configured: + path = Path(configured).expanduser() + if (path / "Hearthstone.exe").exists(): + return path + for pattern in PREFIX_GLOBS: + for candidate in _expand(pattern): + if (candidate / "Hearthstone.exe").exists(): + return candidate + return None + + +def find_log_config(game_dir: Path) -> Path | None: + """log.config dosyasını prefix içindeki kullanıcı dizininde arar.""" + # .../drive_c/Program Files (x86)/Hearthstone -> .../drive_c + drive_c = game_dir.parent.parent + users = drive_c / "users" + if not users.is_dir(): + return None + for user_dir in users.iterdir(): + candidate = user_dir / "AppData" / "Local" / "Blizzard" / "Hearthstone" / "log.config" + if candidate.parent.is_dir(): + return candidate + return None + + +def read_config() -> dict: + if CONFIG_PATH.exists(): + try: + return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return {} + + +def detect(configured_dir: str | None = None) -> Installation | None: + if configured_dir is None: + configured_dir = read_config().get("game_dir") + game_dir = find_game_dir(configured_dir) + if game_dir is None: + return None + return Installation( + game_dir=game_dir, + logs_dir=game_dir / "Logs", + log_config=find_log_config(game_dir), + client_config=game_dir / "client.config", + ) + + +def latest_log_dir(logs_dir: Path) -> Path | None: + """En son oturumun log dizini. Oyun her açılışta yenisini oluşturur.""" + if not logs_dir.is_dir(): + return None + candidates = [d for d in logs_dir.iterdir() if d.is_dir() and LOG_DIR_RE.match(d.name)] + if not candidates: + return None + return max(candidates, key=lambda d: d.stat().st_mtime) + + +def parse_log_config(path: Path) -> dict[str, dict[str, str]]: + sections: dict[str, dict[str, str]] = {} + current: dict[str, str] | None = None + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return sections + for line in text.splitlines(): + line = line.strip() + if line.startswith("[") and line.endswith("]"): + current = {} + sections[line[1:-1]] = current + elif "=" in line and current is not None: + key, _, value = line.partition("=") + current[key.strip()] = value.strip() + return sections + + +def check_log_config(path: Path | None) -> list[str]: + """Eksik veya hatalı log.config ayarlarını insan okur biçimde döner.""" + if path is None or not path.exists(): + return ["log.config bulunamadı"] + sections = parse_log_config(path) + problems: list[str] = [] + for component, required in REQUIRED_COMPONENTS.items(): + section = sections.get(component) + if section is None: + problems.append(f"[{component}] bölümü eksik") + continue + for key, value in required.items(): + actual = section.get(key) + if actual is None: + problems.append(f"[{component}] {key} eksik") + elif actual.lower() != value.lower(): + problems.append(f"[{component}] {key}={actual}, beklenen {value}") + return problems + + +def check_client_config(path: Path) -> list[str]: + """Log boyut limiti açıksa uzun oturumlarda log kesiliyor.""" + if not path.exists(): + return ["client.config yok, log boyut limiti kaldırılmamış"] + sections = parse_log_config(path) + limit = sections.get("Log", {}).get("FileSizeLimit.Int") + if limit != "-1": + return [f"client.config FileSizeLimit.Int={limit}, beklenen -1"] + return [] diff --git a/core/logtail.py b/core/logtail.py new file mode 100644 index 0000000..e8eca89 --- /dev/null +++ b/core/logtail.py @@ -0,0 +1,92 @@ +"""Log dosyalarını offset tabanlı takip eder. + +Oyun her açılışta yeni bir oturum dizini yaratıp dosyaları sıfırdan yazar. +Dosya küçüldüyse ya da inode değiştiyse baştan okunur. Yarım kalan son satır +tamamlanana kadar tamponda bekletilir. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass(slots=True) +class TailedFile: + path: Path + offset: int = 0 + inode: int | None = None + buffer: str = "" + # Uygulama oyun açıkken başlatıldıysa geçmiş satırları tekrar işlememek için + # dosyanın sonuna atlayabiliyoruz. + start_at_end: bool = False + _started: bool = field(default=False, repr=False) + + def read_new_lines(self) -> list[str]: + try: + stat = self.path.stat() + except OSError: + return [] + + if self.inode is None: + self.inode = stat.st_ino + if self.start_at_end and not self._started: + self.offset = stat.st_size + elif stat.st_ino != self.inode or stat.st_size < self.offset: + # Yeni oturum ya da dosya kırpıldı. + self.inode = stat.st_ino + self.offset = 0 + self.buffer = "" + self._started = True + + if stat.st_size == self.offset: + return [] + + try: + with self.path.open("r", encoding="utf-8", errors="replace") as handle: + handle.seek(self.offset) + chunk = handle.read() + self.offset = handle.tell() + except OSError: + return [] + + if not chunk: + return [] + + data = self.buffer + chunk + lines = data.split("\n") + self.buffer = lines.pop() + return lines + + +class LogSet: + """Bir oturum dizinindeki ilgili log dosyalarını birlikte takip eder.""" + + def __init__(self, log_dir: Path, components=("Power",), start_at_end: bool = False): + self.log_dir = log_dir + self.files = { + name: TailedFile(log_dir / f"{name}.log", start_at_end=start_at_end) + for name in components + } + + def read_new_lines(self, component: str = "Power") -> list[str]: + tailed = self.files.get(component) + return tailed.read_new_lines() if tailed else [] + + def read_all_components(self) -> dict[str, list[str]]: + return {name: tailed.read_new_lines() for name, tailed in self.files.items()} + + +def read_file_lines(path: Path): + """Tam dosyayı satır satır okur. Replay ve testler için.""" + with path.open("r", encoding="utf-8", errors="replace") as handle: + for line in handle: + yield line.rstrip("\n") + + +def file_size(path: Path) -> int: + try: + return os.stat(path).st_size + except OSError: + return 0 diff --git a/core/parser_power.py b/core/parser_power.py new file mode 100644 index 0000000..dc31969 --- /dev/null +++ b/core/parser_power.py @@ -0,0 +1,218 @@ +"""Power.log satır ayrıştırıcısı. + +Sadece ``GameState.*`` satırları işlenir. ``PowerTaskList.*`` satırları aynı +içeriğin istemci tarafındaki kopyasıdır, ikisi birden işlenirse her olay iki +kere sayılır. Logda 74927 GameState satırına karşılık 74384 PowerTaskList +satırı var, yani kopya hacmi ciddi. + +Ayrıştırıcı durum tutmaz, sadece satırı olaya çevirir. Oyun durumu core.state +tarafından kurulur. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +# D 18:50:06.6268596 GameState.DebugPrintPower() - TAG_CHANGE Entity=3 tag=TURN value=1 +LINE_RE = re.compile( + r"^\w \s*(?P\d+:\d+:\d+\.\d+) (?P\w+)\.(?P\w+)\(\) - (?P\s*)(?P.*?)\s*$" +) + +# [entityName=Confront the Tol'vir id=35 zone=HAND zonePos=0 cardId=CATA_560 player=2] +ENTITY_BRACKET_RE = re.compile( + r"\[entityName=(?P.*?) id=(?P\d+) zone=(?P\w+) " + r"zonePos=(?P-?\d+) cardId=(?P\w*) player=(?P\d+)\]" +) + +FULL_ENTITY_CREATING_RE = re.compile(r"^FULL_ENTITY - Creating ID=(?P\d+) CardID=(?P\w*)$") +FULL_ENTITY_UPDATING_RE = re.compile(r"^FULL_ENTITY - Updating (?P.+?) CardID=(?P\w*)$") +SHOW_ENTITY_RE = re.compile(r"^SHOW_ENTITY - Updating (?P.+?) CardID=(?P\w*)$") +HIDE_ENTITY_RE = re.compile(r"^HIDE_ENTITY - Entity=(?P.+?) tag=(?P\w+) value=(?P\w+)$") +CHANGE_ENTITY_RE = re.compile(r"^CHANGE_ENTITY - Updating (?P.+?) CardID=(?P\w*)$") +TAG_CHANGE_RE = re.compile( + r"^TAG_CHANGE Entity=(?P.+?) tag=(?P\w+) value=(?P-?\w+)(?P.*)$" +) +TAG_LINE_RE = re.compile(r"^tag=(?P\w+) value=(?P-?\w+)$") +GAME_ENTITY_RE = re.compile(r"^GameEntity EntityID=(?P\d+)$") +PLAYER_ENTITY_RE = re.compile(r"^Player EntityID=(?P\d+) PlayerID=(?P\d+)") +BLOCK_START_RE = re.compile( + r"^BLOCK_START BlockType=(?P\w+) Entity=(?P.+?) EffectCardId=.*?" + r"EffectIndex=(?P-?\d+) Target=(?P.+?) SubOption=(?P-?\d+)" +) +PLAYER_NAME_RE = re.compile(r"^PlayerID=(?P\d+), PlayerName=(?P.+)$") +KEY_VALUE_RE = re.compile(r"^(?PBuildNumber|GameType|FormatType|ScenarioID)=(?P.+)$") + +UNKNOWN_PLAYER = "UNKNOWN HUMAN PLAYER" + + +@dataclass(slots=True) +class Event: + """Ayrıştırılmış tek bir log olayı.""" + + kind: str + ts: str + indent: int + data: dict = field(default_factory=dict) + + +@dataclass(slots=True) +class EntityRef: + """TAG_CHANGE gibi satırlarda varlığa yapılan atıf. + + Üç biçimde gelebilir: sayısal id, oyuncu adı (Oyuncu#12345), ya da köşeli + parantezli tam blok. Çözümlemeyi core.state yapar, burada sadece taşınır. + """ + + entity_id: int | None = None + name: str | None = None + card_id: str = "" + zone: str = "" + player: int | None = None + + +def parse_entity_ref(raw: str) -> EntityRef: + raw = raw.strip() + m = ENTITY_BRACKET_RE.search(raw) + if m: + return EntityRef( + entity_id=int(m.group("id")), + name=m.group("name"), + card_id=m.group("card"), + zone=m.group("zone"), + player=int(m.group("player")), + ) + if raw.isdigit(): + return EntityRef(entity_id=int(raw)) + if raw == "GameEntity": + return EntityRef(name="GameEntity") + return EntityRef(name=raw) + + +def parse_line(line: str) -> Event | None: + """Tek bir Power.log satırını olaya çevirir, ilgisizse None döner.""" + m = LINE_RE.match(line) + if not m: + return None + if m.group("cls") != "GameState": + return None + + ts = m.group("ts") + indent = len(m.group("indent")) + body = m.group("body") + fn = m.group("fn") + + if fn == "DebugPrintGame": + return _parse_game_meta(ts, indent, body) + if fn == "DebugPrintPower": + return _parse_power(ts, indent, body) + return None + + +def _parse_game_meta(ts: str, indent: int, body: str) -> Event | None: + m = KEY_VALUE_RE.match(body) + if m: + return Event("game_meta", ts, indent, {"key": m.group("key"), "value": m.group("value")}) + m = PLAYER_NAME_RE.match(body) + if m: + return Event( + "player_name", + ts, + indent, + {"player_id": int(m.group("pid")), "name": m.group("name")}, + ) + return None + + +def _parse_power(ts: str, indent: int, body: str) -> Event | None: + if body == "CREATE_GAME": + return Event("create_game", ts, indent) + + m = FULL_ENTITY_CREATING_RE.match(body) + if m: + return Event( + "full_entity", + ts, + indent, + {"entity_id": int(m.group("id")), "card_id": m.group("card")}, + ) + + m = FULL_ENTITY_UPDATING_RE.match(body) or CHANGE_ENTITY_RE.match(body) + if m: + ref = parse_entity_ref(m.group("ref")) + return Event("full_entity", ts, indent, {"ref": ref, "card_id": m.group("card")}) + + m = SHOW_ENTITY_RE.match(body) + if m: + ref = parse_entity_ref(m.group("ref").removeprefix("Entity=")) + return Event("show_entity", ts, indent, {"ref": ref, "card_id": m.group("card")}) + + m = HIDE_ENTITY_RE.match(body) + if m: + return Event( + "hide_entity", + ts, + indent, + { + "ref": parse_entity_ref(m.group("ref")), + "tag": m.group("tag"), + "value": m.group("value"), + }, + ) + + m = TAG_CHANGE_RE.match(body) + if m: + return Event( + "tag_change", + ts, + indent, + { + "ref": parse_entity_ref(m.group("ref")), + "tag": m.group("tag"), + "value": m.group("value"), + }, + ) + + m = GAME_ENTITY_RE.match(body) + if m: + return Event("game_entity", ts, indent, {"entity_id": int(m.group("id"))}) + + m = PLAYER_ENTITY_RE.match(body) + if m: + return Event( + "player_entity", + ts, + indent, + {"entity_id": int(m.group("id")), "player_id": int(m.group("pid"))}, + ) + + m = TAG_LINE_RE.match(body) + if m: + # Bir üstteki varlık bloğuna ait etiket satırı. + return Event("entity_tag", ts, indent, {"tag": m.group("tag"), "value": m.group("value")}) + + m = BLOCK_START_RE.match(body) + if m: + return Event( + "block_start", + ts, + indent, + { + "block_type": m.group("type"), + "ref": parse_entity_ref(m.group("ref")), + "target": parse_entity_ref(m.group("target")), + }, + ) + + if body == "BLOCK_END": + return Event("block_end", ts, indent) + + return None + + +def parse_lines(lines): + """Satır dizisini olay dizisine çevirir.""" + for line in lines: + event = parse_line(line) + if event is not None: + yield event diff --git a/core/state.py b/core/state.py new file mode 100644 index 0000000..ff37565 --- /dev/null +++ b/core/state.py @@ -0,0 +1,511 @@ +"""Oyun durumu makinesi. + +Power.log olaylarını alıp varlık tablosunu, bölge geçişlerini ve maç +metadatasını kurar. Tamamen stdlib, UI ve ağ bilmez. + +Tasarım notu: olaylar sayılmaz, durum tutulur. Log satırları tekrar edebildiği +için "her DECK -> HAND satırında sayacı bir azalt" yaklaşımı yanlış sonuç verir. +Bunun yerine her varlığın bölgesi saklanır ve yalnızca gerçek bölge değişimi +işlenir. +""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass, field + +from .parser_power import UNKNOWN_PLAYER, EntityRef, Event + +DECK = "DECK" +HAND = "HAND" +PLAY = "PLAY" +GRAVEYARD = "GRAVEYARD" +SETASIDE = "SETASIDE" +REMOVEDFROMGAME = "REMOVEDFROMGAME" +SECRET = "SECRET" + +# Mulligan bitene kadar süren adımlar. Bunlardan sonrası artık oyun içidir. +OPENING_STEPS = frozenset( + {"BEGIN_FIRST", "BEGIN_SHUFFLE", "BEGIN_DRAW", "BEGIN_MULLIGAN", "INVALID"} +) + + +@dataclass(slots=True) +class Entity: + entity_id: int + card_id: str = "" + name: str = "" + tags: dict[str, str] = field(default_factory=dict) + # Varlığın oyuna girerken bulunduğu ilk bölge. İlk yerleşim geçiş sayılmaz. + initial_zone: str | None = None + # Maç açılışında (mulligan öncesi) yaratıldı mı? Oyun sırasında desteye + # karıştırılan kartları başlangıç destesinden ayırmak için gerekli. + from_opening: bool = False + # Oyun sırasında destemizden çıkıp da kimliği o an bilinmeyen kartları + # sonradan çözebilmek için işaretliyoruz. + left_deck_unknown: bool = False + # Doğrudan destenin içinde yaratılan kart bir kez sayılsın diye. + counted_shuffle_in: bool = False + + @property + def controller(self) -> int | None: + value = self.tags.get("CONTROLLER") + return int(value) if value and value.isdigit() else None + + @property + def zone(self) -> str: + return self.tags.get("ZONE", "") + + +@dataclass(slots=True) +class CardEvent: + """Arayüzde gösterilecek tek bir kart olayı.""" + + card_id: str + name: str + turn: int + kind: str # drawn, played, discarded, revealed, mulliganed + entity_id: int = 0 + + +@dataclass +class Game: + started_ts: str = "" + ended_ts: str = "" + meta: dict[str, str] = field(default_factory=dict) + entities: dict[int, Entity] = field(default_factory=dict) + player_entity_ids: dict[int, int] = field(default_factory=dict) # player_id -> entity_id + player_names: dict[int, str] = field(default_factory=dict) + game_entity_id: int | None = None + local_player_id: int | None = None + first_player_id: int | None = None + turn: int = 0 + result: str = "" # WON / LOST / TIED + concluded: bool = False + opening_done: bool = False + + # Kendi destem. Takip, hangi destenin oynandığından bağımsız yürür: + # sadece "destemden ne çıktı" ve "desteme ne karıştı" kaydedilir, kalan + # liste istenen an bir deste listesine göre hesaplanır. Böylece deste + # maçın ortasında da seçilebilir, sonradan da atanabilir. + deck_list: Counter[str] | None = None + cards_left_deck: Counter[str] = field(default_factory=Counter) + cards_shuffled_in: Counter[str] = field(default_factory=Counter) + unresolved_left_deck: int = 0 # kimliği hiç açığa çıkmadan destemden çıkanlar + + my_events: list[CardEvent] = field(default_factory=list) + opponent_events: list[CardEvent] = field(default_factory=list) + + @property + def opponent_player_id(self) -> int | None: + if self.local_player_id is None: + return None + return 2 if self.local_player_id == 1 else 1 + + @property + def my_name(self) -> str: + return self.player_names.get(self.local_player_id or 0, "") + + @property + def opponent_name(self) -> str: + return self.player_names.get(self.opponent_player_id or 0, "") + + def hero_card_id(self, player_id: int | None) -> str: + """Oyuncunun kahraman kartı. Sınıf tespiti için kullanılır.""" + if player_id is None: + return "" + player_entity_id = self.player_entity_ids.get(player_id) + if player_entity_id is None: + return "" + hero_id = self.entities.get(player_entity_id, Entity(0)).tags.get("HERO_ENTITY") + if hero_id and hero_id.isdigit(): + return self.entities.get(int(hero_id), Entity(0)).card_id + return "" + + def zone_count(self, player_id: int | None, zone: str) -> int: + if player_id is None: + return 0 + return sum( + 1 + for e in self.entities.values() + if e.controller == player_id and e.zone == zone and e.entity_id != self.game_entity_id + ) + + @property + def my_deck_count(self) -> int: + return self.zone_count(self.local_player_id, DECK) + + @property + def my_hand_count(self) -> int: + return self.zone_count(self.local_player_id, HAND) + + @property + def opponent_deck_count(self) -> int: + return self.zone_count(self.opponent_player_id, DECK) + + @property + def opponent_hand_count(self) -> int: + return self.zone_count(self.opponent_player_id, HAND) + + @property + def game_turn(self) -> int: + """Oyuncu turu (her iki oyuncunun sırası birlikte bir tur sayılır).""" + return (self.turn + 1) // 2 + + def set_deck_list(self, cards: Counter[str]) -> None: + self.deck_list = Counter(cards) + + def remaining_deck(self, deck_list: Counter[str] | None = None) -> Counter[str]: + """Destede kaldığı bilinen kartlar. + + Çıkan kartlar listeden düşülür, oyun içinde karıştırılanlar eklenir. + Listede olmayan bir kart çıktıysa (yanlış deste seçilmiş olabilir) + eksiye düşmemek için kırpılır. + """ + source = deck_list if deck_list is not None else self.deck_list + if source is None: + return Counter() + remaining = Counter(source) + remaining.update(self.cards_shuffled_in) + remaining.subtract(self.cards_left_deck) + return Counter({card: count for card, count in remaining.items() if count > 0}) + + def deck_list_mismatch(self, deck_list: Counter[str] | None = None) -> int: + """Seçilen destede olmayıp destemizden çıkan kart sayısı. + + Sıfırdan büyükse büyük ihtimalle yanlış deste seçilidir. Deste otomatik + eşleştirmede aday elemek için kullanılır. + """ + source = deck_list if deck_list is not None else self.deck_list + if source is None: + return 0 + available = Counter(source) + available.update(self.cards_shuffled_in) + mismatch = 0 + for card, count in self.cards_left_deck.items(): + extra = count - available.get(card, 0) + if extra > 0: + mismatch += extra + return mismatch + + +class Tracker: + """Olay akışını tüketip Game nesnelerini üretir.""" + + def __init__(self, on_game_end=None, on_update=None): + self.game: Game | None = None + self.games: list[Game] = [] + self._current_entity: Entity | None = None + self._pending_meta: dict[str, str] = {} + self._pending_names: dict[int, str] = {} + # Maçın ilk anlarında hangi oyuncunun biz olduğu daha belli değildir, + # ama kartlar çoktan dağıtılmaya başlar. Bu geçişleri tamponlayıp + # yerel oyuncu belirlenince işliyoruz. + self._pending_zone: list[tuple[Entity, str, str]] = [] + self._on_game_end = on_game_end + self._on_update = on_update + + # --- olay girişi --------------------------------------------------- + + def feed(self, events) -> None: + for event in events: + self.handle(event) + + def handle(self, event: Event) -> None: + handler = getattr(self, f"_on_{event.kind}", None) + if handler is not None: + handler(event) + + # --- oyun sınırları ------------------------------------------------ + + def _on_create_game(self, event: Event) -> None: + self._finish_game(event.ts) + self.game = Game(started_ts=event.ts, meta=dict(self._pending_meta)) + for player_id, name in self._pending_names.items(): + self._set_player_name(self.game, player_id, name) + self._pending_meta.clear() + self._pending_names.clear() + self._pending_zone.clear() + self.games.append(self.game) + self._current_entity = None + + def _finish_game(self, ts: str) -> None: + game = self.game + if game is None: + return + game.ended_ts = ts + game.concluded = True + if self._on_game_end is not None: + self._on_game_end(game) + self.game = None + + def close(self) -> None: + """Akış bittiğinde açık maçı kapatır.""" + self._finish_game(self.game.ended_ts if self.game else "") + + # --- metadata ------------------------------------------------------ + + def _on_game_meta(self, event: Event) -> None: + key, value = event.data["key"], event.data["value"] + if self.game is not None and not self.game.concluded: + self.game.meta[key] = value + else: + self._pending_meta[key] = value + + def _on_player_name(self, event: Event) -> None: + player_id, name = event.data["player_id"], event.data["name"] + if self.game is not None and not self.game.concluded: + self._set_player_name(self.game, player_id, name) + else: + self._pending_names[player_id] = name + + def _set_player_name(self, game: Game, player_id: int, name: str) -> None: + game.player_names[player_id] = name + # Maç başında yalnızca bizim adımız bilinir, rakip UNKNOWN HUMAN PLAYER + # olarak yazılır. Yerel oyuncuyu bu farktan tespit ediyoruz. İki isim + # hangi sırada gelirse gelsin çalışması için her seferinde yeniden + # değerlendiriyoruz. + if game.local_player_id is not None: + return + known = [pid for pid, n in game.player_names.items() if n != UNKNOWN_PLAYER] + unknown = [pid for pid, n in game.player_names.items() if n == UNKNOWN_PLAYER] + if len(known) == 1 and len(unknown) == 1: + game.local_player_id = known[0] + self._flush_pending_zone() + + def _flush_pending_zone(self) -> None: + """Yerel oyuncu belirlendi, bekleyen bölge geçişlerini şimdi işle.""" + pending, self._pending_zone = self._pending_zone, [] + for entity, old_zone, new_zone in pending: + self._dispatch_zone_change(entity, old_zone, new_zone) + + # --- varlıklar ----------------------------------------------------- + + def _entity(self, entity_id: int) -> Entity: + game = self.game + assert game is not None + entity = game.entities.get(entity_id) + if entity is None: + entity = Entity(entity_id) + game.entities[entity_id] = entity + return entity + + def _resolve(self, ref: EntityRef) -> Entity | None: + game = self.game + if game is None: + return None + if ref.entity_id is not None: + entity = self._entity(ref.entity_id) + if ref.card_id and not entity.card_id: + self._set_card_id(entity, ref.card_id) + if ref.name and not entity.name: + entity.name = ref.name + return entity + if ref.name == "GameEntity": + return self._entity(game.game_entity_id) if game.game_entity_id else None + if ref.name: + for player_id, name in game.player_names.items(): + if name == ref.name: + entity_id = game.player_entity_ids.get(player_id) + if entity_id is not None: + return self._entity(entity_id) + return None + + def _on_game_entity(self, event: Event) -> None: + if self.game is None: + return + entity = self._entity(event.data["entity_id"]) + self.game.game_entity_id = entity.entity_id + self._current_entity = entity + + def _on_player_entity(self, event: Event) -> None: + if self.game is None: + return + entity = self._entity(event.data["entity_id"]) + self.game.player_entity_ids[event.data["player_id"]] = entity.entity_id + self._current_entity = entity + + def _on_full_entity(self, event: Event) -> None: + if self.game is None: + return + ref = event.data.get("ref") + entity_id = event.data.get("entity_id") + entity = self._entity(entity_id) if entity_id is not None else self._resolve(ref) + if entity is None: + return + if event.data.get("card_id"): + self._set_card_id(entity, event.data["card_id"]) + self._current_entity = entity + + def _on_show_entity(self, event: Event) -> None: + if self.game is None: + return + entity = self._resolve(event.data["ref"]) + if entity is None: + return + if event.data.get("card_id"): + self._set_card_id(entity, event.data["card_id"]) + self._current_entity = entity + + def _on_hide_entity(self, event: Event) -> None: + if self.game is None: + return + entity = self._resolve(event.data["ref"]) + if entity is None: + return + self._apply_tag(entity, event.data["tag"], event.data["value"]) + + def _on_entity_tag(self, event: Event) -> None: + if self.game is None or self._current_entity is None: + return + self._apply_tag(self._current_entity, event.data["tag"], event.data["value"]) + + def _on_tag_change(self, event: Event) -> None: + if self.game is None: + return + entity = self._resolve(event.data["ref"]) + if entity is None: + return + self._apply_tag(entity, event.data["tag"], event.data["value"]) + self._current_entity = None + + # --- etiket uygulaması --------------------------------------------- + + def _apply_tag(self, entity: Entity, tag: str, value: str) -> None: + game = self.game + assert game is not None + + if tag == "ZONE": + self._apply_zone(entity, value) + return + + entity.tags[tag] = value + + if tag == "CONTROLLER": + # Kontrolcü artık biliniyor, destede yaratılmış kartı şimdi sayabiliriz. + self._maybe_register_shuffle_in(entity) + + if tag == "TURN" and entity.entity_id == game.game_entity_id and value.isdigit(): + game.turn = int(value) + elif tag == "FIRST_PLAYER" and value == "1": + for player_id, entity_id in game.player_entity_ids.items(): + if entity_id == entity.entity_id: + game.first_player_id = player_id + elif tag == "STEP" and value not in OPENING_STEPS: + game.opening_done = True + elif tag == "PLAYSTATE" and value in ("WON", "LOST", "TIED"): + for player_id, entity_id in game.player_entity_ids.items(): + if entity_id == entity.entity_id and player_id == game.local_player_id: + game.result = value + + def _apply_zone(self, entity: Entity, new_zone: str) -> None: + game = self.game + assert game is not None + old_zone = entity.tags.get("ZONE") + entity.tags["ZONE"] = new_zone + + if old_zone is None: + # İlk yerleşim, geçiş değil. + entity.initial_zone = new_zone + entity.from_opening = not game.opening_done + self._maybe_register_shuffle_in(entity) + return + if old_zone == new_zone: + return + + if game.local_player_id is None: + # Maçın ilk anları: kimin kim olduğu henüz belli değil, sonra işlenecek. + self._pending_zone.append((entity, old_zone, new_zone)) + return + + self._dispatch_zone_change(entity, old_zone, new_zone) + + def _dispatch_zone_change(self, entity: Entity, old_zone: str, new_zone: str) -> None: + game = self.game + assert game is not None + controller = entity.controller + if controller is None: + return + if controller == game.local_player_id: + self._track_my_zone_change(entity, old_zone, new_zone) + elif controller == game.opponent_player_id: + self._track_opponent_zone_change(entity, old_zone, new_zone) + + def _track_my_zone_change(self, entity: Entity, old_zone: str, new_zone: str) -> None: + game = self.game + assert game is not None + + if old_zone == DECK: + self._remove_from_deck(entity) + if new_zone == HAND: + game.my_events.append( + CardEvent(entity.card_id, entity.name, game.game_turn, "drawn", entity.entity_id) + ) + elif new_zone == DECK: + # Oyun sırasında desteye karıştırılan kart. + if entity.card_id: + game.cards_shuffled_in[entity.card_id] += 1 + if old_zone == HAND and new_zone == PLAY: + game.my_events.append( + CardEvent(entity.card_id, entity.name, game.game_turn, "played", entity.entity_id) + ) + + def _track_opponent_zone_change(self, entity: Entity, old_zone: str, new_zone: str) -> None: + game = self.game + assert game is not None + if old_zone == HAND and new_zone in (PLAY, SECRET): + kind = "played" + elif old_zone == HAND and new_zone == GRAVEYARD: + kind = "discarded" + else: + return + game.opponent_events.append( + CardEvent(entity.card_id, entity.name, game.game_turn, kind, entity.entity_id) + ) + + def _maybe_register_shuffle_in(self, entity: Entity) -> None: + """Oyun sırasında doğrudan destenin içinde yaratılan kartı listeye ekler. + + Tracking gibi etkiler kartı desteye "karıştırırken" yeni bir varlık + yaratır, bu varlık HAND -> DECK geçişi yapmaz. Saymazsak kart daha + sonra desteden çıktığında kalan liste eksiye kayar. + """ + game = self.game + if game is None or entity.counted_shuffle_in: + return + if entity.initial_zone != DECK or entity.from_opening: + return + if entity.controller is None or entity.controller != game.local_player_id: + return + if not entity.card_id: + return + entity.counted_shuffle_in = True + game.cards_shuffled_in[entity.card_id] += 1 + + def _remove_from_deck(self, entity: Entity) -> None: + """Kart destemizden çıktı. Kimliği henüz bilinmiyorsa işaretle.""" + game = self.game + assert game is not None + if not entity.card_id: + if not entity.left_deck_unknown: + entity.left_deck_unknown = True + game.unresolved_left_deck += 1 + return + game.cards_left_deck[entity.card_id] += 1 + + def _set_card_id(self, entity: Entity, card_id: str) -> None: + """Kart kimliği sonradan açığa çıktığında geciken düşümü tamamla.""" + entity.card_id = card_id + game = self.game + if game is not None and game.local_player_id is None: + # Yedek tespit: isimlerden çözülemediyse (yeniden bağlanma gibi), + # elindeki kartı bize açık olan oyuncu biziz. + if entity.zone == HAND and entity.controller is not None: + game.local_player_id = entity.controller + self._flush_pending_zone() + self._maybe_register_shuffle_in(entity) + if entity.left_deck_unknown: + entity.left_deck_unknown = False + if game is not None: + game.unresolved_left_deck -= 1 + self._remove_from_deck(entity) diff --git a/core/watcher.py b/core/watcher.py new file mode 100644 index 0000000..c42dda4 --- /dev/null +++ b/core/watcher.py @@ -0,0 +1,70 @@ +"""Canlı log takibi. + +Oyun açıkken en yeni oturum dizinini bulur, Power.log'a eklenen satırları +okuyup durum makinesini besler. Kendi zamanlayıcısı yoktur: poll() çağrısını +arayüz (QTimer) ya da terminal döngüsü yapar. Böylece Qt'ye bağımlı değil. +""" + +from __future__ import annotations + +from pathlib import Path + +from . import logdir, logtail +from .parser_power import parse_lines +from .state import Game, Tracker + +# Yeni oturum dizini kontrolü her poll'da yapılmasın, dosya sistemi taraması. +LOG_DIR_CHECK_INTERVAL = 20 + + +class Watcher: + def __init__( + self, + install: logdir.Installation, + on_game_end=None, + on_update=None, + start_at_end: bool = True, + ): + self.install = install + self.on_update = on_update + self.tracker = Tracker(on_game_end=on_game_end) + self.log_dir: Path | None = None + self.log_set: logtail.LogSet | None = None + # Uygulama oyunun ortasında açılırsa geçmiş maçları tekrar işlememek + # için dosyanın sonundan başlıyoruz. Devam eden maçın başı kaçar, bu + # yüzden bir sonraki maçtan itibaren takip tam olur. + self.start_at_end = start_at_end + self._checks = 0 + + @property + def game(self) -> Game | None: + return self.tracker.game + + def poll(self) -> bool: + """Yeni satır varsa işler. Durum değiştiyse True döner.""" + if self._checks % LOG_DIR_CHECK_INTERVAL == 0: + self._refresh_log_dir() + self._checks += 1 + + if self.log_set is None: + return False + lines = self.log_set.read_new_lines("Power") + if not lines: + return False + self.tracker.feed(parse_lines(lines)) + if self.on_update is not None: + self.on_update(self.tracker.game) + return True + + def _refresh_log_dir(self) -> None: + latest = logdir.latest_log_dir(self.install.logs_dir) + if latest is None or latest == self.log_dir: + return + # Yeni oturum: oyun yeniden açılmış. + self.log_dir = latest + self.log_set = logtail.LogSet( + latest, components=("Power",), start_at_end=self.start_at_end + ) + # İlk dizinden sonrakiler baştan okunmalı, oyun yeni açıldığı için + # dosya zaten boştur ve maçın başını kaçırmak istemeyiz. + self.start_at_end = False diff --git a/data/__init__.py b/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/data/cards.py b/data/cards.py new file mode 100644 index 0000000..4eda30b --- /dev/null +++ b/data/cards.py @@ -0,0 +1,96 @@ +"""Kart verisi: HearthstoneJSON'dan indirir, diske cache'ler. + +Not: api.hearthstonejson.com varsayılan urllib User-Agent'ına 403 dönüyor, +bu yüzden kendi UA'mızı göndermek zorunlu. +""" + +from __future__ import annotations + +import json +import os +import time +import urllib.error +import urllib.request +from pathlib import Path + +CARDS_URL = "https://api.hearthstonejson.com/v1/latest/enUS/cards.json" +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" +MAX_AGE_SECONDS = 7 * 24 * 3600 + + +def _download(url: str, timeout: int = 60) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read() + + +def cache_is_stale(path: Path = CARDS_PATH, max_age: int = MAX_AGE_SECONDS) -> bool: + if not path.exists(): + return True + return (time.time() - path.stat().st_mtime) > max_age + + +def update_cache(force: bool = False) -> bool: + """Kart verisini indirir. Ağ yoksa mevcut cache korunur.""" + if not force and not cache_is_stale(): + return True + CACHE_DIR.mkdir(parents=True, exist_ok=True) + try: + payload = _download(CARDS_URL) + except (urllib.error.URLError, TimeoutError, OSError): + return CARDS_PATH.exists() + tmp = CARDS_PATH.with_suffix(".tmp") + tmp.write_bytes(payload) + tmp.replace(CARDS_PATH) + return True + + +class CardDB: + """Kart kimliğinden isim, mana, sınıf gibi alanlara erişim.""" + + def __init__(self, cards: list[dict]): + self.by_id: dict[str, dict] = {c["id"]: c for c in cards if "id" in c} + self.by_dbf: dict[int, dict] = {c["dbfId"]: c for c in cards if "dbfId" in c} + + @classmethod + def load(cls, path: Path = CARDS_PATH, auto_update: bool = True) -> "CardDB": + if auto_update: + update_cache() + if not path.exists(): + raise FileNotFoundError( + f"Kart verisi yok: {path}. İnternete bağlanıp tekrar deneyin." + ) + return cls(json.loads(path.read_text(encoding="utf-8"))) + + def get(self, card_id: str) -> dict: + return self.by_id.get(card_id, {}) + + def name(self, card_id: str) -> str: + return self.get(card_id).get("name", card_id or "?") + + def cost(self, card_id: str) -> int: + return self.get(card_id).get("cost", 0) + + def card_class(self, card_id: str) -> str: + card = self.get(card_id) + classes = card.get("classes") + if classes: + return classes[0] + return card.get("cardClass", "") + + def rarity(self, card_id: str) -> str: + return self.get(card_id).get("rarity", "") + + def card_type(self, card_id: str) -> str: + return self.get(card_id).get("type", "") + + def dbf_id(self, card_id: str) -> int | None: + return self.get(card_id).get("dbfId") + + def id_from_dbf(self, dbf_id: int) -> str: + return self.by_dbf.get(dbf_id, {}).get("id", "") + + def collectible_by_dbf(self, dbf_id: int) -> dict: + return self.by_dbf.get(dbf_id, {}) diff --git a/data/decks.py b/data/decks.py new file mode 100644 index 0000000..69fea22 --- /dev/null +++ b/data/decks.py @@ -0,0 +1,174 @@ +"""Deste kütüphanesi: oyunun önbelleğinden ve kullanıcının kaydettiği deste +kodlarından desteleri toplar, maça uygun olanı seçer. + +Kart kimliği (CATA_560) ile dbfId (122939) arasındaki çeviriyi burada yapıyoruz, +core katmanı yalnızca kart kimlikleriyle çalışır. +""" + +from __future__ import annotations + +import json +import os +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path + +from core import deckstring +from core.state import Game + +from .cards import CardDB +from .localdecks import LocalDeck, read_all_decks + +CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "deste" +DECKS_PATH = CONFIG_DIR / "decks.json" + + +@dataclass +class TrackedDeck: + name: str + cards: Counter[str] = field(default_factory=Counter) # kart kimliği -> adet + hero_card_id: str = "" + player_class: str = "" + format_name: str = "" + source: str = "local" # local (oyun önbelleği) veya manual (deste kodu) + deck_code: str = "" + + @property + def card_count(self) -> int: + return sum(self.cards.values()) + + +def _deck_from_dbf( + cards_db: CardDB, + name: str, + dbf_cards: dict[int, int], + hero_dbf: int, + format_name: str, + source: str, + deck_code: str = "", +) -> TrackedDeck: + counter: Counter[str] = Counter() + for dbf_id, count in dbf_cards.items(): + card_id = cards_db.id_from_dbf(dbf_id) + if card_id: + counter[card_id] += count + hero_card_id = cards_db.id_from_dbf(hero_dbf) if hero_dbf else "" + return TrackedDeck( + name=name, + cards=counter, + hero_card_id=hero_card_id, + player_class=cards_db.card_class(hero_card_id) if hero_card_id else "", + format_name=format_name, + source=source, + deck_code=deck_code, + ) + + +def from_local(cards_db: CardDB, deck: LocalDeck) -> TrackedDeck: + return _deck_from_dbf( + cards_db, deck.name, deck.cards, deck.hero_dbf, deck.format_name, "local" + ) + + +def from_deck_code(cards_db: CardDB, code: str, name: str = "") -> TrackedDeck: + """Deste kodundan takip edilebilir deste üretir.""" + parsed = deckstring.parse(code) + hero = parsed.heroes[0] if parsed.heroes else 0 + return _deck_from_dbf( + cards_db, + name or "Deste kodu", + parsed.as_counter(), + hero, + parsed.format_name, + "manual", + code.strip(), + ) + + +class DeckLibrary: + """Oyunun önbelleğindeki ve elle eklenen desteler.""" + + def __init__(self, cards_db: CardDB, game_dir: Path | None = None): + self.cards_db = cards_db + self.game_dir = game_dir + self.decks: list[TrackedDeck] = [] + + def load(self) -> "DeckLibrary": + self.decks = [] + if self.game_dir is not None: + for local in read_all_decks(self.game_dir): + self.decks.append(from_local(self.cards_db, local)) + self.decks.extend(self._load_manual()) + return self + + def _load_manual(self) -> list[TrackedDeck]: + if not DECKS_PATH.exists(): + return [] + try: + payload = json.loads(DECKS_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [] + result: list[TrackedDeck] = [] + for entry in payload.get("decks", []): + code = entry.get("code", "") + if not code: + continue + try: + result.append(from_deck_code(self.cards_db, code, entry.get("name", ""))) + except deckstring.DeckstringError: + continue + return result + + def add_manual(self, code: str, name: str = "") -> TrackedDeck: + """Deste kodunu kaydeder ve kütüphaneye ekler.""" + deck = from_deck_code(self.cards_db, code, name) + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + payload = {"decks": []} + if DECKS_PATH.exists(): + try: + payload = json.loads(DECKS_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + pass + entries = payload.setdefault("decks", []) + if not any(e.get("code") == deck.deck_code for e in entries): + entries.append({"name": deck.name, "code": deck.deck_code}) + DECKS_PATH.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8" + ) + self.decks.append(deck) + return deck + + # --- eşleştirme ----------------------------------------------------- + + def candidates(self, player_class: str) -> list[TrackedDeck]: + if not player_class: + return list(self.decks) + return [d for d in self.decks if d.player_class == player_class] + + def match(self, game: Game) -> TrackedDeck | None: + """Maça en uygun desteyi seçer. + + Sınıfa göre süzer, sonra destemizden çıkmış kartlarla çelişmeyenleri + bırakır. Tek aday kalırsa odur; birden fazla aday varsa çıkan kartları + en çok kapsayan seçilir. Hiçbiri uymuyorsa None döner ve arayüz + kullanıcıya sorar. + """ + hero = game.hero_card_id(game.local_player_id) + player_class = self.cards_db.card_class(hero) if hero else "" + candidates = self.candidates(player_class) + if not candidates: + return None + + consistent = [d for d in candidates if game.deck_list_mismatch(d.cards) == 0] + pool = consistent or candidates + if len(pool) == 1: + return pool[0] + + def coverage(deck: TrackedDeck) -> tuple[int, int]: + covered = sum( + min(count, deck.cards.get(card, 0)) + for card, count in game.cards_left_deck.items() + ) + return (covered, -game.deck_list_mismatch(deck.cards)) + + return max(pool, key=coverage) diff --git a/data/images.py b/data/images.py new file mode 100644 index 0000000..fff5e58 --- /dev/null +++ b/data/images.py @@ -0,0 +1,92 @@ +"""Kart görselleri: art.hearthstonejson.com'dan indirir, diske önbelleğe alır. + +İki boyut kullanılıyor: + tile 256x59 kart satırının arka planındaki sanat şeridi + render 256x388 fareyle üzerine gelince gösterilen tam kart (metni dahil) + +İndirmeler arka plan iş parçacıklarında yapılır, arayüz beklemez. Yeni bir +görsel hazır olduğunda `generation` sayacı artar; arayüz bu sayacı izleyip +kendini tazeler. Böylece Qt sinyali gerekmiyor ve bu modül stdlib kalıyor. +""" + +from __future__ import annotations + +import os +import threading +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +TILE_URL = "https://art.hearthstonejson.com/v1/tiles/{card_id}.png" +RENDER_URL = "https://art.hearthstonejson.com/v1/render/latest/enUS/256x/{card_id}.png" +USER_AGENT = "deste/0.1 (kisisel hearthstone tracker; +local)" + +CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "deste" / "images" + +TILE = "tile" +RENDER = "render" + + +class ImageCache: + def __init__(self, max_workers: int = 4): + self.generation = 0 + self._lock = threading.Lock() + self._pending: set[tuple[str, str]] = set() + self._failed: set[tuple[str, str]] = set() + self._pool = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="deste-img") + for kind in (TILE, RENDER): + (CACHE_DIR / kind).mkdir(parents=True, exist_ok=True) + + def path_for(self, card_id: str, kind: str = TILE) -> Path: + return CACHE_DIR / kind / f"{card_id}.png" + + def get(self, card_id: str, kind: str = TILE) -> Path | None: + """Görsel diskteyse yolunu döner, değilse indirmeye alır ve None döner.""" + if not card_id: + return None + path = self.path_for(card_id, kind) + if path.exists(): + return path + key = (card_id, kind) + with self._lock: + if key in self._pending or key in self._failed: + return None + self._pending.add(key) + self._pool.submit(self._download, card_id, kind) + return None + + def prefetch(self, card_ids, kind: str = TILE) -> None: + for card_id in card_ids: + self.get(card_id, kind) + + def _download(self, card_id: str, kind: str) -> None: + url = (TILE_URL if kind == TILE else RENDER_URL).format(card_id=card_id) + key = (card_id, kind) + try: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(request, timeout=20) as response: + payload = response.read() + except (urllib.error.URLError, TimeoutError, OSError): + with self._lock: + self._pending.discard(key) + self._failed.add(key) + return + + path = self.path_for(card_id, kind) + try: + tmp = path.with_suffix(".tmp") + tmp.write_bytes(payload) + tmp.replace(path) + except OSError: + with self._lock: + self._pending.discard(key) + self._failed.add(key) + return + + with self._lock: + self._pending.discard(key) + self.generation += 1 + + def shutdown(self) -> None: + self._pool.shutdown(wait=False, cancel_futures=True) diff --git a/data/localdecks.py b/data/localdecks.py new file mode 100644 index 0000000..74f8651 --- /dev/null +++ b/data/localdecks.py @@ -0,0 +1,250 @@ +"""Oyunun çevrimdışı önbelleğinden destelerimizi okur. + +Hearthstone, hesabın destelerini şu dosyada saklıyor: + /users//AppData/Local/Blizzard/Hearthstone/Cache/Offline/ + offlineData___REGION_.cache + +Dosya 12 baytlık bir başlıktan sonra bölümler halinde ilerliyor: + [u32 kayıt sayısı] ( [u32 uzunluk] [protobuf payload] ) * sayı + +Protobuf şemaları resmî değil, bayt seviyesinden çıkarıldı: + Deste kaydı : 1=id, 2=isim, 4=kahraman dbfId, 19=format + İçerik kaydı : 2=deste id, 3=tekrarlı yığın + yığın : 1={1=kart dbfId, 2=premium}, 3=adet + +Bu biçim resmî olmadığı için her şey savunmacı yazıldı: çözülemeyen kayıt +sessizce atlanır, hiçbir durumda çökmez. Okuma başarısız olursa deste kodu +yapıştırma yolu devrede kalır. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass, field +from pathlib import Path + +HEADER_SIZE = 12 +MAX_RECORDS_PER_SECTION = 10_000 + +# Protobuf alan numaraları +F_DECK_ID = 1 +F_DECK_NAME = 2 +F_DECK_HERO = 4 +F_DECK_FORMAT = 19 +F_CONTENTS_DECK_ID = 2 +F_CONTENTS_STACK = 3 +F_STACK_CARD = 1 +F_STACK_COUNT = 3 +F_CARD_DBF = 1 + +FORMAT_NAMES = {1: "WILD", 2: "STANDARD", 3: "CLASSIC", 4: "TWIST"} + + +@dataclass(slots=True) +class LocalDeck: + deck_id: int + name: str + hero_dbf: int = 0 + format_id: int = 0 + cards: dict[int, int] = field(default_factory=dict) # dbfId -> adet + + @property + def format_name(self) -> str: + return FORMAT_NAMES.get(self.format_id, "") + + @property + def card_count(self) -> int: + return sum(self.cards.values()) + + +# --- asgari protobuf okuyucu ------------------------------------------- + + +def _varint(data: bytes, index: int) -> tuple[int, int]: + value = 0 + shift = 0 + while index < len(data): + byte = data[index] + index += 1 + value |= (byte & 0x7F) << shift + if not byte & 0x80: + return value, index + shift += 7 + if shift > 70: + break + raise ValueError("bozuk varint") + + +def parse_message(data: bytes) -> dict[int, list]: + """Protobuf mesajını {alan: [değer]} sözlüğüne çevirir. + + Uzunluk belirtilmiş alanlar ham bayt olarak döner, gerekirse çağıran + tarafından tekrar çözülür. + """ + fields: dict[int, list] = {} + index = 0 + while index < len(data): + key, index = _varint(data, index) + field_number, wire_type = key >> 3, key & 7 + if field_number == 0: + raise ValueError("geçersiz alan numarası") + if wire_type == 0: + value, index = _varint(data, index) + elif wire_type == 2: + length, index = _varint(data, index) + if index + length > len(data): + raise ValueError("uzunluk taşması") + value = data[index : index + length] + index += length + elif wire_type == 5: + value = data[index : index + 4] + index += 4 + elif wire_type == 1: + value = data[index : index + 8] + index += 8 + else: + raise ValueError(f"bilinmeyen wire type {wire_type}") + fields.setdefault(field_number, []).append(value) + return fields + + +def iter_records(data: bytes): + """Dosyadaki bölümleri gezip her kaydın ham baytını verir.""" + index = HEADER_SIZE + while index + 4 <= len(data): + (count,) = struct.unpack_from(" MAX_RECORDS_PER_SECTION: + return + for _ in range(count): + if index + 4 > len(data): + return + (length,) = struct.unpack_from(" len(data): + return + yield data[index : index + length] + index += length + + +# --- kayıt sınıflandırma ----------------------------------------------- + + +def _as_text(raw: bytes) -> str: + return raw.decode("utf-8", errors="replace").strip() + + +def _parse_deck_record(fields: dict[int, list]) -> LocalDeck | None: + name_values = fields.get(F_DECK_NAME) + id_values = fields.get(F_DECK_ID) + if not name_values or not id_values: + return None + if not isinstance(name_values[0], bytes) or not isinstance(id_values[0], int): + return None + hero = fields.get(F_DECK_HERO, [0])[0] + fmt = fields.get(F_DECK_FORMAT, [0])[0] + return LocalDeck( + deck_id=id_values[0], + name=_as_text(name_values[0]), + hero_dbf=hero if isinstance(hero, int) else 0, + format_id=fmt if isinstance(fmt, int) else 0, + ) + + +def _parse_contents_record(fields: dict[int, list]) -> tuple[int, dict[int, int]] | None: + deck_ids = fields.get(F_CONTENTS_DECK_ID) + stacks = fields.get(F_CONTENTS_STACK) + if not deck_ids or not stacks or not isinstance(deck_ids[0], int): + return None + cards: dict[int, int] = {} + for stack_raw in stacks: + if not isinstance(stack_raw, bytes): + continue + try: + stack = parse_message(stack_raw) + except ValueError: + continue + card_raw = stack.get(F_STACK_CARD, [None])[0] + count = stack.get(F_STACK_COUNT, [0])[0] + if not isinstance(card_raw, bytes) or not isinstance(count, int) or count <= 0: + continue + try: + card = parse_message(card_raw) + except ValueError: + continue + dbf = card.get(F_CARD_DBF, [None])[0] + if isinstance(dbf, int): + # Aynı kartın normal ve altın kopyaları ayrı yığın olarak geliyor. + cards[dbf] = cards.get(dbf, 0) + count + if not cards: + return None + return deck_ids[0], cards + + +def read_decks(cache_path: Path) -> list[LocalDeck]: + """Önbellekten desteleri okur. Hata durumunda boş liste döner.""" + try: + data = cache_path.read_bytes() + except OSError: + return [] + + decks: dict[int, LocalDeck] = {} + contents: dict[int, dict[int, int]] = {} + + for raw in iter_records(data): + try: + fields = parse_message(raw) + except ValueError: + continue + deck = _parse_deck_record(fields) + if deck is not None: + # Aynı deste birden fazla bölümde tekrar edebiliyor. + decks.setdefault(deck.deck_id, deck) + continue + parsed = _parse_contents_record(fields) + if parsed is not None: + deck_id, cards = parsed + contents.setdefault(deck_id, cards) + + for deck_id, cards in contents.items(): + if deck_id in decks: + decks[deck_id].cards = cards + + # Oyun aynı desteyi "yerel" ve "orijinal" olmak üzere iki kopya halinde + # önbelleğe alıyor. İsim ve içerik aynıysa tek deste olarak gösteriyoruz. + unique: dict[tuple, LocalDeck] = {} + for deck in decks.values(): + if not deck.cards: + continue + signature = (deck.name, tuple(sorted(deck.cards.items()))) + unique.setdefault(signature, deck) + return list(unique.values()) + + +def find_cache_files(game_dir: Path) -> list[Path]: + """Prefix içindeki çevrimdışı önbellek dosyalarını bulur. + + Wine prefix'lerinde users/ çoğu zaman users/steamuser'a sembolik + bağdır, aynı dosyayı iki kez okumamak için gerçek yola göre tekilleştiriyoruz. + """ + drive_c = game_dir.parent.parent + users = drive_c / "users" + if not users.is_dir(): + return [] + seen: dict[Path, Path] = {} + for user_dir in sorted(users.iterdir()): + offline = ( + user_dir / "AppData" / "Local" / "Blizzard" / "Hearthstone" / "Cache" / "Offline" + ) + if not offline.is_dir(): + continue + for cache_file in sorted(offline.glob("offlineData_*.cache")): + seen.setdefault(cache_file.resolve(), cache_file) + return list(seen.values()) + + +def read_all_decks(game_dir: Path) -> list[LocalDeck]: + decks: list[LocalDeck] = [] + for cache_file in find_cache_files(game_dir): + decks.extend(read_decks(cache_file)) + return decks diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..5c8b41d --- /dev/null +++ b/install.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# +# deste kurulum betiği. +# +# Yaptıkları: +# 1. Masaüstü kısayolu (uygulama menüsü + masaüstü) +# 2. Simgeyi hicolor temasına kopyalar +# 3. KWin kuralları: deste her zaman üstte, Hearthstone tam ekrana geçmesin +# 4. KWin'e yapılandırmayı yeniden okutur +# +# Hepsi geri alınabilir: ./install.sh --uninstall +# Sadece kısayol, KWin'e dokunma: ./install.sh --no-kwin +# Hearthstone kuralı olmadan: ./install.sh --no-hs-rule + +set -euo pipefail + +APP_ID="deste" +REPO_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" +ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps" +DESKTOP_FILE="$DESKTOP_DIR/$APP_ID.desktop" +USER_DESKTOP="$(xdg-user-dir DESKTOP 2>/dev/null || echo "$HOME/Desktop")" +KWIN_RULES="${XDG_CONFIG_HOME:-$HOME/.config}/kwinrulesrc" +RULE_ID="deste-overlay" +HS_RULE_ID="deste-hearthstone" + +DO_KWIN=1 +DO_HS_RULE=1 +DO_UNINSTALL=0 +for arg in "$@"; do + case "$arg" in + --no-kwin) DO_KWIN=0 ;; + --no-hs-rule) DO_HS_RULE=0 ;; + --uninstall) DO_UNINSTALL=1 ;; + *) echo "bilinmeyen seçenek: $arg" >&2; exit 1 ;; + esac +done + +info() { printf ' %s\n' "$1"; } + +reconfigure_kwin() { + if command -v qdbus6 >/dev/null 2>&1; then + qdbus6 org.kde.KWin /KWin reconfigure >/dev/null 2>&1 || true + elif command -v qdbus >/dev/null 2>&1; then + qdbus org.kde.KWin /KWin reconfigure >/dev/null 2>&1 || true + fi +} + +# --- kaldırma ------------------------------------------------------------- + +if [ "$DO_UNINSTALL" = 1 ]; then + echo "deste kaldırılıyor" + rm -f "$DESKTOP_FILE" "$USER_DESKTOP/$APP_ID.desktop" "$ICON_DIR/$APP_ID.svg" + info "kısayol ve simge silindi" + + if [ -f "$KWIN_RULES" ]; then + current="$(kreadconfig6 --file kwinrulesrc --group General --key rules 2>/dev/null || echo "")" + if [ -n "$current" ]; then + new="$(printf '%s' "$current" | tr ',' '\n' | grep -vx "$RULE_ID" | grep -vx "$HS_RULE_ID" | paste -sd, -)" + kwriteconfig6 --file kwinrulesrc --group General --key rules "$new" + kwriteconfig6 --file kwinrulesrc --group "$RULE_ID" --key Description --delete 2>/dev/null || true + # Grubu tamamen sil + python3 - "$KWIN_RULES" "$RULE_ID" "$HS_RULE_ID" <<'PY' +import sys, re +path, groups = sys.argv[1], sys.argv[2:] +try: + text = open(path, encoding="utf-8").read() +except OSError: + sys.exit(0) +for group in groups: + pattern = re.compile(r"\n?\[" + re.escape(group) + r"\][^\[]*", re.S) + text = pattern.sub("\n", text) +open(path, "w", encoding="utf-8").write(text) +PY + info "KWin kuralı silindi" + fi + fi + + reconfigure_kwin + update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true + echo "tamam" + exit 0 +fi + +# --- kurulum -------------------------------------------------------------- + +echo "deste kuruluyor: $REPO_DIR" + +if ! python3 -c "import PyQt6" 2>/dev/null; then + echo "UYARI: PyQt6 bulunamadı. Kurulum: sudo pacman -S python-pyqt6" >&2 +fi + +chmod +x "$REPO_DIR/run.sh" + +mkdir -p "$DESKTOP_DIR" "$ICON_DIR" +install -m644 "$REPO_DIR/assets/deste.svg" "$ICON_DIR/$APP_ID.svg" + +# PNG boyutları: Plasma'nın tepsisi ve menüsü ikonu isimle çözerken hazır +# rasterları tercih ediyor, sadece SVG bırakınca boş kare çıkabiliyor. +if [ ! -f "$REPO_DIR/assets/icons/deste-48.png" ]; then + (cd "$REPO_DIR" && python3 tools/make_icons.py >/dev/null 2>&1) || true +fi +for size in 16 22 24 32 48 64 96 128 256; do + src="$REPO_DIR/assets/icons/deste-$size.png" + [ -f "$src" ] || continue + dest_dir="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/${size}x${size}/apps" + mkdir -p "$dest_dir" + install -m644 "$src" "$dest_dir/$APP_ID.png" +done +info "simge: SVG + PNG boyutları hicolor temasına kuruldu" + +cat > "$DESKTOP_FILE" </dev/null 2>&1; then + gio set "$USER_DESKTOP/$APP_ID.desktop" metadata::trusted true 2>/dev/null || true + fi + info "masaüstü: $USER_DESKTOP/$APP_ID.desktop" +fi + +update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true + +# gtk-update-icon-cache burada bilerek çağrılmıyor: kullanıcı dizinindeki +# hicolor'da index.theme olmadığı için ürettiği önbellek Qt'nin ikonu +# yükleyememesine yol açıyor. Plasma'nın uygulama veritabanını tazelemek yeterli. +if command -v kbuildsycoca6 >/dev/null 2>&1; then + kbuildsycoca6 --noincremental >/dev/null 2>&1 || true + info "Plasma uygulama veritabanı tazelendi" +fi + +if [ "$DO_KWIN" = 1 ]; then + # KWin kuralı: pencere her zaman üstte kalsın. + # aboverule=2 -> Force (KWin rules.h: Unused=0, DontAffect=1, Force=2, ...) + # wmclassmatch=1 -> tam eşleşme + kwriteconfig6 --file kwinrulesrc --group "$RULE_ID" --key Description "deste overlay" + kwriteconfig6 --file kwinrulesrc --group "$RULE_ID" --key wmclass "$APP_ID" + kwriteconfig6 --file kwinrulesrc --group "$RULE_ID" --key wmclassmatch 1 + kwriteconfig6 --file kwinrulesrc --group "$RULE_ID" --key wmclasscomplete false + kwriteconfig6 --file kwinrulesrc --group "$RULE_ID" --key above true + kwriteconfig6 --file kwinrulesrc --group "$RULE_ID" --key aboverule 2 + + # Not: boyut/konum bilerek zorlanmıyor. Force kuralı pencereyi sabitliyor + # ama sürüklemeyi de kilitliyor. Konum ve boyut kullanıcıya bırakıldı. + + current="$(kreadconfig6 --file kwinrulesrc --group General --key rules 2>/dev/null || echo "")" + if ! printf '%s' "$current" | tr ',' '\n' | grep -qx "$RULE_ID"; then + if [ -z "$current" ]; then + kwriteconfig6 --file kwinrulesrc --group General --key rules "$RULE_ID" + else + kwriteconfig6 --file kwinrulesrc --group General --key rules "$current,$RULE_ID" + fi + fi + kwriteconfig6 --file kwinrulesrc --group General --key count \ + "$(kreadconfig6 --file kwinrulesrc --group General --key rules | tr ',' '\n' | grep -c . || echo 1)" + info "KWin kuralı: her zaman üstte" + + # Hearthstone kuralı: tam ekrana geçmesin ve çerçevesiz olsun. + # + # Sebebi: KWin'de tam ekran pencereler "her zaman üstte" katmanının da + # üstündeki aktif katmana çıkıyor, o yüzden overlay oyunun altında kalıyor. + # Tam ekranı zorla kapatınca oyun kenarlıksız pencere olarak ekranı + # kaplamaya devam ediyor ama overlay üstte kalabiliyor. + # + # umu/Proton ile açılan oyunların sınıfı steam_app_default olduğu için + # başlığa da bakıyoruz, başka oyunlar etkilenmesin. + if [ "$DO_HS_RULE" = 1 ]; then + kwriteconfig6 --file kwinrulesrc --group "$HS_RULE_ID" --key Description "Hearthstone (deste)" + kwriteconfig6 --file kwinrulesrc --group "$HS_RULE_ID" --key wmclass "steam_app_default" + kwriteconfig6 --file kwinrulesrc --group "$HS_RULE_ID" --key wmclassmatch 1 + kwriteconfig6 --file kwinrulesrc --group "$HS_RULE_ID" --key wmclasscomplete false + kwriteconfig6 --file kwinrulesrc --group "$HS_RULE_ID" --key title "Hearthstone" + kwriteconfig6 --file kwinrulesrc --group "$HS_RULE_ID" --key titlematch 1 + kwriteconfig6 --file kwinrulesrc --group "$HS_RULE_ID" --key fullscreen false + kwriteconfig6 --file kwinrulesrc --group "$HS_RULE_ID" --key fullscreenrule 2 + kwriteconfig6 --file kwinrulesrc --group "$HS_RULE_ID" --key noborder true + kwriteconfig6 --file kwinrulesrc --group "$HS_RULE_ID" --key noborderrule 2 + + current="$(kreadconfig6 --file kwinrulesrc --group General --key rules 2>/dev/null || echo "")" + if ! printf '%s' "$current" | tr ',' '\n' | grep -qx "$HS_RULE_ID"; then + kwriteconfig6 --file kwinrulesrc --group General --key rules "$current,$HS_RULE_ID" + fi + kwriteconfig6 --file kwinrulesrc --group General --key count \ + "$(kreadconfig6 --file kwinrulesrc --group General --key rules | tr ',' '\n' | grep -c . || echo 1)" + info "KWin kuralı: Hearthstone tam ekrana geçmesin, çerçevesiz olsun" + fi + + reconfigure_kwin +fi + +echo +echo "tamam. Uygulama menüsünde ve masaüstünde 'deste' olarak görünüyor." +echo "Doğrudan çalıştırmak için: $REPO_DIR/run.sh" diff --git a/main.py b/main.py new file mode 100644 index 0000000..6956956 --- /dev/null +++ b/main.py @@ -0,0 +1,63 @@ +"""deste: Hearthstone deck tracker (Linux / Wayland). + +Kullanım: + python main.py # arayüzü başlat + python main.py --full # mevcut oturum logunu baştan okuyup başlat +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from core import config, logdir +from core.watcher import Watcher +from ui import i18n + + +def main(argv: list[str]) -> int: + settings = config.load() + i18n.set_language(settings.get("language", "auto")) + + install = logdir.detect(settings.get("game_dir") or None) + if install is None: + print( + i18n.t("error_no_install") + "\n" + i18n.t("error_no_install_hint"), + file=sys.stderr, + ) + return 1 + + problems = logdir.check_log_config(install.log_config) + problems += logdir.check_client_config(install.client_config) + if problems: + print(i18n.t("error_log_config") + ", ".join(problems), file=sys.stderr) + print(i18n.t("error_log_config_hint"), file=sys.stderr) + + from PyQt6.QtWidgets import QApplication + + from data.cards import CardDB + from data.decks import DeckLibrary + from ui.window import TrackerWindow + + app = QApplication(sys.argv) + app.setApplicationName("deste") + app.setDesktopFileName("deste") + + cards = CardDB.load() + library = DeckLibrary(cards, install.game_dir).load() + + watcher = Watcher(install, start_at_end="--full" not in argv) + if "--full" in argv: + for _ in range(500): + if not watcher.poll(): + break + + window = TrackerWindow(watcher, library, cards, settings) + window.show() + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..c81ea47 --- /dev/null +++ b/run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# deste'yi kendi dizininden başlatır. Masaüstü kısayolu bunu çağırır. +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")" +exec python3 main.py "$@" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_replay.py b/tests/test_replay.py new file mode 100644 index 0000000..b4cce9a --- /dev/null +++ b/tests/test_replay.py @@ -0,0 +1,240 @@ +"""Gerçek log korpusu üstünde tutarlılık testleri. + +Bu testler uydurma veri kullanmaz, makinedeki gerçek Hearthstone oturum +loglarını okur. En kıymetli kontrol, Power.log'dan kurulan durumun bağımsız +bir kaynakla (Zone.log) çapraz doğrulanmasıdır. + +Çalıştırma: + python -m tests.test_replay [log_dizini] +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core import logdir, logtail +from core.parser_power import parse_lines +from core.state import DECK, Game, Tracker + +ZONE_LINE_RE = re.compile( + r"^\w \s*(?P\d+:\d+:\d+\.\d+) ZoneChangeList\.ProcessChanges\(\) - .*" + r"\[entityName=(?P.*?) id=(?P\d+) zone=\w+ zonePos=-?\d+ " + r"cardId=(?P\w*) player=\d+\] zone from (?P[A-Z ]+) -> (?P[A-Z ]+)$" +) + + +class Failure(Exception): + pass + + +def check(condition: bool, message: str) -> None: + if not condition: + raise Failure(message) + + +def load_games(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 zone_draws_by_window(log_dir: Path, windows: list[tuple[str, str]]) -> list[set[int]]: + """Zone.log'dan her maç penceresi için FRIENDLY DECK -> FRIENDLY HAND + geçişi yapan varlık kimliklerini toplar.""" + results: list[set[int]] = [set() for _ in windows] + zone_log = log_dir / "Zone.log" + if not zone_log.exists(): + return results + for line in logtail.read_file_lines(zone_log): + match = ZONE_LINE_RE.match(line) + if not match: + continue + if match.group("src") != "FRIENDLY DECK" or match.group("dst") != "FRIENDLY HAND": + continue + ts = match.group("ts") + for index, (start, end) in enumerate(windows): + if start <= ts and (not end or ts <= end): + results[index].add(int(match.group("id"))) + break + return results + + +def initial_deck_sizes(game: Game) -> dict[int, int]: + sizes: dict[int, int] = {} + for entity in game.entities.values(): + if entity.from_opening and entity.initial_zone == DECK and entity.controller is not None: + sizes[entity.controller] = sizes.get(entity.controller, 0) + 1 + return sizes + + +def run(log_dir: Path) -> int: + games = load_games(log_dir) + 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}") + + power_log = log_dir / "Power.log" + expected_games = sum( + 1 + for line in logtail.read_file_lines(power_log) + if line.endswith("GameState.DebugPrintPower() - CREATE_GAME") + ) + + print(f"Log dizini: {log_dir}") + print(f"Beklenen maç sayısı (CREATE_GAME): {expected_games}") + print(f"Ayrıştırılan maç sayısı: {len(games)}\n") + + record( + "maç sayısı CREATE_GAME ile eşleşiyor", + lambda: check(len(games) == expected_games, f"{len(games)} != {expected_games}"), + ) + + record( + "her maçın yerel oyuncusu belirlendi", + lambda: check( + all(g.local_player_id is not None for g in games), + f"{sum(1 for g in games if g.local_player_id is None)} maçta belirlenemedi", + ), + ) + + # Oturumun son maçı log yazılırken hâlâ devam ediyor olabilir, onu muaf + # tutuyoruz. Öncekilerin hepsinin sonucu bilinmek zorunda. + finished_games = games[:-1] if games else [] + record( + "biten maçların sonucu var", + lambda: check( + all(g.result in ("WON", "LOST", "TIED") for g in finished_games), + f"{[i for i, g in enumerate(finished_games, 1) if not g.result]} numaralı maçlarda sonuç yok", + ), + ) + + record( + "her maçta iki kahraman da tespit edildi", + lambda: check( + all( + g.hero_card_id(g.local_player_id) and g.hero_card_id(g.opponent_player_id) + for g in games + ), + "bazı maçlarda kahraman kartı boş", + ), + ) + + def check_initial_decks() -> None: + # Hearthstone'da geçerli deste boyutu 30, Renathal benzeri etkilerle 40. + for index, game in enumerate(games, start=1): + sizes = initial_deck_sizes(game) + for player_id, size in sizes.items(): + check( + size in (30, 40), + f"maç {index}, oyuncu {player_id} başlangıç destesi {size} kart", + ) + record("başlangıç desteleri 30 ya da 40 kart", check_initial_decks) + + # Bağımsız kaynakla çapraz doğrulama. + windows = [(g.started_ts, g.ended_ts) for g in games] + zone_draws = zone_draws_by_window(log_dir, windows) + + def check_draws() -> None: + # Kimlik kümesi karşılaştırılıyor, sayı değil: Zone.log aynı geçişi + # birden fazla satırda yazabiliyor, ayrıca mulligan sonrası aynı kart + # tekrar çekilebiliyor. + for index, (game, drawn_ids) in enumerate(zip(games, zone_draws), start=1): + tracked = {e.entity_id for e in game.my_events if e.kind == "drawn"} + missing = drawn_ids - tracked + extra = tracked - drawn_ids + check( + not missing and not extra, + f"maç {index}: Zone.log'da olup bizde olmayan {sorted(missing)}, " + f"bizde olup Zone.log'da olmayan {sorted(extra)}", + ) + record("çekilen kartlar Zone.log ile birebir eşleşiyor", check_draws) + + def check_deck_counts() -> None: + for index, game in enumerate(games, start=1): + check( + 0 <= game.my_deck_count <= 60, + f"maç {index}: deste sayacı mantıksız ({game.my_deck_count})", + ) + check( + 0 <= game.opponent_deck_count <= 60, + f"maç {index}: rakip deste sayacı mantıksız ({game.opponent_deck_count})", + ) + record("deste sayaçları mantıklı aralıkta", check_deck_counts) + + # Deste takibi: kart verisi ve deste kütüphanesi varsa en kıymetli + # tutarlılık kontrolü. Kalan liste, oyunun kendi deste sayacıyla birebir + # tutmalı. Tutmuyorsa ya çekiş kaçırılmış ya da desteye karışan kart + # sayılmamıştır. + library = None + try: + from data.cards import CardDB + from data.decks import DeckLibrary + + install = logdir.detect() + cards_db = CardDB.load(auto_update=False) + library = DeckLibrary(cards_db, install.game_dir if install else None).load() + except Exception as exc: # kart verisi yoksa bu bölümü atla + print(f" atlandı deste takibi kontrolleri ({exc})") + + if library is not None and library.decks: + def check_deck_tracking() -> None: + checked = 0 + for index, game in enumerate(games, start=1): + deck = library.match(game) + if deck is None: + continue + checked += 1 + remaining = sum(game.remaining_deck(deck.cards).values()) + check( + remaining == game.my_deck_count, + f"maç {index} ({deck.name}): kalan liste {remaining}, " + f"oyunun deste sayacı {game.my_deck_count}", + ) + check( + game.deck_list_mismatch(deck.cards) == 0, + f"maç {index} ({deck.name}): listede olmayan " + f"{game.deck_list_mismatch(deck.cards)} kart destemden çıktı", + ) + check(checked > 0, "hiçbir maça deste eşleşmedi") + + record("kalan deste listesi oyunun sayacıyla tutuyor", check_deck_tracking) + + 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: + log_dir = Path(argv[0]).expanduser() + else: + install = logdir.detect() + if install is None: + print("Hearthstone kurulumu bulunamadı.", file=sys.stderr) + return 1 + found = logdir.latest_log_dir(install.logs_dir) + if found is None: + print("Log dizini yok.", file=sys.stderr) + return 1 + log_dir = found + return run(log_dir) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/live.py b/tools/live.py new file mode 100644 index 0000000..3b89bd0 --- /dev/null +++ b/tools/live.py @@ -0,0 +1,122 @@ +"""Terminalde canlı takip. Arayüzden önce boru hattını doğrulamak için. + +Kullanım: + python -m tools.live # oyunun ortasından itibaren takip + python -m tools.live --full # mevcut oturum logunu baştan oku + python -m tools.live --once # tek seferlik anlık durum yazdır +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core import logdir +from core.state import Game +from core.watcher import Watcher +from data.cards import CardDB +from data.decks import DeckLibrary + +CLASS_SHORT = { + "DEATHKNIGHT": "DK", + "DEMONHUNTER": "DH", + "DRUID": "Druid", + "HUNTER": "Hunter", + "MAGE": "Mage", + "PALADIN": "Paladin", + "PRIEST": "Priest", + "ROGUE": "Rogue", + "SHAMAN": "Shaman", + "WARLOCK": "Warlock", + "WARRIOR": "Warrior", +} + + +def render(game: Game | None, library: DeckLibrary, cards: CardDB) -> str: + if game is None: + return "Maç yok. Oyunda bir maça girildiğinde burası dolacak." + + def class_of(player_id): + hero = game.hero_card_id(player_id) + return CLASS_SHORT.get(cards.card_class(hero), cards.card_class(hero) or "?") + + lines: list[str] = [] + mode = game.meta.get("GameType", "?").removeprefix("GT_") + lines.append( + f"{class_of(game.local_player_id)} vs {class_of(game.opponent_player_id)} " + f"({game.opponent_name}) {mode} tur {game.game_turn}" + + (f" SONUÇ: {game.result}" if game.result else "") + ) + + deck = library.match(game) + if deck is not None: + remaining = game.remaining_deck(deck.cards) + total = sum(remaining.values()) + lines.append(f"\nDestem: {deck.name} ({total} kart kaldı, oyun sayacı {game.my_deck_count})") + ordered = sorted( + remaining.items(), key=lambda kv: (cards.cost(kv[0]), cards.name(kv[0])) + ) + for card_id, count in ordered: + chance = 100.0 * count / total if total else 0.0 + lines.append( + f" [{cards.cost(card_id)}] {cards.name(card_id):<28} x{count} %{chance:.0f}" + ) + else: + lines.append(f"\nDestem: eşleşmedi ({game.my_deck_count} kart kaldı)") + + lines.append( + f"\nRakip: elinde {game.opponent_hand_count}, destesinde {game.opponent_deck_count}" + ) + seen: dict[str, int] = {} + for event in game.opponent_events: + if event.card_id: + seen[event.card_id] = seen.get(event.card_id, 0) + 1 + for card_id, count in sorted(seen.items(), key=lambda kv: cards.cost(kv[0])): + lines.append(f" [{cards.cost(card_id)}] {cards.name(card_id)}" + (f" x{count}" if count > 1 else "")) + return "\n".join(lines) + + +def main(argv: list[str]) -> int: + install = logdir.detect() + if install is None: + print("Hearthstone kurulumu bulunamadı.", file=sys.stderr) + return 1 + + problems = logdir.check_log_config(install.log_config) + logdir.check_client_config( + install.client_config + ) + if problems: + print("log yapılandırma uyarıları: " + ", ".join(problems), file=sys.stderr) + + cards = CardDB.load() + library = DeckLibrary(cards, install.game_dir).load() + print(f"{len(library.decks)} deste yüklendi: {', '.join(d.name for d in library.decks)}") + + watcher = Watcher(install, start_at_end="--full" not in argv) + if "--full" in argv: + # Mevcut oturumu baştan okuyup güncel duruma gel. + for _ in range(200): + if not watcher.poll(): + break + + if "--once" in argv: + watcher.poll() + print(render(watcher.game, library, cards)) + return 0 + + print("Canlı takip başladı, çıkmak için Ctrl+C.\n") + try: + while True: + if watcher.poll(): + print("\033[2J\033[H", end="") + print(render(watcher.game, library, cards)) + time.sleep(0.5) + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/make_icons.py b/tools/make_icons.py new file mode 100644 index 0000000..8ac8044 --- /dev/null +++ b/tools/make_icons.py @@ -0,0 +1,57 @@ +"""assets/deste.svg dosyasından PNG ikon boyutlarını üretir. + +Plasma'nın tepsisi ve uygulama menüsü ikonu isimle çözüyor ve bazı yollarda +SVG yerine hazır PNG bekliyor. install.sh bunları hicolor temasına kopyalar. + +Kullanım: python -m tools.make_icons +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +SIZES = (16, 22, 24, 32, 48, 64, 96, 128, 256) +ROOT = Path(__file__).resolve().parent.parent +SVG_PATH = ROOT / "assets" / "deste.svg" +OUT_DIR = ROOT / "assets" / "icons" + + +def main() -> int: + from PyQt6.QtCore import QSize, Qt + from PyQt6.QtGui import QIcon + from PyQt6.QtWidgets import QApplication + + if not SVG_PATH.exists(): + print(f"SVG yok: {SVG_PATH}", file=sys.stderr) + return 1 + + # Referans tutulmazsa QApplication hemen toplanıyor ve ilk QPixmap + # çağrısında Qt "önce QGuiApplication kur" diyip abort ediyor. + app = QApplication(sys.argv) # noqa: F841 + OUT_DIR.mkdir(parents=True, exist_ok=True) + icon = QIcon(str(SVG_PATH)) + for size in SIZES: + pixmap = icon.pixmap(QSize(size, size)) + if pixmap.isNull(): + print(f"{size}px üretilemedi", file=sys.stderr) + continue + # Yüksek DPI ölçeklemesi devrede olabilir, istenen boyuta indir. + # Not: enum yerine düz sayı geçilirse PyQt6 sessizce abort ediyor. + if pixmap.width() != size: + pixmap = pixmap.scaled( + size, + size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + target = OUT_DIR / f"deste-{size}.png" + pixmap.save(str(target)) + print(f" {target.name} {pixmap.width()}x{pixmap.height()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/replay.py b/tools/replay.py new file mode 100644 index 0000000..710bc33 --- /dev/null +++ b/tools/replay.py @@ -0,0 +1,138 @@ +"""Kayıtlı log dizinini baştan sona işleyip maç özetlerini basar. + +Geliştirmenin tamamı bunun üstünde döner: oyunu açmadan, gerçek log verisiyle +parser ve durum makinesi doğrulanır. + +Kullanım: + python -m tools.replay # en son oturum + python -m tools.replay + python -m tools.replay --cards # kart isimlerini de göster +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core import logdir, logtail +from core.parser_power import parse_lines +from core.state import Game, Tracker + +CLASS_SHORT = { + "DEATHKNIGHT": "DK", + "DEMONHUNTER": "DH", + "DRUID": "Druid", + "HUNTER": "Hunter", + "MAGE": "Mage", + "PALADIN": "Paladin", + "PRIEST": "Priest", + "ROGUE": "Rogue", + "SHAMAN": "Shaman", + "WARLOCK": "Warlock", + "WARRIOR": "Warrior", +} + + +def summarize(game: Game, cards=None) -> str: + mode = game.meta.get("GameType", "?").removeprefix("GT_") + fmt = game.meta.get("FormatType", "?").removeprefix("FT_") + + def class_of(player_id): + hero = game.hero_card_id(player_id) + if cards is None or not hero: + return hero or "?" + return CLASS_SHORT.get(cards.card_class(hero), cards.card_class(hero) or "?") + + coin = "önce" if game.first_player_id == game.local_player_id else "sonra" + result = game.result or "?" + return ( + f"{game.started_ts[:8]} {mode:<8} {fmt:<9} " + f"{class_of(game.local_player_id):<8} vs {class_of(game.opponent_player_id):<8} " + f"{result:<5} tur {game.game_turn:<3} {coin:<5} " + f"rakip açığa çıkan {len(game.opponent_events):<3} " + f"benim çektiğim {sum(1 for e in game.my_events if e.kind == 'drawn')}" + ) + + +def print_deck_detail(game: Game, library, cards) -> None: + """Maç için seçilen desteyi ve destede kalanları yazdırır.""" + deck = library.match(game) + if deck is None: + print(" deste eşleşmedi") + return + remaining = game.remaining_deck(deck.cards) + total_remaining = sum(remaining.values()) + mismatch = game.deck_list_mismatch(deck.cards) + status = "" if mismatch == 0 else f", listede olmayan {mismatch} kart çıktı" + print( + f" deste: {deck.name} ({deck.source}) | " + f"kalan liste {total_remaining}, oyunun deste sayacı {game.my_deck_count}" + f"{status}" + ) + if cards is None: + return + ordered = sorted(remaining.items(), key=lambda kv: (cards.cost(kv[0]), cards.name(kv[0]))) + line = ", ".join(f"{cards.name(c)}x{n}" for c, n in ordered[:8]) + if line: + print(f" kalanlar: {line}{' ...' if len(ordered) > 8 else ''}") + + +def main(argv: list[str]) -> int: + args = [a for a in argv if not a.startswith("--")] + want_cards = "--cards" in argv or "--deck" in argv + want_deck = "--deck" in argv + + if args: + log_dir = Path(args[0]).expanduser() + else: + install = logdir.detect() + if install is None: + print("Hearthstone kurulumu bulunamadı.", file=sys.stderr) + return 1 + found = logdir.latest_log_dir(install.logs_dir) + if found is None: + print(f"Log dizini yok: {install.logs_dir}", file=sys.stderr) + return 1 + log_dir = found + + power_log = log_dir / "Power.log" + if not power_log.exists(): + print(f"Power.log yok: {power_log}", file=sys.stderr) + return 1 + + cards = None + library = None + if want_cards or want_deck: + from data.cards import CardDB + + cards = CardDB.load() + if want_deck: + from data.decks import DeckLibrary + + install = logdir.detect() + library = DeckLibrary(cards, install.game_dir if install else None).load() + + games: list[Game] = [] + tracker = Tracker(on_game_end=games.append) + tracker.feed(parse_lines(logtail.read_file_lines(power_log))) + tracker.close() + + print(f"Log dizini: {log_dir}") + print(f"Power.log: {power_log.stat().st_size / 1_000_000:.1f} MB") + print(f"Maç sayısı: {len(games)}\n") + for index, game in enumerate(games, start=1): + print(f"{index:>3}. {summarize(game, cards)}") + if library is not None: + print_deck_detail(game, library, cards) + + wins = sum(1 for g in games if g.result == "WON") + losses = sum(1 for g in games if g.result == "LOST") + unknown = len(games) - wins - losses + print(f"\nGaliyet: {wins}G {losses}M" + (f" ({unknown} sonuçsuz)" if unknown else "")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/i18n.py b/ui/i18n.py new file mode 100644 index 0000000..9f0a828 --- /dev/null +++ b/ui/i18n.py @@ -0,0 +1,87 @@ +"""Arayüz metinleri: Türkçe ve İngilizce. + +Qt Linguist yerine düz bir sözlük: metin sayısı az, derleme adımı istemiyoruz +ve dil menüden anında değişebiliyor. Anahtar bulunamazsa anahtarın kendisi +dönüyor, uygulama metin yüzünden çökmesin. +""" + +from __future__ import annotations + +import os + +LANGUAGES = ("tr", "en") +LANGUAGE_NAMES = {"tr": "Türkçe", "en": "English"} + +STRINGS: dict[str, dict[str, str]] = { + "waiting_match": {"tr": "Maç bekleniyor", "en": "Waiting for a match"}, + "deck_empty": { + "tr": "Maça girdiğinde destendeki kartlar burada görünecek.", + "en": "Your deck will show up here once a match starts.", + }, + "opponent_empty": { + "tr": "Rakip henüz kart göstermedi.", + "en": "The opponent has not revealed a card yet.", + }, + "opponent_title": {"tr": "RAKİP", "en": "OPPONENT"}, + "opponent_counts": { + "tr": "el {hand} deste {deck}", + "en": "hand {hand} deck {deck}", + }, + "deck_auto": {"tr": "Otomatik", "en": "Automatic"}, + "deck_tooltip": {"tr": "Takip edilen deste", "en": "Tracked deck"}, + "menu_to_window": {"tr": "Pencere moduna geç", "en": "Switch to window mode"}, + "menu_to_overlay": {"tr": "Overlay moduna geç", "en": "Switch to overlay mode"}, + "menu_opacity": {"tr": "Saydamlık", "en": "Opacity"}, + "opacity_full": {"tr": "Tam", "en": "Full"}, + "opacity_step": {"tr": "%{value}", "en": "{value}%"}, + "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_hide": {"tr": "Gizle (tepsiye)", "en": "Hide to tray"}, + "menu_quit": {"tr": "Çıkış", "en": "Quit"}, + "tray_toggle": {"tr": "Göster / Gizle", "en": "Show / Hide"}, + "tray_mode": {"tr": "Overlay / Pencere", "en": "Overlay / Window"}, + "result_won": {"tr": "Kazandın", "en": "You won"}, + "result_lost": {"tr": "Kaybettin", "en": "You lost"}, + "result_tied": {"tr": "Berabere", "en": "Tie"}, + "error_no_install": { + "tr": "Hearthstone kurulumu bulunamadı.", + "en": "Hearthstone installation not found.", + }, + "error_no_install_hint": { + "tr": "Yolu ~/.config/deste/config.json içinde game_dir olarak belirtin.", + "en": "Set the path as game_dir in ~/.config/deste/config.json.", + }, + "error_log_config": { + "tr": "Log yapılandırması eksik: ", + "en": "Log configuration is incomplete: ", + }, + "error_log_config_hint": { + "tr": "Oyunun logları eksik yazılıyor olabilir.", + "en": "The game may not be writing complete logs.", + }, +} + +_current = "en" + + +def detect(preference: str = "auto") -> str: + """Ayardaki dili çözer. "auto" ise ortamın diline bakar.""" + if preference in LANGUAGES: + return preference + locale = os.environ.get("LC_ALL") or os.environ.get("LANG") or "" + return "tr" if locale.lower().startswith("tr") else "en" + + +def set_language(code: str) -> None: + global _current + _current = code if code in LANGUAGES else detect(code) + + +def current() -> str: + return _current + + +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/icon.py b/ui/icon.py new file mode 100644 index 0000000..0e30a3d --- /dev/null +++ b/ui/icon.py @@ -0,0 +1,77 @@ +"""Uygulama ikonu. + +Önemli ayrıntı: ikonu QIcon.fromTheme ile kurarsak Qt, sistem tepsisine +(StatusNotifierItem) ikonun kendisini değil *adını* gönderiyor. Plasma o adı +kendi ikon önbelleğinden çözmeye çalışıyor ve yeni kurulmuş bir ikonu +bulamayınca tepside boş kare çıkıyor. + +Bu yüzden ikon her zaman dosyadan kuruluyor ve önceden üretilmiş PNG'ler +eklenerek gerçek piksel verisi taşınıyor. Böylece tepsi ikonu, tema önbelleği +ne durumda olursa olsun görünüyor. +""" + +from __future__ import annotations + +from pathlib import Path + +from PyQt6.QtCore import QRectF, QSize, Qt +from PyQt6.QtGui import QColor, QIcon, QPainter, QPixmap + +from . import theme + +ASSETS = Path(__file__).resolve().parent.parent / "assets" +SVG_PATH = ASSETS / "deste.svg" +PNG_DIR = ASSETS / "icons" +PNG_SIZES = (16, 22, 24, 32, 48, 64, 96, 128, 256) + +_cached: QIcon | None = None + + +def _fallback_pixmap(size: int) -> QPixmap: + pixmap = QPixmap(size, size) + pixmap.fill(QColor(0, 0, 0, 0)) + scale = size / 64.0 + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.scale(scale, scale) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(theme.BACKGROUND)) + painter.drawRoundedRect(QRectF(2, 2, 60, 60), 13, 13) + painter.setBrush(QColor(theme.MANA_BLUE)) + painter.drawRoundedRect(QRectF(16, 14, 24, 34), 4, 4) + painter.setBrush(QColor(theme.ACCENT)) + painter.drawRoundedRect(QRectF(25, 13, 25, 36), 4, 4) + painter.end() + return pixmap + + +def app_icon() -> QIcon: + global _cached + if _cached is not None: + return _cached + + icon = QIcon() + added = False + + for size in PNG_SIZES: + png = PNG_DIR / f"deste-{size}.png" + if png.exists(): + pixmap = QPixmap(str(png)) + if not pixmap.isNull(): + icon.addPixmap(pixmap) + added = True + + if not added and SVG_PATH.exists(): + source = QIcon(str(SVG_PATH)) + for size in PNG_SIZES: + pixmap = source.pixmap(QSize(size, size)) + if not pixmap.isNull(): + icon.addPixmap(pixmap) + added = True + + if not added: + for size in PNG_SIZES: + icon.addPixmap(_fallback_pixmap(size)) + + _cached = icon + return _cached diff --git a/ui/theme.py b/ui/theme.py new file mode 100644 index 0000000..0bf3177 --- /dev/null +++ b/ui/theme.py @@ -0,0 +1,133 @@ +"""Görsel tema. Koyu, yarı saydam, oyunun üstünde okunaklı.""" + +from __future__ import annotations + +BACKGROUND = "#0d0f16" +SURFACE = "#171a25" +SURFACE_ALT = "#222738" +ROW_BASE = "#12151f" +TEXT = "#eceef5" +TEXT_DIM = "#8b90a3" +ACCENT = "#e0a63c" +BORDER = "#2b3145" +MANA_BLUE = "#1f4e8c" +WIN = "#5fbf6a" +LOSS = "#d0605a" + +RARITY_COLORS = { + "COMMON": "#c9cedb", + "FREE": "#c9cedb", + "RARE": "#3f7fd8", + "EPIC": "#a951d8", + "LEGENDARY": "#e0a63c", +} + +CLASS_COLORS = { + "DEATHKNIGHT": "#4a6fa5", + "DEMONHUNTER": "#5c9c48", + "DRUID": "#8b5a2b", + "HUNTER": "#3f8f4f", + "MAGE": "#4aa3c7", + "PALADIN": "#d4b84a", + "PRIEST": "#c9cedb", + "ROGUE": "#7d8494", + "SHAMAN": "#3355aa", + "WARLOCK": "#8a5fbf", + "WARRIOR": "#a5453c", + "NEUTRAL": "#8b90a3", +} + +CLASS_NAMES = { + "DEATHKNIGHT": "Death Knight", + "DEMONHUNTER": "Demon Hunter", + "DRUID": "Druid", + "HUNTER": "Hunter", + "MAGE": "Mage", + "PALADIN": "Paladin", + "PRIEST": "Priest", + "ROGUE": "Rogue", + "SHAMAN": "Shaman", + "WARLOCK": "Warlock", + "WARRIOR": "Warrior", + "NEUTRAL": "Neutral", +} + + +def rgb(hex_color: str) -> tuple[int, int, int]: + hex_color = hex_color.lstrip("#") + return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + + +STYLESHEET = f""" +QWidget {{ + color: {TEXT}; + font-size: 12px; +}} +QWidget#topbar {{ + background: rgba(28, 33, 49, 170); + border-bottom: 1px solid {BORDER}; + border-top-left-radius: 9px; + border-top-right-radius: 9px; +}} +QLabel#header {{ + font-size: 13px; + font-weight: 600; +}} +QLabel#subheader {{ + color: {TEXT_DIM}; + font-size: 11px; +}} +QLabel#section {{ + color: {TEXT_DIM}; + font-size: 10px; + font-weight: 600; + letter-spacing: 1px; +}} +QLabel#dim {{ + color: {TEXT_DIM}; +}} +QLabel#count {{ + color: {ACCENT}; + font-weight: 600; +}} +QLabel#empty {{ + color: {TEXT_DIM}; + padding: 10px 4px; +}} +QComboBox, QPushButton, QToolButton {{ + background: rgba(35, 40, 58, 200); + border: 1px solid {BORDER}; + border-radius: 4px; + padding: 3px 8px; +}} +QComboBox:hover, QPushButton:hover, QToolButton:hover {{ + background: {SURFACE_ALT}; + border-color: {ACCENT}; +}} +QComboBox::drop-down {{ border: none; width: 16px; }} +QToolButton#menu {{ padding: 2px 6px; font-size: 15px; }} +QToolButton#menu::menu-indicator {{ image: none; width: 0; }} +QComboBox QAbstractItemView {{ + background: {SURFACE}; + border: 1px solid {BORDER}; + selection-background-color: {SURFACE_ALT}; + outline: none; +}} +QMenu {{ + background: {SURFACE}; + border: 1px solid {BORDER}; + padding: 4px; +}} +QMenu::item {{ padding: 5px 22px 5px 12px; border-radius: 3px; }} +QMenu::item:selected {{ background: {SURFACE_ALT}; }} +QMenu::separator {{ height: 1px; background: {BORDER}; margin: 4px 6px; }} +QScrollArea, QScrollArea > QWidget, QScrollArea > QWidget > QWidget {{ + border: none; + background: transparent; +}} +QScrollBar:vertical {{ background: transparent; width: 5px; margin: 0; }} +QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 2px; min-height: 20px; }} +QScrollBar::handle:vertical:hover {{ background: {TEXT_DIM}; }} +QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }} +QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }} +""" diff --git a/ui/widgets.py b/ui/widgets.py new file mode 100644 index 0000000..97f1944 --- /dev/null +++ b/ui/widgets.py @@ -0,0 +1,406 @@ +"""Kart satırı, kart listesi ve fareyle üzerine gelince açılan kart önizlemesi. + +Satır tasarımı: kart sanatının şeridi (256x59) satırın arka planında durur, +soldan sağa koyu bir gradyanla kararır ki üstündeki yazı okunsun. Solda mana +bedeli, sağda adet. Bu düzen deck tracker'ların oturmuş dili, oyundan gelen +görselle birlikte satır tanınabilir oluyor. +""" + +from __future__ import annotations + +from PyQt6.QtCore import QPoint, QRect, QSize, Qt, QTimer +from PyQt6.QtGui import ( + QColor, + QFont, + QLinearGradient, + QPainter, + QPainterPath, + QPen, + QPixmap, +) +from PyQt6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget + +from data.images import RENDER, TILE + +from . import theme + +ROW_HEIGHT = 26 +COST_WIDTH = 26 +COUNT_WIDTH = 30 +PREVIEW_DELAY_MS = 200 +HIDE_DELAY_MS = 140 +PREVIEW_GAP = 8 + + +class ElidedLabel(QLabel): + """Metni kısaltarak gösteren, genişliği pencereye dayatmayan etiket. + + Düz QLabel uzun metinde sizeHint'ini büyütüyor, üst düzey pencerede bu + minimum genişliğe dönüşüyor ve pencere kendiliğinden genişliyordu (maç + sonucu yazısı geldiğinde panel şişiyordu). Burada sizeHint yok sayılıyor, + metin sığmazsa üç noktayla kesiliyor. + """ + + def __init__(self, text: str = "", parent=None): + super().__init__(parent) + self._full = text + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + self.setMinimumWidth(0) + self._apply() + + def minimumSizeHint(self) -> QSize: # noqa: N802 + return QSize(0, super().minimumSizeHint().height()) + + def set_full_text(self, text: str) -> None: + if text != self._full: + self._full = text + self._apply() + + def resizeEvent(self, event) -> None: # noqa: N802 + super().resizeEvent(event) + self._apply() + + def _apply(self) -> None: + width = self.width() + if width <= 0: + super().setText(self._full) + return + super().setText( + self.fontMetrics().elidedText(self._full, Qt.TextElideMode.ElideRight, width) + ) + + +class CardRow(QWidget): + def __init__(self, images, parent=None): + super().__init__(parent) + self.images = images + self.setFixedHeight(ROW_HEIGHT) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setMouseTracking(True) + self.card_id = "" + self.card_name = "" + self.cost = 0 + self.count = 1 + self.rarity = "" + self.chance = None + self.faded = False + # Panelin saydamlığı satırlara da uygulanır, yoksa oyun yalnızca + # kenarlardan görünür ve overlay opak bir blok gibi durur. + self.opacity = 1.0 + self._pixmap: QPixmap | None = None + self._pixmap_source = "" + + def set_card( + self, + card_id: str, + name: str, + cost: int, + count: int, + rarity: str = "", + chance=None, + faded: bool = False, + ) -> None: + changed = card_id != self.card_id + self.card_id = card_id + self.card_name = name + self.cost = cost + self.count = count + self.rarity = rarity + self.chance = chance + self.faded = faded + if changed: + self._pixmap = None + self._pixmap_source = "" + self.update() + + def _tile(self) -> QPixmap | None: + """Sanat şeridi. Diskte yoksa indirmeye alınır, o ana kadar düz zemin.""" + path = self.images.get(self.card_id, TILE) if self.images else None + if path is None: + return None + if self._pixmap is not None and self._pixmap_source == str(path): + return self._pixmap + pixmap = QPixmap(str(path)) + if pixmap.isNull(): + return None + self._pixmap = pixmap + self._pixmap_source = str(path) + return pixmap + + def paintEvent(self, event) -> None: # noqa: N802 (Qt adlandırması) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + width, height = self.width(), self.height() - 2 + if width <= 0 or height <= 0: + return + + path = QPainterPath() + path.addRoundedRect(0.0, 0.0, float(width), float(height), 3.0, 3.0) + painter.setClipPath(path) + + # Zemin ve sanat panel saydamlığını izler, yazı biraz daha opak kalır + # ki oyunun üstünde okunabilirliği düşmesin. + base_opacity = self.opacity + text_opacity = min(1.0, self.opacity + 0.22) + + painter.setOpacity(base_opacity) + painter.fillRect(0, 0, width, height, QColor(theme.ROW_BASE)) + + tile = self._tile() + if tile is not None: + # Şerit satırın tamamını kaplar, dikeyde ortadan kırpılır. Sağa + # yaslamak satırın ortasında sert bir kesik bırakıyordu. + scaled = tile.scaled( + width, + height, + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation, + ) + top = max((scaled.height() - height) // 2, 0) + painter.setOpacity(base_opacity * (0.62 if self.faded else 0.95)) + painter.drawPixmap( + QRect(0, 0, width, height), scaled, QRect(0, top, width, height) + ) + painter.setOpacity(base_opacity) + + # Soldan sağa kararan gradyan: isim her zaman okunur kalsın. + gradient = QLinearGradient(0, 0, width, 0) + gradient.setColorAt(0.0, QColor(theme.ROW_BASE)) + gradient.setColorAt(0.30, QColor(*theme.rgb(theme.ROW_BASE), 240)) + gradient.setColorAt(0.62, QColor(*theme.rgb(theme.ROW_BASE), 170)) + gradient.setColorAt(1.0, QColor(*theme.rgb(theme.ROW_BASE), 55)) + painter.fillRect(0, 0, width, height, gradient) + + if self.faded: + # Kullanılmış kart: listeden düşmez, kararır. Tamamen silmek yerine + # yerinde bırakmak "bunu çektim" bilgisini görünür tutuyor. + painter.fillRect(0, 0, width, height, QColor(0, 0, 0, 95)) + + # Mana bedeli kutusu + painter.setOpacity(base_opacity) + cost_color = QColor(theme.MANA_BLUE) + painter.fillRect(0, 0, COST_WIDTH, height, cost_color) + font = QFont(self.font()) + font.setBold(True) + font.setPointSizeF(font.pointSizeF() + 0.5) + painter.setFont(font) + painter.setOpacity(text_opacity) + painter.setPen(QPen(QColor("#ffffff" if not self.faded else theme.TEXT_DIM))) + painter.drawText( + QRect(0, 0, COST_WIDTH, height), + int(Qt.AlignmentFlag.AlignCenter), + str(self.cost), + ) + + # Nadirlik şeridi mana kutusunun hemen sağında + painter.fillRect( + COST_WIDTH, 0, 2, height, QColor(theme.RARITY_COLORS.get(self.rarity, theme.TEXT_DIM)) + ) + + # Adet ve yüzde + font.setBold(self.count > 1) + painter.setFont(font) + right_edge = width - 6 + align_right = int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) + + def draw_badge(rect: QRect, text: str, color: str, strong: bool) -> None: + """Sayıyı koyu bir kutunun içine yazar. + + Kart sanatının parlak kısımlarında sadece gölge yetmiyordu, sayılar + kayboluyordu. Arkasına yarı opak bir zemin koyunca her kartta okunur + oluyor. + """ + painter.setOpacity(min(1.0, base_opacity + 0.15)) + badge = QPainterPath() + badge.addRoundedRect( + float(rect.x() - 3), 3.0, float(rect.width() + 6), float(height - 6), 3.0, 3.0 + ) + painter.fillPath(badge, QColor(8, 10, 16, 215 if strong else 185)) + painter.setOpacity(1.0) + painter.setPen(QPen(QColor(color))) + painter.drawText(rect, align_right, text) + + if self.chance is not None: + draw_badge( + QRect(right_edge - 30, 0, 30, height), + f"%{self.chance:.0f}", + theme.TEXT if not self.faded else theme.TEXT_DIM, + False, + ) + right_edge -= 38 + if self.count > 1: + draw_badge( + QRect(right_edge - 16, 0, 16, height), + str(self.count), + theme.ACCENT, + True, + ) + right_edge -= 26 + + # İsim, gölgeli çizilir ki sanatın üstünde kaybolmasın + name_rect = QRect(COST_WIDTH + 8, 0, max(right_edge - COST_WIDTH - 12, 20), height) + font.setBold(False) + painter.setFont(font) + metrics = painter.fontMetrics() + elided = metrics.elidedText(self.card_name, Qt.TextElideMode.ElideRight, name_rect.width()) + painter.setPen(QPen(QColor(0, 0, 0, 190))) + painter.drawText(name_rect.translated(1, 1), int(Qt.AlignmentFlag.AlignVCenter), elided) + painter.setPen(QPen(QColor(theme.TEXT if not self.faded else theme.TEXT_DIM))) + painter.drawText(name_rect, int(Qt.AlignmentFlag.AlignVCenter), elided) + painter.end() + + # --- önizleme ------------------------------------------------------- + + def enterEvent(self, event) -> None: # noqa: N802 + window = self.window() + preview = getattr(window, "preview", None) + if preview is not None and self.card_id: + preview.request(self.card_id, self) + super().enterEvent(event) + + def leaveEvent(self, event) -> None: # noqa: N802 + window = self.window() + preview = getattr(window, "preview", None) + if preview is not None: + preview.cancel() + super().leaveEvent(event) + + +class CardPreview(QWidget): + """Fareyle üzerine gelinen kartın tam görseli. + + Kartın kendi render'ı metnini de içerdiği için ayrıca açıklama yazmaya + gerek kalmıyor. Görsel henüz inmediyse pencere açılmaz, indiği anda çıkar. + """ + + def __init__(self, images, parent=None): + super().__init__(parent, Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint) + self.images = images + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating) + # Önizleme fare olaylarını yutarsa satır "fare çıktı" sanıp gizliyor, + # sonra fare tekrar satıra giriyor ve açılıp kapanma döngüsü oluşuyor. + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self._label = QLabel(self) + self._label.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._label) + self._card_id = "" + self._anchor: QWidget | None = None + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.timeout.connect(self._show_now) + # Satırdan satıra geçerken önizleme kapanıp açılmasın diye gizleme + # kısa bir gecikmeyle yapılıyor; bu arada yeni istek gelirse iptal olur. + self._hide_timer = QTimer(self) + self._hide_timer.setSingleShot(True) + self._hide_timer.timeout.connect(self.hide) + + def request(self, card_id: str, anchor: QWidget) -> None: + self._hide_timer.stop() + if card_id == self._card_id and self.isVisible(): + self._anchor = anchor + return + self._card_id = card_id + self._anchor = anchor + self.images.get(card_id, RENDER) + if self.isVisible(): + # Zaten açıksa gecikmeye gerek yok, doğrudan kartı değiştir. + self._show_now() + else: + self._timer.start(PREVIEW_DELAY_MS) + + def cancel(self) -> None: + self._timer.stop() + self._hide_timer.start(HIDE_DELAY_MS) + + def _show_now(self) -> None: + if not self._card_id or self._anchor is None: + return + path = self.images.get(self._card_id, RENDER) + if path is None: + # Görsel hâlâ iniyor, birazdan tekrar dene. + self._timer.start(400) + return + pixmap = QPixmap(str(path)) + if pixmap.isNull(): + return + self._label.setPixmap(pixmap) + self.resize(pixmap.size()) + + # Konum yalnızca pencerenin kendi koordinatlarından üretilir. + # Wayland'da uygulama pencerenin gerçek ekran konumunu bilmiyor; + # ekran kenarına göre kırpma yapmaya kalkarsak (eski "max(y, 0)") + # önizleme paletin dibine düşüyor. Pencereye göre hesaplayınca + # aradaki bilinmeyen kayma sadeleşiyor, ekran kenarını da bileşik + # yönetici kendisi düzeltiyor. + window = self._anchor.window() + origin = window.mapToGlobal(QPoint(0, 0)) + anchor = self._anchor.mapToGlobal(QPoint(0, 0)) + x = origin.x() - pixmap.width() - PREVIEW_GAP + y = anchor.y() + self._anchor.height() // 2 - pixmap.height() // 2 + top = origin.y() + bottom = max(origin.y() + window.height() - pixmap.height(), top) + self.move(x, min(max(y, top), bottom)) + if not self.isVisible(): + self.show() + self.raise_() + + +class CardList(QWidget): + """Satırları havuzda tutan liste. Her güncellemede widget yaratmak titretir.""" + + def __init__(self, images, parent=None): + super().__init__(parent) + self.images = images + self.opacity = 1.0 + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(2) + self._rows: list[CardRow] = [] + self._empty = QLabel("") + self._empty.setObjectName("empty") + self._empty.setWordWrap(True) + self._empty.setVisible(False) + self._layout.addWidget(self._empty) + self._layout.addStretch(1) + + def set_empty_text(self, text: str) -> None: + self._empty.setText(text) + + def set_opacity(self, value: float) -> None: + self.opacity = value + for row in self._rows: + row.opacity = value + row.update() + + def set_cards(self, entries: list[dict]) -> None: + self._empty.setVisible(not entries) + while len(self._rows) < len(entries): + row = CardRow(self.images, self) + row.opacity = self.opacity + self._rows.append(row) + self._layout.insertWidget(self._layout.count() - 1, row) + for index, row in enumerate(self._rows): + if index < len(entries): + entry = entries[index] + row.set_card( + entry.get("card_id", ""), + entry.get("name", "?"), + entry.get("cost", 0), + entry.get("count", 1), + entry.get("rarity", ""), + entry.get("chance"), + entry.get("faded", False), + ) + row.setVisible(True) + else: + row.setVisible(False) + + def repaint_rows(self) -> None: + """Yeni görsel indiğinde satırları tazeler.""" + for row in self._rows: + if row.isVisible(): + row.update() diff --git a/ui/window.py b/ui/window.py new file mode 100644 index 0000000..50ee940 --- /dev/null +++ b/ui/window.py @@ -0,0 +1,608 @@ +"""Tracker penceresi. + +İki mod: + overlay : çerçevesiz, üstte duran, sürüklenebilir, yarı saydam panel + window : sıradan pencere (başlık çubuğu, Alt+Tab) + +Wayland'da uygulama kendini "her zaman üstte" yapamaz, bunu pencere yöneticisi +belirler; install.sh bunun için bir KWin kuralı yazıyor. +""" + +from __future__ import annotations + +from collections import Counter + +from PyQt6.QtCore import QEvent, QPoint, Qt, QTimer +from PyQt6.QtGui import QAction, QActionGroup, QColor, QIcon, QPainter, QPainterPath +from PyQt6.QtWidgets import ( + QHBoxLayout, + QLabel, + QLayout, + QMenu, + QScrollArea, + QSizeGrip, + QSizePolicy, + QSystemTrayIcon, + QToolButton, + QVBoxLayout, + QWidget, +) + +from core import config +from core.state import Game +from data.images import TILE, ImageCache + +from . import i18n, theme +from .i18n import LANGUAGE_NAMES, LANGUAGES, t +from .icon import app_icon +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] + + +class ClassDot(QLabel): + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(8, 8) + self.set_class("NEUTRAL") + + def set_class(self, player_class: str) -> None: + color = theme.CLASS_COLORS.get(player_class, theme.TEXT_DIM) + self.setStyleSheet(f"background: {color}; border-radius: 4px;") + + +class TrackerWindow(QWidget): + def __init__(self, watcher, library, cards, settings: dict): + super().__init__() + self.watcher = watcher + self.library = library + self.cards = cards + self.settings = settings + self.mode = settings.get("window_mode", "overlay") + self.images = ImageCache() + # Ö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) + self._last_signature = None + # Boyut ve konum değişikliklerini hemen değil, kısa bir gecikmeyle + # kaydediyoruz: sürükleme sırasında her pikselde dosyaya yazmayalım. + self._geometry_timer = QTimer(self) + self._geometry_timer.setSingleShot(True) + self._geometry_timer.timeout.connect(self._save_geometry) + self._last_image_generation = -1 + + self.setWindowTitle("deste") + self.setWindowIcon(app_icon()) + self.setStyleSheet(theme.STYLESHEET) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self._build() + self._build_tray() + self._apply_mode(self.mode, first=True) + + self._apply_opacity() + self.timer = QTimer(self) + self.timer.timeout.connect(self._tick) + self.timer.start(POLL_INTERVAL_MS) + self._refresh(force=True) + + # --- kurulum -------------------------------------------------------- + + def _build(self) -> None: + outer = QVBoxLayout(self) + outer.setContentsMargins(1, 1, 1, 1) + outer.setSpacing(0) + # Yerleşimin minimum boyutu pencereye dayatılmasın: maç sonucu gibi + # yeni bir yazı geldiğinde pencere kendiliğinden genişliyordu ve + # kullanıcı her maçta boyutu elle düzeltmek zorunda kalıyordu. + outer.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint) + self.setMinimumSize(MIN_WIDTH, MIN_HEIGHT) + + # Üst çubuk: başlık ve deste seçimi ayrı bir zeminde dursun ki panel + # düz bir liste yığını gibi görünmesin. + topbar = QWidget() + topbar.setObjectName("topbar") + outer.addWidget(topbar) + top_layout = QVBoxLayout(topbar) + top_layout.setContentsMargins(10, 8, 8, 8) + top_layout.setSpacing(6) + + body = QWidget() + outer.addWidget(body, 1) + layout = QVBoxLayout(body) + layout.setContentsMargins(9, 8, 9, 9) + layout.setSpacing(6) + + # Başlık: sınıf noktaları, tur, menü + header = QHBoxLayout() + header.setSpacing(6) + self.my_dot = ClassDot() + header.addWidget(self.my_dot) + self.header_label = ElidedLabel(t("waiting_match")) + self.header_label.setObjectName("header") + header.addWidget(self.header_label, 1) + # Sabit genişlik: "T12" ile "T12 ✓" arasındaki fark pencereyi büyütmesin. + self.turn_label = QLabel("") + self.turn_label.setObjectName("dim") + self.turn_label.setFixedWidth(48) + self.turn_label.setAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) + header.addWidget(self.turn_label) + + self.menu_button = QToolButton() + self.menu_button.setObjectName("menu") + self.menu_button.setText("⋮") + self.menu = self._build_menu() + self.menu_button.clicked.connect( + lambda: self._popup(self.menu_button, self.menu, align_right=True) + ) + header.addWidget(self.menu_button) + top_layout.addLayout(header) + + # Deste satırı + deck_row = QHBoxLayout() + deck_row.setSpacing(6) + self.deck_button = QToolButton() + self.deck_button.setToolTip(t("deck_tooltip")) + self.deck_button.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + self.deck_button.clicked.connect( + lambda: self._popup(self.deck_button, self.deck_menu, align_right=False) + ) + deck_row.addWidget(self.deck_button, 1) + self.deck_count_label = QLabel("") + self.deck_count_label.setObjectName("count") + deck_row.addWidget(self.deck_count_label) + top_layout.addLayout(deck_row) + self._rebuild_deck_menu() + + # Kendi destem + self.my_list = CardList(self.images) + self.my_list.set_empty_text(t("deck_empty")) + layout.addWidget(self._scrollable(self.my_list), 3) + + # Rakip + opponent_header = QHBoxLayout() + opponent_header.setSpacing(6) + self.opponent_dot = ClassDot() + opponent_header.addWidget(self.opponent_dot) + self.opponent_title = QLabel(t("opponent_title")) + self.opponent_title.setObjectName("section") + opponent_header.addWidget(self.opponent_title, 1) + self.opponent_counts = QLabel("") + self.opponent_counts.setObjectName("dim") + opponent_header.addWidget(self.opponent_counts) + layout.addLayout(opponent_header) + + self.opponent_list = CardList(self.images) + self.opponent_list.set_empty_text(t("opponent_empty")) + layout.addWidget(self._scrollable(self.opponent_list), 2) + + # Overlay modunda pencere çerçevesi yok, boyutlandırmak için köşede + # bir tutamak gerekiyor. Wayland'da startSystemResize üzerinden çalışır. + grip_row = QHBoxLayout() + grip_row.setContentsMargins(0, 0, 0, 0) + grip_row.addStretch(1) + self.size_grip = QSizeGrip(self) + self.size_grip.setFixedSize(14, 14) + self.size_grip.installEventFilter(self) + grip_row.addWidget(self.size_grip, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom) + layout.addLayout(grip_row) + + @staticmethod + def _scrollable(widget: QWidget) -> QScrollArea: + area = QScrollArea() + area.setWidgetResizable(True) + area.setWidget(widget) + area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + area.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + area.viewport().setAutoFillBackground(False) + return area + + def _build_menu(self) -> QMenu: + menu = QMenu(self) + self.mode_action = QAction( + t("menu_to_window") if self.mode == "overlay" else t("menu_to_overlay"), self + ) + self.mode_action.triggered.connect(self._toggle_mode) + menu.addAction(self.mode_action) + + opacity_menu = menu.addMenu(t("menu_opacity")) + group = QActionGroup(self) + group.setExclusive(True) + current = float(self.settings.get("opacity", 0.90)) + for value in OPACITY_STEPS: + label = ( + t("opacity_full") + if value >= 1.0 + else t("opacity_step", value=round(value * 100)) + ) + action = QAction(label, self, checkable=True) + action.setChecked(abs(value - current) < 0.01) + action.triggered.connect(lambda _checked, v=value: self._set_opacity(v)) + group.addAction(action) + opacity_menu.addAction(action) + + self.chance_action = QAction(t("menu_draw_chance"), self, checkable=True) + self.chance_action.setChecked(bool(self.settings.get("show_draw_chance", True))) + self.chance_action.triggered.connect(self._toggle_chance) + menu.addAction(self.chance_action) + + language_menu = menu.addMenu(t("menu_language")) + language_group = QActionGroup(self) + language_group.setExclusive(True) + for code in LANGUAGES: + action = QAction(LANGUAGE_NAMES[code], self, checkable=True) + action.setChecked(code == i18n.current()) + action.triggered.connect(lambda _checked, c=code: self._set_language(c)) + language_group.addAction(action) + language_menu.addAction(action) + + menu.addSeparator() + reload_action = QAction(t("menu_reload_decks"), self) + reload_action.triggered.connect(self._reload_decks) + menu.addAction(reload_action) + + menu.addSeparator() + hide_action = QAction(t("menu_hide"), self) + hide_action.triggered.connect(self.hide) + menu.addAction(hide_action) + quit_action = QAction(t("menu_quit"), self) + quit_action.triggered.connect(self._quit) + menu.addAction(quit_action) + return menu + + def _build_tray(self) -> None: + self.tray = QSystemTrayIcon(app_icon(), self) + self.tray.setToolTip("deste") + self.tray_menu = QMenu() + self._fill_tray_menu() + self.tray.setContextMenu(self.tray_menu) + self.tray.activated.connect(self._on_tray_activated) + self.tray.show() + + def _fill_tray_menu(self) -> None: + self.tray_menu.clear() + show_action = QAction(t("tray_toggle"), self) + show_action.triggered.connect(self._toggle_visible) + self.tray_menu.addAction(show_action) + mode_action = QAction(t("tray_mode"), self) + mode_action.triggered.connect(self._toggle_mode) + self.tray_menu.addAction(mode_action) + self.tray_menu.addSeparator() + quit_action = QAction(t("menu_quit"), self) + quit_action.triggered.connect(self._quit) + self.tray_menu.addAction(quit_action) + + def _set_language(self, code: str) -> None: + """Dili anında değiştirir: menüler ve sabit metinler yeniden kurulur.""" + if code == i18n.current(): + return + i18n.set_language(code) + self.settings["language"] = code + config.save(self.settings) + self.menu = self._build_menu() + self._fill_tray_menu() + self.deck_button.setToolTip(t("deck_tooltip")) + self.my_list.set_empty_text(t("deck_empty")) + self.opponent_list.set_empty_text(t("opponent_empty")) + self.opponent_title.setText(t("opponent_title")) + self._rebuild_deck_menu() + self._refresh(force=True) + + def _on_tray_activated(self, reason) -> None: + if reason == QSystemTrayIcon.ActivationReason.Trigger: + self._toggle_visible() + + def _toggle_visible(self) -> None: + if self.isVisible(): + self.hide() + else: + self.show() + self.raise_() + + def _quit(self) -> None: + self._save_geometry() + self.images.shutdown() + self.tray.hide() + from PyQt6.QtWidgets import QApplication + + QApplication.quit() + + def _rebuild_deck_menu(self) -> None: + menu = QMenu(self) + group = QActionGroup(self) + group.setExclusive(True) + selected = self.settings.get("selected_deck", "") + auto_label = t("deck_auto") + for name in [auto_label] + [d.name for d in self.library.decks]: + action = QAction(name, self, checkable=True) + action.setChecked((name == auto_label and not selected) or name == selected) + action.triggered.connect(lambda _checked, n=name: self._select_deck(n)) + group.addAction(action) + menu.addAction(action) + self.deck_menu = menu + self._set_deck_text(selected or auto_label) + + def _set_deck_text(self, name: str) -> None: + # Ok işareti metnin parçası: menü düğmeye setMenu ile bağlı olmadığı + # için Qt'nin kendi göstergesi çizilmiyor. + self.deck_button.setText(f"{name} ▾") + + def _set_turn_text(self, game: Game | None) -> None: + """Tur sayısı ve maç sonucu. + + Sonuç "WON" yazısıyla değil renkli bir işaretle gösteriliyor: etiket + sabit genişlikte kalsın, yazı geldiğinde panel büyümesin diye. + """ + if game is None: + self.turn_label.setText("") + self.turn_label.setToolTip("") + return + mark, color = RESULT_MARK.get(game.result, ("", "")) + if mark: + self.turn_label.setText( + f'T{game.game_turn} {mark}' + ) + self.turn_label.setToolTip(t(RESULT_TOOLTIP[game.result])) + else: + self.turn_label.setText(f"T{game.game_turn}") + self.turn_label.setToolTip("") + + def _popup(self, button: QToolButton, menu: QMenu, align_right: bool) -> None: + """Menüyü düğmenin altında açar. + + Qt'nin kendi yerleştirmesi menüyü ekrana sığdırmaya çalışırken + pencerenin gerçek konumunu bildiğini varsayıyor. Wayland'da bilmiyor, + o yüzden menü ekranın dibine düşüyordu. Konumu düğmeden türetince + menü her zaman düğmenin altında açılıyor. + """ + width = menu.sizeHint().width() + x = button.width() - width if align_right else 0 + menu.popup(button.mapToGlobal(QPoint(x, button.height() + 3))) + + def _reload_decks(self) -> None: + self.library.load() + self._rebuild_deck_menu() + self._refresh(force=True) + + def _select_deck(self, name: str) -> None: + self.settings["selected_deck"] = "" if name == t("deck_auto") else name + config.save(self.settings) + self._set_deck_text(name) + self._refresh(force=True) + + def _set_opacity(self, value: float) -> None: + self.settings["opacity"] = value + config.save(self.settings) + self._apply_opacity() + + def _apply_opacity(self) -> None: + value = float(self.settings.get("opacity", 0.90)) + self.my_list.set_opacity(value) + self.opponent_list.set_opacity(value) + self.update() + + def _toggle_chance(self) -> None: + self.settings["show_draw_chance"] = self.chance_action.isChecked() + config.save(self.settings) + self._refresh(force=True) + + # --- zemin ---------------------------------------------------------- + + def paintEvent(self, event) -> None: # noqa: N802 + """Yarı saydam, yuvarlatılmış zemin. Oyun arkadan görünsün diye.""" + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + alpha = int(255 * float(self.settings.get("opacity", 0.90))) + path = QPainterPath() + radius = 10.0 if self.mode == "overlay" else 6.0 + path.addRoundedRect(0.0, 0.0, float(self.width()), float(self.height()), radius, radius) + painter.fillPath(path, QColor(*theme.rgb(theme.BACKGROUND), alpha)) + pen_color = QColor(*theme.rgb(theme.BORDER), min(alpha + 40, 255)) + painter.setPen(pen_color) + painter.drawPath(path) + painter.end() + + # --- mod yönetimi --------------------------------------------------- + + def _toggle_mode(self) -> None: + self._save_geometry() + self._apply_mode("window" if self.mode == "overlay" else "overlay") + + def _apply_mode(self, mode: str, first: bool = False) -> None: + self.mode = mode + self.settings["window_mode"] = mode + config.save(self.settings) + + if mode == "overlay": + self.setWindowFlags( + Qt.WindowType.FramelessWindowHint + | Qt.WindowType.Tool + | Qt.WindowType.WindowStaysOnTopHint + ) + self.mode_action.setText(t("menu_to_window")) + else: + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowStaysOnTopHint) + self.mode_action.setText(t("menu_to_overlay")) + + self.size_grip.setVisible(mode == "overlay") + self._restore_geometry() + self.show() + + def _save_geometry(self) -> None: + # Yalnızca boyut saklanır. Wayland'da pencerenin konumunu bileşik + # yönetici belirliyor ve uygulamaya söylemiyor; buradan okunan konum + # gerçeği yansıtmıyor, kaydedip geri yüklersek pencere ekran dışına + # düşmüş gibi davranıyor ve menüler yanlış yere açılıyor. + geometry = self.settings.setdefault("geometry", {}) + rect = self.geometry() + geometry[self.mode] = [rect.width(), rect.height()] + config.save(self.settings) + + def _restore_geometry(self) -> None: + saved = self.settings.get("geometry", {}).get(self.mode) + if saved and len(saved) == 4: # eski biçim: x, y, w, h + saved = saved[2:] + if saved and len(saved) == 2: + self.resize(max(saved[0], MIN_WIDTH), max(saved[1], MIN_HEIGHT)) + else: + self.resize(310, 640) + + def mousePressEvent(self, event) -> None: # noqa: N802 + # Taşımayı pencere yöneticisi yapar. Kendi move() çağrımız Wayland'da + # pencereyi kıpırdatmıyor, sadece Qt'nin konum bilgisini bozuyordu. + if self.mode == "overlay" and event.button() == Qt.MouseButton.LeftButton: + handle = self.windowHandle() + if handle is not None: + handle.startSystemMove() + + def eventFilter(self, obj, event): # noqa: N802 + # Boyut tutamağı bırakıldığında yeni boyutu kaydet. Pencere yöneticisi + # ya da tiling script'i boyutu değiştirdiğinde kaydetmiyoruz, yoksa + # bizim tercihimiz onların dayattığı boyutla eziliyor. + if obj is self.size_grip and event.type() == QEvent.Type.MouseButtonRelease: + self._geometry_timer.start(200) + return super().eventFilter(obj, event) + + def closeEvent(self, event) -> None: # noqa: N802 + # Kapatma tepsiye gizler, uygulama arka planda kalır. + self._save_geometry() + if self.tray.isVisible(): + event.ignore() + self.hide() + else: + super().closeEvent(event) + + # --- güncelleme ----------------------------------------------------- + + def _tick(self) -> None: + changed = self.watcher.poll() + if changed: + self._refresh() + if self.images.generation != self._last_image_generation: + self._last_image_generation = self.images.generation + self.my_list.repaint_rows() + self.opponent_list.repaint_rows() + + def _selected_deck(self, game: Game | None): + name = self.settings.get("selected_deck", "") + if name: + for deck in self.library.decks: + if deck.name == name: + return deck + if game is None: + return None + return self.library.match(game) + + def _entry(self, card_id: str, count: int, chance=None, faded: bool = False) -> dict: + return { + "card_id": card_id, + "name": self.cards.name(card_id), + "cost": self.cards.cost(card_id), + "count": count, + "rarity": self.cards.rarity(card_id), + "chance": chance, + "faded": faded, + } + + def _refresh(self, force: bool = False) -> None: + game = self.watcher.game + signature = self._signature(game) + if not force and signature == self._last_signature: + return + self._last_signature = signature + + if game is None or game.local_player_id is None: + self.header_label.set_full_text(t("waiting_match")) + self._set_turn_text(None) + self.my_dot.set_class("NEUTRAL") + self.opponent_dot.set_class("NEUTRAL") + self.my_list.set_cards([]) + self.opponent_list.set_cards([]) + self.deck_count_label.setText("") + self.opponent_counts.setText("") + return + + my_class = self.cards.card_class(game.hero_card_id(game.local_player_id)) + opponent_class = self.cards.card_class(game.hero_card_id(game.opponent_player_id)) + self.my_dot.set_class(my_class or "NEUTRAL") + self.opponent_dot.set_class(opponent_class or "NEUTRAL") + self.header_label.set_full_text( + f"{theme.CLASS_NAMES.get(my_class, my_class or '?')}" + f" vs {theme.CLASS_NAMES.get(opponent_class, opponent_class or '?')}" + ) + self._set_turn_text(game) + + deck = self._selected_deck(game) + if deck is not None: + if not self.settings.get("selected_deck"): + self._set_deck_text(deck.name) + remaining = game.remaining_deck(deck.cards) + total = sum(remaining.values()) + self.deck_count_label.setText(str(total)) + show_chance = bool(self.settings.get("show_draw_chance", True)) + + # Destede kalmayan kartlar listeden düşmez, solgun gösterilir: + # "bu kartı çektim/oynadım" bilgisi listeden silinince kayboluyor. + all_cards = Counter(deck.cards) + all_cards.update(game.cards_shuffled_in) + entries = [] + for card_id in sorted( + all_cards, key=lambda c: (self.cards.cost(c), self.cards.name(c)) + ): + count = remaining.get(card_id, 0) + entries.append( + self._entry( + card_id, + count, + (100.0 * count / total) if (show_chance and total and count) else None, + faded=count == 0, + ) + ) + self.my_list.set_cards(entries) + self.my_list.set_opacity(float(self.settings.get("opacity", 0.90))) + self.images.prefetch([e["card_id"] for e in entries], TILE) + else: + self.deck_count_label.setText(str(game.my_deck_count)) + self.my_list.set_cards([]) + + self.opponent_counts.setText( + t( + "opponent_counts", + hand=game.opponent_hand_count, + deck=game.opponent_deck_count, + ) + ) + seen: dict[str, int] = {} + for event in game.opponent_events: + if event.card_id: + seen[event.card_id] = seen.get(event.card_id, 0) + 1 + opponent_entries = [ + self._entry(card_id, count) + for card_id, count in sorted( + seen.items(), key=lambda kv: (self.cards.cost(kv[0]), self.cards.name(kv[0])) + ) + ] + self.opponent_list.set_cards(opponent_entries) + self.opponent_list.set_opacity(float(self.settings.get("opacity", 0.90))) + self.images.prefetch([e["card_id"] for e in opponent_entries], TILE) + + @staticmethod + def _signature(game: Game | None): + if game is None: + return None + return ( + id(game), + game.turn, + game.result, + len(game.my_events), + len(game.opponent_events), + game.my_deck_count, + game.opponent_hand_count, + game.opponent_deck_count, + )