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
This commit is contained in:
yusufipk
2026-07-27 22:31:13 +07:00
commit c2a5a2d4d2
41 changed files with 4521 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.venv/
+226
View File
@@ -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:
- `<prefix>/users/<user>/AppData/Local/Blizzard/Hearthstone/log.config` with
`[Power] Verbose=1`, `[Zone]`, `[LoadingScreen]`, `[Arena]`
- `<game dir>/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 <log_dir> --deck # replay a recorded session
python -m tests.test_replay <log_dir> # 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.
+221
View File
@@ -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:
- `<prefix>/users/<kullanıcı>/AppData/Local/Blizzard/Hearthstone/log.config`
içinde `[Power] Verbose=1`, `[Zone]`, `[LoadingScreen]`, `[Arena]`
- `<oyun dizini>/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 <log_dizini> --deck # kayıtlı oturumu baştan oynat
python -m tests.test_replay <log_dizini> # 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.
+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1b2033"/>
<stop offset="1" stop-color="#0d0f16"/>
</linearGradient>
<linearGradient id="front" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e0a63c"/>
<stop offset="1" stop-color="#b57c1f"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="13" fill="url(#bg)"/>
<g transform="rotate(-14 32 34)">
<rect x="14" y="16" width="24" height="34" rx="4" fill="#2b3145"/>
</g>
<g transform="rotate(-6 32 34)">
<rect x="19" y="14" width="24" height="34" rx="4" fill="#1f4e8c"/>
</g>
<rect x="24" y="13" width="25" height="36" rx="4" fill="url(#front)"/>
<rect x="27.5" y="17" width="18" height="15" rx="2" fill="#0d0f16" opacity="0.55"/>
<circle cx="27" cy="16" r="5" fill="#0d0f16"/>
<circle cx="27" cy="16" r="3.6" fill="#4aa3c7"/>
</svg>

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File
+37
View File
@@ -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)
+202
View File
@@ -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")
+161
View File
@@ -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 []
+92
View File
@@ -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
+218
View File
@@ -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<ts>\d+:\d+:\d+\.\d+) (?P<cls>\w+)\.(?P<fn>\w+)\(\) - (?P<indent>\s*)(?P<body>.*?)\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<name>.*?) id=(?P<id>\d+) zone=(?P<zone>\w+) "
r"zonePos=(?P<pos>-?\d+) cardId=(?P<card>\w*) player=(?P<player>\d+)\]"
)
FULL_ENTITY_CREATING_RE = re.compile(r"^FULL_ENTITY - Creating ID=(?P<id>\d+) CardID=(?P<card>\w*)$")
FULL_ENTITY_UPDATING_RE = re.compile(r"^FULL_ENTITY - Updating (?P<ref>.+?) CardID=(?P<card>\w*)$")
SHOW_ENTITY_RE = re.compile(r"^SHOW_ENTITY - Updating (?P<ref>.+?) CardID=(?P<card>\w*)$")
HIDE_ENTITY_RE = re.compile(r"^HIDE_ENTITY - Entity=(?P<ref>.+?) tag=(?P<tag>\w+) value=(?P<value>\w+)$")
CHANGE_ENTITY_RE = re.compile(r"^CHANGE_ENTITY - Updating (?P<ref>.+?) CardID=(?P<card>\w*)$")
TAG_CHANGE_RE = re.compile(
r"^TAG_CHANGE Entity=(?P<ref>.+?) tag=(?P<tag>\w+) value=(?P<value>-?\w+)(?P<rest>.*)$"
)
TAG_LINE_RE = re.compile(r"^tag=(?P<tag>\w+) value=(?P<value>-?\w+)$")
GAME_ENTITY_RE = re.compile(r"^GameEntity EntityID=(?P<id>\d+)$")
PLAYER_ENTITY_RE = re.compile(r"^Player EntityID=(?P<id>\d+) PlayerID=(?P<pid>\d+)")
BLOCK_START_RE = re.compile(
r"^BLOCK_START BlockType=(?P<type>\w+) Entity=(?P<ref>.+?) EffectCardId=.*?"
r"EffectIndex=(?P<effect>-?\d+) Target=(?P<target>.+?) SubOption=(?P<sub>-?\d+)"
)
PLAYER_NAME_RE = re.compile(r"^PlayerID=(?P<pid>\d+), PlayerName=(?P<name>.+)$")
KEY_VALUE_RE = re.compile(r"^(?P<key>BuildNumber|GameType|FormatType|ScenarioID)=(?P<value>.+)$")
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
+511
View File
@@ -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)
+70
View File
@@ -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
View File
+96
View File
@@ -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, {})
+174
View File
@@ -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)
+92
View File
@@ -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)
+250
View File
@@ -0,0 +1,250 @@
"""Oyunun çevrimdışı önbelleğinden destelerimizi okur.
Hearthstone, hesabın destelerini şu dosyada saklıyor:
<prefix>/users/<kullanıcı>/AppData/Local/Blizzard/Hearthstone/Cache/Offline/
offlineData_<hi>_<lo>_REGION_<bölge>.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("<I", data, index)
index += 4
if count == 0 or count > MAX_RECORDS_PER_SECTION:
return
for _ in range(count):
if index + 4 > len(data):
return
(length,) = struct.unpack_from("<I", data, index)
index += 4
if length == 0 or index + length > 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/<isim> ç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
Executable
+209
View File
@@ -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" <<EOF
[Desktop Entry]
Type=Application
Name=deste
GenericName=Hearthstone deck tracker
Comment=Hearthstone destesini ve rakibin kartlarını takip eder
Exec=$REPO_DIR/run.sh
Icon=$APP_ID
Terminal=false
Categories=Game;Utility;
Keywords=hearthstone;deck;tracker;deste;
StartupNotify=false
StartupWMClass=$APP_ID
EOF
chmod +x "$DESKTOP_FILE"
info "kısayol: $DESKTOP_FILE"
if [ -d "$USER_DESKTOP" ]; then
install -m755 "$DESKTOP_FILE" "$USER_DESKTOP/$APP_ID.desktop"
# Plasma masaüstündeki kısayolun çalışması için güvenilir işaretlenmeli
if command -v gio >/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"
+63
View File
@@ -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:]))
Executable
+5
View File
@@ -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 "$@"
View File
+240
View File
@@ -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<ts>\d+:\d+:\d+\.\d+) ZoneChangeList\.ProcessChanges\(\) - .*"
r"\[entityName=(?P<name>.*?) id=(?P<id>\d+) zone=\w+ zonePos=-?\d+ "
r"cardId=(?P<card>\w*) player=\d+\] zone from (?P<src>[A-Z ]+) -> (?P<dst>[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:]))
View File
+122
View File
@@ -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:]))
+57
View File
@@ -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())
+138
View File
@@ -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 <log_dizini>
python -m tools.replay <log_dizini> --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:]))
View File
+87
View File
@@ -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
+77
View File
@@ -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
+133
View File
@@ -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; }}
"""
+406
View File
@@ -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()
+608
View File
@@ -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} <span style="color:{color}">{mark}</span>'
)
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,
)