mirror of
https://github.com/yusufipk/deste-hearthstone-linux-tracker.git
synced 2026-09-11 10:46:12 +00:00
Hearthstone deck tracker for Linux, first public version
Reads the game's own Power.log: no screen capture, no image recognition, no memory reading. - deck tracking (remaining cards, draw chance, played cards fade in place) - opponent panel (revealed cards, hand and deck counts) - decks read from the game's offline cache, matched to the match - card art in rows, full card on hover - overlay and window modes, adjustable transparency, tray icon - English and Turkish interface - consistency tests over a real log corpus, cross-checked against Zone.log
This commit is contained in:
+122
@@ -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())
|
||||
+138
@@ -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:]))
|
||||
Reference in New Issue
Block a user