treefmt / nix fmt (pull_request) Successful in 6s
test ebook search / test-ebook-search (pull_request) Successful in 39s
pytest / pytest (pull_request) Successful in 40s
build_systems / build-brain (pull_request) Successful in 1m0s
build_systems / build-bob (pull_request) Successful in 1m2s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m33s
build_systems / build-jeeves (pull_request) Successful in 2m25s
test ebook search / test-ebook-search (push) Successful in 35s
build_systems / build-brain (push) Successful in 37s
build_systems / build-bob (push) Successful in 37s
build_systems / build-jeeves (push) Successful in 2m17s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 29s
build_systems / build-rhapsody-in-green (push) Successful in 49s
- implement FastAPI and HTMX lobby and game interfaces - support up to four human and AI-controlled players - add configurable rules, victory conditions, and expansion modules - support validated JSON cards, patrons, objectives, and outposts
395 lines
14 KiB
Python
395 lines
14 KiB
Python
"""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)
|