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
View File
+37
View File
@@ -0,0 +1,37 @@
"""Kullanıcı ayarları. ~/.config/deste/config.json"""
from __future__ import annotations
import json
import os
from pathlib import Path
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "deste"
CONFIG_PATH = CONFIG_DIR / "config.json"
DEFAULTS = {
"game_dir": "",
"window_mode": "overlay", # overlay veya window
"opacity": 0.92,
"geometry": {}, # mod -> [genişlik, yükseklik]
"show_draw_chance": True,
"selected_deck": "", # boş ise otomatik eşleştirme
"language": "auto", # auto, tr, en
}
def load() -> dict:
data = dict(DEFAULTS)
if CONFIG_PATH.exists():
try:
data.update(json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError):
pass
return data
def save(data: dict) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
tmp = CONFIG_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(CONFIG_PATH)
+202
View File
@@ -0,0 +1,202 @@
"""Hearthstone deste kodu (deckstring) çözücü ve kodlayıcı.
Biçim: base64 içinde varint akışı.
0x00, sürüm(1), format, kahraman sayısı + kahramanlar,
tek kopyalı kart sayısı + kartlar,
çift kopyalı kart sayısı + kartlar,
n kopyalı kart sayısı + (kart, adet) çiftleri,
isteğe bağlı sideboard bölümü (ETC gibi kartlar için).
Kartlar dbfId ile tutulur, kart kimliğine çevirmek için data.cards gerekir.
Tamamen stdlib.
"""
from __future__ import annotations
import base64
from dataclasses import dataclass, field
DECKSTRING_VERSION = 1
FORMAT_UNKNOWN = 0
FORMAT_WILD = 1
FORMAT_STANDARD = 2
FORMAT_CLASSIC = 3
FORMAT_TWIST = 4
FORMAT_NAMES = {
FORMAT_UNKNOWN: "UNKNOWN",
FORMAT_WILD: "WILD",
FORMAT_STANDARD: "STANDARD",
FORMAT_CLASSIC: "CLASSIC",
FORMAT_TWIST: "TWIST",
}
class DeckstringError(ValueError):
"""Geçersiz ya da bozuk deste kodu."""
@dataclass(slots=True)
class Deck:
cards: list[tuple[int, int]] = field(default_factory=list) # (dbfId, adet)
heroes: list[int] = field(default_factory=list)
format_id: int = FORMAT_UNKNOWN
# (dbfId, adet, sahip kartın dbfId'si)
sideboards: list[tuple[int, int, int]] = field(default_factory=list)
@property
def format_name(self) -> str:
return FORMAT_NAMES.get(self.format_id, "UNKNOWN")
@property
def card_count(self) -> int:
return sum(count for _, count in self.cards)
def as_counter(self) -> dict[int, int]:
result: dict[int, int] = {}
for dbf_id, count in self.cards:
result[dbf_id] = result.get(dbf_id, 0) + count
return result
class _Reader:
def __init__(self, data: bytes):
self.data = data
self.index = 0
def byte(self) -> int:
if self.index >= len(self.data):
raise DeckstringError("deste kodu beklenenden kısa")
value = self.data[self.index]
self.index += 1
return value
def varint(self) -> int:
value = 0
shift = 0
while True:
byte = self.byte()
value |= (byte & 0x7F) << shift
if not byte & 0x80:
return value
shift += 7
if shift > 63:
raise DeckstringError("bozuk varint")
@property
def at_end(self) -> bool:
return self.index >= len(self.data)
def _write_varint(chunks: list[bytes], value: int) -> None:
while True:
piece = value & 0x7F
value >>= 7
if value:
chunks.append(bytes((piece | 0x80,)))
else:
chunks.append(bytes((piece,)))
return
def parse(deckstring: str) -> Deck:
"""Deste kodunu çözer. Geçersizse DeckstringError fırlatır."""
text = deckstring.strip()
if not text:
raise DeckstringError("boş deste kodu")
# Oyundan kopyalanan kodlarda başta yorum satırları olabiliyor.
for line in text.splitlines():
line = line.strip()
if line and not line.startswith("#"):
text = line
break
try:
raw = base64.b64decode(text + "=" * (-len(text) % 4))
except (ValueError, TypeError) as exc:
raise DeckstringError("base64 çözülemedi") from exc
reader = _Reader(raw)
if reader.byte() != 0:
raise DeckstringError("geçersiz deste kodu başlığı")
version = reader.varint()
if version != DECKSTRING_VERSION:
raise DeckstringError(f"desteklenmeyen sürüm: {version}")
deck = Deck(format_id=reader.varint())
for _ in range(reader.varint()):
deck.heroes.append(reader.varint())
deck.heroes.sort()
for _ in range(reader.varint()):
deck.cards.append((reader.varint(), 1))
for _ in range(reader.varint()):
deck.cards.append((reader.varint(), 2))
for _ in range(reader.varint()):
card = reader.varint()
deck.cards.append((card, reader.varint()))
deck.cards.sort()
# Sideboard bölümü isteğe bağlı, eski kodlarda hiç yok.
if not reader.at_end and reader.byte() == 1:
for _ in range(reader.varint()):
deck.sideboards.append((reader.varint(), 1, reader.varint()))
for _ in range(reader.varint()):
deck.sideboards.append((reader.varint(), 2, reader.varint()))
for _ in range(reader.varint()):
card = reader.varint()
count = reader.varint()
deck.sideboards.append((card, count, reader.varint()))
deck.sideboards.sort(key=lambda item: (item[2], item[0]))
return deck
def write(deck: Deck) -> str:
"""Desteyi tekrar deste koduna çevirir."""
chunks: list[bytes] = [b"\0"]
_write_varint(chunks, DECKSTRING_VERSION)
_write_varint(chunks, deck.format_id)
_write_varint(chunks, len(deck.heroes))
for hero in sorted(deck.heroes):
_write_varint(chunks, hero)
singles = sorted(c for c, n in deck.cards if n == 1)
doubles = sorted(c for c, n in deck.cards if n == 2)
others = sorted((c, n) for c, n in deck.cards if n not in (1, 2))
_write_varint(chunks, len(singles))
for card in singles:
_write_varint(chunks, card)
_write_varint(chunks, len(doubles))
for card in doubles:
_write_varint(chunks, card)
_write_varint(chunks, len(others))
for card, count in others:
_write_varint(chunks, card)
_write_varint(chunks, count)
if deck.sideboards:
chunks.append(b"\1")
for wanted in (1, 2):
group = sorted(
(card, owner) for card, count, owner in deck.sideboards if count == wanted
)
_write_varint(chunks, len(group))
for card, owner in group:
_write_varint(chunks, card)
_write_varint(chunks, owner)
group_n = sorted(
(card, count, owner)
for card, count, owner in deck.sideboards
if count not in (1, 2)
)
_write_varint(chunks, len(group_n))
for card, count, owner in group_n:
_write_varint(chunks, card)
_write_varint(chunks, count)
_write_varint(chunks, owner)
return base64.b64encode(b"".join(chunks)).decode("ascii")
+161
View File
@@ -0,0 +1,161 @@
"""Hearthstone kurulumunu, log dizinini ve log.config durumunu bulur.
Oyun Wine/Proton prefix'i içinde çalıştığı için yol sabit değil. Önce
yapılandırma dosyasına, sonra bilinen prefix konumlarına bakılır.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
LOG_DIR_RE = re.compile(r"^Hearthstone_\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2}$")
# Gerekli log bileşenleri. Power için Verbose=1 şart, yoksa oyun durumu eksik gelir.
REQUIRED_COMPONENTS = {
"Power": {"LogLevel": "1", "FilePrinting": "true", "Verbose": "1"},
"Zone": {"LogLevel": "1", "FilePrinting": "true"},
"LoadingScreen": {"LogLevel": "1", "FilePrinting": "true"},
"Arena": {"LogLevel": "1", "FilePrinting": "true"},
}
# Prefix içindeki oyun dizini, kullanıcıya göre değişen kısımlar glob ile.
PREFIX_GLOBS = (
"~/Games/*/drive_c/Program Files (x86)/Hearthstone",
"~/Games/*/*/drive_c/Program Files (x86)/Hearthstone",
"~/.wine/drive_c/Program Files (x86)/Hearthstone",
"~/.local/share/lutris/prefixes/*/drive_c/Program Files (x86)/Hearthstone",
"~/.steam/steam/steamapps/compatdata/*/pfx/drive_c/Program Files (x86)/Hearthstone",
)
CONFIG_PATH = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "deste" / "config.json"
@dataclass(slots=True)
class Installation:
game_dir: Path
logs_dir: Path
log_config: Path | None
client_config: Path
def _expand(pattern: str) -> list[Path]:
pattern = os.path.expanduser(pattern)
root = Path("/")
parts = Path(pattern).parts[1:]
try:
return sorted(root.glob(str(Path(*parts))))
except (OSError, ValueError):
return []
def find_game_dir(configured: str | None = None) -> Path | None:
"""Hearthstone kurulum dizinini bulur."""
if configured:
path = Path(configured).expanduser()
if (path / "Hearthstone.exe").exists():
return path
for pattern in PREFIX_GLOBS:
for candidate in _expand(pattern):
if (candidate / "Hearthstone.exe").exists():
return candidate
return None
def find_log_config(game_dir: Path) -> Path | None:
"""log.config dosyasını prefix içindeki kullanıcı dizininde arar."""
# .../drive_c/Program Files (x86)/Hearthstone -> .../drive_c
drive_c = game_dir.parent.parent
users = drive_c / "users"
if not users.is_dir():
return None
for user_dir in users.iterdir():
candidate = user_dir / "AppData" / "Local" / "Blizzard" / "Hearthstone" / "log.config"
if candidate.parent.is_dir():
return candidate
return None
def read_config() -> dict:
if CONFIG_PATH.exists():
try:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return {}
def detect(configured_dir: str | None = None) -> Installation | None:
if configured_dir is None:
configured_dir = read_config().get("game_dir")
game_dir = find_game_dir(configured_dir)
if game_dir is None:
return None
return Installation(
game_dir=game_dir,
logs_dir=game_dir / "Logs",
log_config=find_log_config(game_dir),
client_config=game_dir / "client.config",
)
def latest_log_dir(logs_dir: Path) -> Path | None:
"""En son oturumun log dizini. Oyun her açılışta yenisini oluşturur."""
if not logs_dir.is_dir():
return None
candidates = [d for d in logs_dir.iterdir() if d.is_dir() and LOG_DIR_RE.match(d.name)]
if not candidates:
return None
return max(candidates, key=lambda d: d.stat().st_mtime)
def parse_log_config(path: Path) -> dict[str, dict[str, str]]:
sections: dict[str, dict[str, str]] = {}
current: dict[str, str] | None = None
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return sections
for line in text.splitlines():
line = line.strip()
if line.startswith("[") and line.endswith("]"):
current = {}
sections[line[1:-1]] = current
elif "=" in line and current is not None:
key, _, value = line.partition("=")
current[key.strip()] = value.strip()
return sections
def check_log_config(path: Path | None) -> list[str]:
"""Eksik veya hatalı log.config ayarlarını insan okur biçimde döner."""
if path is None or not path.exists():
return ["log.config bulunamadı"]
sections = parse_log_config(path)
problems: list[str] = []
for component, required in REQUIRED_COMPONENTS.items():
section = sections.get(component)
if section is None:
problems.append(f"[{component}] bölümü eksik")
continue
for key, value in required.items():
actual = section.get(key)
if actual is None:
problems.append(f"[{component}] {key} eksik")
elif actual.lower() != value.lower():
problems.append(f"[{component}] {key}={actual}, beklenen {value}")
return problems
def check_client_config(path: Path) -> list[str]:
"""Log boyut limiti açıksa uzun oturumlarda log kesiliyor."""
if not path.exists():
return ["client.config yok, log boyut limiti kaldırılmamış"]
sections = parse_log_config(path)
limit = sections.get("Log", {}).get("FileSizeLimit.Int")
if limit != "-1":
return [f"client.config FileSizeLimit.Int={limit}, beklenen -1"]
return []
+92
View File
@@ -0,0 +1,92 @@
"""Log dosyalarını offset tabanlı takip eder.
Oyun her açılışta yeni bir oturum dizini yaratıp dosyaları sıfırdan yazar.
Dosya küçüldüyse ya da inode değiştiyse baştan okunur. Yarım kalan son satır
tamamlanana kadar tamponda bekletilir.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
@dataclass(slots=True)
class TailedFile:
path: Path
offset: int = 0
inode: int | None = None
buffer: str = ""
# Uygulama oyun açıkken başlatıldıysa geçmiş satırları tekrar işlememek için
# dosyanın sonuna atlayabiliyoruz.
start_at_end: bool = False
_started: bool = field(default=False, repr=False)
def read_new_lines(self) -> list[str]:
try:
stat = self.path.stat()
except OSError:
return []
if self.inode is None:
self.inode = stat.st_ino
if self.start_at_end and not self._started:
self.offset = stat.st_size
elif stat.st_ino != self.inode or stat.st_size < self.offset:
# Yeni oturum ya da dosya kırpıldı.
self.inode = stat.st_ino
self.offset = 0
self.buffer = ""
self._started = True
if stat.st_size == self.offset:
return []
try:
with self.path.open("r", encoding="utf-8", errors="replace") as handle:
handle.seek(self.offset)
chunk = handle.read()
self.offset = handle.tell()
except OSError:
return []
if not chunk:
return []
data = self.buffer + chunk
lines = data.split("\n")
self.buffer = lines.pop()
return lines
class LogSet:
"""Bir oturum dizinindeki ilgili log dosyalarını birlikte takip eder."""
def __init__(self, log_dir: Path, components=("Power",), start_at_end: bool = False):
self.log_dir = log_dir
self.files = {
name: TailedFile(log_dir / f"{name}.log", start_at_end=start_at_end)
for name in components
}
def read_new_lines(self, component: str = "Power") -> list[str]:
tailed = self.files.get(component)
return tailed.read_new_lines() if tailed else []
def read_all_components(self) -> dict[str, list[str]]:
return {name: tailed.read_new_lines() for name, tailed in self.files.items()}
def read_file_lines(path: Path):
"""Tam dosyayı satır satır okur. Replay ve testler için."""
with path.open("r", encoding="utf-8", errors="replace") as handle:
for line in handle:
yield line.rstrip("\n")
def file_size(path: Path) -> int:
try:
return os.stat(path).st_size
except OSError:
return 0
+218
View File
@@ -0,0 +1,218 @@
"""Power.log satır ayrıştırıcısı.
Sadece ``GameState.*`` satırları işlenir. ``PowerTaskList.*`` satırları aynı
içeriğin istemci tarafındaki kopyasıdır, ikisi birden işlenirse her olay iki
kere sayılır. Logda 74927 GameState satırına karşılık 74384 PowerTaskList
satırı var, yani kopya hacmi ciddi.
Ayrıştırıcı durum tutmaz, sadece satırı olaya çevirir. Oyun durumu core.state
tarafından kurulur.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
# D 18:50:06.6268596 GameState.DebugPrintPower() - TAG_CHANGE Entity=3 tag=TURN value=1
LINE_RE = re.compile(
r"^\w \s*(?P<ts>\d+:\d+:\d+\.\d+) (?P<cls>\w+)\.(?P<fn>\w+)\(\) - (?P<indent>\s*)(?P<body>.*?)\s*$"
)
# [entityName=Confront the Tol'vir id=35 zone=HAND zonePos=0 cardId=CATA_560 player=2]
ENTITY_BRACKET_RE = re.compile(
r"\[entityName=(?P<name>.*?) id=(?P<id>\d+) zone=(?P<zone>\w+) "
r"zonePos=(?P<pos>-?\d+) cardId=(?P<card>\w*) player=(?P<player>\d+)\]"
)
FULL_ENTITY_CREATING_RE = re.compile(r"^FULL_ENTITY - Creating ID=(?P<id>\d+) CardID=(?P<card>\w*)$")
FULL_ENTITY_UPDATING_RE = re.compile(r"^FULL_ENTITY - Updating (?P<ref>.+?) CardID=(?P<card>\w*)$")
SHOW_ENTITY_RE = re.compile(r"^SHOW_ENTITY - Updating (?P<ref>.+?) CardID=(?P<card>\w*)$")
HIDE_ENTITY_RE = re.compile(r"^HIDE_ENTITY - Entity=(?P<ref>.+?) tag=(?P<tag>\w+) value=(?P<value>\w+)$")
CHANGE_ENTITY_RE = re.compile(r"^CHANGE_ENTITY - Updating (?P<ref>.+?) CardID=(?P<card>\w*)$")
TAG_CHANGE_RE = re.compile(
r"^TAG_CHANGE Entity=(?P<ref>.+?) tag=(?P<tag>\w+) value=(?P<value>-?\w+)(?P<rest>.*)$"
)
TAG_LINE_RE = re.compile(r"^tag=(?P<tag>\w+) value=(?P<value>-?\w+)$")
GAME_ENTITY_RE = re.compile(r"^GameEntity EntityID=(?P<id>\d+)$")
PLAYER_ENTITY_RE = re.compile(r"^Player EntityID=(?P<id>\d+) PlayerID=(?P<pid>\d+)")
BLOCK_START_RE = re.compile(
r"^BLOCK_START BlockType=(?P<type>\w+) Entity=(?P<ref>.+?) EffectCardId=.*?"
r"EffectIndex=(?P<effect>-?\d+) Target=(?P<target>.+?) SubOption=(?P<sub>-?\d+)"
)
PLAYER_NAME_RE = re.compile(r"^PlayerID=(?P<pid>\d+), PlayerName=(?P<name>.+)$")
KEY_VALUE_RE = re.compile(r"^(?P<key>BuildNumber|GameType|FormatType|ScenarioID)=(?P<value>.+)$")
UNKNOWN_PLAYER = "UNKNOWN HUMAN PLAYER"
@dataclass(slots=True)
class Event:
"""Ayrıştırılmış tek bir log olayı."""
kind: str
ts: str
indent: int
data: dict = field(default_factory=dict)
@dataclass(slots=True)
class EntityRef:
"""TAG_CHANGE gibi satırlarda varlığa yapılan atıf.
Üç biçimde gelebilir: sayısal id, oyuncu adı (Oyuncu#12345), ya da köşeli
parantezli tam blok. Çözümlemeyi core.state yapar, burada sadece taşınır.
"""
entity_id: int | None = None
name: str | None = None
card_id: str = ""
zone: str = ""
player: int | None = None
def parse_entity_ref(raw: str) -> EntityRef:
raw = raw.strip()
m = ENTITY_BRACKET_RE.search(raw)
if m:
return EntityRef(
entity_id=int(m.group("id")),
name=m.group("name"),
card_id=m.group("card"),
zone=m.group("zone"),
player=int(m.group("player")),
)
if raw.isdigit():
return EntityRef(entity_id=int(raw))
if raw == "GameEntity":
return EntityRef(name="GameEntity")
return EntityRef(name=raw)
def parse_line(line: str) -> Event | None:
"""Tek bir Power.log satırını olaya çevirir, ilgisizse None döner."""
m = LINE_RE.match(line)
if not m:
return None
if m.group("cls") != "GameState":
return None
ts = m.group("ts")
indent = len(m.group("indent"))
body = m.group("body")
fn = m.group("fn")
if fn == "DebugPrintGame":
return _parse_game_meta(ts, indent, body)
if fn == "DebugPrintPower":
return _parse_power(ts, indent, body)
return None
def _parse_game_meta(ts: str, indent: int, body: str) -> Event | None:
m = KEY_VALUE_RE.match(body)
if m:
return Event("game_meta", ts, indent, {"key": m.group("key"), "value": m.group("value")})
m = PLAYER_NAME_RE.match(body)
if m:
return Event(
"player_name",
ts,
indent,
{"player_id": int(m.group("pid")), "name": m.group("name")},
)
return None
def _parse_power(ts: str, indent: int, body: str) -> Event | None:
if body == "CREATE_GAME":
return Event("create_game", ts, indent)
m = FULL_ENTITY_CREATING_RE.match(body)
if m:
return Event(
"full_entity",
ts,
indent,
{"entity_id": int(m.group("id")), "card_id": m.group("card")},
)
m = FULL_ENTITY_UPDATING_RE.match(body) or CHANGE_ENTITY_RE.match(body)
if m:
ref = parse_entity_ref(m.group("ref"))
return Event("full_entity", ts, indent, {"ref": ref, "card_id": m.group("card")})
m = SHOW_ENTITY_RE.match(body)
if m:
ref = parse_entity_ref(m.group("ref").removeprefix("Entity="))
return Event("show_entity", ts, indent, {"ref": ref, "card_id": m.group("card")})
m = HIDE_ENTITY_RE.match(body)
if m:
return Event(
"hide_entity",
ts,
indent,
{
"ref": parse_entity_ref(m.group("ref")),
"tag": m.group("tag"),
"value": m.group("value"),
},
)
m = TAG_CHANGE_RE.match(body)
if m:
return Event(
"tag_change",
ts,
indent,
{
"ref": parse_entity_ref(m.group("ref")),
"tag": m.group("tag"),
"value": m.group("value"),
},
)
m = GAME_ENTITY_RE.match(body)
if m:
return Event("game_entity", ts, indent, {"entity_id": int(m.group("id"))})
m = PLAYER_ENTITY_RE.match(body)
if m:
return Event(
"player_entity",
ts,
indent,
{"entity_id": int(m.group("id")), "player_id": int(m.group("pid"))},
)
m = TAG_LINE_RE.match(body)
if m:
# Bir üstteki varlık bloğuna ait etiket satırı.
return Event("entity_tag", ts, indent, {"tag": m.group("tag"), "value": m.group("value")})
m = BLOCK_START_RE.match(body)
if m:
return Event(
"block_start",
ts,
indent,
{
"block_type": m.group("type"),
"ref": parse_entity_ref(m.group("ref")),
"target": parse_entity_ref(m.group("target")),
},
)
if body == "BLOCK_END":
return Event("block_end", ts, indent)
return None
def parse_lines(lines):
"""Satır dizisini olay dizisine çevirir."""
for line in lines:
event = parse_line(line)
if event is not None:
yield event
+511
View File
@@ -0,0 +1,511 @@
"""Oyun durumu makinesi.
Power.log olaylarını alıp varlık tablosunu, bölge geçişlerini ve maç
metadatasını kurar. Tamamen stdlib, UI ve ağ bilmez.
Tasarım notu: olaylar sayılmaz, durum tutulur. Log satırları tekrar edebildiği
için "her DECK -> HAND satırında sayacı bir azalt" yaklaşımı yanlış sonuç verir.
Bunun yerine her varlığın bölgesi saklanır ve yalnızca gerçek bölge değişimi
işlenir.
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from .parser_power import UNKNOWN_PLAYER, EntityRef, Event
DECK = "DECK"
HAND = "HAND"
PLAY = "PLAY"
GRAVEYARD = "GRAVEYARD"
SETASIDE = "SETASIDE"
REMOVEDFROMGAME = "REMOVEDFROMGAME"
SECRET = "SECRET"
# Mulligan bitene kadar süren adımlar. Bunlardan sonrası artık oyun içidir.
OPENING_STEPS = frozenset(
{"BEGIN_FIRST", "BEGIN_SHUFFLE", "BEGIN_DRAW", "BEGIN_MULLIGAN", "INVALID"}
)
@dataclass(slots=True)
class Entity:
entity_id: int
card_id: str = ""
name: str = ""
tags: dict[str, str] = field(default_factory=dict)
# Varlığın oyuna girerken bulunduğu ilk bölge. İlk yerleşim geçiş sayılmaz.
initial_zone: str | None = None
# Maç açılışında (mulligan öncesi) yaratıldı mı? Oyun sırasında desteye
# karıştırılan kartları başlangıç destesinden ayırmak için gerekli.
from_opening: bool = False
# Oyun sırasında destemizden çıkıp da kimliği o an bilinmeyen kartları
# sonradan çözebilmek için işaretliyoruz.
left_deck_unknown: bool = False
# Doğrudan destenin içinde yaratılan kart bir kez sayılsın diye.
counted_shuffle_in: bool = False
@property
def controller(self) -> int | None:
value = self.tags.get("CONTROLLER")
return int(value) if value and value.isdigit() else None
@property
def zone(self) -> str:
return self.tags.get("ZONE", "")
@dataclass(slots=True)
class CardEvent:
"""Arayüzde gösterilecek tek bir kart olayı."""
card_id: str
name: str
turn: int
kind: str # drawn, played, discarded, revealed, mulliganed
entity_id: int = 0
@dataclass
class Game:
started_ts: str = ""
ended_ts: str = ""
meta: dict[str, str] = field(default_factory=dict)
entities: dict[int, Entity] = field(default_factory=dict)
player_entity_ids: dict[int, int] = field(default_factory=dict) # player_id -> entity_id
player_names: dict[int, str] = field(default_factory=dict)
game_entity_id: int | None = None
local_player_id: int | None = None
first_player_id: int | None = None
turn: int = 0
result: str = "" # WON / LOST / TIED
concluded: bool = False
opening_done: bool = False
# Kendi destem. Takip, hangi destenin oynandığından bağımsız yürür:
# sadece "destemden ne çıktı" ve "desteme ne karıştı" kaydedilir, kalan
# liste istenen an bir deste listesine göre hesaplanır. Böylece deste
# maçın ortasında da seçilebilir, sonradan da atanabilir.
deck_list: Counter[str] | None = None
cards_left_deck: Counter[str] = field(default_factory=Counter)
cards_shuffled_in: Counter[str] = field(default_factory=Counter)
unresolved_left_deck: int = 0 # kimliği hiç açığa çıkmadan destemden çıkanlar
my_events: list[CardEvent] = field(default_factory=list)
opponent_events: list[CardEvent] = field(default_factory=list)
@property
def opponent_player_id(self) -> int | None:
if self.local_player_id is None:
return None
return 2 if self.local_player_id == 1 else 1
@property
def my_name(self) -> str:
return self.player_names.get(self.local_player_id or 0, "")
@property
def opponent_name(self) -> str:
return self.player_names.get(self.opponent_player_id or 0, "")
def hero_card_id(self, player_id: int | None) -> str:
"""Oyuncunun kahraman kartı. Sınıf tespiti için kullanılır."""
if player_id is None:
return ""
player_entity_id = self.player_entity_ids.get(player_id)
if player_entity_id is None:
return ""
hero_id = self.entities.get(player_entity_id, Entity(0)).tags.get("HERO_ENTITY")
if hero_id and hero_id.isdigit():
return self.entities.get(int(hero_id), Entity(0)).card_id
return ""
def zone_count(self, player_id: int | None, zone: str) -> int:
if player_id is None:
return 0
return sum(
1
for e in self.entities.values()
if e.controller == player_id and e.zone == zone and e.entity_id != self.game_entity_id
)
@property
def my_deck_count(self) -> int:
return self.zone_count(self.local_player_id, DECK)
@property
def my_hand_count(self) -> int:
return self.zone_count(self.local_player_id, HAND)
@property
def opponent_deck_count(self) -> int:
return self.zone_count(self.opponent_player_id, DECK)
@property
def opponent_hand_count(self) -> int:
return self.zone_count(self.opponent_player_id, HAND)
@property
def game_turn(self) -> int:
"""Oyuncu turu (her iki oyuncunun sırası birlikte bir tur sayılır)."""
return (self.turn + 1) // 2
def set_deck_list(self, cards: Counter[str]) -> None:
self.deck_list = Counter(cards)
def remaining_deck(self, deck_list: Counter[str] | None = None) -> Counter[str]:
"""Destede kaldığı bilinen kartlar.
Çıkan kartlar listeden düşülür, oyun içinde karıştırılanlar eklenir.
Listede olmayan bir kart çıktıysa (yanlış deste seçilmiş olabilir)
eksiye düşmemek için kırpılır.
"""
source = deck_list if deck_list is not None else self.deck_list
if source is None:
return Counter()
remaining = Counter(source)
remaining.update(self.cards_shuffled_in)
remaining.subtract(self.cards_left_deck)
return Counter({card: count for card, count in remaining.items() if count > 0})
def deck_list_mismatch(self, deck_list: Counter[str] | None = None) -> int:
"""Seçilen destede olmayıp destemizden çıkan kart sayısı.
Sıfırdan büyükse büyük ihtimalle yanlış deste seçilidir. Deste otomatik
eşleştirmede aday elemek için kullanılır.
"""
source = deck_list if deck_list is not None else self.deck_list
if source is None:
return 0
available = Counter(source)
available.update(self.cards_shuffled_in)
mismatch = 0
for card, count in self.cards_left_deck.items():
extra = count - available.get(card, 0)
if extra > 0:
mismatch += extra
return mismatch
class Tracker:
"""Olay akışını tüketip Game nesnelerini üretir."""
def __init__(self, on_game_end=None, on_update=None):
self.game: Game | None = None
self.games: list[Game] = []
self._current_entity: Entity | None = None
self._pending_meta: dict[str, str] = {}
self._pending_names: dict[int, str] = {}
# Maçın ilk anlarında hangi oyuncunun biz olduğu daha belli değildir,
# ama kartlar çoktan dağıtılmaya başlar. Bu geçişleri tamponlayıp
# yerel oyuncu belirlenince işliyoruz.
self._pending_zone: list[tuple[Entity, str, str]] = []
self._on_game_end = on_game_end
self._on_update = on_update
# --- olay girişi ---------------------------------------------------
def feed(self, events) -> None:
for event in events:
self.handle(event)
def handle(self, event: Event) -> None:
handler = getattr(self, f"_on_{event.kind}", None)
if handler is not None:
handler(event)
# --- oyun sınırları ------------------------------------------------
def _on_create_game(self, event: Event) -> None:
self._finish_game(event.ts)
self.game = Game(started_ts=event.ts, meta=dict(self._pending_meta))
for player_id, name in self._pending_names.items():
self._set_player_name(self.game, player_id, name)
self._pending_meta.clear()
self._pending_names.clear()
self._pending_zone.clear()
self.games.append(self.game)
self._current_entity = None
def _finish_game(self, ts: str) -> None:
game = self.game
if game is None:
return
game.ended_ts = ts
game.concluded = True
if self._on_game_end is not None:
self._on_game_end(game)
self.game = None
def close(self) -> None:
"""Akış bittiğinde açık maçı kapatır."""
self._finish_game(self.game.ended_ts if self.game else "")
# --- metadata ------------------------------------------------------
def _on_game_meta(self, event: Event) -> None:
key, value = event.data["key"], event.data["value"]
if self.game is not None and not self.game.concluded:
self.game.meta[key] = value
else:
self._pending_meta[key] = value
def _on_player_name(self, event: Event) -> None:
player_id, name = event.data["player_id"], event.data["name"]
if self.game is not None and not self.game.concluded:
self._set_player_name(self.game, player_id, name)
else:
self._pending_names[player_id] = name
def _set_player_name(self, game: Game, player_id: int, name: str) -> None:
game.player_names[player_id] = name
# Maç başında yalnızca bizim adımız bilinir, rakip UNKNOWN HUMAN PLAYER
# olarak yazılır. Yerel oyuncuyu bu farktan tespit ediyoruz. İki isim
# hangi sırada gelirse gelsin çalışması için her seferinde yeniden
# değerlendiriyoruz.
if game.local_player_id is not None:
return
known = [pid for pid, n in game.player_names.items() if n != UNKNOWN_PLAYER]
unknown = [pid for pid, n in game.player_names.items() if n == UNKNOWN_PLAYER]
if len(known) == 1 and len(unknown) == 1:
game.local_player_id = known[0]
self._flush_pending_zone()
def _flush_pending_zone(self) -> None:
"""Yerel oyuncu belirlendi, bekleyen bölge geçişlerini şimdi işle."""
pending, self._pending_zone = self._pending_zone, []
for entity, old_zone, new_zone in pending:
self._dispatch_zone_change(entity, old_zone, new_zone)
# --- varlıklar -----------------------------------------------------
def _entity(self, entity_id: int) -> Entity:
game = self.game
assert game is not None
entity = game.entities.get(entity_id)
if entity is None:
entity = Entity(entity_id)
game.entities[entity_id] = entity
return entity
def _resolve(self, ref: EntityRef) -> Entity | None:
game = self.game
if game is None:
return None
if ref.entity_id is not None:
entity = self._entity(ref.entity_id)
if ref.card_id and not entity.card_id:
self._set_card_id(entity, ref.card_id)
if ref.name and not entity.name:
entity.name = ref.name
return entity
if ref.name == "GameEntity":
return self._entity(game.game_entity_id) if game.game_entity_id else None
if ref.name:
for player_id, name in game.player_names.items():
if name == ref.name:
entity_id = game.player_entity_ids.get(player_id)
if entity_id is not None:
return self._entity(entity_id)
return None
def _on_game_entity(self, event: Event) -> None:
if self.game is None:
return
entity = self._entity(event.data["entity_id"])
self.game.game_entity_id = entity.entity_id
self._current_entity = entity
def _on_player_entity(self, event: Event) -> None:
if self.game is None:
return
entity = self._entity(event.data["entity_id"])
self.game.player_entity_ids[event.data["player_id"]] = entity.entity_id
self._current_entity = entity
def _on_full_entity(self, event: Event) -> None:
if self.game is None:
return
ref = event.data.get("ref")
entity_id = event.data.get("entity_id")
entity = self._entity(entity_id) if entity_id is not None else self._resolve(ref)
if entity is None:
return
if event.data.get("card_id"):
self._set_card_id(entity, event.data["card_id"])
self._current_entity = entity
def _on_show_entity(self, event: Event) -> None:
if self.game is None:
return
entity = self._resolve(event.data["ref"])
if entity is None:
return
if event.data.get("card_id"):
self._set_card_id(entity, event.data["card_id"])
self._current_entity = entity
def _on_hide_entity(self, event: Event) -> None:
if self.game is None:
return
entity = self._resolve(event.data["ref"])
if entity is None:
return
self._apply_tag(entity, event.data["tag"], event.data["value"])
def _on_entity_tag(self, event: Event) -> None:
if self.game is None or self._current_entity is None:
return
self._apply_tag(self._current_entity, event.data["tag"], event.data["value"])
def _on_tag_change(self, event: Event) -> None:
if self.game is None:
return
entity = self._resolve(event.data["ref"])
if entity is None:
return
self._apply_tag(entity, event.data["tag"], event.data["value"])
self._current_entity = None
# --- etiket uygulaması ---------------------------------------------
def _apply_tag(self, entity: Entity, tag: str, value: str) -> None:
game = self.game
assert game is not None
if tag == "ZONE":
self._apply_zone(entity, value)
return
entity.tags[tag] = value
if tag == "CONTROLLER":
# Kontrolcü artık biliniyor, destede yaratılmış kartı şimdi sayabiliriz.
self._maybe_register_shuffle_in(entity)
if tag == "TURN" and entity.entity_id == game.game_entity_id and value.isdigit():
game.turn = int(value)
elif tag == "FIRST_PLAYER" and value == "1":
for player_id, entity_id in game.player_entity_ids.items():
if entity_id == entity.entity_id:
game.first_player_id = player_id
elif tag == "STEP" and value not in OPENING_STEPS:
game.opening_done = True
elif tag == "PLAYSTATE" and value in ("WON", "LOST", "TIED"):
for player_id, entity_id in game.player_entity_ids.items():
if entity_id == entity.entity_id and player_id == game.local_player_id:
game.result = value
def _apply_zone(self, entity: Entity, new_zone: str) -> None:
game = self.game
assert game is not None
old_zone = entity.tags.get("ZONE")
entity.tags["ZONE"] = new_zone
if old_zone is None:
# İlk yerleşim, geçiş değil.
entity.initial_zone = new_zone
entity.from_opening = not game.opening_done
self._maybe_register_shuffle_in(entity)
return
if old_zone == new_zone:
return
if game.local_player_id is None:
# Maçın ilk anları: kimin kim olduğu henüz belli değil, sonra işlenecek.
self._pending_zone.append((entity, old_zone, new_zone))
return
self._dispatch_zone_change(entity, old_zone, new_zone)
def _dispatch_zone_change(self, entity: Entity, old_zone: str, new_zone: str) -> None:
game = self.game
assert game is not None
controller = entity.controller
if controller is None:
return
if controller == game.local_player_id:
self._track_my_zone_change(entity, old_zone, new_zone)
elif controller == game.opponent_player_id:
self._track_opponent_zone_change(entity, old_zone, new_zone)
def _track_my_zone_change(self, entity: Entity, old_zone: str, new_zone: str) -> None:
game = self.game
assert game is not None
if old_zone == DECK:
self._remove_from_deck(entity)
if new_zone == HAND:
game.my_events.append(
CardEvent(entity.card_id, entity.name, game.game_turn, "drawn", entity.entity_id)
)
elif new_zone == DECK:
# Oyun sırasında desteye karıştırılan kart.
if entity.card_id:
game.cards_shuffled_in[entity.card_id] += 1
if old_zone == HAND and new_zone == PLAY:
game.my_events.append(
CardEvent(entity.card_id, entity.name, game.game_turn, "played", entity.entity_id)
)
def _track_opponent_zone_change(self, entity: Entity, old_zone: str, new_zone: str) -> None:
game = self.game
assert game is not None
if old_zone == HAND and new_zone in (PLAY, SECRET):
kind = "played"
elif old_zone == HAND and new_zone == GRAVEYARD:
kind = "discarded"
else:
return
game.opponent_events.append(
CardEvent(entity.card_id, entity.name, game.game_turn, kind, entity.entity_id)
)
def _maybe_register_shuffle_in(self, entity: Entity) -> None:
"""Oyun sırasında doğrudan destenin içinde yaratılan kartı listeye ekler.
Tracking gibi etkiler kartı desteye "karıştırırken" yeni bir varlık
yaratır, bu varlık HAND -> DECK geçişi yapmaz. Saymazsak kart daha
sonra desteden çıktığında kalan liste eksiye kayar.
"""
game = self.game
if game is None or entity.counted_shuffle_in:
return
if entity.initial_zone != DECK or entity.from_opening:
return
if entity.controller is None or entity.controller != game.local_player_id:
return
if not entity.card_id:
return
entity.counted_shuffle_in = True
game.cards_shuffled_in[entity.card_id] += 1
def _remove_from_deck(self, entity: Entity) -> None:
"""Kart destemizden çıktı. Kimliği henüz bilinmiyorsa işaretle."""
game = self.game
assert game is not None
if not entity.card_id:
if not entity.left_deck_unknown:
entity.left_deck_unknown = True
game.unresolved_left_deck += 1
return
game.cards_left_deck[entity.card_id] += 1
def _set_card_id(self, entity: Entity, card_id: str) -> None:
"""Kart kimliği sonradan açığa çıktığında geciken düşümü tamamla."""
entity.card_id = card_id
game = self.game
if game is not None and game.local_player_id is None:
# Yedek tespit: isimlerden çözülemediyse (yeniden bağlanma gibi),
# elindeki kartı bize açık olan oyuncu biziz.
if entity.zone == HAND and entity.controller is not None:
game.local_player_id = entity.controller
self._flush_pending_zone()
self._maybe_register_shuffle_in(entity)
if entity.left_deck_unknown:
entity.left_deck_unknown = False
if game is not None:
game.unresolved_left_deck -= 1
self._remove_from_deck(entity)
+70
View File
@@ -0,0 +1,70 @@
"""Canlı log takibi.
Oyun açıkken en yeni oturum dizinini bulur, Power.log'a eklenen satırları
okuyup durum makinesini besler. Kendi zamanlayıcısı yoktur: poll() çağrısını
arayüz (QTimer) ya da terminal döngüsü yapar. Böylece Qt'ye bağımlı değil.
"""
from __future__ import annotations
from pathlib import Path
from . import logdir, logtail
from .parser_power import parse_lines
from .state import Game, Tracker
# Yeni oturum dizini kontrolü her poll'da yapılmasın, dosya sistemi taraması.
LOG_DIR_CHECK_INTERVAL = 20
class Watcher:
def __init__(
self,
install: logdir.Installation,
on_game_end=None,
on_update=None,
start_at_end: bool = True,
):
self.install = install
self.on_update = on_update
self.tracker = Tracker(on_game_end=on_game_end)
self.log_dir: Path | None = None
self.log_set: logtail.LogSet | None = None
# Uygulama oyunun ortasında açılırsa geçmiş maçları tekrar işlememek
# için dosyanın sonundan başlıyoruz. Devam eden maçın başı kaçar, bu
# yüzden bir sonraki maçtan itibaren takip tam olur.
self.start_at_end = start_at_end
self._checks = 0
@property
def game(self) -> Game | None:
return self.tracker.game
def poll(self) -> bool:
"""Yeni satır varsa işler. Durum değiştiyse True döner."""
if self._checks % LOG_DIR_CHECK_INTERVAL == 0:
self._refresh_log_dir()
self._checks += 1
if self.log_set is None:
return False
lines = self.log_set.read_new_lines("Power")
if not lines:
return False
self.tracker.feed(parse_lines(lines))
if self.on_update is not None:
self.on_update(self.tracker.game)
return True
def _refresh_log_dir(self) -> None:
latest = logdir.latest_log_dir(self.install.logs_dir)
if latest is None or latest == self.log_dir:
return
# Yeni oturum: oyun yeniden açılmış.
self.log_dir = latest
self.log_set = logtail.LogSet(
latest, components=("Power",), start_at_end=self.start_at_end
)
# İlk dizinden sonrakiler baştan okunmalı, oyun yeni açıldığı için
# dosya zaten boştur ve maçın başını kaçırmak istemeyiz.
self.start_at_end = False