"""Typed content, configuration, commands, and game state.""" from __future__ import annotations from enum import StrEnum from typing import Annotated, Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator class StrictModel(BaseModel): """Base model that rejects misspelled fields.""" model_config = ConfigDict(extra="forbid") Slug = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,47}$")] ShortText = Annotated[str, Field(min_length=1, max_length=80)] TOKEN_DARK_INK_THRESHOLD = 0.82 STANDARD_RESOURCE_SYMBOLS = { "onyx": "O", "sapphire": "S", "emerald": "E", "ruby": "R", "diamond": "D", "gold": "G", } class ResourceDefinition(StrictModel): """A normal or wild resource rendered by the UI.""" id: Slug label: ShortText symbol: Annotated[str, Field(min_length=1, max_length=4)] color: Annotated[str, Field(pattern=r"^#[0-9a-fA-F]{6}$")] @model_validator(mode="after") def normalize_standard_symbol(self) -> ResourceDefinition: """Store the conventional symbol for a standard gem label.""" self.symbol = STANDARD_RESOURCE_SYMBOLS.get(self.label.casefold(), self.symbol) return self @property def ink_color(self) -> str: """Return readable token lettering for the configured background.""" red, green, blue = (int(self.color[index : index + 2], 16) for index in (1, 3, 5)) perceived_brightness = (299 * red + 587 * green + 114 * blue) / 255_000 return "#111111" if perceived_brightness >= TOKEN_DARK_INK_THRESHOLD else "#ffffff" class RequirementKind(StrEnum): """Identify how a requirement selects resource colors.""" COLOR = "color" ANY_COLOR = "any_color" class Requirement(StrictModel): """A fixed-color or same-color bonus requirement.""" id: Slug kind: RequirementKind count: Annotated[int, Field(ge=1, le=99)] resource: Slug | None = None exclude: list[Slug] = Field(default_factory=list) distinct_from: list[Slug] = Field(default_factory=list) @model_validator(mode="after") def validate_shape(self) -> Requirement: """Validate fields that depend on the requirement kind.""" if self.kind == RequirementKind.COLOR and self.resource is None: error = "color requirements need a resource" raise ValueError(error) if self.kind == RequirementKind.ANY_COLOR and self.resource is not None: error = "any_color requirements cannot name a resource" raise ValueError(error) return self class CardEffectKind(StrEnum): """Identify a supported server-side card effect.""" NONE = "none" VIRTUAL_WILD = "virtual_wild" COPY_BONUS = "copy_bonus" COPY_AND_CLAIM = "copy_and_claim" MULTI_BONUS = "multi_bonus" CLAIM_FREE = "claim_free" class CardEffect(StrictModel): """A bounded built-in card effect; uploaded code is never evaluated.""" kind: CardEffectKind = CardEffectKind.NONE amount: Annotated[int, Field(ge=1, le=10)] = 1 target_tier: Annotated[int, Field(ge=1, le=3)] | None = None class AlternateCost(StrictModel): """Purchase a card by discarding owned cards of one effective color.""" discard_resource: Slug count: Annotated[int, Field(ge=1, le=10)] class CardDefinition(StrictModel): """A card supplied by a user-owned content pack.""" id: Slug label: ShortText deck: Literal["base", "eastern"] = "base" tier: Annotated[int, Field(ge=1, le=3)] points: Annotated[int, Field(ge=0, le=99)] = 0 bonus_resource: Slug | None = None cost: dict[Slug, Annotated[int, Field(ge=0, le=99)]] = Field(default_factory=dict) effect: CardEffect = Field(default_factory=CardEffect) alternate_cost: AlternateCost | None = None @model_validator(mode="after") def validate_effect(self) -> CardDefinition: """Validate fields that depend on the selected card effect.""" effect = self.effect if effect.kind in {CardEffectKind.CLAIM_FREE, CardEffectKind.COPY_AND_CLAIM}: if effect.target_tier is None: error = "claim effects require target_tier" raise ValueError(error) if effect.target_tier >= self.tier: error = "free-card effects must target a lower tier" raise ValueError(error) elif effect.target_tier is not None: error = "target_tier is only valid for claim effects" raise ValueError(error) if effect.kind == CardEffectKind.MULTI_BONUS and self.bonus_resource is None: error = "multi_bonus cards require bonus_resource" raise ValueError(error) return self class PatronDefinition(StrictModel): """Define a patron claimed by meeting permanent-bonus requirements.""" id: Slug label: ShortText points: Annotated[int, Field(ge=0, le=99)] requirements: list[Requirement] class ObjectiveDefinition(StrictModel): """Define an optional objective-based victory condition.""" id: Slug label: ShortText minimum_score: Annotated[int, Field(ge=0, le=999)] requirements: list[Requirement] class OutpostPower(StrEnum): """Identify a built-in outpost ability.""" RESOURCE_AFTER_PURCHASE = "resource_after_purchase" RESOURCE_AFTER_DOUBLE = "resource_after_double" DOUBLE_WILD = "double_wild" POINTS_PER_OUTPOST = "points_per_outpost" BLIND_RESERVE_TWO = "blind_reserve_two" class OutpostDefinition(StrictModel): """Define an outpost and the bonuses required to claim it.""" id: Slug label: ShortText requirements: list[Requirement] power: OutpostPower value: Annotated[int, Field(ge=1, le=10)] = 1 class PackMetadata(StrictModel): """Describe the identity and authorship of a content pack.""" id: Slug name: ShortText version: Annotated[str, Field(min_length=1, max_length=32)] author: Annotated[str, Field(max_length=80)] = "" class ContentPack(StrictModel): """Versioned user-provided game content.""" schema_version: Literal[1] metadata: PackMetadata resources: Annotated[list[ResourceDefinition], Field(min_length=5, max_length=5)] wild_resource: ResourceDefinition cards: list[CardDefinition] patrons: list[PatronDefinition] = Field(default_factory=list) objectives: list[ObjectiveDefinition] = Field(default_factory=list) outposts: list[OutpostDefinition] = Field(default_factory=list) @model_validator(mode="before") @classmethod def normalize_patrons(cls, data: object) -> object: """Normalize the legacy governors key to the neutral patrons key.""" if isinstance(data, dict): if "patrons" in data and "governors" in data: error = "use patrons or governors, not both" raise ValueError(error) if "governors" in data: data = dict(data) data["patrons"] = data.pop("governors") return data @model_validator(mode="after") def validate_references(self) -> ContentPack: # noqa: C901, PLR0912 - validates each reference family """Validate identifiers and references across the complete pack.""" resources = {item.id for item in self.resources} if self.wild_resource.id in resources: error = "wild resource ID must be distinct" raise ValueError(error) id_collections = [ [item.id for item in self.resources], [item.id for item in self.cards], [item.id for item in self.patrons], [item.id for item in self.objectives], [item.id for item in self.outposts], ] for ids in id_collections: if len(ids) != len(set(ids)): error = "IDs must be unique within each content collection" raise ValueError(error) req_ids: set[str] for card in self.cards: if card.bonus_resource is not None and card.bonus_resource not in resources: error = f"card {card.id} has an unknown bonus resource" raise ValueError(error) if not set(card.cost) <= resources: error = f"card {card.id} has an unknown cost resource" raise ValueError(error) if card.alternate_cost and card.alternate_cost.discard_resource not in resources: error = f"card {card.id} has an unknown alternate-cost resource" raise ValueError(error) requirement_owners: list[PatronDefinition | ObjectiveDefinition | OutpostDefinition] = [ *self.patrons, *self.objectives, *self.outposts, ] for owner in requirement_owners: req_ids = {requirement.id for requirement in owner.requirements} if len(req_ids) != len(owner.requirements): error = f"{owner.id} has duplicate requirement IDs" raise ValueError(error) for requirement in owner.requirements: if requirement.resource and requirement.resource not in resources: error = f"{owner.id} references an unknown resource" raise ValueError(error) if not set(requirement.exclude) <= resources: error = f"{owner.id} excludes an unknown resource" raise ValueError(error) if not set(requirement.distinct_from) <= req_ids: error = f"{owner.id} references an unknown requirement" raise ValueError(error) return self @property def resource_ids(self) -> tuple[str, ...]: """Return normal resource identifiers in display order.""" return tuple(resource.id for resource in self.resources) def card(self, card_id: str) -> CardDefinition: """Return a card definition by identifier.""" return next(card for card in self.cards if card.id == card_id) class Modules(StrictModel): """Select optional rule modules for a game.""" objectives: bool = False outposts: bool = False eastern_decks: bool = False fortifications: bool = False class Interactions(StrictModel): """Configure interactions between optional rule modules.""" outpost_reserve_applies_eastern: bool = True virtual_wild_can_double: bool = True retain_outposts_after_discard: bool = True outposts_before_objectives: bool = True purchase_resource_on_conquest: bool = True chained_claim_is_not_purchase: bool = True fortifications_on_eastern: bool = True fortifications_block_free_claim: bool = True one_fortification_decision_per_purchase_chain: bool = True class GameSettings(StrictModel): """Configure win conditions, limits, markets, and rule modules.""" target_score: Annotated[int, Field(ge=1, le=999)] = 15 victory_condition: Literal["score", "objective", "either", "both"] = "score" first_player_mode: Literal["random", "selected"] = "random" first_player_seat: Annotated[int, Field(ge=0, le=3)] = 0 token_limit: Annotated[int, Field(ge=1, le=99)] = 10 reserve_limit: Annotated[int, Field(ge=0, le=20)] = 3 base_market_size: Annotated[int, Field(ge=1, le=10)] = 4 eastern_market_size: Annotated[int, Field(ge=1, le=10)] = 2 objective_count: Annotated[int, Field(ge=1, le=10)] = 3 fortifications_per_player: Annotated[int, Field(ge=1, le=10)] = 3 modules: Modules = Field(default_factory=Modules) interactions: Interactions = Field(default_factory=Interactions) class OwnedCard(StrictModel): """Track an owned card and any copied resource assignment.""" card_id: Slug copied_resource: Slug | None = None class PlayerState(StrictModel): """Store the mutable state belonging to one player.""" seat: int name: ShortText tokens: dict[str, int] cards: list[OwnedCard] = Field(default_factory=list) reserved: list[Slug] = Field(default_factory=list) patrons: list[Slug] = Field(default_factory=list) outposts: list[Slug] = Field(default_factory=list) objective_met: Slug | None = None fortifications_available: int = 0 purchased_card_count: int = 0 class PendingChoice(StrictModel): """Describe a choice that must be resolved before play continues.""" kind: Literal[ "discard_tokens", "copy_bonus", "free_card", "reserve_keep", "resource", "fortification", "conquest", "patron", "outpost", ] seat: int options: list[str] = Field(default_factory=list) amount: int = 1 context: dict[str, Any] = Field(default_factory=dict) class GameState(StrictModel): """Store a complete authoritative game snapshot.""" room_code: str seed: int revision: int = 0 players: list[PlayerState] supply: dict[str, int] decks: dict[str, list[Slug]] markets: dict[str, list[Slug]] available_patrons: list[Slug] available_objectives: list[Slug] fortifications: dict[Slug, dict[int, int]] = Field(default_factory=dict) current_seat: int = 0 first_seat: int = 0 round_number: int = 1 pending: PendingChoice | None = None finish_at_seat: int | None = None objective_qualifiers: list[int] = Field(default_factory=list) winners: list[int] = Field(default_factory=list) finished: bool = False turn_purchase_count: int = 0 turn_chain_free_count: int = 0 log: list[str] = Field(default_factory=list) class GameCommand(StrictModel): """Represent one revision-bound action submitted to the engine.""" command_id: Annotated[str, Field(min_length=8, max_length=64)] expected_revision: Annotated[int, Field(ge=0)] type: Literal[ "take_distinct", "take_double", "reserve", "purchase", "choose", "decline", ] payload: dict[str, Any] = Field(default_factory=dict)