Files
dotfiles/python/gems/domain/models.py
T
Richie add7a6a848
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
feat(gems): add multiplayer gem game with custom content packs
- 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
2026-07-22 20:47:03 -04:00

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)