from dataclasses import dataclass, field
from pathlib import Path
from yasuki_core.game_pieces.constants import Side
from yasuki_core.engine.players import PlayerId
[docs]
@dataclass(frozen=True, slots=True)
class L5RCard:
id: str
name: str
side: Side
# The card's stable printed identity — the database card slug (e.g. "ancestral_estate"), shared
# by every copy and printing, distinct from the per-instance ``id``. Per-card effect handlers key
# off it; None for fabricated demo cards and spawned tokens, which fall back to plain behavior.
printed_id: str | None = None
clan: str | None = None
# All Clan names printed on the card (the card_clans relation); ``clan`` is only the first.
# Recruit's off-clan and Proclaim rules test alignment membership against the full list.
clans: tuple[str, ...] = ()
keywords: tuple[str, ...] = ()
traits: tuple[str, ...] = ()
# The card's primary printed type (e.g. "Item", "Personality"), kept so the web client can pick a
# type-default placeholder image for a print with no scanned art yet.
card_type: str | None = None
# Token card ids this card can create in play (the card_creates relation), resolved once at deck
# load. The context menu offers a "Create" item per id; the spawn resolves the id against
# TableState.creatable_tokens to build a full token card.
creates: tuple[str, ...] = ()
text: str = ""
is_unique: bool = False
bowed: bool = False
face_up: bool = True
inverted: bool = False
# Named counters on the card (e.g. "wealth" → +1GP each): scalar host state, never cards
# (docs/engine/counters-vs-cards.md). In equality — replay checks must see counter drift — but
# out of the generated hash, which a dict cannot join.
counters: dict[str, int] = field(default_factory=dict, hash=False)
image_front: Path | None = None
image_back: Path | None = None
owner: PlayerId | None = None
# Two distinct disclosures, both narrower than turning the card face up. ``shown`` marks a card the
# owner has revealed to their opponent: a face-down card the opponent may then identify while its
# owner still sees a back, or a hand card made public to all. ``peekers`` holds the seats privately
# peeking at the card — each may identify it, nobody else learns what they saw.
shown: bool = False
peekers: frozenset[PlayerId] = frozenset()
# Double-faced cards (e.g. flip strongholds): back_card_id links the other face, back holds it as
# a resolved card when available, and showing_back selects which face is presented. Distinct from
# face_up, which conceals a card behind its generic deck back.
back_card_id: str | None = None
back: "L5RCard | None" = None
showing_back: bool = False
# A sandbox piece spawned onto the table (SpawnCard), not a card drawn from a deck. Only tokens
# may be removed from the table; a real card is never destroyed outright.
is_token: bool = False
# An art-swap payload when the deck entry borrows another printing's art for the front: the donor
# image and both frames' (era, layout) plus the recipient keywords, all the browser canvas needs to
# recomposite it. Pure client-render metadata, so it stays out of card identity (compare=False).
art_swap: dict | None = field(default=None, compare=False)
# A free-text annotation a player wrote on the face-up card (e.g. "dead"), shown over its art. It
# rides along while the card stays public — including into a discard — and clears on entering a deck.
note: str | None = field(default=None, compare=False)
def __post_init__(self):
# Normalize collections to tuples for consistent immutability
if not isinstance(self.clans, tuple):
object.__setattr__(self, "clans", tuple(self.clans))
if not isinstance(self.keywords, tuple):
object.__setattr__(self, "keywords", tuple(self.keywords))
if not isinstance(self.traits, tuple):
object.__setattr__(self, "traits", tuple(self.traits))
if not isinstance(self.creates, tuple):
object.__setattr__(self, "creates", tuple(self.creates))
if not isinstance(self.peekers, frozenset):
object.__setattr__(self, "peekers", frozenset(self.peekers))
# Always copy: replace()-built cards (e.g. synthetic back faces) must not share the mutable
# tally with their source.
object.__setattr__(self, "counters", dict(self.counters))
# State transitions
[docs]
def bow(self) -> None:
if not self.bowed:
object.__setattr__(self, "bowed", True)
[docs]
def unbow(self) -> None:
if self.bowed:
object.__setattr__(self, "bowed", False)
[docs]
def adjust_counter(self, name: str, delta: int) -> None:
"""Add ``delta`` to the named counter, flooring at zero. A zeroed counter is removed, so
cards with the same effective state compare and serialize identically."""
count = max(0, self.counters.get(name, 0) + delta)
if count:
self.counters[name] = count
else:
self.counters.pop(name, None)
[docs]
def set_note(self, text: str | None) -> None:
object.__setattr__(self, "note", text or None)
[docs]
def set_owner(self, owner: PlayerId | None) -> None:
object.__setattr__(self, "owner", owner)
[docs]
def turn_face_up(self) -> None:
if not self.face_up:
object.__setattr__(self, "face_up", True)
[docs]
def turn_face_down(self) -> None:
if self.face_up:
object.__setattr__(self, "face_up", False)
[docs]
def flip(self) -> None:
object.__setattr__(self, "face_up", not self.face_up)
[docs]
def invert(self) -> None:
if not self.inverted:
object.__setattr__(self, "inverted", True)
[docs]
def uninvert(self) -> None:
if self.inverted:
object.__setattr__(self, "inverted", False)
[docs]
def show(self) -> None:
if not self.shown:
object.__setattr__(self, "shown", True)
[docs]
def unshow(self) -> None:
if self.shown:
object.__setattr__(self, "shown", False)
[docs]
def add_peeker(self, seat: PlayerId) -> None:
if seat not in self.peekers:
object.__setattr__(self, "peekers", self.peekers | {seat})
[docs]
def remove_peeker(self, seat: PlayerId) -> None:
if seat in self.peekers:
object.__setattr__(self, "peekers", self.peekers - {seat})
[docs]
def clear_peekers(self) -> None:
if self.peekers:
object.__setattr__(self, "peekers", frozenset())
[docs]
def flip_face(self) -> None:
if self.back_card_id is not None:
object.__setattr__(self, "showing_back", not self.showing_back)
@property
def active_face(self) -> "L5RCard":
"""The face currently presented: the back card when flipped to it, otherwise this card."""
if self.showing_back and self.back is not None:
return self.back
return self