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
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 555 B |
|
After Width: | Height: | Size: 801 B |
|
After Width: | Height: | Size: 833 B |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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 []
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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, {})
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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:]))
|
||||||
@@ -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 "$@"
|
||||||
@@ -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:]))
|
||||||
@@ -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:]))
|
||||||
@@ -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())
|
||||||
@@ -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:]))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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; }}
|
||||||
|
"""
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||