Source code for yasuki_core.engine.table

from dataclasses import dataclass, field
from enum import Enum
from typing import NamedTuple, Literal, Final
from collections.abc import Iterator

from yasuki_core.engine.players import PlayerId
from yasuki_core.engine.zones import (
    Zone,
    HandZone,
    BattlefieldZone,
    FateDiscardZone,
    FateBanishZone,
    DynastyDiscardZone,
    DynastyBanishZone,
    ProvinceZone,
)
from yasuki_core.game_pieces.cards import L5RCard
from yasuki_core.game_pieces.constants import Side
from yasuki_core.game_pieces.deck import Deck, FateDeck, DynastyDeck


[docs] class ZoneRole(str, Enum): HAND = "hand" FATE_DISCARD = "fate_discard" FATE_BANISH = "fate_banish" DYNASTY_DISCARD = "dynasty_discard" DYNASTY_BANISH = "dynasty_banish" PROVINCE = "province"
# Per-seat zones present from table construction; provinces are created on demand # (CREATE_PROVINCE) and so are absent here. _FIXED_ZONES: tuple[tuple[ZoneRole, type[Zone]], ...] = ( (ZoneRole.HAND, HandZone), (ZoneRole.FATE_DISCARD, FateDiscardZone), (ZoneRole.FATE_BANISH, FateBanishZone), (ZoneRole.DYNASTY_DISCARD, DynastyDiscardZone), (ZoneRole.DYNASTY_BANISH, DynastyBanishZone), )
[docs] class ZoneKey(NamedTuple): owner: PlayerId role: ZoneRole idx: int | None = None # set only for PROVINCE; None otherwise
[docs] class DeckKey(NamedTuple): owner: PlayerId side: Side # FATE or DYNASTY
[docs] class BoardPos(NamedTuple): x: float y: float
[docs] @dataclass(slots=True) class SeatInfo: name: str honor: int = 0 # set from the stronghold + sensei at setup; 0 until then # Whether this seat waives every Personality's Honor Requirement when recruiting. Granted by # cards' effects; false until one sets it. ignores_honor_requirements: bool = False ready: bool = False connected: bool = False avatar: dict | None = None # the user's avatar spec; None falls back to the name's initials
[docs] @dataclass(slots=True) class TableState: """Authoritative, per-room game state. Holds the full truth for one table: both seats, every zone and deck, the shared battlefield, and an identity map from card id to card. Mutations bump ``seq`` so clients can detect dropped messages and request a fresh snapshot. Attributes ---------- seats : dict mapping PlayerId to SeatInfo The two seats and their public status (name, honor, ready, connected). zones : dict mapping ZoneKey to Zone Owned, role-keyed zones (hands, discards, banishes, provinces). decks : dict mapping DeckKey to Deck Each seat's fate and dynasty decks. battlefield : BattlefieldZone Shared, public play area; member cards have a position in ``positions``. positions : dict mapping str to BoardPos Table coordinates for battlefield cards, keyed by card id. attachments : dict mapping str to (str or ZoneKey) The attachment graph, keyed by the attached (child) card id. A value is either a parent card id — the child sits behind that battlefield card — or a province ``ZoneKey`` a fortification or region is attached to. Only battlefield cards appear as children; a card leaving the battlefield drops its entry and detaches whatever is hung off it. cards_by_id : dict mapping str to L5RCard Identity map over every card on the table, for fast intent lookup. creatable_tokens : dict mapping str to L5RCard Token templates the loaded decks can create, keyed by token card id, resolved at deck load. seq : int Monotonic view version, bumped on every state change: by ``apply_intent`` for game intents and by :meth:`bump_version` for non-intent seat metadata, so no two distinct broadcasts share a ``seq``. The action log records only intents, so logged ``seq`` values may skip the bumps. """ seats: dict[PlayerId, SeatInfo] zones: dict[ZoneKey, Zone] decks: dict[DeckKey, Deck] battlefield: BattlefieldZone # L5RCard is frozen, so battlefield positions live here, keyed by card id, not on the card. positions: dict[str, BoardPos] = field(default_factory=dict) # The child->parent attachment graph, external to the frozen card. See the class docstring. attachments: dict[str, "AttachTarget"] = field(default_factory=dict) cards_by_id: dict[str, L5RCard] = field(default_factory=dict) # A SpawnCard naming a token_id copies the matching template onto the battlefield, so spawning a # creatable token needs no live database call. creatable_tokens: dict[str, L5RCard] = field(default_factory=dict) seq: int = 0
[docs] @classmethod def empty_two_seat(cls, p1_name: str = "P1", p2_name: str = "P2") -> "TableState": """Build an empty, ready-to-fill two-seat table. Each seat gets its fixed zones (hand, discards, banishes) and empty fate/dynasty decks. Provinces, deck contents, and starting honor are populated later at deck-load setup. Parameters ---------- p1_name : str, optional Display name for seat P1. Default 'P1'. p2_name : str, optional Display name for seat P2. Default 'P2'. """ seats = { PlayerId.P1: SeatInfo(name=p1_name), PlayerId.P2: SeatInfo(name=p2_name), } zones: dict[ZoneKey, Zone] = {} decks: dict[DeckKey, Deck] = {} for seat in PlayerId: for role, zone_cls in _FIXED_ZONES: zones[ZoneKey(seat, role)] = zone_cls(owner=seat) decks[DeckKey(seat, Side.FATE)] = FateDeck(cards=[]) decks[DeckKey(seat, Side.DYNASTY)] = DynastyDeck(cards=[]) return cls(seats=seats, zones=zones, decks=decks, battlefield=BattlefieldZone())
[docs] def bump_version(self) -> None: """Advance :attr:`seq` for a state change made outside ``apply_intent`` (seat metadata), so every broadcast that changed the view carries a strictly newer ``seq`` than the last.""" self.seq += 1
[docs] def iter_all_cards(self) -> Iterator[L5RCard]: """Yield every card located on the table, across all zones, decks, and the battlefield.""" for zone in self.zones.values(): yield from zone.cards for deck in self.decks.values(): yield from deck.cards yield from self.battlefield.cards
[docs] def validate(self) -> None: """Check the table's structural invariants, raising ``ValueError`` on the first violation. Verifies that card ids are unique across the whole table, that ``cards_by_id`` indexes exactly the located cards, that battlefield positions reference only battlefield cards, and that every zone and deck key is well-formed (known owner, province-only ``idx``, matching zone owner, fate/dynasty deck side). """ located: dict[str, L5RCard] = {} for card in self.iter_all_cards(): if card.id in located: raise ValueError(f"Duplicate card id on table: {card.id!r}") located[card.id] = card if set(self.cards_by_id) != set(located): raise ValueError("cards_by_id is out of sync with located cards") battlefield_ids = {card.id for card in self.battlefield.cards} stray = set(self.positions) - battlefield_ids if stray: raise ValueError(f"positions reference non-battlefield cards: {sorted(stray)}") for child_id, target in self.attachments.items(): if child_id not in battlefield_ids: raise ValueError(f"attachment child not on battlefield: {child_id!r}") if isinstance(target, ZoneKey): if not isinstance(self.zones.get(target), ProvinceZone): raise ValueError(f"attachment references missing province: {target}") elif target not in battlefield_ids: raise ValueError(f"attachment references non-battlefield card: {target!r}") # No card-to-card cycles: walking parents from any child must terminate. for start in self.attachments: seen = {start} cursor = self.attachments.get(start) while isinstance(cursor, str): if cursor in seen: raise ValueError(f"attachment cycle involving {start!r}") seen.add(cursor) cursor = self.attachments.get(cursor) for key, zone in self.zones.items(): if key.owner not in self.seats: raise ValueError(f"zone key has unknown owner: {key}") if key.role is ZoneRole.PROVINCE: if not isinstance(key.idx, int) or key.idx < 0: raise ValueError(f"province zone needs a non-negative idx: {key}") elif key.idx is not None: raise ValueError(f"non-province zone must not carry an idx: {key}") if zone.owner != key.owner: raise ValueError(f"zone owner {zone.owner} does not match key {key}") for key, deck in self.decks.items(): if key.owner not in self.seats: raise ValueError(f"deck key has unknown owner: {key}") if key.side not in (Side.FATE, Side.DYNASTY): raise ValueError(f"deck side must be FATE or DYNASTY: {key}")
# Sentinel destination for the shared battlefield in MOVE_CARD, distinct from any owned ZoneKey or # DeckKey. A card moved here also carries a BoardPos. BATTLEFIELD: Final = "battlefield" # Where a card lands on the battlefield when no position is supplied. DEFAULT_BOARD_POS: Final = BoardPos(0.0, 0.0) # A dynasty card drawn while every province is full lands here: a negative sentinel the client # recognises and lays out next to the owner's dynasty deck, like an unplaced pre-game permanent. UNPLACED_BOARD_POS: Final = BoardPos(-1.0, -1.0) MoveDest = ZoneKey | DeckKey | Literal["battlefield"] # What a card may be attached to: another battlefield card (by id) or a province zone. AttachTarget = str | ZoneKey # Ownership and zone predicates — pure read-only queries on the table, shared by the manual sim # (intents.py) and the rules engine. A None owner means public (any seat may act).
[docs] def owns_card(state: TableState, seat: PlayerId, card_id: str) -> bool: """Return whether ``seat`` may act on the card. True for the card's owner and for public (owner-less) cards; False if the card is unknown or belongs to the other seat.""" card = state.cards_by_id.get(card_id) if card is None: return False return card.owner is None or card.owner == seat
[docs] def owns_zone(state: TableState, seat: PlayerId, zone_key: ZoneKey) -> bool: """Return whether ``seat`` may act on the zone. True for the zone's owner and for public zones; False if the zone does not exist or belongs to the other seat.""" zone = state.zones.get(zone_key) if zone is None: return False return zone.owner is None or zone.owner == seat
[docs] def owns_deck(state: TableState, seat: PlayerId, deck_key: DeckKey) -> bool: """Return whether ``seat`` owns the deck. Decks are always owned, so this is the key's owner; False if the deck does not exist.""" if deck_key not in state.decks: return False return deck_key.owner == seat
[docs] def zone_owned_by_card(zone: Zone, card: L5RCard) -> bool: """Return whether the card and zone owners are compatible: True unless both are set and differ. Guards against placing one seat's card into the other seat's owned zone.""" return zone.owner is None or card.owner is None or zone.owner == card.owner
[docs] def zone_accepts(zone: Zone, card: L5RCard) -> bool: """Return whether the card satisfies the zone's side and capacity constraints, without mutating. Mirrors the checks ``Zone.add`` makes before appending.""" if zone.allowed_side is not None and card.side is not zone.allowed_side: return False return zone.has_capacity()