Add MTGA Assistant: native Linux deck and game tracker
PySide6 app that follows MTG Arena's Player.log under Steam/Proton and shows my remaining library with draw odds, my zones, and everything revealed about the opponent (board, graveyard, exile, known hand/library, seen this match). - Auto-detects the Steam library, Proton prefix log and MTGA install - Card names/costs/types from MTGA's local SQLite card database - Tracks card identity across zone changes, shuffles and Bo3 games - Scryfall card images with a lazy, size-capped LRU disk cache - Live log following with restart detection, plus --replay mode - Flags log gaps where MTGA omits oversized game-state messages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
734336168c
commit
7dc2ce0da8
21 files changed
+2736
No files matched your search
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
@@ -1,2 +1,64 @@
|
||||
# mtga-assistant
|
||||
|
||||
A native Linux deck and game tracker for **Magic: The Gathering Arena** running under Steam/Proton.
|
||||
It follows MTGA's `Player.log`, much like Untapped.gg and other trackers do, and shows:
|
||||
|
||||
- **My deck**: remaining library with draw odds, hand, battlefield, graveyard, exile, sideboard.
|
||||
Between matches, it shows the last deck you submitted.
|
||||
- **Opponent**: battlefield, stack, graveyard, exile, cards *known* to be in their hand or library
|
||||
(cards you saw that went back into a hidden zone, or were revealed), and every card seen this match.
|
||||
- **Card preview**: hover a card to see its image from [Scryfall](https://scryfall.com).
|
||||
|
||||
Card names, mana costs and types come from MTGA's own local card database, so they work offline.
|
||||
|
||||
## Requirements
|
||||
|
||||
- MTGA installed through Steam (Proton). The log, install and card DB are auto-detected from your
|
||||
Steam library folders.
|
||||
- **Detailed logs enabled** in MTGA: *Options → Account → Detailed Logs (Plugin Support)*.
|
||||
- [uv](https://docs.astral.sh/uv/) (`pacman -S uv`).
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
uv run mtga-assistant # follow the live Player.log
|
||||
uv run mtga-assistant --replay path/to/Player-prev.log --rate 40 # replay a saved log
|
||||
uv run pytest # tests
|
||||
```
|
||||
|
||||
Start it before or after MTGA: on startup it reads the existing log to catch up on a match in
|
||||
progress, then follows new lines. When MTGA restarts and replaces the log, the tracker resets.
|
||||
|
||||
On Wayland the window's app id is `mtga-assistant`. For example, to keep it floating on another
|
||||
workspace in Hyprland:
|
||||
|
||||
```
|
||||
windowrule = float, class:^(mtga-assistant)$
|
||||
windowrule = workspace 2, class:^(mtga-assistant)$
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Optional: `~/.config/mtga-assistant/config.toml`
|
||||
|
||||
```toml
|
||||
log_path = "/path/to/Player.log" # override auto-detection
|
||||
mtga_dir = "/path/to/steamapps/common/MTGA"
|
||||
cache_max_mb = 500 # image cache cap
|
||||
```
|
||||
|
||||
## Image cache
|
||||
|
||||
Images are downloaded only for cards that actually appear, at Scryfall's `normal` size (about
|
||||
60–100 KB each), and stored in `~/.cache/mtga-assistant/images`. When the cache passes
|
||||
`cache_max_mb`, the least recently viewed images are deleted. *Cache → Clear image cache* empties it.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- MTGA leaves oversized game-state messages out of the log ("Message summarized because one or
|
||||
more GameStateMessages exceeded the 50 GameObject or 50 Annotation limit"), typically during board
|
||||
wipes. Some zones can then be stale until they next change. The app detects this and shows a
|
||||
warning.
|
||||
- After a library shuffle, a known card's position in the opponent's library is lost. It stays listed
|
||||
under "Library … known" until it shows up again. If it is drawn without being revealed, it may
|
||||
stay listed.
|
||||
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "mtga-assistant"
|
||||
version = "0.1.0"
|
||||
description = "Native Linux deck and game tracker for MTG Arena, driven by Player.log"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"PySide6-Essentials>=6.8",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mtga-assistant = "mtga_assistant.app:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mtga_assistant"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .app import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Entry point: mtga-assistant [--log PATH] [--replay PATH [--rate N]]."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from . import paths
|
||||
from .carddb import CardDB
|
||||
from .images import ImageCache
|
||||
from .ui.main_window import MainWindow
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="MTG Arena deck and game tracker")
|
||||
ap.add_argument("--log", type=Path, help="Player.log to follow (default: auto-detect)")
|
||||
ap.add_argument("--replay", type=Path, help="replay a saved log instead of following the live one")
|
||||
ap.add_argument("--rate", type=float, default=40.0, help="replay speed in log events per second")
|
||||
ap.add_argument("--mtga-dir", type=Path, help="MTGA install directory (default: auto-detect)")
|
||||
args, qt_args = ap.parse_known_args()
|
||||
|
||||
cfg = paths.load_config()
|
||||
if args.log:
|
||||
cfg.log_path = args.log
|
||||
if args.mtga_dir:
|
||||
cfg.mtga_dir = args.mtga_dir
|
||||
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
app.setApplicationName("MTGA Assistant")
|
||||
app.setDesktopFileName("mtga-assistant") # Wayland app_id, usable in Hyprland window rules
|
||||
|
||||
mtga_dir = paths.find_mtga_dir(cfg)
|
||||
db_path = paths.find_card_db(mtga_dir) if mtga_dir else None
|
||||
cards = CardDB(db_path)
|
||||
images = ImageCache(paths.CACHE_DIR, cards, cfg.cache_max_mb * 1024 * 1024)
|
||||
|
||||
if args.replay:
|
||||
win = MainWindow(cards, images, args.replay, replay_rate=args.rate)
|
||||
else:
|
||||
win = MainWindow(cards, images, paths.find_log_path(cfg))
|
||||
app.aboutToQuit.connect(win.shutdown)
|
||||
win.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Read-only access to MTGA's local SQLite card database (Raw_CardDatabase_*.mtga)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
CARD_TYPES = {
|
||||
1: "Artifact", 2: "Creature", 3: "Enchantment", 4: "Instant", 5: "Land",
|
||||
6: "Phenomenon", 7: "Plane", 8: "Planeswalker", 9: "Scheme", 10: "Sorcery",
|
||||
11: "Kindred", 12: "Vanguard", 13: "Dungeon", 14: "Battle", 15: "Conspiracy",
|
||||
}
|
||||
RARITIES = {0: "token", 1: "land", 2: "common", 3: "uncommon", 4: "rare", 5: "mythic"}
|
||||
|
||||
# Cards.LinkedFaceType values whose first LinkedFaceGrpId is the "base" card: DFC back
|
||||
# faces, split/room halves, adventure and omen sides, meld results, specializations...
|
||||
# (Observed: odd types are the derived face, except omens where 20 is the derived side.)
|
||||
DERIVED_FACE_TYPES = {1, 3, 5, 7, 9, 11, 13, 15, 17, 20}
|
||||
|
||||
_MANA_TOKEN = re.compile(r"o(\([^)]*\)|[^o(]+)")
|
||||
|
||||
|
||||
def parse_mana(text: str) -> list[str]:
|
||||
"""'o2o(W/U)oU' -> ['2', 'W/U', 'U']."""
|
||||
return [m.group(1).strip("()") for m in _MANA_TOKEN.finditer(text or "")]
|
||||
|
||||
|
||||
def mana_value(symbols: list[str]) -> int:
|
||||
total = 0
|
||||
for s in symbols:
|
||||
if s.isdigit():
|
||||
total += int(s)
|
||||
elif s.startswith("2/"):
|
||||
total += 2
|
||||
elif s != "X":
|
||||
total += 1
|
||||
return total
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CardInfo:
|
||||
grp_id: int
|
||||
name: str
|
||||
mana: tuple[str, ...] = ()
|
||||
types: tuple[str, ...] = ()
|
||||
set_code: str = ""
|
||||
collector_number: str = ""
|
||||
rarity: str = ""
|
||||
is_token: bool = False
|
||||
power: str = ""
|
||||
toughness: str = ""
|
||||
linked_faces: tuple[int, ...] = field(default=())
|
||||
linked_face_type: int = 0
|
||||
|
||||
@property
|
||||
def mana_value(self) -> int:
|
||||
return mana_value(list(self.mana))
|
||||
|
||||
@property
|
||||
def is_land(self) -> bool:
|
||||
return "Land" in self.types
|
||||
|
||||
@property
|
||||
def type_line(self) -> str:
|
||||
return " ".join(self.types)
|
||||
|
||||
|
||||
def unknown_card(grp_id: int) -> CardInfo:
|
||||
return CardInfo(grp_id=grp_id, name=f"Unknown card #{grp_id}")
|
||||
|
||||
|
||||
class CardDB:
|
||||
def __init__(self, path: Path | None):
|
||||
self.path = path
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
if path is not None:
|
||||
# immutable=1: MTGA may rewrite the file on update; never take locks on it.
|
||||
uri = f"file:{path}?mode=ro&immutable=1"
|
||||
self._conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._conn is not None
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get(self, grp_id: int) -> CardInfo:
|
||||
if self._conn is None:
|
||||
return unknown_card(grp_id)
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT c.GrpId,
|
||||
(SELECT Loc FROM Localizations_enUS l WHERE l.LocId = c.TitleId
|
||||
ORDER BY l.Formatted = 1 DESC, l.Formatted LIMIT 1),
|
||||
c.OldSchoolManaText, c.Types, c.ExpansionCode, c.CollectorNumber,
|
||||
c.Rarity, c.IsToken, c.Power, c.Toughness, c.LinkedFaceGrpIds, c.LinkedFaceType
|
||||
FROM Cards c WHERE c.GrpId = ?
|
||||
""",
|
||||
(grp_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return unknown_card(grp_id)
|
||||
(gid, name, mana, types, set_code, cn, rarity, is_token, power, tough, linked, ltype) = row
|
||||
return CardInfo(
|
||||
grp_id=gid,
|
||||
name=_strip_tags(name) if name else f"Card #{gid}",
|
||||
mana=tuple(parse_mana(mana)),
|
||||
types=tuple(CARD_TYPES.get(int(t), "?") for t in (types or "").split(",") if t),
|
||||
set_code=set_code or "",
|
||||
collector_number=cn or "",
|
||||
rarity=RARITIES.get(rarity, ""),
|
||||
is_token=bool(is_token),
|
||||
power=power or "",
|
||||
toughness=tough or "",
|
||||
linked_faces=tuple(int(x) for x in (linked or "").split(",") if x),
|
||||
linked_face_type=int(ltype or 0),
|
||||
)
|
||||
|
||||
def base_grp(self, grp_id: int) -> int:
|
||||
"""Canonical grpId for a physical card, e.g. a DFC's back face -> its front face."""
|
||||
info = self.get(grp_id)
|
||||
if info.linked_face_type in DERIVED_FACE_TYPES and info.linked_faces:
|
||||
return info.linked_faces[0]
|
||||
return grp_id
|
||||
|
||||
|
||||
def _strip_tags(s: str) -> str:
|
||||
# Some titles carry markup such as <nobr> or <sprite=...>.
|
||||
return re.sub(r"<[^>]+>", "", s)
|
||||
@@ -0,0 +1,366 @@
|
||||
"""Game/match state reconstructed from GRE messages. Pure Python, no Qt.
|
||||
|
||||
Model notes (derived from MTGA's GRE protocol as it appears in Player.log):
|
||||
|
||||
* Each zone message carries the zone's *complete* ``objectInstanceIds`` list, so
|
||||
zones are replaced wholesale on every update.
|
||||
* Game objects are only sent for cards the local player can see. Hidden cards
|
||||
(libraries, the opponent's hand) exist only as instance ids in a zone list.
|
||||
* When a card changes zone it usually gets a new instance id, announced by an
|
||||
``ObjectIdChanged`` annotation (orig_id -> new_id). We follow these chains so
|
||||
that an opponent's card we have seen keeps its identity when it goes back into
|
||||
a hidden zone (bounced to hand, put back into library, ...).
|
||||
* ``Shuffle`` annotations renumber a library (OldIds -> NewIds) in random order,
|
||||
so known cards in a shuffled library lose their position but are still there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .logparse import ClientDeck, DeckSubmitted, Event, GreMessages, MatchRoom
|
||||
|
||||
HIDDEN_ZONES = {"ZoneType_Library", "ZoneType_Hand"}
|
||||
CARD_OBJECT_TYPES = {"GameObjectType_Card"}
|
||||
# Zones whose cards are "out of the library" for my remaining-library maths.
|
||||
MY_TRACKED_ZONES = (
|
||||
"ZoneType_Hand",
|
||||
"ZoneType_Stack",
|
||||
"ZoneType_Battlefield",
|
||||
"ZoneType_Graveyard",
|
||||
"ZoneType_Exile",
|
||||
"ZoneType_Command",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Zone:
|
||||
zone_id: int
|
||||
type: str
|
||||
owner: int | None
|
||||
ids: list[int]
|
||||
|
||||
|
||||
def _details(annotation: dict[str, Any]) -> dict[str, list[Any]]:
|
||||
out: dict[str, list[Any]] = {}
|
||||
for d in annotation.get("details") or []:
|
||||
vals = d.get("valueInt32") or d.get("valueString") or d.get("valueUint32") or []
|
||||
out[d.get("key", "")] = vals
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameState:
|
||||
"""State of one game (one game of a best-of-three)."""
|
||||
|
||||
my_seat: int
|
||||
deck: Counter[int]
|
||||
sideboard: Counter[int]
|
||||
game_number: int = 1
|
||||
zones: dict[int, Zone] = field(default_factory=dict)
|
||||
objects: dict[int, dict[str, Any]] = field(default_factory=dict)
|
||||
life: dict[int, int] = field(default_factory=dict)
|
||||
turn: int = 0
|
||||
active_player: int = 0
|
||||
phase: str = ""
|
||||
complete: bool = False
|
||||
applied_state_ids: set[int] = field(default_factory=set)
|
||||
# MTGA omits oversized GRE messages from the log ("Message summarized..."); after such a
|
||||
# gap some zones may be stale until the server next sends them.
|
||||
desynced: bool = False
|
||||
|
||||
# Opponent knowledge.
|
||||
parent: dict[int, int] = field(default_factory=dict) # new instance id -> previous id
|
||||
known: dict[int, int] = field(default_factory=dict) # opp instance id -> grpId, survives hiding
|
||||
opp_library_unplaced: Counter[int] = field(default_factory=Counter) # known cards shuffled away
|
||||
# grpId -> instance ids seen. Resolved to physical cards (chain roots) only when counting,
|
||||
# because an object can be logged a message before the ObjectIdChanged that links it.
|
||||
opp_seen: dict[int, set[int]] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def opp_seat(self) -> int:
|
||||
return 2 if self.my_seat == 1 else 1
|
||||
|
||||
# -- applying messages -------------------------------------------------
|
||||
|
||||
def apply(self, gsm: dict[str, Any]) -> None:
|
||||
state_id = gsm.get("gameStateId")
|
||||
if state_id is not None:
|
||||
if state_id in self.applied_state_ids:
|
||||
return
|
||||
self.applied_state_ids.add(state_id)
|
||||
|
||||
if gsm.get("type") == "GameStateType_Full":
|
||||
self.zones.clear()
|
||||
self.objects.clear()
|
||||
self.desynced = False
|
||||
elif (prev := gsm.get("prevGameStateId")) and self.applied_state_ids - {state_id} \
|
||||
and prev not in self.applied_state_ids:
|
||||
self.desynced = True
|
||||
|
||||
if info := gsm.get("gameInfo"):
|
||||
self.game_number = info.get("gameNumber", self.game_number)
|
||||
if info.get("stage") == "GameStage_GameOver" or info.get("matchState") in (
|
||||
"MatchState_GameComplete",
|
||||
"MatchState_MatchComplete",
|
||||
):
|
||||
self.complete = True
|
||||
for p in gsm.get("players") or []:
|
||||
seat = p.get("systemSeatNumber")
|
||||
if seat is not None and "lifeTotal" in p:
|
||||
self.life[seat] = p["lifeTotal"]
|
||||
if ti := gsm.get("turnInfo"):
|
||||
self.turn = ti.get("turnNumber", self.turn)
|
||||
self.active_player = ti.get("activePlayer", self.active_player)
|
||||
self.phase = ti.get("phase", self.phase)
|
||||
|
||||
for ann in gsm.get("annotations") or []:
|
||||
self._apply_annotation(ann)
|
||||
|
||||
for z in gsm.get("zones") or []:
|
||||
zid = z["zoneId"]
|
||||
self.zones[zid] = Zone(zid, z.get("type", ""), z.get("ownerSeatId"), list(z.get("objectInstanceIds") or []))
|
||||
|
||||
for obj in gsm.get("gameObjects") or []:
|
||||
self._apply_object(obj)
|
||||
|
||||
for iid in gsm.get("diffDeletedInstanceIds") or []:
|
||||
self.objects.pop(iid, None)
|
||||
|
||||
def _apply_annotation(self, ann: dict[str, Any]) -> None:
|
||||
types = ann.get("type") or []
|
||||
if "AnnotationType_ObjectIdChanged" in types:
|
||||
d = _details(ann)
|
||||
if d.get("orig_id") and d.get("new_id"):
|
||||
orig, new = d["orig_id"][0], d["new_id"][0]
|
||||
self.parent[new] = orig
|
||||
if orig in self.known:
|
||||
self.known[new] = self.known[orig]
|
||||
if "AnnotationType_Shuffle" in types:
|
||||
d = _details(ann)
|
||||
for old in d.get("OldIds") or []:
|
||||
if old in self.known:
|
||||
self.opp_library_unplaced[self.known.pop(old)] += 1
|
||||
|
||||
def _apply_object(self, obj: dict[str, Any]) -> None:
|
||||
iid = obj["instanceId"]
|
||||
self.objects[iid] = obj
|
||||
grp = obj.get("grpId") or 0
|
||||
if not grp or obj.get("ownerSeatId") != self.opp_seat:
|
||||
return
|
||||
otype = obj.get("type")
|
||||
if otype in CARD_OBJECT_TYPES:
|
||||
newly_visible = iid not in self.known
|
||||
self.known[iid] = grp
|
||||
ids = self.opp_seen.setdefault(grp, set())
|
||||
if newly_visible and self.opp_library_unplaced[grp] > 0 \
|
||||
and self.root_of(iid) not in {self.root_of(i) for i in ids}:
|
||||
# Possibly the known card we lost track of in a shuffle resurfacing.
|
||||
self.opp_library_unplaced[grp] -= 1
|
||||
ids.add(iid)
|
||||
elif otype == "GameObjectType_RevealedCard":
|
||||
self.opp_seen.setdefault(grp, set()).add(-iid) # distinct key space for reveals
|
||||
|
||||
def root_of(self, iid: int) -> int:
|
||||
"""First instance id of the chain this id belongs to (the physical card)."""
|
||||
seen = set()
|
||||
while iid in self.parent and iid not in seen:
|
||||
seen.add(iid)
|
||||
iid = self.parent[iid]
|
||||
return iid
|
||||
|
||||
# -- queries ------------------------------------------------------------
|
||||
|
||||
def zone_of_type(self, ztype: str, owner: int | None) -> Zone | None:
|
||||
for z in self.zones.values():
|
||||
if z.type == ztype and (owner is None or z.owner == owner):
|
||||
return z
|
||||
return None
|
||||
|
||||
def cards_in(self, ztype: str, *, owner: int | None = None, controller: int | None = None,
|
||||
include_tokens: bool = True) -> Counter[int]:
|
||||
"""grpId counts of visible objects in zones of the given type."""
|
||||
out: Counter[int] = Counter()
|
||||
for z in self.zones.values():
|
||||
if z.type != ztype:
|
||||
continue
|
||||
for iid in z.ids:
|
||||
obj = self.objects.get(iid)
|
||||
if not obj or not obj.get("grpId"):
|
||||
continue
|
||||
otype = obj.get("type")
|
||||
if otype not in CARD_OBJECT_TYPES and not (include_tokens and otype == "GameObjectType_Token"):
|
||||
continue
|
||||
if owner is not None and obj.get("ownerSeatId") != owner:
|
||||
continue
|
||||
if controller is not None and obj.get("controllerSeatId", obj.get("ownerSeatId")) != controller:
|
||||
continue
|
||||
out[obj["grpId"]] += 1
|
||||
return out
|
||||
|
||||
def zone_size(self, ztype: str, owner: int) -> int:
|
||||
z = self.zone_of_type(ztype, owner)
|
||||
return len(z.ids) if z else 0
|
||||
|
||||
def opp_known_in(self, ztype: str) -> Counter[int]:
|
||||
"""Opponent cards we know are in a hidden zone (by tracking identity)."""
|
||||
out: Counter[int] = Counter()
|
||||
z = self.zone_of_type(ztype, self.opp_seat)
|
||||
if z:
|
||||
for iid in z.ids:
|
||||
if iid in self.known:
|
||||
out[self.known[iid]] += 1
|
||||
# Cards the opponent revealed from that zone (e.g. "reveal your hand").
|
||||
if z:
|
||||
for obj in self.objects.values():
|
||||
if (obj.get("type") == "GameObjectType_RevealedCard" and obj.get("zoneId") == z.zone_id
|
||||
and obj.get("grpId")):
|
||||
out[obj["grpId"]] += 1
|
||||
if ztype == "ZoneType_Library":
|
||||
out += self.opp_library_unplaced
|
||||
return out
|
||||
|
||||
def opp_seen_counts(self, normalize=lambda g: g) -> Counter[int]:
|
||||
merged: dict[int, set[int]] = {}
|
||||
for g, ids in self.opp_seen.items():
|
||||
# Negative ids are reveal copies, which have no chain.
|
||||
merged.setdefault(normalize(g), set()).update(i if i < 0 else self.root_of(i) for i in ids)
|
||||
return Counter({g: len(r) for g, r in merged.items() if r})
|
||||
|
||||
def my_outside_library(self, normalize) -> Counter[int]:
|
||||
"""My non-token cards outside the library, keyed by deck grpId."""
|
||||
out: Counter[int] = Counter()
|
||||
for ztype in MY_TRACKED_ZONES:
|
||||
for g, n in self.cards_in(ztype, owner=self.my_seat, include_tokens=False).items():
|
||||
out[normalize(g)] += n
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchTracker:
|
||||
"""Everything the UI needs, across the games of a match."""
|
||||
|
||||
card_lookup: Any = None # CardDB-like with .get(grpId) -> CardInfo; optional
|
||||
match_id: str = ""
|
||||
event_id: str = ""
|
||||
players: dict[int, str] = field(default_factory=dict)
|
||||
my_seat: int | None = None
|
||||
game: GameState | None = None
|
||||
in_match: bool = False
|
||||
game_results: list[str] = field(default_factory=list) # "Won"/"Lost" per finished game
|
||||
last_result: str = ""
|
||||
next_deck: ClientDeck | None = None
|
||||
# Opponent cards seen in earlier games of this match (max count in any one game).
|
||||
opp_seen_prev_games: Counter[int] = field(default_factory=Counter)
|
||||
deck_name: str = ""
|
||||
submitted_deck: DeckSubmitted | None = None
|
||||
version: int = 0 # bumped on every change, for cheap UI refresh checks
|
||||
|
||||
def handle(self, ev: Event) -> None:
|
||||
if isinstance(ev, GreMessages):
|
||||
for m in ev.messages:
|
||||
self._handle_gre(m)
|
||||
elif isinstance(ev, MatchRoom):
|
||||
self._handle_room(ev)
|
||||
elif isinstance(ev, DeckSubmitted):
|
||||
self.submitted_deck = ev
|
||||
self.deck_name = ev.name
|
||||
elif isinstance(ev, ClientDeck):
|
||||
self.next_deck = ev
|
||||
self.version += 1
|
||||
|
||||
def _start_match(self) -> None:
|
||||
self.opp_seen_prev_games.clear()
|
||||
self.game_results.clear()
|
||||
self.last_result = ""
|
||||
self.players.clear()
|
||||
self.in_match = True
|
||||
|
||||
def _handle_room(self, ev: MatchRoom) -> None:
|
||||
if ev.state == "MatchGameRoomStateType_Playing":
|
||||
# ConnectResp (which starts the match on our side) may arrive before or after this.
|
||||
if not self.in_match:
|
||||
self._start_match()
|
||||
self.match_id = ev.match_id
|
||||
for p in ev.players:
|
||||
self.players[p.get("systemSeatId")] = p.get("playerName", "")
|
||||
self.event_id = p.get("eventId", self.event_id)
|
||||
elif ev.state == "MatchGameRoomStateType_MatchCompleted":
|
||||
self.in_match = False
|
||||
res = ev.final_result or {}
|
||||
for r in res.get("resultList") or []:
|
||||
if r.get("scope") == "MatchScope_Match" and self.my_seat is not None:
|
||||
won = r.get("winningTeamId") == self.my_seat
|
||||
self.last_result = "Won" if won else "Lost"
|
||||
if self.game:
|
||||
self.game.complete = True
|
||||
|
||||
def _handle_gre(self, m: dict[str, Any]) -> None:
|
||||
mtype = m.get("type")
|
||||
if mtype == "GREMessageType_ConnectResp":
|
||||
seats = m.get("systemSeatIds") or [1]
|
||||
self.my_seat = seats[0]
|
||||
deck_msg = (m.get("connectResp") or {}).get("deckMessage") or {}
|
||||
if not self.in_match:
|
||||
self._start_match()
|
||||
self.next_deck = None
|
||||
self.game = GameState(
|
||||
my_seat=self.my_seat,
|
||||
deck=Counter(deck_msg.get("deckCards") or []),
|
||||
sideboard=Counter(deck_msg.get("sideboardCards") or []),
|
||||
)
|
||||
return
|
||||
gsm = m.get("gameStateMessage")
|
||||
if gsm is None or self.game is None:
|
||||
return
|
||||
game_no = (gsm.get("gameInfo") or {}).get("gameNumber")
|
||||
if gsm.get("type") == "GameStateType_Full" and game_no and game_no != self.game.game_number:
|
||||
# Next game of the match: no new ConnectResp, just a fresh Full state.
|
||||
prev = self.game
|
||||
self.opp_seen_prev_games = self.opp_seen_match
|
||||
deck = self.next_deck or ClientDeck(prev.deck, prev.sideboard)
|
||||
self.next_deck = None
|
||||
self.game = GameState(my_seat=prev.my_seat, deck=Counter(deck.main),
|
||||
sideboard=Counter(deck.sideboard), game_number=game_no)
|
||||
self.game.apply(gsm)
|
||||
results = (gsm.get("gameInfo") or {}).get("results") or []
|
||||
game_results = [r for r in results if r.get("scope") == "MatchScope_Game"]
|
||||
if len(game_results) > len(self.game_results) and self.my_seat is not None:
|
||||
self.game_results = [
|
||||
"Won" if r.get("winningTeamId") == self.my_seat else "Lost" for r in game_results
|
||||
]
|
||||
self.game.complete = True
|
||||
|
||||
# -- derived views ------------------------------------------------------
|
||||
|
||||
@property
|
||||
def opp_seen_match(self) -> Counter[int]:
|
||||
"""Opponent cards seen this match: for each card, the most copies seen in any one game."""
|
||||
out = self.opp_seen_prev_games.copy()
|
||||
if self.game is not None:
|
||||
for g, n in self.game.opp_seen_counts(self.normalize).items():
|
||||
out[g] = max(out[g], n)
|
||||
return out
|
||||
|
||||
def normalize(self, grp: int) -> int:
|
||||
"""Map a game object's grpId to its card's canonical grpId (e.g. DFC back -> front)."""
|
||||
g = self.game
|
||||
if (g is not None and grp in g.deck) or self.card_lookup is None:
|
||||
return grp
|
||||
return self.card_lookup.base_grp(grp)
|
||||
|
||||
def my_library_remaining(self) -> Counter[int]:
|
||||
g = self.game
|
||||
if g is None:
|
||||
return Counter()
|
||||
remaining = g.deck.copy()
|
||||
remaining.subtract(g.my_outside_library(self.normalize))
|
||||
return +remaining # drop zero/negative entries
|
||||
|
||||
@property
|
||||
def opponent_name(self) -> str:
|
||||
if self.my_seat is None:
|
||||
return ""
|
||||
return self.players.get(2 if self.my_seat == 1 else 1, "")
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Card images from Scryfall with a size-capped on-disk LRU cache.
|
||||
|
||||
Images are fetched lazily, only for cards that actually show up, and stored as
|
||||
``<cache>/images/<grpId>.jpg``. A file's mtime is bumped whenever it is used, and
|
||||
the least recently used files are evicted when the cache exceeds its cap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
|
||||
from .carddb import CardDB
|
||||
|
||||
USER_AGENT = "mtga-assistant/0.1 (personal desktop tracker)"
|
||||
SCRYFALL = "https://api.scryfall.com"
|
||||
MIN_REQUEST_INTERVAL = 0.11 # Scryfall asks for <= 10 requests/second
|
||||
|
||||
|
||||
class ImageCache(QObject):
|
||||
image_ready = Signal(int) # grpId
|
||||
size_changed = Signal(int) # bytes on disk
|
||||
_downloaded = Signal(int)
|
||||
|
||||
def __init__(self, cache_dir: Path, cards: CardDB, max_bytes: int):
|
||||
super().__init__()
|
||||
self.dir = cache_dir / "images"
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cards = cards
|
||||
self.max_bytes = max_bytes
|
||||
self._pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix="scryfall")
|
||||
self._lock = threading.Lock()
|
||||
self._inflight: set[int] = set()
|
||||
self._failed: set[int] = set()
|
||||
self._last_request = 0.0
|
||||
self._pixmaps: dict[int, QPixmap] = {}
|
||||
# Emitted from worker threads; Qt queues delivery onto this object's (GUI) thread.
|
||||
self._downloaded.connect(self._on_downloaded)
|
||||
self.evict()
|
||||
|
||||
def path_for(self, grp_id: int) -> Path:
|
||||
return self.dir / f"{grp_id}.jpg"
|
||||
|
||||
def pixmap(self, grp_id: int) -> QPixmap | None:
|
||||
"""Return the image if cached; otherwise start a download and return None."""
|
||||
if pm := self._pixmaps.get(grp_id):
|
||||
return pm
|
||||
path = self.path_for(grp_id)
|
||||
if path.exists():
|
||||
try:
|
||||
path.touch()
|
||||
except OSError:
|
||||
pass
|
||||
pm = QPixmap(str(path))
|
||||
if not pm.isNull():
|
||||
if len(self._pixmaps) > 200:
|
||||
self._pixmaps.clear()
|
||||
self._pixmaps[grp_id] = pm
|
||||
return pm
|
||||
self.request(grp_id)
|
||||
return None
|
||||
|
||||
def request(self, grp_id: int) -> None:
|
||||
with self._lock:
|
||||
if grp_id in self._inflight or grp_id in self._failed:
|
||||
return
|
||||
self._inflight.add(grp_id)
|
||||
self._pool.submit(self._download, grp_id)
|
||||
|
||||
def is_failed(self, grp_id: int) -> bool:
|
||||
return grp_id in self._failed
|
||||
|
||||
# -- worker side ---------------------------------------------------------
|
||||
|
||||
def _throttle(self) -> None:
|
||||
with self._lock:
|
||||
wait = self._last_request + MIN_REQUEST_INTERVAL - time.monotonic()
|
||||
self._last_request = max(time.monotonic(), self._last_request + MIN_REQUEST_INTERVAL)
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
|
||||
def _candidate_urls(self, grp_id: int) -> list[str]:
|
||||
q = "format=image&version=normal"
|
||||
urls = [f"{SCRYFALL}/cards/arena/{grp_id}?{q}"]
|
||||
info = self.cards.get(grp_id)
|
||||
if info.set_code and info.collector_number:
|
||||
set_code = urllib.parse.quote(info.set_code.lower())
|
||||
cn = urllib.parse.quote(info.collector_number)
|
||||
urls.append(f"{SCRYFALL}/cards/{set_code}/{cn}?{q}")
|
||||
if info.name and not info.name.startswith(("Unknown card", "Card #")):
|
||||
exact = urllib.parse.quote(info.name.split(" // ")[0])
|
||||
urls.append(f"{SCRYFALL}/cards/named?exact={exact}&{q}")
|
||||
return urls
|
||||
|
||||
def _download(self, grp_id: int) -> None:
|
||||
ok = False
|
||||
try:
|
||||
for url in self._candidate_urls(grp_id):
|
||||
self._throttle()
|
||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "*/*"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
data = resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
continue
|
||||
break
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
break
|
||||
tmp = self.path_for(grp_id).with_suffix(".part")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(self.path_for(grp_id))
|
||||
ok = True
|
||||
break
|
||||
finally:
|
||||
with self._lock:
|
||||
self._inflight.discard(grp_id)
|
||||
if not ok:
|
||||
self._failed.add(grp_id)
|
||||
if ok:
|
||||
self._downloaded.emit(grp_id)
|
||||
|
||||
def _on_downloaded(self, grp_id: int) -> None:
|
||||
self.evict()
|
||||
self.image_ready.emit(grp_id)
|
||||
|
||||
# -- cache management ------------------------------------------------------
|
||||
|
||||
def disk_usage(self) -> int:
|
||||
return sum(p.stat().st_size for p in self.dir.glob("*.jpg"))
|
||||
|
||||
def evict(self) -> None:
|
||||
files = []
|
||||
for p in self.dir.glob("*.jpg"):
|
||||
try:
|
||||
st = p.stat()
|
||||
except OSError:
|
||||
continue
|
||||
files.append((st.st_mtime, st.st_size, p))
|
||||
total = sum(f[1] for f in files)
|
||||
if total > self.max_bytes:
|
||||
for _, size, p in sorted(files):
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
total -= size
|
||||
if p.stem.isdigit():
|
||||
self._pixmaps.pop(int(p.stem), None)
|
||||
if total <= self.max_bytes * 0.9:
|
||||
break
|
||||
self.size_changed.emit(total)
|
||||
|
||||
def clear(self) -> None:
|
||||
for p in self.dir.glob("*"):
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
self._pixmaps.clear()
|
||||
self._failed.clear()
|
||||
self.size_changed.emit(0)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._pool.shutdown(wait=False, cancel_futures=True)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Turn Player.log lines into typed events.
|
||||
|
||||
The log interleaves plain text with JSON payloads. The payloads we care about are:
|
||||
|
||||
* a line starting with ``{`` that holds ``greToClientEvent`` (in-game state) or
|
||||
``matchGameRoomStateChangedEvent`` (match start / end), preceded by a header line;
|
||||
* ``[UnityCrossThreadLogger]==> EventSetDeckV3 {...}`` requests, whose ``request``
|
||||
field is itself a JSON string carrying the deck submitted to an event.
|
||||
|
||||
This module has no Qt dependency so it can be unit-tested and replayed headlessly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
MAX_PENDING_LINES = 5000
|
||||
|
||||
|
||||
@dataclass
|
||||
class GreMessages:
|
||||
messages: list[dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchRoom:
|
||||
state: str
|
||||
match_id: str
|
||||
players: list[dict[str, Any]]
|
||||
final_result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeckSubmitted:
|
||||
name: str
|
||||
event: str
|
||||
main: Counter[int] = field(default_factory=Counter)
|
||||
sideboard: Counter[int] = field(default_factory=Counter)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientDeck:
|
||||
"""Deck the client submitted for the next game of a match (after sideboarding)."""
|
||||
|
||||
main: Counter[int]
|
||||
sideboard: Counter[int]
|
||||
|
||||
|
||||
Event = GreMessages | MatchRoom | DeckSubmitted | ClientDeck
|
||||
|
||||
_REQUEST_RE = re.compile(r"==> (EventSetDeck\w*) (\{.*)$")
|
||||
|
||||
|
||||
class LogParser:
|
||||
"""Incremental line parser. Feed complete lines; get events back."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: list[str] = []
|
||||
|
||||
def reset(self) -> None:
|
||||
self._pending.clear()
|
||||
|
||||
def feed(self, line: str) -> list[Event]:
|
||||
line = line.rstrip("\r\n")
|
||||
if self._pending:
|
||||
# Continuing a multi-line JSON body.
|
||||
if line.startswith("[") and not line.startswith("[{"):
|
||||
self._pending.clear() # a new log entry began; drop the fragment
|
||||
else:
|
||||
self._pending.append(line)
|
||||
# Pretty-printed bodies close with a '}' at column 0; only reparse then.
|
||||
return self._try_pending() if line.startswith("}") else []
|
||||
|
||||
if line.startswith("{"):
|
||||
self._pending = [line]
|
||||
return self._try_pending()
|
||||
|
||||
if "==> EventSetDeck" in line:
|
||||
m = _REQUEST_RE.search(line)
|
||||
if m:
|
||||
try:
|
||||
return _deck_submitted(json.loads(m.group(2)))
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||
return []
|
||||
return []
|
||||
|
||||
def _try_pending(self) -> list[Event]:
|
||||
text = "\n".join(self._pending)
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
if len(self._pending) > MAX_PENDING_LINES:
|
||||
self._pending.clear()
|
||||
return []
|
||||
self._pending.clear()
|
||||
return _classify(obj)
|
||||
|
||||
|
||||
def _classify(obj: Any) -> list[Event]:
|
||||
if not isinstance(obj, dict):
|
||||
return []
|
||||
if gre := obj.get("greToClientEvent"):
|
||||
msgs = gre.get("greToClientMessages") or []
|
||||
return [GreMessages(msgs)] if msgs else []
|
||||
if room := obj.get("matchGameRoomStateChangedEvent"):
|
||||
info = room.get("gameRoomInfo") or {}
|
||||
cfg = info.get("gameRoomConfig") or {}
|
||||
return [
|
||||
MatchRoom(
|
||||
state=info.get("stateType", ""),
|
||||
match_id=cfg.get("matchId", "") or (info.get("finalMatchResult") or {}).get("matchId", ""),
|
||||
players=cfg.get("reservedPlayers") or [],
|
||||
final_result=info.get("finalMatchResult"),
|
||||
)
|
||||
]
|
||||
payload = obj.get("payload")
|
||||
if isinstance(payload, dict) and payload.get("type") == "ClientMessageType_SubmitDeckResp":
|
||||
deck = (payload.get("submitDeckResp") or {}).get("deck") or {}
|
||||
return [ClientDeck(Counter(deck.get("deckCards") or []), Counter(deck.get("sideboardCards") or []))]
|
||||
return []
|
||||
|
||||
|
||||
def _deck_submitted(payload: dict[str, Any]) -> list[Event]:
|
||||
req = json.loads(payload["request"])
|
||||
deck = req.get("Deck") or {}
|
||||
main: Counter[int] = Counter()
|
||||
side: Counter[int] = Counter()
|
||||
for c in deck.get("MainDeck") or []:
|
||||
main[int(c["cardId"])] += int(c["quantity"])
|
||||
for c in deck.get("Sideboard") or []:
|
||||
side[int(c["cardId"])] += int(c["quantity"])
|
||||
summary = req.get("Summary") or {}
|
||||
return [DeckSubmitted(name=summary.get("Name", ""), event=req.get("EventName", ""), main=main, sideboard=side)]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Background thread that follows Player.log (or replays a saved log) and emits events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from .logparse import LogParser
|
||||
|
||||
POLL_INTERVAL = 0.25
|
||||
BATCH_MAX = 200
|
||||
|
||||
|
||||
class LogFollower(QThread):
|
||||
events = Signal(list) # list[Event]
|
||||
status = Signal(str)
|
||||
reset = Signal() # log was replaced (MTGA restarted): state must be rebuilt
|
||||
|
||||
def __init__(self, path: Path, replay_rate: float | None = None):
|
||||
"""replay_rate: if set, read the file once at that many events/second, then stop."""
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.replay_rate = replay_rate
|
||||
self._stop = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop = True
|
||||
|
||||
def run(self) -> None:
|
||||
if self.replay_rate:
|
||||
self._replay()
|
||||
else:
|
||||
self._follow()
|
||||
|
||||
def _replay(self) -> None:
|
||||
parser = LogParser()
|
||||
delay = 1.0 / self.replay_rate
|
||||
self.status.emit(f"Replaying {self.path.name}")
|
||||
with open(self.path, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
if self._stop:
|
||||
return
|
||||
evs = parser.feed(line)
|
||||
if evs:
|
||||
self.events.emit(evs)
|
||||
time.sleep(delay)
|
||||
self.status.emit(f"Replay of {self.path.name} finished")
|
||||
|
||||
def _follow(self) -> None:
|
||||
parser = LogParser()
|
||||
f = None
|
||||
inode = None
|
||||
pos = 0
|
||||
buf = b""
|
||||
waiting_reported = False
|
||||
following = False
|
||||
while not self._stop:
|
||||
try:
|
||||
st = os.stat(self.path)
|
||||
except FileNotFoundError:
|
||||
if not waiting_reported:
|
||||
self.status.emit(f"Waiting for {self.path}")
|
||||
waiting_reported = True
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
waiting_reported = False
|
||||
|
||||
if f is None or st.st_ino != inode or st.st_size < pos:
|
||||
# First open, or MTGA restarted and replaced/truncated the log.
|
||||
if f is not None:
|
||||
f.close()
|
||||
self.reset.emit()
|
||||
f = open(self.path, "rb")
|
||||
inode, pos, buf = st.st_ino, 0, b""
|
||||
parser.reset()
|
||||
self.status.emit(f"Reading {self.path.name}")
|
||||
following = False
|
||||
|
||||
chunk = f.read()
|
||||
if not chunk:
|
||||
if not following:
|
||||
self.status.emit(f"Following {self.path.name}")
|
||||
following = True
|
||||
time.sleep(POLL_INTERVAL)
|
||||
continue
|
||||
pos = f.tell()
|
||||
buf += chunk
|
||||
lines = buf.split(b"\n")
|
||||
buf = lines.pop() # keep a trailing partial line for the next read
|
||||
|
||||
batch = []
|
||||
for line in lines:
|
||||
batch.extend(parser.feed(line.decode("utf-8", errors="replace")))
|
||||
if len(batch) >= BATCH_MAX:
|
||||
self.events.emit(batch)
|
||||
batch = []
|
||||
if batch:
|
||||
self.events.emit(batch)
|
||||
if f is not None:
|
||||
f.close()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Locate MTGA's Player.log and install directory inside a Steam/Proton setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
MTGA_APP_ID = "2141910"
|
||||
LOG_SUBPATH = Path(
|
||||
"pfx/drive_c/users/steamuser/AppData/LocalLow/Wizards Of The Coast/MTGA"
|
||||
)
|
||||
|
||||
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "mtga-assistant"
|
||||
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "mtga-assistant"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
||||
|
||||
STEAM_ROOTS = [
|
||||
Path.home() / ".local/share/Steam",
|
||||
Path.home() / ".steam/steam",
|
||||
Path.home() / ".steam/root",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
log_path: Path | None = None
|
||||
mtga_dir: Path | None = None
|
||||
cache_max_mb: int = 500
|
||||
|
||||
|
||||
def load_config() -> Config:
|
||||
cfg = Config()
|
||||
try:
|
||||
data = tomllib.loads(CONFIG_FILE.read_text())
|
||||
except (FileNotFoundError, tomllib.TOMLDecodeError):
|
||||
return cfg
|
||||
if p := data.get("log_path"):
|
||||
cfg.log_path = Path(p).expanduser()
|
||||
if p := data.get("mtga_dir"):
|
||||
cfg.mtga_dir = Path(p).expanduser()
|
||||
if (mb := data.get("cache_max_mb")) is not None:
|
||||
cfg.cache_max_mb = int(mb)
|
||||
return cfg
|
||||
|
||||
|
||||
def steam_libraries() -> list[Path]:
|
||||
"""All Steam library folders listed in any libraryfolders.vdf we can find."""
|
||||
libs: list[Path] = []
|
||||
for root in STEAM_ROOTS:
|
||||
vdf = root / "steamapps/libraryfolders.vdf"
|
||||
try:
|
||||
text = vdf.read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
for m in re.finditer(r'"path"\s+"([^"]+)"', text):
|
||||
p = Path(m.group(1).replace("\\\\", "\\"))
|
||||
if p not in libs:
|
||||
libs.append(p)
|
||||
if root not in libs and (root / "steamapps").is_dir():
|
||||
libs.append(root)
|
||||
return libs
|
||||
|
||||
|
||||
def find_mtga_library() -> Path | None:
|
||||
for lib in steam_libraries():
|
||||
if (lib / f"steamapps/appmanifest_{MTGA_APP_ID}.acf").exists():
|
||||
return lib
|
||||
return None
|
||||
|
||||
|
||||
def find_log_path(cfg: Config) -> Path | None:
|
||||
if cfg.log_path:
|
||||
return cfg.log_path
|
||||
libs = steam_libraries()
|
||||
mtga_lib = find_mtga_library()
|
||||
# The prefix normally lives in the same library as the game, but check all of them.
|
||||
if mtga_lib:
|
||||
libs = [mtga_lib] + [l for l in libs if l != mtga_lib]
|
||||
for lib in libs:
|
||||
d = lib / "steamapps/compatdata" / MTGA_APP_ID / LOG_SUBPATH
|
||||
if d.is_dir():
|
||||
return d / "Player.log"
|
||||
return None
|
||||
|
||||
|
||||
def find_mtga_dir(cfg: Config) -> Path | None:
|
||||
if cfg.mtga_dir:
|
||||
return cfg.mtga_dir
|
||||
lib = find_mtga_library()
|
||||
if lib:
|
||||
d = lib / "steamapps/common/MTGA"
|
||||
if d.is_dir():
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def find_card_db(mtga_dir: Path) -> Path | None:
|
||||
raw = mtga_dir / "MTGA_Data/Downloads/Raw"
|
||||
dbs = sorted(raw.glob("Raw_CardDatabase_*.mtga"), key=lambda p: p.stat().st_mtime)
|
||||
return dbs[-1] if dbs else None
|
||||
Whitespace-only changes.
@@ -0,0 +1,184 @@
|
||||
"""Sectioned card list: count, mana cost, name, note. Emits the hovered card's grpId."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from PySide6.QtCore import QModelIndex, QRectF, QSize, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QPainter, QPen
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QHeaderView,
|
||||
QStyledItemDelegate,
|
||||
QStyleOptionViewItem,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
)
|
||||
|
||||
from ..carddb import CardInfo
|
||||
|
||||
GRP_ROLE = Qt.ItemDataRole.UserRole + 1
|
||||
MANA_ROLE = Qt.ItemDataRole.UserRole + 2
|
||||
|
||||
COL_COUNT, COL_MANA, COL_NAME, COL_NOTE = range(4)
|
||||
|
||||
MANA_COLORS = {
|
||||
"W": "#f8f6d8",
|
||||
"U": "#c1d7e9",
|
||||
"B": "#bab1ab",
|
||||
"R": "#e49977",
|
||||
"G": "#a3c095",
|
||||
"C": "#cac5c0",
|
||||
}
|
||||
GENERIC_COLOR = "#cac5c0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Row:
|
||||
info: CardInfo
|
||||
count: int = 1
|
||||
note: str = ""
|
||||
dim: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Section:
|
||||
key: str # stable id used to remember expanded/collapsed state
|
||||
title: str
|
||||
rows: list[Row] = field(default_factory=list)
|
||||
collapsed_by_default: bool = False
|
||||
|
||||
|
||||
def sort_key(row: Row) -> tuple:
|
||||
i = row.info
|
||||
return (i.is_land, i.mana_value, i.name)
|
||||
|
||||
|
||||
class ManaDelegate(QStyledItemDelegate):
|
||||
PIP = 16
|
||||
|
||||
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
|
||||
symbols = index.data(MANA_ROLE)
|
||||
if not symbols:
|
||||
super().paint(painter, option, index)
|
||||
return
|
||||
self.initStyleOption(option, index)
|
||||
style = option.widget.style() if option.widget else None
|
||||
if style:
|
||||
# Draw selection/hover background without text.
|
||||
option.text = ""
|
||||
style.drawControl(style.ControlElement.CE_ItemViewItem, option, painter, option.widget)
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
size = min(self.PIP, option.rect.height() - 4)
|
||||
x = option.rect.left() + 2
|
||||
y = option.rect.top() + (option.rect.height() - size) // 2
|
||||
font = QFont(option.font)
|
||||
font.setPixelSize(int(size * 0.7))
|
||||
font.setBold(True)
|
||||
painter.setFont(font)
|
||||
for sym in symbols:
|
||||
if x + size > option.rect.right():
|
||||
break
|
||||
rect = QRectF(x, y, size, size)
|
||||
parts = [p for p in sym.split("/") if p]
|
||||
phyrexian = "P" in parts
|
||||
parts = [p for p in parts if p != "P"] or ["C"]
|
||||
fills = [MANA_COLORS.get(p, GENERIC_COLOR) for p in parts]
|
||||
painter.setPen(QPen(QColor("#555"), 0.8))
|
||||
if len(fills) >= 2: # hybrid: two half circles
|
||||
painter.setBrush(QBrush(QColor(fills[0])))
|
||||
painter.drawPie(rect, 90 * 16, 180 * 16)
|
||||
painter.setBrush(QBrush(QColor(fills[1])))
|
||||
painter.drawPie(rect, 270 * 16, 180 * 16)
|
||||
else:
|
||||
painter.setBrush(QBrush(QColor(fills[0])))
|
||||
painter.drawEllipse(rect)
|
||||
if phyrexian:
|
||||
label = "φ"
|
||||
elif sym == "C":
|
||||
label = "◇"
|
||||
elif parts[0] not in MANA_COLORS:
|
||||
label = parts[0] # generic number, X, or the "2" of a 2/C hybrid
|
||||
else:
|
||||
label = ""
|
||||
if label:
|
||||
painter.setPen(QColor("#111"))
|
||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, label)
|
||||
x += size + 2
|
||||
painter.restore()
|
||||
|
||||
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
|
||||
symbols = index.data(MANA_ROLE) or []
|
||||
return QSize(len(symbols) * (self.PIP + 2) + 4, self.PIP + 6)
|
||||
|
||||
|
||||
class CardList(QTreeWidget):
|
||||
hovered = Signal(int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setColumnCount(4)
|
||||
self.setHeaderHidden(True)
|
||||
self.setRootIsDecorated(True)
|
||||
self.setIndentation(12)
|
||||
self.setUniformRowHeights(True)
|
||||
self.setMouseTracking(True)
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.setItemDelegateForColumn(COL_MANA, ManaDelegate(self))
|
||||
hdr = self.header()
|
||||
hdr.setStretchLastSection(False)
|
||||
hdr.setSectionResizeMode(COL_COUNT, QHeaderView.ResizeMode.ResizeToContents)
|
||||
hdr.setSectionResizeMode(COL_MANA, QHeaderView.ResizeMode.ResizeToContents)
|
||||
hdr.setSectionResizeMode(COL_NAME, QHeaderView.ResizeMode.Stretch)
|
||||
hdr.setSectionResizeMode(COL_NOTE, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.itemEntered.connect(self._on_entered)
|
||||
self.currentItemChanged.connect(lambda cur, _prev: self._on_entered(cur, 0))
|
||||
self._expanded: dict[str, bool] = {}
|
||||
self._last_render: list | None = None
|
||||
|
||||
def _on_entered(self, item: QTreeWidgetItem | None, _col: int) -> None:
|
||||
if item is not None and (grp := item.data(0, GRP_ROLE)):
|
||||
self.hovered.emit(int(grp))
|
||||
|
||||
def set_sections(self, sections: list[Section]) -> None:
|
||||
# Skip rebuilding (and losing scroll position) when nothing changed.
|
||||
signature = [(s.key, s.title, [(r.info.grp_id, r.count, r.note, r.dim) for r in s.rows]) for s in sections]
|
||||
if signature == self._last_render:
|
||||
return
|
||||
self._last_render = signature
|
||||
|
||||
for i in range(self.topLevelItemCount()):
|
||||
top = self.topLevelItem(i)
|
||||
self._expanded[top.data(0, GRP_ROLE + 10)] = top.isExpanded()
|
||||
scroll = self.verticalScrollBar().value()
|
||||
|
||||
self.clear()
|
||||
bold = QFont(self.font())
|
||||
bold.setBold(True)
|
||||
dim_brush = QBrush(self.palette().color(self.palette().ColorRole.PlaceholderText))
|
||||
for sec in sections:
|
||||
top = QTreeWidgetItem([sec.title, "", "", ""])
|
||||
top.setFirstColumnSpanned(True)
|
||||
top.setFont(0, bold)
|
||||
top.setData(0, GRP_ROLE + 10, sec.key)
|
||||
top.setFlags(Qt.ItemFlag.ItemIsEnabled)
|
||||
self.addTopLevelItem(top)
|
||||
top.setFirstColumnSpanned(True)
|
||||
for row in sorted(sec.rows, key=sort_key):
|
||||
it = QTreeWidgetItem([f"{row.count}×" if row.count else "", "", row.info.name, row.note])
|
||||
it.setData(0, GRP_ROLE, row.info.grp_id)
|
||||
it.setData(COL_MANA, MANA_ROLE, list(row.info.mana))
|
||||
it.setToolTip(COL_NAME, f"{row.info.name}\n{row.info.type_line}")
|
||||
it.setTextAlignment(COL_COUNT, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
it.setTextAlignment(COL_NOTE, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
if row.dim:
|
||||
for c in range(4):
|
||||
it.setForeground(c, dim_brush)
|
||||
top.addChild(it)
|
||||
top.setExpanded(self._expanded.get(sec.key, not sec.collapsed_by_default))
|
||||
self.verticalScrollBar().setValue(scroll)
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(340, 600)
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Main window: my deck | opponent | card preview."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..carddb import CardDB
|
||||
from ..gamestate import GameState, MatchTracker
|
||||
from ..images import ImageCache
|
||||
from ..logtail import LogFollower
|
||||
from .card_list import CardList, Row, Section
|
||||
from .preview import CardPreview
|
||||
|
||||
REFRESH_MS = 150
|
||||
|
||||
|
||||
def _panel(title: str, body: QWidget) -> QWidget:
|
||||
w = QWidget()
|
||||
lay = QVBoxLayout(w)
|
||||
lay.setContentsMargins(4, 4, 4, 4)
|
||||
label = QLabel(f"<b>{title}</b>")
|
||||
lay.addWidget(label)
|
||||
lay.addWidget(body, 1)
|
||||
return w
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, cards: CardDB, images: ImageCache, log_path: Path | None,
|
||||
replay_rate: float | None = None):
|
||||
super().__init__()
|
||||
self.setWindowTitle("MTGA Assistant")
|
||||
self.cards = cards
|
||||
self.images = images
|
||||
self.tracker = MatchTracker(card_lookup=cards)
|
||||
self.follower: LogFollower | None = None
|
||||
self._rendered_version = -1
|
||||
|
||||
# Header
|
||||
self.header = QLabel("Waiting for a match…")
|
||||
self.header.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.warning = QLabel()
|
||||
self.warning.setStyleSheet("color: #d08770;")
|
||||
self.warning.setWordWrap(True)
|
||||
self.warning.hide()
|
||||
|
||||
# Panels
|
||||
self.mine = CardList()
|
||||
self.opp = CardList()
|
||||
self.preview = CardPreview(cards, images)
|
||||
self.mine.hovered.connect(self.preview.show_card)
|
||||
self.opp.hovered.connect(self.preview.show_card)
|
||||
|
||||
split = QSplitter(Qt.Orientation.Horizontal)
|
||||
split.addWidget(_panel("My deck", self.mine))
|
||||
split.addWidget(_panel("Opponent", self.opp))
|
||||
split.addWidget(self.preview)
|
||||
split.setStretchFactor(0, 3)
|
||||
split.setStretchFactor(1, 3)
|
||||
split.setStretchFactor(2, 2)
|
||||
|
||||
central = QWidget()
|
||||
lay = QVBoxLayout(central)
|
||||
top = QHBoxLayout()
|
||||
top.addWidget(self.header, 1)
|
||||
lay.addLayout(top)
|
||||
lay.addWidget(self.warning)
|
||||
lay.addWidget(split, 1)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
# Status bar
|
||||
self.status_log = QLabel()
|
||||
self.status_cache = QLabel()
|
||||
self.statusBar().addWidget(self.status_log, 1)
|
||||
self.statusBar().addPermanentWidget(self.status_cache)
|
||||
images.size_changed.connect(self._on_cache_size)
|
||||
self._on_cache_size(images.disk_usage())
|
||||
if not cards.available:
|
||||
self.warning.setText("MTGA card database not found: card names are unavailable. "
|
||||
"Set mtga_dir in ~/.config/mtga-assistant/config.toml.")
|
||||
self.warning.show()
|
||||
|
||||
self._build_menu()
|
||||
|
||||
self.refresh_timer = QTimer(self)
|
||||
self.refresh_timer.setInterval(REFRESH_MS)
|
||||
self.refresh_timer.timeout.connect(self.refresh)
|
||||
self.refresh_timer.start()
|
||||
|
||||
self.resize(1280, 800)
|
||||
if log_path is not None:
|
||||
self.start_log(log_path, replay_rate)
|
||||
else:
|
||||
self.status_log.setText("Player.log not found: use File → Open log…")
|
||||
|
||||
# -- menu ----------------------------------------------------------------
|
||||
|
||||
def _build_menu(self) -> None:
|
||||
file_menu = self.menuBar().addMenu("&File")
|
||||
open_act = QAction("&Open log…", self)
|
||||
open_act.triggered.connect(lambda: self._choose_log(None))
|
||||
replay_act = QAction("&Replay log…", self)
|
||||
replay_act.triggered.connect(lambda: self._choose_log(40.0))
|
||||
quit_act = QAction("&Quit", self)
|
||||
quit_act.setShortcut(QKeySequence.StandardKey.Quit)
|
||||
quit_act.triggered.connect(self.close)
|
||||
file_menu.addActions([open_act, replay_act])
|
||||
file_menu.addSeparator()
|
||||
file_menu.addAction(quit_act)
|
||||
|
||||
cache_menu = self.menuBar().addMenu("&Cache")
|
||||
clear_act = QAction("&Clear image cache", self)
|
||||
clear_act.triggered.connect(self._clear_cache)
|
||||
cache_menu.addAction(clear_act)
|
||||
|
||||
def _choose_log(self, replay_rate: float | None) -> None:
|
||||
start = str(self.follower.path.parent) if self.follower else str(Path.home())
|
||||
path, _ = QFileDialog.getOpenFileName(self, "Choose an MTGA log", start, "Logs (*.log);;All files (*)")
|
||||
if path:
|
||||
self.start_log(Path(path), replay_rate)
|
||||
|
||||
def _clear_cache(self) -> None:
|
||||
if QMessageBox.question(self, "Clear image cache",
|
||||
f"Delete all cached card images in {self.images.dir}?") == QMessageBox.StandardButton.Yes:
|
||||
self.images.clear()
|
||||
|
||||
def _on_cache_size(self, size: int) -> None:
|
||||
self.status_cache.setText(f"Image cache: {size / 1_048_576:.1f} / {self.images.max_bytes / 1_048_576:.0f} MB")
|
||||
|
||||
# -- log -----------------------------------------------------------------
|
||||
|
||||
def start_log(self, path: Path, replay_rate: float | None = None) -> None:
|
||||
if self.follower:
|
||||
self.follower.stop()
|
||||
self.follower.wait(2000)
|
||||
self._reset_tracker()
|
||||
self.follower = LogFollower(path, replay_rate)
|
||||
self.follower.events.connect(self._on_events)
|
||||
self.follower.status.connect(self.status_log.setText)
|
||||
self.follower.reset.connect(self._reset_tracker)
|
||||
self.follower.start()
|
||||
|
||||
def _reset_tracker(self) -> None:
|
||||
self.tracker = MatchTracker(card_lookup=self.cards)
|
||||
self._rendered_version = -1
|
||||
|
||||
def _on_events(self, events: list) -> None:
|
||||
for ev in events:
|
||||
self.tracker.handle(ev)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self.follower:
|
||||
self.follower.stop()
|
||||
self.follower.wait(2000)
|
||||
self.follower = None
|
||||
self.images.shutdown()
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
self.shutdown()
|
||||
super().closeEvent(event)
|
||||
|
||||
# -- rendering -------------------------------------------------------------
|
||||
|
||||
def _rows(self, counts: Counter[int], note=None, dim: bool = False) -> list[Row]:
|
||||
rows = []
|
||||
for grp, n in counts.items():
|
||||
if n <= 0:
|
||||
continue
|
||||
info = self.cards.get(grp)
|
||||
rows.append(Row(info=info, count=n, note=note(grp, n) if note else "", dim=dim))
|
||||
return rows
|
||||
|
||||
def refresh(self) -> None:
|
||||
t = self.tracker
|
||||
if t.version == self._rendered_version:
|
||||
return
|
||||
self._rendered_version = t.version
|
||||
g = t.game
|
||||
self._render_header(t, g)
|
||||
if g is None:
|
||||
self._render_idle(t)
|
||||
return
|
||||
self._render_mine(t, g)
|
||||
self._render_opp(t, g)
|
||||
|
||||
def _render_header(self, t: MatchTracker, g: GameState | None) -> None:
|
||||
if g is None:
|
||||
self.header.setText("Waiting for a match…")
|
||||
self.warning.setVisible(not self.cards.available)
|
||||
return
|
||||
opp = t.opponent_name or "opponent"
|
||||
parts = [f"<b>vs {opp}</b>"]
|
||||
if t.event_id:
|
||||
parts.append(t.event_id.replace("_", " "))
|
||||
parts.append(f"Game {g.game_number}")
|
||||
if g.turn:
|
||||
whose = "your" if g.active_player == g.my_seat else "their"
|
||||
parts.append(f"Turn {g.turn} ({whose})")
|
||||
if g.life:
|
||||
parts.append(f"Life: you {g.life.get(g.my_seat, '?')} · them {g.life.get(g.opp_seat, '?')}")
|
||||
if t.game_results:
|
||||
w = t.game_results.count("Won")
|
||||
parts.append(f"Games {w}-{len(t.game_results) - w}")
|
||||
if not t.in_match and t.last_result:
|
||||
parts.append(f"<b>Match {t.last_result.lower()}</b>")
|
||||
self.header.setText(" · ".join(parts))
|
||||
|
||||
lib_size = g.zone_size("ZoneType_Library", g.my_seat)
|
||||
unaccounted = sum(t.my_library_remaining().values()) - lib_size if g.zones else 0
|
||||
if g.desynced or unaccounted:
|
||||
self.warning.setText(
|
||||
"Part of the game log was skipped by MTGA (oversized message), so some zones may be "
|
||||
"out of date until they next change."
|
||||
+ (f" {unaccounted} card(s) of yours are unaccounted for." if unaccounted > 0 else "")
|
||||
)
|
||||
self.warning.show()
|
||||
else:
|
||||
self.warning.setVisible(not self.cards.available)
|
||||
|
||||
def _render_idle(self, t: MatchTracker) -> None:
|
||||
sections = []
|
||||
if d := t.submitted_deck:
|
||||
sections.append(Section("deck", f"{d.name or 'Last submitted deck'} ({sum(d.main.values())})",
|
||||
self._rows(d.main)))
|
||||
if d.sideboard:
|
||||
sections.append(Section("side", f"Sideboard ({sum(d.sideboard.values())})",
|
||||
self._rows(d.sideboard), collapsed_by_default=True))
|
||||
self.mine.set_sections(sections)
|
||||
self.opp.set_sections([])
|
||||
|
||||
def _render_mine(self, t: MatchTracker, g: GameState) -> None:
|
||||
me = g.my_seat
|
||||
lib_size = g.zone_size("ZoneType_Library", me)
|
||||
remaining = t.my_library_remaining()
|
||||
denom = lib_size or sum(remaining.values()) or 1
|
||||
|
||||
def pct(_grp: int, n: int) -> str:
|
||||
return f"{100 * n / denom:.0f}%"
|
||||
|
||||
# Every card in the deck; ones with no copies left stay listed, greyed out.
|
||||
lib_rows = [
|
||||
Row(self.cards.get(grp), n, pct(grp, n) if n else "", dim=not n)
|
||||
for grp in g.deck
|
||||
for n in [remaining.get(grp, 0)]
|
||||
]
|
||||
|
||||
sections = [
|
||||
Section("library", f"Library ({lib_size})", lib_rows),
|
||||
Section("hand", f"Hand ({g.zone_size('ZoneType_Hand', me)})", self._rows(g.cards_in("ZoneType_Hand", owner=me))),
|
||||
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=me))),
|
||||
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', me)})",
|
||||
self._rows(g.cards_in("ZoneType_Graveyard", owner=me))),
|
||||
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=me))),
|
||||
Section("sideboard", f"Sideboard ({sum(g.sideboard.values())})", self._rows(g.sideboard),
|
||||
collapsed_by_default=True),
|
||||
]
|
||||
self.mine.set_sections([s for s in sections if s.rows or s.key in ("library", "hand")])
|
||||
|
||||
def _render_opp(self, t: MatchTracker, g: GameState) -> None:
|
||||
opp = g.opp_seat
|
||||
hand_size = g.zone_size("ZoneType_Hand", opp)
|
||||
lib_size = g.zone_size("ZoneType_Library", opp)
|
||||
known_hand = g.opp_known_in("ZoneType_Hand")
|
||||
known_lib = g.opp_known_in("ZoneType_Library")
|
||||
seen = t.opp_seen_match
|
||||
sections = [
|
||||
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=opp))),
|
||||
Section("stack", "Stack", self._rows(g.cards_in("ZoneType_Stack", owner=opp))),
|
||||
Section("hand", f"Hand ({hand_size}, {sum(known_hand.values())} known)", self._rows(known_hand)),
|
||||
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', opp)})",
|
||||
self._rows(g.cards_in("ZoneType_Graveyard", owner=opp))),
|
||||
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=opp))),
|
||||
Section("library", f"Library ({lib_size}, {sum(known_lib.values())} known)", self._rows(known_lib)),
|
||||
Section("seen", f"Seen this match ({sum(seen.values())} cards)",
|
||||
[r for r in self._rows(seen) if not r.info.is_token]),
|
||||
]
|
||||
self.opp.set_sections([s for s in sections if s.rows or s.key in ("battlefield", "hand", "seen")])
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Large image of the hovered card, with a text fallback while it downloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QPixmap, QResizeEvent
|
||||
from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from ..carddb import CardDB
|
||||
from ..images import ImageCache
|
||||
|
||||
|
||||
class CardPreview(QWidget):
|
||||
def __init__(self, cards: CardDB, images: ImageCache, parent=None):
|
||||
super().__init__(parent)
|
||||
self.cards = cards
|
||||
self.images = images
|
||||
self.grp_id: int | None = None
|
||||
self._pixmap: QPixmap | None = None
|
||||
|
||||
self.image = QLabel()
|
||||
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.image.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
|
||||
self.image.setMinimumSize(200, 280)
|
||||
self.caption = QLabel()
|
||||
self.caption.setWordWrap(True)
|
||||
self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(4, 4, 4, 4)
|
||||
lay.addWidget(self.image, 1)
|
||||
lay.addWidget(self.caption)
|
||||
|
||||
images.image_ready.connect(self._on_image_ready)
|
||||
self._show_placeholder("Hover a card to preview it")
|
||||
|
||||
def show_card(self, grp_id: int) -> None:
|
||||
if grp_id == self.grp_id:
|
||||
return
|
||||
self.grp_id = grp_id
|
||||
info = self.cards.get(grp_id)
|
||||
details = [info.type_line]
|
||||
if info.power or info.toughness:
|
||||
details.append(f"{info.power}/{info.toughness}")
|
||||
if info.set_code:
|
||||
details.append(f"{info.set_code} #{info.collector_number}")
|
||||
self.caption.setText(f"<b>{info.name}</b><br>{' · '.join(d for d in details if d)}")
|
||||
pm = self.images.pixmap(grp_id)
|
||||
if pm is not None:
|
||||
self._pixmap = pm
|
||||
self._rescale()
|
||||
elif self.images.is_failed(grp_id):
|
||||
self._show_placeholder("No image available")
|
||||
else:
|
||||
self._show_placeholder("Loading image…")
|
||||
|
||||
def _show_placeholder(self, text: str) -> None:
|
||||
self._pixmap = None
|
||||
self.image.setPixmap(QPixmap())
|
||||
self.image.setText(text)
|
||||
|
||||
def _on_image_ready(self, grp_id: int) -> None:
|
||||
if grp_id == self.grp_id:
|
||||
self.grp_id = None
|
||||
self.show_card(grp_id)
|
||||
|
||||
def _rescale(self) -> None:
|
||||
if self._pixmap is None:
|
||||
return
|
||||
self.image.setPixmap(
|
||||
self._pixmap.scaled(self.image.size(), Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
)
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
super().resizeEvent(event)
|
||||
self._rescale()
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(320, 600)
|
||||
Vendored
+601
@@ -0,0 +1,601 @@
|
||||
{"MatchesV3":[]}
|
||||
[UnityCrossThreadLogger]==> EventJoin {"id":"53b1b1d2-ef72-461f-a4c6-93218bbffe0c","request":"{\"EventName\":\"Traditional_Historic_Play\",\"EntryCurrencyType\":\"None\",\"EntryCurrencyPaid\":0,\"CustomTokenId\":null,\"EventChoice\":\"\",\"DebugIgnoreEntryLimits\":false}"}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:34:31 AM
|
||||
<== EventJoin(53b1b1d2-ef72-461f-a4c6-93218bbffe0c)
|
||||
{"Course":{"CourseId":"9f838f46-df35-409e-919c-50298839beb1","InternalEventName":"Traditional_Historic_Play","CurrentModule":"DeckSelect","ModulePayload":"","CourseDeckSummary":{"Attributes":[],"PreferredCosmetics":{"Avatar":"","Sleeve":"","Pet":"","Title":"","Emotes":[]}},"CardPool":[],"CardStyles":[]},"InventoryInfo":{"SeqId":4,"Changes":[],"Gems":2800,"Gold":4395,"TotalVaultProgress":965,"wcTrackPosition":7,"WildCardCommons":173,"WildCardUnCommons":153,"WildCardRares":5,"CustomTokens":{"HotPocketsProgressiveRewards":1,"PlayInToken":5,"DraftToken":5,"Token_JumpIn":2,"StarterBundle_Gate_1":1,"SealedToken":2,"BonusPackProgress":7},"Boosters":[],"VouchersV2":{},"PrizeWallsUnlocked":[],"Cosmetics":{"ArtStyles":[{"Type":"ArtStyle","Id":"416780.DA","ArtId":416780,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"415990.DA","ArtId":415990,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"406523.SG","ArtId":406523,"Variant":"SG","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"404135.DA","ArtId":404135,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"406559.SG","ArtId":406559,"Variant":"SG","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"406626.SG","ArtId":406626,"Variant":"SG","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"404488.DA","ArtId":404488,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"409514.SG","ArtId":409514,"Variant":"SG","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"402959.DA","ArtId":402959,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"400332.DA","ArtId":400332,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"402057.DA","ArtId":402057,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"406664.SG","ArtId":406664,"Variant":"SG","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"413908.MP","ArtId":413908,"Variant":"MP","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"416644.SH","ArtId":416644,"Variant":"SH","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"416688.DA","ArtId":416688,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"419665.DA","ArtId":419665,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"411692.DA","ArtId":411692,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"416781.DA","ArtId":416781,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"419492.DA","ArtId":419492,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"410632.DA","ArtId":410632,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"410641.DA","ArtId":410641,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"410635.DA","ArtId":410635,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"419672.DA","ArtId":419672,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"416724.DA","ArtId":416724,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"416568.DA","ArtId":416568,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"419467.DA","ArtId":419467,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"SeasonReward"},{"Type":"ArtStyle","Id":"419630.DA","ArtId":419630,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"SeasonReward"},{"Type":"ArtStyle","Id":"419514.DA","ArtId":419514,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421172.DA","ArtId":421172,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"417870.JP","ArtId":417870,"Variant":"JP","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421184.DA","ArtId":421184,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421370.FF","ArtId":421370,"Variant":"FF","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421371.FF","ArtId":421371,"Variant":"FF","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421372.FF","ArtId":421372,"Variant":"FF","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421373.FF","ArtId":421373,"Variant":"FF","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421374.FF","ArtId":421374,"Variant":"FF","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"419511.DA","ArtId":419511,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"SeasonReward"},{"Type":"ArtStyle","Id":"419475.DA","ArtId":419475,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"SeasonReward"},{"Type":"ArtStyle","Id":"421185.DA","ArtId":421185,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421198.DA","ArtId":421198,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421180.DA","ArtId":421180,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"SeasonReward"},{"Type":"ArtStyle","Id":"421284.DA","ArtId":421284,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"SeasonReward"},{"Type":"ArtStyle","Id":"417357.DA","ArtId":417357,"Variant":"Line truncated
|
||||
[UnityCrossThreadLogger]==> DeckUpsertDeckV3 {"id":"7668648d-95b7-4564-8077-91d1e069f937","request":"{\"Summary\":{\"DeckId\":\"ad644e5b-5720-44ab-8d98-022c13d54788\",\"Mana\":\"\",\"Name\":\"Cruel Tutelage\",\"Attributes\":[{\"name\":\"Version\",\"value\":\"11\"},{\"name\":\"TileID\",\"value\":\"71860\"},{\"name\":\"LastPlayed\",\"value\":\"\\\"2026-10-16T00:34:31.4451208+00:00\\\"\"},{\"name\":\"LastUpdated\",\"value\":\"\\\"2026-10-14T15:39:44.5372957+00:00\\\"\"},{\"name\":\"IsFavorite\",\"value\":\"false\"},{\"name\":\"Format\",\"value\":\"TraditionalHistoric\"}],\"Description\":\"\",\"DeckTileId\":71860,\"DeckArtId\":0,\"IsCompanionValid\":false,\"PreferredCosmetics\":{\"Avatar\":\"\",\"Sleeve\":\"\",\"Pet\":\"\",\"Title\":\"\",\"Emotes\":[]},\"NetDeckFolderId\":null,\"IsNetDeck\":false,\"checkInbox\":false},\"Deck\":{\"MainDeck\":[{\"cardId\":71860,\"quantity\":4},{\"cardId\":97170,\"quantity\":1},{\"cardId\":93986,\"quantity\":4},{\"cardId\":77498,\"quantity\":4},{\"cardId\":94278,\"quantity\":4},{\"cardId\":73266,\"quantity\":4},{\"cardId\":102507,\"quantity\":4},{\"cardId\":75662,\"quantity\":4},{\"cardId\":97169,\"quantity\":6},{\"cardId\":95614,\"quantity\":2},{\"cardId\":91788,\"quantity\":4},{\"cardId\":83950,\"quantity\":4},{\"cardId\":68740,\"quantity\":4},{\"cardId\":94351,\"quantity\":1},{\"cardId\":87448,\"quantity\":1},{\"cardId\":73474,\"quantity\":1},{\"cardId\":106251,\"quantity\":4},{\"cardId\":70335,\"quantity\":4}],\"Sideboard\":[{\"cardId\":95614,\"quantity\":1},{\"cardId\":82853,\"quantity\":3},{\"cardId\":75118,\"quantity\":1},{\"cardId\":102775,\"quantity\":1},{\"cardId\":70205,\"quantity\":2},{\"cardId\":90718,\"quantity\":1},{\"cardId\":94877,\"quantity\":2},{\"cardId\":92204,\"quantity\":2},{\"cardId\":92407,\"quantity\":1},{\"cardId\":88998,\"quantity\":1}],\"CommandZone\":[],\"Companions\":[],\"CardSkins\":[]},\"ActionType\":\"Updated\"}"}
|
||||
[UnityCrossThreadLogger]==> EventSetDeckV3 {"id":"ddbd8dd5-7f9f-4156-9a57-f8337cc06ae5","request":"{\"EventName\":\"Traditional_Historic_Play\",\"Summary\":{\"DeckId\":\"ad644e5b-5720-44ab-8d98-022c13d54788\",\"Mana\":\"\",\"Name\":\"Cruel Tutelage\",\"Attributes\":[{\"name\":\"Version\",\"value\":\"11\"},{\"name\":\"TileID\",\"value\":\"71860\"},{\"name\":\"LastPlayed\",\"value\":\"\\\"2026-10-16T00:34:31.4451208+00:00\\\"\"},{\"name\":\"LastUpdated\",\"value\":\"\\\"2026-10-14T15:39:44.5372957+00:00\\\"\"},{\"name\":\"IsFavorite\",\"value\":\"false\"},{\"name\":\"Format\",\"value\":\"TraditionalHistoric\"}],\"Description\":\"\",\"DeckTileId\":71860,\"DeckArtId\":0,\"IsCompanionValid\":false,\"PreferredCosmetics\":{\"Avatar\":\"\",\"Sleeve\":\"\",\"Pet\":\"\",\"Title\":\"\",\"Emotes\":[]},\"NetDeckFolderId\":null,\"IsNetDeck\":false,\"checkInbox\":false},\"Deck\":{\"MainDeck\":[{\"cardId\":71860,\"quantity\":4},{\"cardId\":97170,\"quantity\":1},{\"cardId\":93986,\"quantity\":4},{\"cardId\":77498,\"quantity\":4},{\"cardId\":94278,\"quantity\":4},{\"cardId\":73266,\"quantity\":4},{\"cardId\":102507,\"quantity\":4},{\"cardId\":75662,\"quantity\":4},{\"cardId\":97169,\"quantity\":6},{\"cardId\":95614,\"quantity\":2},{\"cardId\":91788,\"quantity\":4},{\"cardId\":83950,\"quantity\":4},{\"cardId\":68740,\"quantity\":4},{\"cardId\":94351,\"quantity\":1},{\"cardId\":87448,\"quantity\":1},{\"cardId\":73474,\"quantity\":1},{\"cardId\":106251,\"quantity\":4},{\"cardId\":70335,\"quantity\":4}],\"Sideboard\":[{\"cardId\":95614,\"quantity\":1},{\"cardId\":82853,\"quantity\":3},{\"cardId\":75118,\"quantity\":1},{\"cardId\":102775,\"quantity\":1},{\"cardId\":70205,\"quantity\":2},{\"cardId\":90718,\"quantity\":1},{\"cardId\":94877,\"quantity\":2},{\"cardId\":92204,\"quantity\":2},{\"cardId\":92407,\"quantity\":1},{\"cardId\":88998,\"quantity\":1}],\"CommandZone\":[],\"Companions\":[],\"CardSkins\":[]}}"}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:34:31 AM
|
||||
<== DeckUpsertDeckV3(7668648d-95b7-4564-8077-91d1e069f937)
|
||||
{"DeckId":"ad644e5b-5720-44ab-8d98-022c13d54788","Name":"Cruel Tutelage","Attributes":[{"name":"Version","value":"11"},{"name":"TileID","value":"71860"},{"name":"LastPlayed","value":"\"2026-10-16T00:34:31.4451208+00:00\""},{"name":"LastUpdated","value":"\"2026-10-14T15:39:44.5372957+00:00\""},{"name":"IsFavorite","value":"false"},{"name":"Format","value":"TraditionalHistoric"}],"Description":"","DeckTileId":71860,"DeckArtId":0,"PreferredCosmetics":{"Avatar":"","Sleeve":"","Pet":"","Title":"","Emotes":[]}}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:34:31 AM
|
||||
<== EventSetDeckV3(ddbd8dd5-7f9f-4156-9a57-f8337cc06ae5)
|
||||
{"CourseId":"9f838f46-df35-409e-919c-50298839beb1","InternalEventName":"Traditional_Historic_Play","CurrentModule":"CreateMatch","ModulePayload":"","CourseDeckSummary":{"DeckId":"ad644e5b-5720-44ab-8d98-022c13d54788","Name":"Cruel Tutelage","Attributes":[{"name":"Version","value":"11"},{"name":"TileID","value":"71860"},{"name":"LastPlayed","value":"\"2026-10-16T00:34:31.4451208+00:00\""},{"name":"LastUpdated","value":"\"2026-10-14T15:39:44.5372957+00:00\""},{"name":"IsFavorite","value":"false"},{"name":"Format","value":"TraditionalHistoric"}],"Description":"","DeckTileId":71860,"DeckArtId":0,"PreferredCosmetics":{"Avatar":"","Sleeve":"","Pet":"","Title":"","Emotes":[]}},"CourseDeck":{"MainDeck":[{"cardId":71860,"quantity":4},{"cardId":97170,"quantity":1},{"cardId":93986,"quantity":4},{"cardId":77498,"quantity":4},{"cardId":94278,"quantity":4},{"cardId":73266,"quantity":4},{"cardId":102507,"quantity":4},{"cardId":75662,"quantity":4},{"cardId":97169,"quantity":6},{"cardId":95614,"quantity":2},{"cardId":91788,"quantity":4},{"cardId":83950,"quantity":4},{"cardId":68740,"quantity":4},{"cardId":94351,"quantity":1},{"cardId":87448,"quantity":1},{"cardId":73474,"quantity":1},{"cardId":106251,"quantity":4},{"cardId":70335,"quantity":4}],"Sideboard":[{"cardId":95614,"quantity":1},{"cardId":82853,"quantity":3},{"cardId":75118,"quantity":1},{"cardId":102775,"quantity":1},{"cardId":70205,"quantity":2},{"cardId":90718,"quantity":1},{"cardId":94877,"quantity":2},{"cardId":92204,"quantity":2},{"cardId":92407,"quantity":1},{"cardId":88998,"quantity":1}],"CommandZone":[],"Companions":[],"CardSkins":[]},"CardPool":[],"CardStyles":[]}
|
||||
[UnityCrossThreadLogger]==> EventEnterPairing {"id":"180f5e47-4e44-4798-9a30-df046b1d285c","request":"{\"EventName\":\"Traditional_Historic_Play\",\"EventCode\":null}"}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:34:31 AM
|
||||
<== EventEnterPairing(180f5e47-4e44-4798-9a30-df046b1d285c)
|
||||
{"CurrentModule":"CreateMatch","Payload":"Success"}
|
||||
Unloading 4 Unused Serialized files (Serialized files now loaded: 299)
|
||||
Unloading 11767 unused Assets to reduce memory usage. Loaded Objects now: 26584.
|
||||
Total: 61.613200 ms (FindLiveObjects: 1.378800 ms CreateObjectMapping: 1.154200 ms MarkObjects: 54.909400 ms DeleteObjects: 4.170200 ms)
|
||||
|
||||
OnSceneLoaded for MatchScene
|
||||
There can be only one active Event System.
|
||||
OnSceneLoaded for PreGameScene
|
||||
OnSceneUnloaded for NavBar
|
||||
cannot find async scene unload for NavBar
|
||||
OnSceneUnloaded for MainNavigation
|
||||
cannot find async scene unload for MainNavigation
|
||||
Caches cleared
|
||||
Unloading 1 Unused Serialized files (Serialized files now loaded: 255)
|
||||
Unloading 10110 unused Assets to reduce memory usage. Loaded Objects now: 12270.
|
||||
Total: 71.226500 ms (FindLiveObjects: 0.839700 ms CreateObjectMapping: 0.876100 ms MarkObjects: 65.183800 ms DeleteObjects: 4.326200 ms)
|
||||
|
||||
Reference Assets/Core/Art/Meta/Cosmetics/Pets/Images/Icon_Pet_AFR_Dragon_U.png named AFR_DragonSkin3 not cleaned up properly.
|
||||
Reference Assets/Core/Art/Meta/Cosmetics/Pets/Images/Icon_Pet_AFR_Dragon_U.png named AFR_DragonSkin3 not cleaned up properly.
|
||||
Reference Assets/Core/Art/Meta/Cosmetics/Pets/Images/Icon_Pet_AFR_Dragon_U.png named AFR_DragonSkin3 not cleaned up properly.
|
||||
Reference Assets/Core/Art/Meta/Cosmetics/Pets/Images/Icon_Pet_AFR_Dragon_U.png named AFR_DragonSkin3 not cleaned up properly.
|
||||
[UnityCrossThreadLogger]10/16/2026 12:34:51 AM: Match to LOCALPLAYERID0000000000000: AuthenticateResponse
|
||||
{ "transactionId": "c63e3b86-1930-44bd-bcf5-a7662be2fb42", "requestId": 1, "timestamp": "639277076914576915", "authenticateResponse": { "clientId": "LOCALPLAYERID0000000000000", "sessionId": "00000000-0000-0000-0000-000000000000", "screenName":"Local Player" } }
|
||||
[UnityCrossThreadLogger]STATE CHANGED {"old":"None","new":"ConnectedToMatchDoor"}
|
||||
[UnityCrossThreadLogger]Connecting to matchId c0a18506-9d87-4d0c-a2d3-e10bef8659d3
|
||||
[UnityCrossThreadLogger]STATE CHANGED {"old":"ConnectedToMatchDoor","new":"ConnectedToMatchDoor_ConnectingToGRE"}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:34:51 AM: Match to LOCALPLAYERID0000000000000: MatchGameRoomStateChangedEvent
|
||||
{ "transactionId": "2f0123f2-3c0b-417a-a5c4-2258f3d25569", "requestId": 2, "timestamp": "1792110891750", "matchGameRoomStateChangedEvent": { "gameRoomInfo": { "gameRoomConfig": { "reservedPlayers": [ { "userId": "LOCALPLAYERID0000000000000", "playerName":"Local Player", "systemSeatId": 1, "teamId": 1, "courseId": "Avatar_Basic_BilboRiddles_HOB", "sessionId": "00000000-0000-0000-0000-000000000000", "platformId": "SteamWindows", "eventId": "Traditional_Historic_Play" }, { "userId": "OPPONENTID000000", "playerName":"Opponent Player", "systemSeatId": 2, "teamId": 2, "courseId": "Avatar_Basic_Adventurer", "sessionId": "00000000-0000-0000-0000-000000000000", "platformId": "Windows", "eventId": "Traditional_Historic_Play" } ], "matchId": "c0a18506-9d87-4d0c-a2d3-e10bef8659d3" }, "stateType": "MatchGameRoomStateType_Playing", "players": [ { "userId": "LOCALPLAYERID0000000000000", "playerName":"Local Player", "systemSeatId": 1, "teamId": 1 }, { "userId": "OPPONENTID000000", "playerName":"Opponent Player", "systemSeatId": 2, "teamId": 2 } ] } } }
|
||||
[UnityCrossThreadLogger]STATE CHANGED {"old":"ConnectedToMatchDoor_ConnectingToGRE","new":"Playing"}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:34:51 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "2f0123f2-3c0b-417a-a5c4-2258f3d25569", "requestId": 2, "timestamp": "1792110891791", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_ConnectResp", "systemSeatIds": [ 1 ], "msgId": 2, "connectResp": { "status": "ConnectionStatus_Success", "protoVer": "ProtoVersion_PersistentAnnotations", "settings": { "stops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" } ], "autoPassOption": "AutoPassOption_ResolveMyStackEffects", "graveyardOrder": "OrderingType_OrderArbitraryAlways", "manaSelectionType": "ManaSelectionType_Auto", "defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects", "smartStopsSetting": "SmartStopsSetting_Enable", "autoTapStopsSetting": "AutoTapStopsSetting_Enable", "autoOptionalPaymentCancellationSetting": "Setting_Enable", "transientStops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SLine truncated
|
||||
OnSceneLoaded for Battlefield_VOW
|
||||
OnSceneLoaded for DuelScene
|
||||
[UnityCrossThreadLogger]10/16/2026 12:34:53 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
|
||||
{
|
||||
"requestId": 3,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
|
||||
"timestamp": "639277076930443443",
|
||||
"transactionId": "547bd1dd-afcd-4073-bc42-5564a1f55936",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_SetSettingsReq",
|
||||
"setSettingsReq": {
|
||||
"settings": {
|
||||
"autoPassOption": "AutoPassOption_ResolveMyStackEffects",
|
||||
"manaSelectionType": "ManaSelectionType_Auto",
|
||||
"defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects",
|
||||
"autoSelectReplacementSetting": "Setting_Enable"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:34:53 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "547bd1dd-afcd-4073-bc42-5564a1f55936", "requestId": 3, "timestamp": "1792110893078", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_SetSettingsResp", "systemSeatIds": [ 1 ], "msgId": 7, "gameStateId": 1, "setSettingsResp": { "settings": { "stops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" } ], "autoPassOption": "AutoPassOption_ResolveMyStackEffects", "graveyardOrder": "OrderingType_OrderArbitraryAlways", "manaSelectionType": "ManaSelectionType_Auto", "defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects", "smartStopsSetting": "SmartStopsSetting_Enable", "autoTapStopsSetting": "AutoTapStopsSetting_Enable", "autoOptionalPaymentCancellationSetting": "Setting_Enable", "transientStops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "Line truncated
|
||||
OnSceneUnloaded for PreGameScene
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:02 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
|
||||
{
|
||||
"requestId": 4,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
|
||||
"timestamp": "639277077020272410",
|
||||
"transactionId": "70cf4fe7-eed6-46b4-a124-e5e7836a4f3b",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_ChooseStartingPlayerResp",
|
||||
"gameStateId": 1,
|
||||
"respId": 5,
|
||||
"chooseStartingPlayerResp": {
|
||||
"teamType": "TeamType_Individual",
|
||||
"systemSeatId": 1,
|
||||
"teamId": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:02 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "70cf4fe7-eed6-46b4-a124-e5e7836a4f3b", "requestId": 4, "timestamp": "1792110902076", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 8, "gameStateId": 2, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 2, "players": [ { "lifeTotal": 20, "systemSeatNumber": 1, "status": "PlayerStatus_InGame", "maxHandSize": 7, "teamId": 1, "timerIds": [ 1, 2, 3, 4, 5, 6, 7 ], "controllerSeatId": 1, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_MulliganResp", "startingLifeTotal": 20 }, { "lifeTotal": 20, "systemSeatNumber": 2, "status": "PlayerStatus_InGame", "maxHandSize": 7, "teamId": 2, "timerIds": [ 8, 9, 10, 11, 12, 13, 14 ], "controllerSeatId": 2, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_MulliganResp", "startingLifeTotal": 20 } ], "turnInfo": { "activePlayer": 1, "decisionPlayer": 1 }, "zones": [ { "zoneId": 31, "type": "ZoneType_Hand", "visibility": "Visibility_Private", "ownerSeatId": 1, "objectInstanceIds": [ 170, 169, 168, 167, 166, 165, 164 ], "viewers": [ 1 ] }, { "zoneId": 32, "type": "ZoneType_Library", "visibility": "Visibility_Hidden", "ownerSeatId": 1, "objectInstanceIds": [ 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223 ] }, { "zoneId": 35, "type": "ZoneType_Hand", "visibility": "Visibility_Private", "ownerSeatId": 2, "objectInstanceIds": [ 230, 229, 228, 227, 226, 225, 224 ], "viewers": [ 2 ] }, { "zoneId": 36, "type": "ZoneType_Library", "visibility": "Visibility_Hidden", "ownerSeatId": 2, "objectInstanceIds": [ 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283 ] } ], "gameObjects": [ { "instanceId": 164, "grpId": 97169, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "superTypes": [ "SuperType_Basic" ], "cardTypes": [ "CardType_Land" ], "subtypes": [ "SubType_Island" ], "viewers": [ 1 ], "name": 652, "overlayGrpId": 97169, "uniqueAbilities": [ { "id": 54, "grpId": 1002 } ] }, { "instanceId": 165, "grpId": 94278, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Instant" ], "color": [ "CardColor_Blue" ], "viewers": [ 1 ], "name": 529845, "overlayGrpId": 94278, "uniqueAbilities": [ { "id": 29, "grpId": 269 }, { "id": 30, "grpId": 25848 } ] }, { "instanceId": 166, "grpId": 71860, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Enchantment" ], "color": [ "CardColor_Blue" ], "viewers": [ 1 ], "name": 433755, "overlayGrpId": 71860, "uniqueAbilities": [ { "id": 6, "grpId": 92280 }, { "id": 7, "grpId": 137881 } ] }, { "instanceId": 167, "grpId": 91788, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Land" ], "viewers": [ 1 ], "name": 414451, "overlayGrpId": 91788, "uniqueAbilities": [ { "id": 60, "grpId": 136225 } ] }, { "instanceId": 168, "grpId": 87448, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Land" ], "viewers": [ 1 ], "name": 738112, "overlayGrpId": 87448, "uniqueAbilities": [ { "id": 82, "grpId": 180111 }, { "id": 83, "grpId": 1167 }, { "id": 84, "grpId": 169785 }, { "id": 85, "grpId": 169786 } ] }, { "instanceId": 169, "grpId": 73266, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Creature" ], "subtypes": [ "SubType_Crab" ], "color": [ "CardColor_Blue" ], "power": { }, "toughness": { "value": 3 }, "viewers": [ 1 ], "name": 437203, "overlayGrpId": 73266, "uniqueAbilities": [ { "id": 40, "grpId": 138408 } ] }, { "instanceId": 170, "grpId": 68740, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Land" ], "subtypes": [ "SubType_Island", "SubType_Swamp" ], "viewers": [ 1 ], "name": 30133, "overlayGrpId": 68740, "uniqueAbilities": [ { "id": 78, "grpId": 1003 }, { "id": 79, "grpId": 1002 }, { "id": 80, "grpId": 90846 } ] } ], "annotations": [ { "id": 76, "affectorId": 1, "affectedIds": [ 1 ], "type": [ "AnnotationType_NewTurnStarted" ] } ], "pendingMessageCount": 1, "prevGameStateId": 1, "timers": [ { "timerId": 1, "type": "TimerType_ProLine truncated
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:04 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 5,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077041146547",
|
||||
"transactionId": "211ff842-4b99-4b40-9500-eeda87188dd5",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:04 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 6,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077044826475",
|
||||
"transactionId": "2612646a-cad1-45e0-9bd1-e8ea6b7861f4",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:05 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
|
||||
{
|
||||
"requestId": 7,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
|
||||
"timestamp": "639277077056599346",
|
||||
"transactionId": "964f666c-3a18-401f-a657-2ba3d42a4d04",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_MulliganResp",
|
||||
"gameStateId": 2,
|
||||
"respId": 9,
|
||||
"mulliganResp": {
|
||||
"decision": "MulliganOption_AcceptHand"
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:05 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "964f666c-3a18-401f-a657-2ba3d42a4d04", "requestId": 7, "timestamp": "1792110905707", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 10, "gameStateId": 3, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 3, "players": [ { "lifeTotal": 20, "systemSeatNumber": 1, "status": "PlayerStatus_InGame", "maxHandSize": 7, "teamId": 1, "timerIds": [ 1, 2, 3, 4, 5, 6, 7 ], "controllerSeatId": 1, "controllerType": "ControllerType_Player", "startingLifeTotal": 20 } ], "turnInfo": { "activePlayer": 1, "decisionPlayer": 2 }, "pendingMessageCount": 1, "prevGameStateId": 2, "timers": [ { "timerId": 1, "type": "TimerType_Prologue", "durationSec": 120, "elapsedSec": 13, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 13914 }, { "timerId": 6, "type": "TimerType_Inactivity", "durationSec": 150, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30 }, { "timerId": 7, "type": "TimerType_MatchClock", "durationSec": 1800, "elapsedSec": 13, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 13914 }, { "timerId": 8, "type": "TimerType_Prologue", "durationSec": 120, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 1 }, { "timerId": 13, "type": "TimerType_Inactivity", "durationSec": 150, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 1 }, { "timerId": 14, "type": "TimerType_MatchClock", "durationSec": 1800, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 1 } ], "update": "GameStateUpdate_SendAndRecord", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 169, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 164 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 167 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 170 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 168 } } ] } }, { "type": "GREMessageType_PromptReq", "systemSeatIds": [ 1, 2 ], "msgId": 11, "gameStateId": 3, "prompt": { "promptId": 37, "parameters": [ { "parameterName": "PlayerId", "type": "ParameterType_Reference", "reference": { "type": "ReferenceType_PlayerSeatId", "id": 1 } } ] } } ] } }
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:06 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 8,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077061142090",
|
||||
"transactionId": "d1aa0e84-c106-44be-b840-acd3b97f4c84",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {
|
||||
"objectId": 165
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:06 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 9,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077061315553",
|
||||
"transactionId": "06c95a02-f598-42a4-bc78-a217e3abe6d7",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:06 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 10,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077061404856",
|
||||
"transactionId": "4eeb8bf7-e5f4-4b60-b8b5-647447d12006",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {
|
||||
"objectId": 170
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:06 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "06085585-8ac8-4c74-9db9-08ee6bdba377", "requestId": 4, "timestamp": "1792110906631", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_UIMessage", "systemSeatIds": [ 1 ], "uiMessage": { "seatIds": [ 2 ], "onHover": { } } } ] } }
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:06 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "8ac8d6d3-a810-4e92-afb9-2cb4f37fdc26", "requestId": 5, "timestamp": "1792110906823", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_UIMessage", "systemSeatIds": [ 1 ], "uiMessage": { "seatIds": [ 2 ], "onHover": { } } } ] } }
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:07 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "c4344ffc-4182-471b-aeba-50567f8adc6c", "requestId": 6, "timestamp": "1792110907812", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_UIMessage", "systemSeatIds": [ 1 ], "uiMessage": { "seatIds": [ 2 ], "onHover": { } } } ] } }
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:08 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 11,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077082945543",
|
||||
"transactionId": "dd785371-e392-4e8f-a513-eada3c52a626",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {
|
||||
"objectId": 164
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:09 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "f10f5093-bdfd-4b61-9eb5-3df8d014fc77", "requestId": 7, "timestamp": "1792110909129", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_SetSettingsResp", "systemSeatIds": [ 1 ], "msgId": 14, "gameStateId": 3, "setSettingsResp": { "settings": { "stops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" } ], "autoPassOption": "AutoPassOption_Clear", "graveyardOrder": "OrderingType_OrderArbitraryAlways", "manaSelectionType": "ManaSelectionType_Auto", "defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects", "smartStopsSetting": "SmartStopsSetting_Enable", "autoTapStopsSetting": "AutoTapStopsSetting_Enable", "autoOptionalPaymentCancellationSetting": "Setting_Enable", "transientStops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StoLine truncated
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:09 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
|
||||
{
|
||||
"requestId": 12,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
|
||||
"timestamp": "639277077097327477",
|
||||
"transactionId": "7b4a270a-d961-444d-afd7-493b93362c08",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_PerformActionResp",
|
||||
"gameStateId": 6,
|
||||
"respId": 19,
|
||||
"performActionResp": {
|
||||
"actions": [
|
||||
{
|
||||
"actionType": "ActionType_Play",
|
||||
"grpId": 97169,
|
||||
"instanceId": 164,
|
||||
"facetId": 164,
|
||||
"shouldStop": true
|
||||
}
|
||||
],
|
||||
"autoPassPriority": "AutoPassPriority_Yes"
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:09 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 13,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077098083989",
|
||||
"transactionId": "a3f6d8ca-9dfc-4ea8-a29d-6d0bab8a43d5",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:09 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "7b4a270a-d961-444d-afd7-493b93362c08", "requestId": 12, "timestamp": "1792110909786", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 20, "gameStateId": 7, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 7, "zones": [ { "zoneId": 28, "type": "ZoneType_Battlefield", "visibility": "Visibility_Public", "objectInstanceIds": [ 284 ] }, { "zoneId": 30, "type": "ZoneType_Limbo", "visibility": "Visibility_Public", "objectInstanceIds": [ 164 ] }, { "zoneId": 31, "type": "ZoneType_Hand", "visibility": "Visibility_Private", "ownerSeatId": 1, "objectInstanceIds": [ 170, 169, 168, 167, 166, 165 ], "viewers": [ 1 ] } ], "gameObjects": [ { "instanceId": 164, "grpId": 97169, "type": "GameObjectType_Card", "zoneId": 30, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "superTypes": [ "SuperType_Basic" ], "cardTypes": [ "CardType_Land" ], "subtypes": [ "SubType_Island" ], "viewers": [ 1 ], "name": 652, "overlayGrpId": 97169, "uniqueAbilities": [ { "id": 54, "grpId": 1002 } ] }, { "instanceId": 284, "grpId": 97169, "type": "GameObjectType_Card", "zoneId": 28, "visibility": "Visibility_Public", "ownerSeatId": 1, "controllerSeatId": 1, "superTypes": [ "SuperType_Basic" ], "cardTypes": [ "CardType_Land" ], "subtypes": [ "SubType_Island" ], "name": 652, "overlayGrpId": 97169, "uniqueAbilities": [ { "id": 235, "grpId": 1002 } ] } ], "annotations": [ { "id": 80, "affectedIds": [ 164 ], "type": [ "AnnotationType_ObjectIdChanged" ], "details": [ { "key": "orig_id", "type": "KeyValuePairValueType_int32", "valueInt32": [ 164 ] }, { "key": "new_id", "type": "KeyValuePairValueType_int32", "valueInt32": [ 284 ] } ] }, { "id": 81, "affectedIds": [ 284 ], "type": [ "AnnotationType_ZoneTransfer" ], "details": [ { "key": "zone_src", "type": "KeyValuePairValueType_int32", "valueInt32": [ 31 ] }, { "key": "zone_dest", "type": "KeyValuePairValueType_int32", "valueInt32": [ 28 ] }, { "key": "category", "type": "KeyValuePairValueType_string", "valueString": [ "PlayLand" ] } ] }, { "id": 82, "affectorId": 1, "affectedIds": [ 284 ], "type": [ "AnnotationType_UserActionTaken" ], "details": [ { "key": "actionType", "type": "KeyValuePairValueType_int32", "valueInt32": [ 3 ] }, { "key": "abilityGrpId", "type": "KeyValuePairValueType_int32", "valueInt32": [ 0 ] } ] } ], "pendingMessageCount": 1, "prevGameStateId": 6, "timers": [ { "timerId": 3, "type": "TimerType_ActivePlayer", "durationSec": 68, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 5, "type": "TimerType_Delay", "durationSec": 2, "running": true, "behavior": "TimerBehavior_StartDelayedTimer", "elapsedMs": 649 }, { "timerId": 7, "type": "TimerType_MatchClock", "durationSec": 1800, "elapsedSec": 14, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 14564 } ], "update": "GameStateUpdate_SendAndRecord", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 169, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 167 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 170 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 168 } }, { "seatId": 1, "action": { "actionType": "ActionType_Activate_Mana", "instanceId": 284, "abilityGrpId": 1002 } } ], "persistentAnnotations": [ { "id": 5, "affectorId": 28, "affectedIds": [ 284 ], "type": [ "AnnotationType_EnteredZoneThisTurn" ] }, { "id": 84, "affectorId": 284, "affectedIds": [ 284 ], "type": [ "AnnotationType_ColorProduction" ], "details": [ { "key": "colors", "type": "KeyValuePairValueType_int32", "valueInt32": [ 2 ] } ] } ] } }, { "type": "GREMessageType_ActionsAvailableReq", "systemSeatIds": [ 1 ], "msgId": 21, "gameStateId": 7, "prompt": { "promptId": 2 }, "actionsAvailableReq": { "actions": [ { "actionType": "ActionType_Cast", "grpId": 94278, "instanceId": 165, "facetId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ], "shouldStop": true, "autoTapSolution": { "autoTapActions": [ { "instanceId": 284, "abilityGrpId": 1002, "manaPaymentOption": { "mana": [ { "manaId": 1, "color": "ManaColor_Blue", "srcInstanceId": 284, "specs": [ { "type": "ManaSpecType_Predictive" } ], "abilityGrpId": 1002, "count": 1 } ] } } ] } }, { "actionType": "ActionType_Cast", "grpId": 71860, "instanceId": 166, "facetId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count"Line truncated
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:09 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 14,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077099127670",
|
||||
"transactionId": "50fdb5af-7c78-4d45-8d62-e04f26ed0d7a",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {
|
||||
"objectId": 169
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:10 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
|
||||
{
|
||||
"requestId": 15,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
|
||||
"timestamp": "639277077102439495",
|
||||
"transactionId": "a3633719-ff1a-4b3d-9f45-48580a4285ba",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_PerformActionResp",
|
||||
"gameStateId": 7,
|
||||
"respId": 21,
|
||||
"performActionResp": {
|
||||
"actions": [
|
||||
{
|
||||
"actionType": "ActionType_Cast",
|
||||
"grpId": 73266,
|
||||
"instanceId": 169,
|
||||
"facetId": 169,
|
||||
"manaCost": [
|
||||
{
|
||||
"color": [
|
||||
"ManaColor_Blue"
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"shouldStop": true,
|
||||
"autoTapSolution": {
|
||||
"autoTapActions": [
|
||||
{
|
||||
"instanceId": 284,
|
||||
"abilityGrpId": 1002,
|
||||
"manaPaymentOption": {
|
||||
"mana": [
|
||||
{
|
||||
"manaId": 1,
|
||||
"color": "ManaColor_Blue",
|
||||
"srcInstanceId": 284,
|
||||
"specs": [
|
||||
{
|
||||
"type": "ManaSpecType_Predictive"
|
||||
}
|
||||
],
|
||||
"abilityGrpId": 1002,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"autoPassPriority": "AutoPassPriority_Yes"
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:10 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 16,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077103568649",
|
||||
"transactionId": "30282bac-1512-42ea-874f-0cd320e6f609",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:10 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "a3633719-ff1a-4b3d-9f45-48580a4285ba", "requestId": 15, "timestamp": "1792110910338", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 22, "gameStateId": 8, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 8, "zones": [ { "zoneId": 27, "type": "ZoneType_Stack", "visibility": "Visibility_Public", "objectInstanceIds": [ 285 ] }, { "zoneId": 30, "type": "ZoneType_Limbo", "visibility": "Visibility_Public", "objectInstanceIds": [ 169, 164 ] }, { "zoneId": 31, "type": "ZoneType_Hand", "visibility": "Visibility_Private", "ownerSeatId": 1, "objectInstanceIds": [ 170, 168, 167, 166, 165 ], "viewers": [ 1 ] } ], "gameObjects": [ { "instanceId": 169, "grpId": 73266, "type": "GameObjectType_Card", "zoneId": 30, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Creature" ], "subtypes": [ "SubType_Crab" ], "color": [ "CardColor_Blue" ], "power": { }, "toughness": { "value": 3 }, "viewers": [ 1 ], "name": 437203, "overlayGrpId": 73266, "uniqueAbilities": [ { "id": 40, "grpId": 138408 } ] }, { "instanceId": 284, "grpId": 97169, "type": "GameObjectType_Card", "zoneId": 28, "visibility": "Visibility_Public", "ownerSeatId": 1, "controllerSeatId": 1, "superTypes": [ "SuperType_Basic" ], "cardTypes": [ "CardType_Land" ], "subtypes": [ "SubType_Island" ], "isTapped": true, "name": 652, "overlayGrpId": 97169, "uniqueAbilities": [ { "id": 235, "grpId": 1002 } ] }, { "instanceId": 285, "grpId": 73266, "type": "GameObjectType_Card", "zoneId": 27, "visibility": "Visibility_Public", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Creature" ], "subtypes": [ "SubType_Crab" ], "color": [ "CardColor_Blue" ], "power": { }, "toughness": { "value": 3 }, "name": 437203, "overlayGrpId": 73266, "uniqueAbilities": [ { "id": 236, "grpId": 138408 } ] } ], "annotations": [ { "id": 85, "affectedIds": [ 169 ], "type": [ "AnnotationType_ObjectIdChanged" ], "details": [ { "key": "orig_id", "type": "KeyValuePairValueType_int32", "valueInt32": [ 169 ] }, { "key": "new_id", "type": "KeyValuePairValueType_int32", "valueInt32": [ 285 ] } ] }, { "id": 86, "affectedIds": [ 285 ], "type": [ "AnnotationType_ZoneTransfer" ], "details": [ { "key": "zone_src", "type": "KeyValuePairValueType_int32", "valueInt32": [ 31 ] }, { "key": "zone_dest", "type": "KeyValuePairValueType_int32", "valueInt32": [ 27 ] }, { "key": "category", "type": "KeyValuePairValueType_string", "valueString": [ "CastSpell" ] } ] }, { "id": 87, "affectorId": 284, "affectedIds": [ 286 ], "type": [ "AnnotationType_AbilityInstanceCreated" ], "details": [ { "key": "source_zone", "type": "KeyValuePairValueType_int32", "valueInt32": [ 28 ] } ] }, { "id": 88, "affectorId": 286, "affectedIds": [ 284 ], "type": [ "AnnotationType_TappedUntappedPermanent" ], "details": [ { "key": "tapped", "type": "KeyValuePairValueType_int32", "valueInt32": [ 1 ] } ] }, { "id": 89, "affectorId": 1, "affectedIds": [ 286 ], "type": [ "AnnotationType_UserActionTaken" ], "details": [ { "key": "actionType", "type": "KeyValuePairValueType_int32", "valueInt32": [ 4 ] }, { "key": "abilityGrpId", "type": "KeyValuePairValueType_int32", "valueInt32": [ 1002 ] } ] }, { "id": 90, "affectorId": 284, "affectedIds": [ 285 ], "type": [ "AnnotationType_ManaPaid" ], "details": [ { "key": "id", "type": "KeyValuePairValueType_int32", "valueInt32": [ 3 ] }, { "key": "color", "type": "KeyValuePairValueType_int32", "valueInt32": [ 2 ] } ] }, { "id": 91, "affectorId": 284, "affectedIds": [ 286 ], "type": [ "AnnotationType_AbilityInstanceDeleted" ] }, { "id": 92, "affectorId": 1, "affectedIds": [ 285 ], "type": [ "AnnotationType_UserActionTaken" ], "details": [ { "key": "actionType", "type": "KeyValuePairValueType_int32", "valueInt32": [ 1 ] }, { "key": "abilityGrpId", "type": "KeyValuePairValueType_int32", "valueInt32": [ 0 ] } ] } ], "diffDeletedInstanceIds": [ 286 ], "prevGameStateId": 7, "timers": [ { "timerId": 3, "type": "TimerType_ActivePlayer", "durationSec": 75, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 5, "type": "TimerType_Delay", "durationSec": 2, "elapsedSec": 1, "behavior": "TimerBehavior_StartDelayedTimer", "elapsedMs": 1155 }, { "timerId": 7, "type": "TimerType_MatchClock", "durationSec": 1800, "elapsedSec": 15, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 15069 } ], "update": "GameStateUpdate_SendHiFi", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 169, "manaCost": [ { "color": [ "ManaColor_Blue" ], "Line truncated
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:11 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "6a6cc617-24ab-4d80-ad8b-de6ebdde78b2", "requestId": 8, "timestamp": "1792110911594", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_UIMessage", "systemSeatIds": [ 1 ], "uiMessage": { "seatIds": [ 2 ], "onChat": { "text": "Phrase_Basic_Hello" } } } ] } }
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:12 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 17,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077121691790",
|
||||
"transactionId": "a056a52c-9892-4aeb-8a19-2695cc8cfe64",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {
|
||||
"objectId": 285
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:12 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 18,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077121764773",
|
||||
"transactionId": "c4e289e4-ec09-47e9-afd9-f6e84f11d0f9",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:12 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "timestamp": "1792110912338", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_TimerStateMessage", "systemSeatIds": [ 1, 2 ], "msgId": 42, "gameStateId": 26, "timerStateMessage": { "seatId": 2, "timers": [ { "timerId": 8, "type": "TimerType_Prologue", "durationSec": 120, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 9, "type": "TimerType_Epilogue", "durationSec": 145, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 10, "type": "TimerType_ActivePlayer", "durationSec": 61, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 11, "type": "TimerType_NonActivePlayer", "durationSec": 45, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 12, "type": "TimerType_Delay", "durationSec": 2, "behavior": "TimerBehavior_StartDelayedTimer" }, { "timerId": 13, "type": "TimerType_Inactivity", "durationSec": 150, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30 }, { "timerId": 14, "type": "TimerType_MatchClock", "durationSec": 1800, "elapsedSec": 5, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 5417 } ] } } ] } }
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:12 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 19,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077125998520",
|
||||
"transactionId": "f0daff02-794f-4a27-8709-76fa68cbfa9e",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {
|
||||
"objectId": 285
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:12 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 20,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077126278570",
|
||||
"transactionId": "864a802d-e833-4b8e-b714-993f9ebc61de",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onHover": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:14 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
|
||||
{
|
||||
"requestId": 21,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
|
||||
"timestamp": "639277077140142120",
|
||||
"transactionId": "943a2900-e1de-4961-9e09-9002e36d7728",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_UIMessage",
|
||||
"systemSeatId": 1,
|
||||
"uiMessage": {
|
||||
"seatIds": [
|
||||
2
|
||||
],
|
||||
"onChat": {
|
||||
"text": "Phrase_OTJ_Howdy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:15 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "e6f344f9-5e8f-4d56-92a9-9b2a83d750f6", "requestId": 9, "timestamp": "1792110915443", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 43, "gameStateId": 27, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 27, "gameInfo": { "matchID": "c0a18506-9d87-4d0c-a2d3-e10bef8659d3", "gameNumber": 1, "stage": "GameStage_GameOver", "type": "GameType_Duel", "variant": "GameVariant_Normal", "matchState": "MatchState_GameComplete", "matchWinCondition": "MatchWinCondition_Best2of3", "maxTimeoutCount": 4, "maxPipCount": 3, "timeoutDurationSec": 30, "results": [ { "scope": "MatchScope_Game", "result": "ResultType_WinLoss", "winningTeamId": 1, "reason": "ResultReason_Concede" } ], "superFormat": "SuperFormat_Constructed", "mulliganType": "MulliganType_London", "deckConstraintInfo": { "minDeckSize": 60, "maxDeckSize": 250, "maxSideboardSize": 15 } }, "teams": [ { "id": 2, "playerIds": [ 2 ], "status": "TeamStatus_PendingLoss" } ], "players": [ { "lifeTotal": 20, "systemSeatNumber": 1, "status": "PlayerStatus_InGame", "maxHandSize": 7, "turnNumber": 1, "teamId": 1, "timerIds": [ 1, 2, 3, 4, 5, 6, 7 ], "controllerSeatId": 1, "controllerType": "ControllerType_Player", "startingLifeTotal": 20 }, { "lifeTotal": 20, "systemSeatNumber": 2, "status": "PlayerStatus_PendingLoss", "maxHandSize": 7, "turnNumber": 1, "teamId": 2, "timerIds": [ 8, 9, 10, 11, 12, 13, 14 ], "controllerSeatId": 2, "controllerType": "ControllerType_Player", "startingLifeTotal": 20 } ], "prevGameStateId": 26, "timers": [ { "timerId": 10, "type": "TimerType_ActivePlayer", "durationSec": 61, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 13, "type": "TimerType_Inactivity", "durationSec": 150, "elapsedSec": 3, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 3103 }, { "timerId": 14, "type": "TimerType_MatchClock", "durationSec": 1800, "elapsedSec": 8, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 8519 } ], "update": "GameStateUpdate_SendAndRecord", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 167 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 170 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 168 } }, { "seatId": 1, "action": { "actionType": "ActionType_Activate_Mana", "instanceId": 284, "abilityGrpId": 1002 } } ] } }, { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 44, "gameStateId": 28, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 28, "players": [ { "lifeTotal": 20, "systemSeatNumber": 1, "status": "PlayerStatus_InGame", "maxHandSize": 7, "turnNumber": 1, "teamId": 1, "timerIds": [ 1, 2, 3, 4, 5, 6, 7 ], "controllerSeatId": 1, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_EnterSideboardingReq", "startingLifeTotal": 20 }, { "lifeTotal": 20, "systemSeatNumber": 2, "status": "PlayerStatus_PendingLoss", "maxHandSize": 7, "turnNumber": 1, "teamId": 2, "timerIds": [ 8, 9, 10, 11, 12, 13, 14 ], "controllerSeatId": 2, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_EnterSideboardingReq", "startingLifeTotal": 20 } ], "pendingMessageCount": 1, "prevGameStateId": 27, "timers": [ { "timerId": 2, "type": "TimerType_Epilogue", "durationSec": 145, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 1 }, { "timerId": 6, "type": "TimerType_Inactivity", "durationSec": 150, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 1 }, { "timerId": 9, "type": "TimerType_Epilogue", "durationSec": 145, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 1 }, { "timerId": 13, "type": "TimerType_Inactivity", "durationSec": 150, "elapsedSec": 3, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 3105 } ], "update": "GameStateUpdate_SendAndRecord", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 167 } }, { "seatId": 1, "action": { "actionType": "ActionType_PlaLine truncated
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:18 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
|
||||
{
|
||||
"requestId": 22,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
|
||||
"timestamp": "639277077180236913",
|
||||
"transactionId": "8505a700-0acd-406d-9835-99e061658467",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_EnterSideboardingReq",
|
||||
"gameStateId": 28,
|
||||
"respId": 45
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:18 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "8505a700-0acd-406d-9835-99e061658467", "requestId": 22, "timestamp": "1792110918060", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 47, "gameStateId": 29, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 29, "players": [ { "lifeTotal": 20, "systemSeatNumber": 1, "status": "PlayerStatus_InGame", "maxHandSize": 7, "turnNumber": 1, "teamId": 1, "timerIds": [ 1, 2, 3, 4, 5, 6, 7 ], "controllerSeatId": 1, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_SubmitDeckResp", "startingLifeTotal": 20 } ], "turnInfo": { "phase": "Phase_Main1", "turnNumber": 2, "activePlayer": 2, "priorityPlayer": 2, "decisionPlayer": 1, "nextPhase": "Phase_Combat", "nextStep": "Step_BeginCombat" }, "pendingMessageCount": 1, "prevGameStateId": 28, "timers": [ { "timerId": 2, "type": "TimerType_Epilogue", "durationSec": 145, "elapsedSec": 2, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 2618 }, { "timerId": 9, "type": "TimerType_Epilogue", "durationSec": 145, "elapsedSec": 2, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 2618 }, { "timerId": 13, "type": "TimerType_Inactivity", "durationSec": 150, "elapsedSec": 5, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 5722 } ], "update": "GameStateUpdate_SendAndRecord", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 167 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 170 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 168 } }, { "seatId": 1, "action": { "actionType": "ActionType_Activate_Mana", "instanceId": 284, "abilityGrpId": 1002 } } ] } }, { "type": "GREMessageType_SubmitDeckReq", "systemSeatIds": [ 1 ], "msgId": 48, "gameStateId": 29, "submitDeckReq": { "deck": { "deckCards": [ 71860, 71860, 71860, 71860, 97170, 93986, 93986, 93986, 93986, 77498, 77498, 77498, 77498, 94278, 94278, 94278, 94278, 73266, 73266, 73266, 73266, 102507, 102507, 102507, 102507, 75662, 75662, 75662, 75662, 97169, 97169, 97169, 97169, 97169, 97169, 95614, 95614, 91788, 91788, 91788, 91788, 83950, 83950, 83950, 83950, 68740, 68740, 68740, 68740, 94351, 87448, 73474, 106251, 106251, 106251, 106251, 70335, 70335, 70335, 70335 ], "sideboardCards": [ 95614, 82853, 82853, 82853, 75118, 102775, 70205, 70205, 90718, 94877, 94877, 92204, 92204, 92407, 88998 ] } } } ] } }
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:19 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "44b53100-71b3-4ebd-8d05-ac474a01b6cb", "requestId": 10, "timestamp": "1792110918164", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 49, "gameStateId": 30, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 30, "players": [ { "lifeTotal": 20, "systemSeatNumber": 2, "status": "PlayerStatus_PendingLoss", "maxHandSize": 7, "turnNumber": 1, "teamId": 2, "timerIds": [ 8, 9, 10, 11, 12, 13, 14 ], "controllerSeatId": 2, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_SubmitDeckResp", "startingLifeTotal": 20 } ], "turnInfo": { "phase": "Phase_Main1", "turnNumber": 2, "activePlayer": 2, "priorityPlayer": 2, "decisionPlayer": 2, "nextPhase": "Phase_Combat", "nextStep": "Step_BeginCombat" }, "prevGameStateId": 29, "timers": [ { "timerId": 2, "type": "TimerType_Epilogue", "durationSec": 145, "elapsedSec": 2, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 2722 }, { "timerId": 6, "type": "TimerType_Inactivity", "durationSec": 150, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 105 }, { "timerId": 9, "type": "TimerType_Epilogue", "durationSec": 145, "elapsedSec": 2, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 2722 }, { "timerId": 13, "type": "TimerType_Inactivity", "durationSec": 150, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 1 } ], "update": "GameStateUpdate_SendAndRecord", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 167 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 170 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 168 } }, { "seatId": 1, "action": { "actionType": "ActionType_Activate_Mana", "instanceId": 284, "abilityGrpId": 1002 } } ] } } ] } }
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:24 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
|
||||
{
|
||||
"requestId": 23,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
|
||||
"timestamp": "639277077242110190",
|
||||
"transactionId": "b27f4de8-4b27-4a59-b324-482531ceef70",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_SubmitDeckResp",
|
||||
"gameStateId": 29,
|
||||
"respId": 48,
|
||||
"submitDeckResp": {
|
||||
"deck": {
|
||||
"deckCards": [
|
||||
71860,
|
||||
71860,
|
||||
71860,
|
||||
71860,
|
||||
97170,
|
||||
93986,
|
||||
93986,
|
||||
93986,
|
||||
93986,
|
||||
77498,
|
||||
77498,
|
||||
77498,
|
||||
77498,
|
||||
94278,
|
||||
94278,
|
||||
94278,
|
||||
94278,
|
||||
73266,
|
||||
73266,
|
||||
73266,
|
||||
73266,
|
||||
102507,
|
||||
102507,
|
||||
102507,
|
||||
102507,
|
||||
75662,
|
||||
75662,
|
||||
75662,
|
||||
75662,
|
||||
97169,
|
||||
97169,
|
||||
97169,
|
||||
97169,
|
||||
97169,
|
||||
97169,
|
||||
95614,
|
||||
95614,
|
||||
91788,
|
||||
91788,
|
||||
91788,
|
||||
91788,
|
||||
83950,
|
||||
83950,
|
||||
83950,
|
||||
83950,
|
||||
68740,
|
||||
68740,
|
||||
68740,
|
||||
68740,
|
||||
94351,
|
||||
87448,
|
||||
73474,
|
||||
106251,
|
||||
106251,
|
||||
106251,
|
||||
106251,
|
||||
70335,
|
||||
70335,
|
||||
70335,
|
||||
70335
|
||||
],
|
||||
"sideboardCards": [
|
||||
95614,
|
||||
82853,
|
||||
82853,
|
||||
82853,
|
||||
75118,
|
||||
102775,
|
||||
70205,
|
||||
70205,
|
||||
90718,
|
||||
94877,
|
||||
94877,
|
||||
92204,
|
||||
92204,
|
||||
92407,
|
||||
88998
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:24 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "b27f4de8-4b27-4a59-b324-482531ceef70", "requestId": 23, "timestamp": "1792110924266", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_SubmitDeckConfirmation", "systemSeatIds": [ 1 ], "msgId": 52, "gameStateId": 30 }, { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 1, "gameStateId": 1, "gameStateMessage": { "type": "GameStateType_Full", "gameStateId": 1, "gameInfo": { "matchID": "c0a18506-9d87-4d0c-a2d3-e10bef8659d3", "gameNumber": 2, "stage": "GameStage_Start", "type": "GameType_Duel", "variant": "GameVariant_Normal", "matchState": "MatchState_GameInProgress", "matchWinCondition": "MatchWinCondition_Best2of3", "maxTimeoutCount": 4, "maxPipCount": 3, "timeoutDurationSec": 30, "results": [ { "scope": "MatchScope_Game", "result": "ResultType_WinLoss", "winningTeamId": 1, "reason": "ResultReason_Concede" } ], "superFormat": "SuperFormat_Constructed", "mulliganType": "MulliganType_London", "deckConstraintInfo": { "minDeckSize": 60, "maxDeckSize": 250, "maxSideboardSize": 15 } }, "teams": [ { "id": 1, "playerIds": [ 1 ], "status": "TeamStatus_InGame" }, { "id": 2, "playerIds": [ 2 ], "status": "TeamStatus_InGame" } ], "players": [ { "lifeTotal": 20, "systemSeatNumber": 1, "status": "PlayerStatus_InGame", "maxHandSize": 7, "teamId": 1, "timerIds": [ 1, 2, 3, 4, 5, 6, 7 ], "controllerSeatId": 1, "controllerType": "ControllerType_Player", "startingLifeTotal": 20 }, { "lifeTotal": 20, "systemSeatNumber": 2, "status": "PlayerStatus_InGame", "maxHandSize": 7, "teamId": 2, "timerIds": [ 8, 9, 10, 11, 12, 13, 14 ], "controllerSeatId": 2, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_ChooseStartingPlayerResp", "startingLifeTotal": 20 } ], "turnInfo": { "decisionPlayer": 2 }, "zones": [ { "zoneId": 18, "type": "ZoneType_Revealed", "visibility": "Visibility_Public", "ownerSeatId": 1 }, { "zoneId": 19, "type": "ZoneType_Revealed", "visibility": "Visibility_Public", "ownerSeatId": 2 }, { "zoneId": 24, "type": "ZoneType_Suppressed", "visibility": "Visibility_Public" }, { "zoneId": 25, "type": "ZoneType_Pending", "visibility": "Visibility_Public" }, { "zoneId": 26, "type": "ZoneType_Command", "visibility": "Visibility_Public" }, { "zoneId": 27, "type": "ZoneType_Stack", "visibility": "Visibility_Public" }, { "zoneId": 28, "type": "ZoneType_Battlefield", "visibility": "Visibility_Public" }, { "zoneId": 29, "type": "ZoneType_Exile", "visibility": "Visibility_Public" }, { "zoneId": 30, "type": "ZoneType_Limbo", "visibility": "Visibility_Public" }, { "zoneId": 31, "type": "ZoneType_Hand", "visibility": "Visibility_Private", "ownerSeatId": 1, "viewers": [ 1 ] }, { "zoneId": 32, "type": "ZoneType_Library", "visibility": "Visibility_Hidden", "ownerSeatId": 1, "objectInstanceIds": [ 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223 ] }, { "zoneId": 33, "type": "ZoneType_Graveyard", "visibility": "Visibility_Public", "ownerSeatId": 1 }, { "zoneId": 34, "type": "ZoneType_Sideboard", "visibility": "Visibility_Private", "ownerSeatId": 1, "viewers": [ 1 ] }, { "zoneId": 35, "type": "ZoneType_Hand", "visibility": "Visibility_Private", "ownerSeatId": 2, "viewers": [ 2 ] }, { "zoneId": 36, "type": "ZoneType_Library", "visibility": "Visibility_Hidden", "ownerSeatId": 2, "objectInstanceIds": [ 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283 ] }, { "zoneId": 37, "type": "ZoneType_Graveyard", "visibility": "Visibility_Public", "ownerSeatId": 2 }, { "zoneId": 38, "type": "ZoneType_Sideboard", "visibility": "Visibility_Private", "ownerSeatId": 2, "viewers": [ 2 ] } ], "diffDeletedInstanceIds": [ 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 141, 143, 145, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163 ], "timers": [ { "timerId": 1, "type": "TimerType_Prologue", "durationSec": 120, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 2, "type": "TimerType_Epilogue", "durationSec": 145, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 3, "type": "TimerType_ActivePLine truncated
|
||||
Can't add component to object that is being destroyed.
|
||||
Can't add component to object that is being destroyed.
|
||||
OnSceneUnloaded for DuelScene
|
||||
OnSceneLoaded for PreGameScene
|
||||
OnSceneLoaded for DuelScene
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:26 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
|
||||
{
|
||||
"requestId": 24,
|
||||
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
|
||||
"timestamp": "639277077265180717",
|
||||
"transactionId": "b1b6802c-df3c-46c6-a884-ca2ca85417d1",
|
||||
"payload": {
|
||||
"type": "ClientMessageType_SetSettingsReq",
|
||||
"setSettingsReq": {
|
||||
"settings": {
|
||||
"autoPassOption": "AutoPassOption_ResolveMyStackEffects",
|
||||
"manaSelectionType": "ManaSelectionType_Auto",
|
||||
"defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects",
|
||||
"autoSelectReplacementSetting": "Setting_Enable"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:26 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "b1b6802c-df3c-46c6-a884-ca2ca85417d1", "requestId": 24, "timestamp": "1792110926563", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_SetSettingsResp", "systemSeatIds": [ 1 ], "msgId": 4, "gameStateId": 1, "setSettingsResp": { "settings": { "stops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" } ], "autoPassOption": "AutoPassOption_ResolveMyStackEffects", "graveyardOrder": "OrderingType_OrderArbitraryAlways", "manaSelectionType": "ManaSelectionType_Auto", "defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects", "smartStopsSetting": "SmartStopsSetting_Enable", "autoTapStopsSetting": "AutoTapStopsSetting_Enable", "autoOptionalPaymentCancellationSetting": "Setting_Enable", "transientStops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { Line truncated
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:28 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
|
||||
{ "transactionId": "84c23c13-3d3a-4800-98f0-86c88a243951", "requestId": 13, "timestamp": "1792110928334", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_SetSettingsResp", "systemSeatIds": [ 1 ], "msgId": 5, "gameStateId": 1, "setSettingsResp": { "settings": { "stops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" } ], "autoPassOption": "AutoPassOption_Clear", "graveyardOrder": "OrderingType_OrderArbitraryAlways", "manaSelectionType": "ManaSelectionType_Auto", "defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects", "smartStopsSetting": "SmartStopsSetting_Enable", "autoTapStopsSetting": "AutoTapStopsSetting_Enable", "autoOptionalPaymentCancellationSetting": "Setting_Enable", "transientStops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StoLine truncated
|
||||
[UnityCrossThreadLogger]10/16/2026 12:35:28 AM: Match to LOCALPLAYERID0000000000000: MatchGameRoomStateChangedEvent
|
||||
{ "transactionId": "84c23c13-3d3a-4800-98f0-86c88a243951", "requestId": 13, "timestamp": "1792110928334", "matchGameRoomStateChangedEvent": { "gameRoomInfo": { "gameRoomConfig": { "reservedPlayers": [ { "userId": "LOCALPLAYERID0000000000000", "playerName":"Local Player", "systemSeatId": 1, "teamId": 1, "courseId": "Avatar_Basic_BilboRiddles_HOB", "sessionId": "00000000-0000-0000-0000-000000000000", "platformId": "SteamWindows", "eventId": "Traditional_Historic_Play" }, { "userId": "OPPONENTID000000", "playerName":"Opponent Player", "systemSeatId": 2, "teamId": 2, "courseId": "Avatar_Basic_Adventurer", "sessionId": "00000000-0000-0000-0000-000000000000", "platformId": "Windows", "eventId": "Traditional_Historic_Play" } ], "matchId": "c0a18506-9d87-4d0c-a2d3-e10bef8659d3" }, "stateType": "MatchGameRoomStateType_MatchCompleted", "finalMatchResult": { "matchId": "c0a18506-9d87-4d0c-a2d3-e10bef8659d3", "matchCompletedReason": "MatchCompletedReasonType_Success", "resultList": [ { "scope": "MatchScope_Game", "result": "ResultType_WinLoss", "winningTeamId": 1, "reason": "ResultReason_Concede" }, { "scope": "MatchScope_Game", "result": "ResultType_WinLoss", "winningTeamId": 1, "reason": "ResultReason_Concede" }, { "scope": "MatchScope_Match", "result": "ResultType_WinLoss", "winningTeamId": 1, "reason": "ResultReason_Concede" } ] } } } }
|
||||
[UnityCrossThreadLogger]STATE CHANGED {"old":"Playing","new":"MatchCompleted"}
|
||||
OnSceneUnloaded for PreGameScene
|
||||
Unloading 2 Unused Serialized files (Serialized files now loaded: 528)
|
||||
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
|
||||
from mtga_assistant import paths
|
||||
from mtga_assistant.carddb import CardDB, mana_value, parse_mana
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,symbols,mv",
|
||||
[
|
||||
("o2oU", ["2", "U"], 3),
|
||||
("oW", ["W"], 1),
|
||||
("", [], 0),
|
||||
("o1o(R/G)", ["1", "R/G"], 2),
|
||||
("o1o(B/P)o(B/P)", ["1", "B/P", "B/P"], 3),
|
||||
("o(2/B)o(2/R)", ["2/B", "2/R"], 4),
|
||||
("oXoXoR", ["X", "X", "R"], 1),
|
||||
("o10", ["10"], 10),
|
||||
],
|
||||
)
|
||||
def test_parse_mana(text, symbols, mv):
|
||||
assert parse_mana(text) == symbols
|
||||
assert mana_value(symbols) == mv
|
||||
|
||||
|
||||
def test_missing_db_gives_placeholder():
|
||||
db = CardDB(None)
|
||||
assert not db.available
|
||||
assert db.get(123).name == "Unknown card #123"
|
||||
assert db.base_grp(123) == 123
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def real_db():
|
||||
mtga = paths.find_mtga_dir(paths.load_config())
|
||||
path = paths.find_card_db(mtga) if mtga else None
|
||||
if path is None:
|
||||
pytest.skip("MTGA is not installed on this machine")
|
||||
return CardDB(path)
|
||||
|
||||
|
||||
def test_real_db_lookup(real_db):
|
||||
island = real_db.get(97169)
|
||||
assert island.name == "Island"
|
||||
assert island.is_land
|
||||
|
||||
|
||||
def test_real_db_split_card_half_maps_to_whole(real_db):
|
||||
# Desist (half) -> Cease // Desist
|
||||
assert real_db.base_grp(89160) == 89158
|
||||
assert real_db.base_grp(89158) == 89158
|
||||
@@ -0,0 +1,196 @@
|
||||
from pathlib import Path
|
||||
|
||||
from mtga_assistant.carddb import CardInfo
|
||||
from mtga_assistant.gamestate import MatchTracker
|
||||
from mtga_assistant.logparse import GreMessages, LogParser
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "bo3_match.log"
|
||||
|
||||
ME, OPP = 1, 2
|
||||
# Zone ids as MTGA assigns them for a two-player game.
|
||||
BATTLEFIELD, EXILE, LIMBO = 28, 29, 30
|
||||
MY_HAND, MY_LIB, MY_GY = 31, 32, 33
|
||||
OPP_HAND, OPP_LIB, OPP_GY = 35, 36, 37
|
||||
|
||||
|
||||
def replay(path: Path, tracker: MatchTracker, check=None) -> None:
|
||||
p = LogParser()
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
for ev in p.feed(line):
|
||||
tracker.handle(ev)
|
||||
if check and isinstance(ev, GreMessages):
|
||||
check(tracker)
|
||||
|
||||
|
||||
def test_fixture_match_lifecycle():
|
||||
t = MatchTracker()
|
||||
replay(FIXTURE, t)
|
||||
assert t.my_seat == ME
|
||||
assert t.opponent_name == "Opponent Player"
|
||||
assert t.game is not None and t.game.game_number == 2
|
||||
assert t.game_results == ["Won", "Won"]
|
||||
assert t.last_result == "Won"
|
||||
assert not t.in_match
|
||||
assert t.deck_name == "Cruel Tutelage"
|
||||
|
||||
|
||||
def test_fixture_library_count_matches_library_zone():
|
||||
checked = 0
|
||||
|
||||
def check(t: MatchTracker) -> None:
|
||||
nonlocal checked
|
||||
g = t.game
|
||||
if g is None or not g.zones or g.desynced:
|
||||
return
|
||||
assert sum(t.my_library_remaining().values()) == g.zone_size("ZoneType_Library", ME)
|
||||
checked += 1
|
||||
|
||||
replay(FIXTURE, MatchTracker(), check)
|
||||
assert checked >= 15
|
||||
|
||||
|
||||
# -- synthetic GRE messages for opponent knowledge ------------------------------
|
||||
|
||||
|
||||
def gsm(state_id: int, *, zones=(), objects=(), annotations=(), deleted=(), full=False) -> dict:
|
||||
msg = {
|
||||
"type": "GameStateType_Full" if full else "GameStateType_Diff",
|
||||
"gameStateId": state_id,
|
||||
"zones": list(zones),
|
||||
"gameObjects": list(objects),
|
||||
"annotations": list(annotations),
|
||||
"diffDeletedInstanceIds": list(deleted),
|
||||
}
|
||||
if not full:
|
||||
msg["prevGameStateId"] = state_id - 1
|
||||
return {"type": "GREMessageType_GameStateMessage", "gameStateMessage": msg}
|
||||
|
||||
|
||||
def zone(zid: int, ztype: str, ids, owner=None) -> dict:
|
||||
z = {"zoneId": zid, "type": f"ZoneType_{ztype}", "objectInstanceIds": list(ids)}
|
||||
if owner is not None:
|
||||
z["ownerSeatId"] = owner
|
||||
return z
|
||||
|
||||
|
||||
def card(iid: int, grp: int, zid: int, owner=OPP, otype="Card") -> dict:
|
||||
return {"instanceId": iid, "grpId": grp, "type": f"GameObjectType_{otype}", "zoneId": zid,
|
||||
"ownerSeatId": owner, "controllerSeatId": owner}
|
||||
|
||||
|
||||
def id_changed(orig: int, new: int) -> dict:
|
||||
return {"type": ["AnnotationType_ObjectIdChanged"], "details": [
|
||||
{"key": "orig_id", "valueInt32": [orig]}, {"key": "new_id", "valueInt32": [new]}]}
|
||||
|
||||
|
||||
def shuffle(old: list[int], new: list[int]) -> dict:
|
||||
return {"type": ["AnnotationType_Shuffle"], "details": [
|
||||
{"key": "OldIds", "valueInt32": old}, {"key": "NewIds", "valueInt32": new}]}
|
||||
|
||||
|
||||
def start_game(t: MatchTracker, deck=(1001,) * 4) -> None:
|
||||
t.handle(GreMessages([{
|
||||
"type": "GREMessageType_ConnectResp", "systemSeatIds": [ME],
|
||||
"connectResp": {"deckMessage": {"deckCards": list(deck), "sideboardCards": []}},
|
||||
}]))
|
||||
t.handle(GreMessages([gsm(1, full=True, zones=[
|
||||
zone(BATTLEFIELD, "Battlefield", []), zone(LIMBO, "Limbo", []),
|
||||
zone(MY_HAND, "Hand", [], ME), zone(MY_LIB, "Library", range(100, 100 + len(deck)), ME),
|
||||
zone(OPP_HAND, "Hand", [200, 201], OPP), zone(OPP_LIB, "Library", [300, 301, 302], OPP),
|
||||
zone(OPP_GY, "Graveyard", [], OPP),
|
||||
])]))
|
||||
|
||||
|
||||
def test_bounced_card_is_known_in_opponent_hand():
|
||||
t = MatchTracker()
|
||||
start_game(t)
|
||||
# Opponent casts card 200 -> it resolves onto the battlefield as instance 210.
|
||||
t.handle(GreMessages([gsm(2, annotations=[id_changed(200, 210)], zones=[
|
||||
zone(OPP_HAND, "Hand", [201], OPP), zone(BATTLEFIELD, "Battlefield", [210]),
|
||||
], objects=[card(210, 5005, BATTLEFIELD)])]))
|
||||
g = t.game
|
||||
assert g.cards_in("ZoneType_Battlefield", controller=OPP) == {5005: 1}
|
||||
|
||||
# It gets bounced: new id 220, back in the hidden hand, no object sent for it.
|
||||
t.handle(GreMessages([gsm(3, annotations=[id_changed(210, 220)], zones=[
|
||||
zone(OPP_HAND, "Hand", [201, 220], OPP), zone(BATTLEFIELD, "Battlefield", []),
|
||||
], deleted=[210])]))
|
||||
assert g.opp_known_in("ZoneType_Hand") == {5005: 1}
|
||||
assert not g.cards_in("ZoneType_Battlefield", controller=OPP)
|
||||
assert t.opp_seen_match == {5005: 1} # same physical card, counted once
|
||||
|
||||
|
||||
def test_known_card_survives_library_shuffle():
|
||||
t = MatchTracker()
|
||||
start_game(t)
|
||||
g = t.game
|
||||
# A card is revealed on top of their library (e.g. by a "reveal the top card" effect).
|
||||
t.handle(GreMessages([gsm(2, objects=[card(300, 6006, OPP_LIB)])]))
|
||||
assert g.opp_known_in("ZoneType_Library") == {6006: 1}
|
||||
|
||||
t.handle(GreMessages([gsm(3, annotations=[shuffle([300, 301, 302], [310, 311, 312])],
|
||||
zones=[zone(OPP_LIB, "Library", [310, 311, 312], OPP)], deleted=[300])]))
|
||||
assert g.opp_known_in("ZoneType_Library") == {6006: 1}
|
||||
|
||||
# Later it is drawn and played: no longer "known in library".
|
||||
t.handle(GreMessages([gsm(4, zones=[zone(OPP_LIB, "Library", [310, 311], OPP),
|
||||
zone(BATTLEFIELD, "Battlefield", [320])],
|
||||
objects=[card(320, 6006, BATTLEFIELD)])]))
|
||||
assert g.opp_known_in("ZoneType_Library") == {}
|
||||
|
||||
|
||||
def test_revealed_card_in_hand():
|
||||
t = MatchTracker()
|
||||
start_game(t)
|
||||
t.handle(GreMessages([gsm(2, objects=[card(400, 7007, OPP_HAND, otype="RevealedCard")])]))
|
||||
assert t.game.opp_known_in("ZoneType_Hand") == {7007: 1}
|
||||
assert t.opp_seen_match == {7007: 1}
|
||||
|
||||
|
||||
def test_tokens_do_not_count_as_my_cards():
|
||||
t = MatchTracker()
|
||||
start_game(t, deck=(1001,) * 4)
|
||||
t.handle(GreMessages([gsm(2, annotations=[id_changed(100, 150)], zones=[
|
||||
zone(MY_LIB, "Library", [101, 102, 103], ME), zone(BATTLEFIELD, "Battlefield", [150, 151]),
|
||||
], objects=[card(150, 1001, BATTLEFIELD, owner=ME), card(151, 9999, BATTLEFIELD, owner=ME, otype="Token")])]))
|
||||
assert t.my_library_remaining() == {1001: 3}
|
||||
|
||||
|
||||
class FakeCards:
|
||||
def __init__(self, base: dict[int, int]):
|
||||
self.base = base
|
||||
|
||||
def get(self, grp):
|
||||
return CardInfo(grp_id=grp, name=str(grp))
|
||||
|
||||
def base_grp(self, grp):
|
||||
return self.base.get(grp, grp)
|
||||
|
||||
|
||||
def test_transformed_card_maps_back_to_deck_entry():
|
||||
t = MatchTracker(card_lookup=FakeCards({1002: 1001})) # 1002 is 1001's back face
|
||||
start_game(t, deck=(1001,) * 2)
|
||||
t.handle(GreMessages([gsm(2, annotations=[id_changed(100, 150)], zones=[
|
||||
zone(MY_LIB, "Library", [101], ME), zone(BATTLEFIELD, "Battlefield", [150]),
|
||||
], objects=[card(150, 1002, BATTLEFIELD, owner=ME)])]))
|
||||
assert t.my_library_remaining() == {1001: 1}
|
||||
|
||||
|
||||
def test_gap_in_state_ids_marks_desync():
|
||||
t = MatchTracker()
|
||||
start_game(t)
|
||||
t.handle(GreMessages([gsm(2)]))
|
||||
assert not t.game.desynced
|
||||
t.handle(GreMessages([gsm(5)])) # prevGameStateId 4 never seen
|
||||
assert t.game.desynced
|
||||
|
||||
|
||||
def test_object_logged_before_its_id_change_is_one_card():
|
||||
t = MatchTracker()
|
||||
start_game(t)
|
||||
t.handle(GreMessages([gsm(2, objects=[card(210, 5005, BATTLEFIELD)])]))
|
||||
# The new object shows up one message before the annotation linking it to its old id.
|
||||
t.handle(GreMessages([gsm(3, objects=[card(220, 5005, BATTLEFIELD)])]))
|
||||
t.handle(GreMessages([gsm(4, annotations=[id_changed(210, 220)])]))
|
||||
assert t.opp_seen_match == {5005: 1}
|
||||
@@ -0,0 +1,65 @@
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from mtga_assistant.logparse import ClientDeck, DeckSubmitted, GreMessages, LogParser, MatchRoom
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "bo3_match.log"
|
||||
|
||||
|
||||
def parse_file(path: Path) -> list:
|
||||
p = LogParser()
|
||||
out = []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
out.extend(p.feed(line))
|
||||
return out
|
||||
|
||||
|
||||
def test_fixture_event_kinds():
|
||||
events = parse_file(FIXTURE)
|
||||
kinds = Counter(type(e).__name__ for e in events)
|
||||
assert kinds["MatchRoom"] == 2
|
||||
assert kinds["DeckSubmitted"] == 1
|
||||
assert kinds["ClientDeck"] == 1
|
||||
assert kinds["GreMessages"] >= 15
|
||||
|
||||
rooms = [e for e in events if isinstance(e, MatchRoom)]
|
||||
assert rooms[0].state == "MatchGameRoomStateType_Playing"
|
||||
assert rooms[1].state == "MatchGameRoomStateType_MatchCompleted"
|
||||
assert {p["playerName"] for p in rooms[0].players} == {"Local Player", "Opponent Player"}
|
||||
|
||||
|
||||
def test_deck_submitted():
|
||||
deck = next(e for e in parse_file(FIXTURE) if isinstance(e, DeckSubmitted))
|
||||
assert deck.name == "Cruel Tutelage"
|
||||
assert sum(deck.main.values()) == 60
|
||||
assert sum(deck.sideboard.values()) == 15
|
||||
|
||||
|
||||
def test_client_deck_from_pretty_printed_body():
|
||||
deck = next(e for e in parse_file(FIXTURE) if isinstance(e, ClientDeck))
|
||||
assert sum(deck.main.values()) == 60
|
||||
assert sum(deck.sideboard.values()) == 15
|
||||
|
||||
|
||||
def test_single_line_gre_event():
|
||||
p = LogParser()
|
||||
assert p.feed("[UnityCrossThreadLogger]10/16/2026 12:34:51 AM: Match to X: GreToClientEvent\n") == []
|
||||
evs = p.feed('{ "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_UIMessage" } ] } }\n')
|
||||
assert len(evs) == 1 and isinstance(evs[0], GreMessages)
|
||||
|
||||
|
||||
def test_multiline_body_and_abandoned_fragment():
|
||||
p = LogParser()
|
||||
assert p.feed("{\n") == []
|
||||
assert p.feed(' "payload": { "type": "Other" },\n') == []
|
||||
# A new log entry starts before the body closed: the fragment is dropped.
|
||||
assert p.feed("[UnityCrossThreadLogger]something\n") == []
|
||||
evs = p.feed('{ "greToClientEvent": { "greToClientMessages": [ { "type": "T" } ] } }\n')
|
||||
assert len(evs) == 1
|
||||
|
||||
|
||||
def test_non_json_lines_ignored():
|
||||
p = LogParser()
|
||||
for line in ["OnSceneLoaded for DuelScene", "[UnityCrossThreadLogger]STATE CHANGED {\"old\":1}", ""]:
|
||||
assert p.feed(line) == []
|
||||
@@ -0,0 +1,110 @@
|
||||
version = 1
|
||||
revision = 5
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mtga-assistant"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "pyside6-essentials" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "pyside6-essentials", specifier = ">=6.8" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyside6-essentials"
|
||||
version = "6.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "shiboken6" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/90/d528f8dd3f9f20712bc719e10c698d27c998e1dd76ed3f33b6fb606d0972/pyside6_essentials-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:0867b709a724db28b161227c7d6ecb5ed10cc09226a53542417ac6dee1308239", size = 116002775, upload-time = "2026-10-08T09:09:54.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/3c/7597d8dcb36aa8315427635ec6ec9d850ffbad4ce78b9118ac854889f2d3/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:16f4b5e41daf49235ae048458243da9efe5f3d8040f9cc5aa454ce69893d3bd9", size = 84346433, upload-time = "2026-10-08T09:10:02.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/4e/f4fd4c016719be832ec49d3c87940459e87fd666179a0a199a3998d07916/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:08c5841063fc1df69b7fa1ee405f539e66576a83fc48004be3e2c5e35ccbfa69", size = 83810102, upload-time = "2026-10-08T09:10:09.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/61/9a7e32a37b224f68506785bfb0f288d516098ec71681782d72b695a6c95d/pyside6_essentials-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:c9a95102aa23c1f86516a30d5a9be37552d324eee7e567eb7508973f1218cb42", size = 82153795, upload-time = "2026-10-08T09:10:15.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/11/43afb3d0e7c25dd09f0eb7e44f1283dadb5fa8d8b517249fe3fde02e6702/pyside6_essentials-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:c76aa689989bf9bb9b45535e0a81c6733294df720b0eed1308fa30d2606b98cc", size = 62073456, upload-time = "2026-10-08T09:10:20.202Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shiboken6"
|
||||
version = "6.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/45/11536fba5f50a57671eb14441c6098096f3e57074e54c9d7d66039a5831e/shiboken6-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:47105e05baf57d35453e07d240bbe59cb4b8d9565377ea5134ff9fb37d4f5c02", size = 479653, upload-time = "2026-10-08T09:10:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/c2/a04fa003b4360e99d8e3fc2829abacf37396ace63ea1cd616402209ec7c6/shiboken6-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bde565bb0890044b5c7f63808adddede5abc241e9395a2bfed61919884d6240", size = 279268, upload-time = "2026-10-08T09:10:42.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/ca/a4415e6218e0dff764fe2b2d30db2fc8f499a08ace07634232ade3f73adc/shiboken6-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ff72a72b3277b418902d562b987b33a6f6818bed9fe40a49d634d09bc67f620e", size = 276562, upload-time = "2026-10-08T09:10:44.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/b6/bee97e7a2edeab72aafabb46ee5adc939790a638d107860b4cad7a20e407/shiboken6-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:a1906cb8116869178c64b6bab52623bbc8b4ceed7b30bfc3c9aa0f6e67f6438e", size = 1265476, upload-time = "2026-10-08T09:10:45.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d5/5894d35f8a470ca8acff0d1cd8f15b1c94cbfdfe0ce4a6ec0fcf10b95bd1/shiboken6-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:da382e68f0815b31b6dc2bce11a465b148c4f42c37b91546a361de1576567d10", size = 1838851, upload-time = "2026-10-08T09:10:47.123Z" },
|
||||
]
|
||||
Reference in new issue
Block a user