Hearthstone deck tracker for Linux, first public version

Reads the game's own Power.log: no screen capture, no image
recognition, no memory reading.

- deck tracking (remaining cards, draw chance, played cards fade in place)
- opponent panel (revealed cards, hand and deck counts)
- decks read from the game's offline cache, matched to the match
- card art in rows, full card on hover
- overlay and window modes, adjustable transparency, tray icon
- English and Turkish interface
- consistency tests over a real log corpus, cross-checked against Zone.log
This commit is contained in:
yusufipk
2026-07-27 22:31:13 +07:00
commit c2a5a2d4d2
41 changed files with 4521 additions and 0 deletions
+511
View File
@@ -0,0 +1,511 @@
"""Oyun durumu makinesi.
Power.log olaylarını alıp varlık tablosunu, bölge geçişlerini ve maç
metadatasını kurar. Tamamen stdlib, UI ve ağ bilmez.
Tasarım notu: olaylar sayılmaz, durum tutulur. Log satırları tekrar edebildiği
için "her DECK -> HAND satırında sayacı bir azalt" yaklaşımı yanlış sonuç verir.
Bunun yerine her varlığın bölgesi saklanır ve yalnızca gerçek bölge değişimi
işlenir.
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from .parser_power import UNKNOWN_PLAYER, EntityRef, Event
DECK = "DECK"
HAND = "HAND"
PLAY = "PLAY"
GRAVEYARD = "GRAVEYARD"
SETASIDE = "SETASIDE"
REMOVEDFROMGAME = "REMOVEDFROMGAME"
SECRET = "SECRET"
# Mulligan bitene kadar süren adımlar. Bunlardan sonrası artık oyun içidir.
OPENING_STEPS = frozenset(
{"BEGIN_FIRST", "BEGIN_SHUFFLE", "BEGIN_DRAW", "BEGIN_MULLIGAN", "INVALID"}
)
@dataclass(slots=True)
class Entity:
entity_id: int
card_id: str = ""
name: str = ""
tags: dict[str, str] = field(default_factory=dict)
# Varlığın oyuna girerken bulunduğu ilk bölge. İlk yerleşim geçiş sayılmaz.
initial_zone: str | None = None
# Maç açılışında (mulligan öncesi) yaratıldı mı? Oyun sırasında desteye
# karıştırılan kartları başlangıç destesinden ayırmak için gerekli.
from_opening: bool = False
# Oyun sırasında destemizden çıkıp da kimliği o an bilinmeyen kartları
# sonradan çözebilmek için işaretliyoruz.
left_deck_unknown: bool = False
# Doğrudan destenin içinde yaratılan kart bir kez sayılsın diye.
counted_shuffle_in: bool = False
@property
def controller(self) -> int | None:
value = self.tags.get("CONTROLLER")
return int(value) if value and value.isdigit() else None
@property
def zone(self) -> str:
return self.tags.get("ZONE", "")
@dataclass(slots=True)
class CardEvent:
"""Arayüzde gösterilecek tek bir kart olayı."""
card_id: str
name: str
turn: int
kind: str # drawn, played, discarded, revealed, mulliganed
entity_id: int = 0
@dataclass
class Game:
started_ts: str = ""
ended_ts: str = ""
meta: dict[str, str] = field(default_factory=dict)
entities: dict[int, Entity] = field(default_factory=dict)
player_entity_ids: dict[int, int] = field(default_factory=dict) # player_id -> entity_id
player_names: dict[int, str] = field(default_factory=dict)
game_entity_id: int | None = None
local_player_id: int | None = None
first_player_id: int | None = None
turn: int = 0
result: str = "" # WON / LOST / TIED
concluded: bool = False
opening_done: bool = False
# Kendi destem. Takip, hangi destenin oynandığından bağımsız yürür:
# sadece "destemden ne çıktı" ve "desteme ne karıştı" kaydedilir, kalan
# liste istenen an bir deste listesine göre hesaplanır. Böylece deste
# maçın ortasında da seçilebilir, sonradan da atanabilir.
deck_list: Counter[str] | None = None
cards_left_deck: Counter[str] = field(default_factory=Counter)
cards_shuffled_in: Counter[str] = field(default_factory=Counter)
unresolved_left_deck: int = 0 # kimliği hiç açığa çıkmadan destemden çıkanlar
my_events: list[CardEvent] = field(default_factory=list)
opponent_events: list[CardEvent] = field(default_factory=list)
@property
def opponent_player_id(self) -> int | None:
if self.local_player_id is None:
return None
return 2 if self.local_player_id == 1 else 1
@property
def my_name(self) -> str:
return self.player_names.get(self.local_player_id or 0, "")
@property
def opponent_name(self) -> str:
return self.player_names.get(self.opponent_player_id or 0, "")
def hero_card_id(self, player_id: int | None) -> str:
"""Oyuncunun kahraman kartı. Sınıf tespiti için kullanılır."""
if player_id is None:
return ""
player_entity_id = self.player_entity_ids.get(player_id)
if player_entity_id is None:
return ""
hero_id = self.entities.get(player_entity_id, Entity(0)).tags.get("HERO_ENTITY")
if hero_id and hero_id.isdigit():
return self.entities.get(int(hero_id), Entity(0)).card_id
return ""
def zone_count(self, player_id: int | None, zone: str) -> int:
if player_id is None:
return 0
return sum(
1
for e in self.entities.values()
if e.controller == player_id and e.zone == zone and e.entity_id != self.game_entity_id
)
@property
def my_deck_count(self) -> int:
return self.zone_count(self.local_player_id, DECK)
@property
def my_hand_count(self) -> int:
return self.zone_count(self.local_player_id, HAND)
@property
def opponent_deck_count(self) -> int:
return self.zone_count(self.opponent_player_id, DECK)
@property
def opponent_hand_count(self) -> int:
return self.zone_count(self.opponent_player_id, HAND)
@property
def game_turn(self) -> int:
"""Oyuncu turu (her iki oyuncunun sırası birlikte bir tur sayılır)."""
return (self.turn + 1) // 2
def set_deck_list(self, cards: Counter[str]) -> None:
self.deck_list = Counter(cards)
def remaining_deck(self, deck_list: Counter[str] | None = None) -> Counter[str]:
"""Destede kaldığı bilinen kartlar.
Çıkan kartlar listeden düşülür, oyun içinde karıştırılanlar eklenir.
Listede olmayan bir kart çıktıysa (yanlış deste seçilmiş olabilir)
eksiye düşmemek için kırpılır.
"""
source = deck_list if deck_list is not None else self.deck_list
if source is None:
return Counter()
remaining = Counter(source)
remaining.update(self.cards_shuffled_in)
remaining.subtract(self.cards_left_deck)
return Counter({card: count for card, count in remaining.items() if count > 0})
def deck_list_mismatch(self, deck_list: Counter[str] | None = None) -> int:
"""Seçilen destede olmayıp destemizden çıkan kart sayısı.
Sıfırdan büyükse büyük ihtimalle yanlış deste seçilidir. Deste otomatik
eşleştirmede aday elemek için kullanılır.
"""
source = deck_list if deck_list is not None else self.deck_list
if source is None:
return 0
available = Counter(source)
available.update(self.cards_shuffled_in)
mismatch = 0
for card, count in self.cards_left_deck.items():
extra = count - available.get(card, 0)
if extra > 0:
mismatch += extra
return mismatch
class Tracker:
"""Olay akışını tüketip Game nesnelerini üretir."""
def __init__(self, on_game_end=None, on_update=None):
self.game: Game | None = None
self.games: list[Game] = []
self._current_entity: Entity | None = None
self._pending_meta: dict[str, str] = {}
self._pending_names: dict[int, str] = {}
# Maçın ilk anlarında hangi oyuncunun biz olduğu daha belli değildir,
# ama kartlar çoktan dağıtılmaya başlar. Bu geçişleri tamponlayıp
# yerel oyuncu belirlenince işliyoruz.
self._pending_zone: list[tuple[Entity, str, str]] = []
self._on_game_end = on_game_end
self._on_update = on_update
# --- olay girişi ---------------------------------------------------
def feed(self, events) -> None:
for event in events:
self.handle(event)
def handle(self, event: Event) -> None:
handler = getattr(self, f"_on_{event.kind}", None)
if handler is not None:
handler(event)
# --- oyun sınırları ------------------------------------------------
def _on_create_game(self, event: Event) -> None:
self._finish_game(event.ts)
self.game = Game(started_ts=event.ts, meta=dict(self._pending_meta))
for player_id, name in self._pending_names.items():
self._set_player_name(self.game, player_id, name)
self._pending_meta.clear()
self._pending_names.clear()
self._pending_zone.clear()
self.games.append(self.game)
self._current_entity = None
def _finish_game(self, ts: str) -> None:
game = self.game
if game is None:
return
game.ended_ts = ts
game.concluded = True
if self._on_game_end is not None:
self._on_game_end(game)
self.game = None
def close(self) -> None:
"""Akış bittiğinde açık maçı kapatır."""
self._finish_game(self.game.ended_ts if self.game else "")
# --- metadata ------------------------------------------------------
def _on_game_meta(self, event: Event) -> None:
key, value = event.data["key"], event.data["value"]
if self.game is not None and not self.game.concluded:
self.game.meta[key] = value
else:
self._pending_meta[key] = value
def _on_player_name(self, event: Event) -> None:
player_id, name = event.data["player_id"], event.data["name"]
if self.game is not None and not self.game.concluded:
self._set_player_name(self.game, player_id, name)
else:
self._pending_names[player_id] = name
def _set_player_name(self, game: Game, player_id: int, name: str) -> None:
game.player_names[player_id] = name
# Maç başında yalnızca bizim adımız bilinir, rakip UNKNOWN HUMAN PLAYER
# olarak yazılır. Yerel oyuncuyu bu farktan tespit ediyoruz. İki isim
# hangi sırada gelirse gelsin çalışması için her seferinde yeniden
# değerlendiriyoruz.
if game.local_player_id is not None:
return
known = [pid for pid, n in game.player_names.items() if n != UNKNOWN_PLAYER]
unknown = [pid for pid, n in game.player_names.items() if n == UNKNOWN_PLAYER]
if len(known) == 1 and len(unknown) == 1:
game.local_player_id = known[0]
self._flush_pending_zone()
def _flush_pending_zone(self) -> None:
"""Yerel oyuncu belirlendi, bekleyen bölge geçişlerini şimdi işle."""
pending, self._pending_zone = self._pending_zone, []
for entity, old_zone, new_zone in pending:
self._dispatch_zone_change(entity, old_zone, new_zone)
# --- varlıklar -----------------------------------------------------
def _entity(self, entity_id: int) -> Entity:
game = self.game
assert game is not None
entity = game.entities.get(entity_id)
if entity is None:
entity = Entity(entity_id)
game.entities[entity_id] = entity
return entity
def _resolve(self, ref: EntityRef) -> Entity | None:
game = self.game
if game is None:
return None
if ref.entity_id is not None:
entity = self._entity(ref.entity_id)
if ref.card_id and not entity.card_id:
self._set_card_id(entity, ref.card_id)
if ref.name and not entity.name:
entity.name = ref.name
return entity
if ref.name == "GameEntity":
return self._entity(game.game_entity_id) if game.game_entity_id else None
if ref.name:
for player_id, name in game.player_names.items():
if name == ref.name:
entity_id = game.player_entity_ids.get(player_id)
if entity_id is not None:
return self._entity(entity_id)
return None
def _on_game_entity(self, event: Event) -> None:
if self.game is None:
return
entity = self._entity(event.data["entity_id"])
self.game.game_entity_id = entity.entity_id
self._current_entity = entity
def _on_player_entity(self, event: Event) -> None:
if self.game is None:
return
entity = self._entity(event.data["entity_id"])
self.game.player_entity_ids[event.data["player_id"]] = entity.entity_id
self._current_entity = entity
def _on_full_entity(self, event: Event) -> None:
if self.game is None:
return
ref = event.data.get("ref")
entity_id = event.data.get("entity_id")
entity = self._entity(entity_id) if entity_id is not None else self._resolve(ref)
if entity is None:
return
if event.data.get("card_id"):
self._set_card_id(entity, event.data["card_id"])
self._current_entity = entity
def _on_show_entity(self, event: Event) -> None:
if self.game is None:
return
entity = self._resolve(event.data["ref"])
if entity is None:
return
if event.data.get("card_id"):
self._set_card_id(entity, event.data["card_id"])
self._current_entity = entity
def _on_hide_entity(self, event: Event) -> None:
if self.game is None:
return
entity = self._resolve(event.data["ref"])
if entity is None:
return
self._apply_tag(entity, event.data["tag"], event.data["value"])
def _on_entity_tag(self, event: Event) -> None:
if self.game is None or self._current_entity is None:
return
self._apply_tag(self._current_entity, event.data["tag"], event.data["value"])
def _on_tag_change(self, event: Event) -> None:
if self.game is None:
return
entity = self._resolve(event.data["ref"])
if entity is None:
return
self._apply_tag(entity, event.data["tag"], event.data["value"])
self._current_entity = None
# --- etiket uygulaması ---------------------------------------------
def _apply_tag(self, entity: Entity, tag: str, value: str) -> None:
game = self.game
assert game is not None
if tag == "ZONE":
self._apply_zone(entity, value)
return
entity.tags[tag] = value
if tag == "CONTROLLER":
# Kontrolcü artık biliniyor, destede yaratılmış kartı şimdi sayabiliriz.
self._maybe_register_shuffle_in(entity)
if tag == "TURN" and entity.entity_id == game.game_entity_id and value.isdigit():
game.turn = int(value)
elif tag == "FIRST_PLAYER" and value == "1":
for player_id, entity_id in game.player_entity_ids.items():
if entity_id == entity.entity_id:
game.first_player_id = player_id
elif tag == "STEP" and value not in OPENING_STEPS:
game.opening_done = True
elif tag == "PLAYSTATE" and value in ("WON", "LOST", "TIED"):
for player_id, entity_id in game.player_entity_ids.items():
if entity_id == entity.entity_id and player_id == game.local_player_id:
game.result = value
def _apply_zone(self, entity: Entity, new_zone: str) -> None:
game = self.game
assert game is not None
old_zone = entity.tags.get("ZONE")
entity.tags["ZONE"] = new_zone
if old_zone is None:
# İlk yerleşim, geçiş değil.
entity.initial_zone = new_zone
entity.from_opening = not game.opening_done
self._maybe_register_shuffle_in(entity)
return
if old_zone == new_zone:
return
if game.local_player_id is None:
# Maçın ilk anları: kimin kim olduğu henüz belli değil, sonra işlenecek.
self._pending_zone.append((entity, old_zone, new_zone))
return
self._dispatch_zone_change(entity, old_zone, new_zone)
def _dispatch_zone_change(self, entity: Entity, old_zone: str, new_zone: str) -> None:
game = self.game
assert game is not None
controller = entity.controller
if controller is None:
return
if controller == game.local_player_id:
self._track_my_zone_change(entity, old_zone, new_zone)
elif controller == game.opponent_player_id:
self._track_opponent_zone_change(entity, old_zone, new_zone)
def _track_my_zone_change(self, entity: Entity, old_zone: str, new_zone: str) -> None:
game = self.game
assert game is not None
if old_zone == DECK:
self._remove_from_deck(entity)
if new_zone == HAND:
game.my_events.append(
CardEvent(entity.card_id, entity.name, game.game_turn, "drawn", entity.entity_id)
)
elif new_zone == DECK:
# Oyun sırasında desteye karıştırılan kart.
if entity.card_id:
game.cards_shuffled_in[entity.card_id] += 1
if old_zone == HAND and new_zone == PLAY:
game.my_events.append(
CardEvent(entity.card_id, entity.name, game.game_turn, "played", entity.entity_id)
)
def _track_opponent_zone_change(self, entity: Entity, old_zone: str, new_zone: str) -> None:
game = self.game
assert game is not None
if old_zone == HAND and new_zone in (PLAY, SECRET):
kind = "played"
elif old_zone == HAND and new_zone == GRAVEYARD:
kind = "discarded"
else:
return
game.opponent_events.append(
CardEvent(entity.card_id, entity.name, game.game_turn, kind, entity.entity_id)
)
def _maybe_register_shuffle_in(self, entity: Entity) -> None:
"""Oyun sırasında doğrudan destenin içinde yaratılan kartı listeye ekler.
Tracking gibi etkiler kartı desteye "karıştırırken" yeni bir varlık
yaratır, bu varlık HAND -> DECK geçişi yapmaz. Saymazsak kart daha
sonra desteden çıktığında kalan liste eksiye kayar.
"""
game = self.game
if game is None or entity.counted_shuffle_in:
return
if entity.initial_zone != DECK or entity.from_opening:
return
if entity.controller is None or entity.controller != game.local_player_id:
return
if not entity.card_id:
return
entity.counted_shuffle_in = True
game.cards_shuffled_in[entity.card_id] += 1
def _remove_from_deck(self, entity: Entity) -> None:
"""Kart destemizden çıktı. Kimliği henüz bilinmiyorsa işaretle."""
game = self.game
assert game is not None
if not entity.card_id:
if not entity.left_deck_unknown:
entity.left_deck_unknown = True
game.unresolved_left_deck += 1
return
game.cards_left_deck[entity.card_id] += 1
def _set_card_id(self, entity: Entity, card_id: str) -> None:
"""Kart kimliği sonradan açığa çıktığında geciken düşümü tamamla."""
entity.card_id = card_id
game = self.game
if game is not None and game.local_player_id is None:
# Yedek tespit: isimlerden çözülemediyse (yeniden bağlanma gibi),
# elindeki kartı bize açık olan oyuncu biziz.
if entity.zone == HAND and entity.controller is not None:
game.local_player_id = entity.controller
self._flush_pending_zone()
self._maybe_register_shuffle_in(entity)
if entity.left_deck_unknown:
entity.left_deck_unknown = False
if game is not None:
game.unresolved_left_deck -= 1
self._remove_from_deck(entity)