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:
@@ -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")
|
||||
+161
@@ -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
|
||||
+511
@@ -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
|
||||
Reference in New Issue
Block a user