feat(gems): add multiplayer gem game with custom content packs
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
This commit was merged in pull request #43.
This commit is contained in:
2026-07-22 20:47:03 -04:00
parent 247e951a27
commit add7a6a848
41 changed files with 4161 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
"""Domain models and rules for Gems."""
+903
View File
@@ -0,0 +1,903 @@
"""Deterministic, server-authoritative Gems rule engine."""
from __future__ import annotations
import random
from typing import Any
from .models import (
CardDefinition,
CardEffectKind,
ContentPack,
GameCommand,
GameSettings,
GameState,
OutpostDefinition,
OutpostPower,
OwnedCard,
PendingChoice,
PlayerState,
)
from .requirements import requirements_met
MAX_PLAYERS = 4
TWO_PLAYER_COUNT = 2
THREE_PLAYER_COUNT = 3
DISTINCT_TAKE_COUNT = 3
DOUBLE_TAKE_MINIMUM = 4
BLIND_RESERVE_DRAW_COUNT = 2
class RuleError(ValueError):
"""A command was not legal for the current state."""
def deck_key(deck: str, tier: int) -> str:
"""Build the state key for a named deck and tier."""
return f"{deck}:{tier}"
def new_game(
room_code: str,
names: list[str],
pack: ContentPack,
settings: GameSettings,
*,
seed: int,
) -> GameState:
"""Create and deal a deterministic game."""
if not 1 <= len(names) <= MAX_PLAYERS:
error = "Games require one to four seats"
raise RuleError(error)
if settings.first_player_mode == "selected" and settings.first_player_seat >= len(names):
error = "The selected first player is no longer in the room"
raise RuleError(error)
_validate_startable(pack, settings)
rng = random.Random(seed) # noqa: S311 - deterministic seeded shuffle
first_seat = settings.first_player_seat if settings.first_player_mode == "selected" else rng.randrange(len(names))
normal_count = 4 if len(names) <= TWO_PLAYER_COUNT else 5 if len(names) == THREE_PLAYER_COUNT else 7
supply = dict.fromkeys(pack.resource_ids, normal_count)
supply[pack.wild_resource.id] = 5
players = [
PlayerState(
seat=index,
name=name,
tokens=dict.fromkeys((*pack.resource_ids, pack.wild_resource.id), 0),
fortifications_available=settings.fortifications_per_player if settings.modules.fortifications else 0,
)
for index, name in enumerate(names)
]
decks: dict[str, list[str]] = {}
markets: dict[str, list[str]] = {}
for source in ("base", "eastern"):
if source == "eastern" and not settings.modules.eastern_decks:
continue
market_size = settings.base_market_size if source == "base" else settings.eastern_market_size
for tier in (1, 2, 3):
key = deck_key(source, tier)
cards = [card.id for card in pack.cards if card.deck == source and card.tier == tier]
rng.shuffle(cards)
markets[key] = [cards.pop() for _ in range(min(market_size, len(cards)))]
decks[key] = cards
patrons = [patron.id for patron in pack.patrons]
rng.shuffle(patrons)
patrons = patrons[: min(len(patrons), len(players) + 1)]
objectives = [objective.id for objective in pack.objectives]
rng.shuffle(objectives)
objectives = objectives[: settings.objective_count] if settings.modules.objectives else []
return GameState(
room_code=room_code,
seed=seed,
players=players,
supply=supply,
decks=decks,
markets=markets,
available_patrons=[] if settings.modules.objectives else patrons,
available_objectives=objectives,
current_seat=first_seat,
first_seat=first_seat,
)
def _validate_startable(pack: ContentPack, settings: GameSettings) -> None:
for tier in (1, 2, 3):
if not any(card.deck == "base" and card.tier == tier for card in pack.cards):
error = f"The base deck has no tier-{tier} cards"
raise RuleError(error)
if settings.modules.eastern_decks and not any(
card.deck == "eastern" and card.tier == tier for card in pack.cards
):
error = f"The eastern deck has no tier-{tier} cards"
raise RuleError(error)
if settings.modules.objectives and not pack.objectives:
error = "The Objectives module needs objective definitions"
raise RuleError(error)
if settings.modules.outposts and not pack.outposts:
error = "The Outposts module needs outpost definitions"
raise RuleError(error)
def card_map(pack: ContentPack) -> dict[str, CardDefinition]:
"""Index all cards in a content pack by identifier."""
return {card.id: card for card in pack.cards}
def bonuses(player: PlayerState, pack: ContentPack) -> dict[str, int]:
"""Calculate effective bonuses after copies, multiples, and discards."""
cards = card_map(pack)
result = dict.fromkeys(pack.resource_ids, 0)
for owned in player.cards:
definition = cards[owned.card_id]
resource = owned.copied_resource or definition.bonus_resource
if resource is not None:
result[resource] += definition.effect.amount if definition.effect.kind == CardEffectKind.MULTI_BONUS else 1
return result
def score(player: PlayerState, pack: ContentPack) -> int:
"""Calculate a player's score from cards, patrons, and outposts."""
cards = card_map(pack)
patrons = {patron.id: patron for patron in pack.patrons}
outposts = {outpost.id: outpost for outpost in pack.outposts}
value = sum(cards[item.card_id].points for item in player.cards)
value += sum(patrons[item].points for item in player.patrons)
for item in player.outposts:
definition = outposts[item]
if definition.power == OutpostPower.POINTS_PER_OUTPOST:
value += definition.value * len(player.outposts)
return value
def affordable_payments(player: PlayerState, card: CardDefinition, pack: ContentPack, *, double_wild: bool) -> bool:
"""Report whether a player can cover a card's standard cost."""
discount = bonuses(player, pack)
shortage = sum(
max(0, amount - discount.get(resource, 0) - player.tokens.get(resource, 0))
for resource, amount in card.cost.items()
)
wild_value = 2 if double_wild else 1
return shortage <= player.tokens.get(pack.wild_resource.id, 0) * wild_value
def visible_cards(state: GameState) -> set[str]:
"""Return the identifiers of cards currently visible in markets."""
return {card for market in state.markets.values() for card in market}
def apply_command(
state: GameState,
command: GameCommand,
pack: ContentPack,
settings: GameSettings,
*,
actor_seat: int,
) -> GameState:
"""Validate and apply one command, returning a new state snapshot."""
if state.finished:
error = "The game is finished"
raise RuleError(error)
if command.expected_revision != state.revision:
error = "The board changed; refresh and try again"
raise RuleError(error)
if actor_seat != state.current_seat:
error = "It is not your turn"
raise RuleError(error)
if state.pending and state.pending.seat != actor_seat:
error = "Another player must resolve the pending choice"
raise RuleError(error)
result = state.model_copy(deep=True)
if result.pending:
_apply_pending(result, command, pack, settings)
elif command.type == "take_distinct":
_take_distinct(result, command.payload, pack, settings)
elif command.type == "take_double":
_take_double(result, command.payload, pack, settings)
elif command.type == "reserve":
_reserve(result, command.payload, pack, settings)
elif command.type == "purchase":
_purchase(result, command.payload, pack, settings, is_conquest=False)
else:
error = "Choose a normal turn action"
raise RuleError(error)
result.revision += 1
return result
def _take_distinct(state: GameState, payload: dict[str, Any], pack: ContentPack, settings: GameSettings) -> None:
colors = payload.get("resources")
if not isinstance(colors, list) or len(colors) != len(set(colors)):
error = "Choose distinct resources"
raise RuleError(error)
available = [resource for resource in pack.resource_ids if state.supply.get(resource, 0) > 0]
required = DISTINCT_TAKE_COUNT if len(available) >= DISTINCT_TAKE_COUNT else None
if required is not None and len(colors) != required:
error = "Take exactly three different resources when possible"
raise RuleError(error)
if required is None and not 1 <= len(colors) <= len(available):
error = "Choose one or more available resources"
raise RuleError(error)
if not set(colors) <= set(available):
error = "A selected resource is unavailable"
raise RuleError(error)
player = state.players[state.current_seat]
for resource in colors:
state.supply[resource] -= 1
player.tokens[resource] += 1
state.log.append(f"{player.name} took {len(colors)} different resources")
_after_standard_action(state, pack, settings, action="take_distinct")
def _take_double(state: GameState, payload: dict[str, Any], pack: ContentPack, settings: GameSettings) -> None:
resource = payload.get("resource")
if resource not in pack.resource_ids or state.supply.get(resource, 0) < DOUBLE_TAKE_MINIMUM:
error = "That resource cannot be taken twice"
raise RuleError(error)
player = state.players[state.current_seat]
state.supply[resource] -= 2
player.tokens[resource] += 2
state.log.append(f"{player.name} took two {resource} resources")
extra = _owned_outpost(player, pack, OutpostPower.RESOURCE_AFTER_DOUBLE)
if settings.modules.outposts and extra:
options = [item for item in pack.resource_ids if item != resource and state.supply.get(item, 0) > 0]
if options:
state.pending = PendingChoice(
kind="resource", seat=player.seat, options=options, context={"next": "after_action"}
)
return
_after_standard_action(state, pack, settings, action="take_double")
def _reserve(state: GameState, payload: dict[str, Any], pack: ContentPack, settings: GameSettings) -> None:
player = state.players[state.current_seat]
if len(player.reserved) >= settings.reserve_limit:
error = "Your reserve is full"
raise RuleError(error)
card_id = payload.get("card_id")
source = payload.get("deck")
drawn: list[str]
if card_id:
if card_id not in visible_cards(state):
error = "That card is not visible"
raise RuleError(error)
_assert_not_blocked(state, card_id, player.seat)
_remove_visible(state, card_id)
drawn = [card_id]
elif isinstance(source, str) and source in state.decks:
if not state.decks[source]:
error = "That deck is empty"
raise RuleError(error)
draw_count = 1
post = _owned_outpost(player, pack, OutpostPower.BLIND_RESERVE_TWO)
if post and (not source.startswith("eastern:") or settings.interactions.outpost_reserve_applies_eastern):
draw_count = min(BLIND_RESERVE_DRAW_COUNT, len(state.decks[source]))
drawn = [state.decks[source].pop() for _ in range(draw_count)]
else:
error = "Choose a visible card or deck"
raise RuleError(error)
if len(drawn) == BLIND_RESERVE_DRAW_COUNT:
state.pending = PendingChoice(kind="reserve_keep", seat=player.seat, options=drawn, context={"deck": source})
return
_finish_reserve(state, drawn[0], pack, settings)
def _finish_reserve(state: GameState, card_id: str, pack: ContentPack, settings: GameSettings) -> None:
player = state.players[state.current_seat]
player.reserved.append(card_id)
_refill_markets(state, settings)
wild = pack.wild_resource.id
if state.supply[wild] > 0:
state.supply[wild] -= 1
player.tokens[wild] += 1
state.log.append(f"{player.name} reserved a card")
_after_standard_action(state, pack, settings, action="reserve")
def _purchase(
state: GameState,
payload: dict[str, Any],
pack: ContentPack,
settings: GameSettings,
*,
is_conquest: bool,
) -> None:
player = state.players[state.current_seat]
card_id = payload.get("card_id")
if not isinstance(card_id, str):
error = "Choose a card"
raise RuleError(error)
from_reserve = card_id in player.reserved
if not from_reserve:
if card_id not in visible_cards(state):
error = "That card is not available"
raise RuleError(error)
_assert_not_blocked(state, card_id, player.seat)
if is_conquest and state.fortifications.get(card_id, {}).get(player.seat, 0) < settings.fortifications_per_player:
error = "All your fortifications must occupy the conquest card"
raise RuleError(error)
card = pack.card(card_id)
_pay_for_purchase(state, player, card, payload, pack, settings)
if from_reserve:
player.reserved.remove(card_id)
else:
_remove_visible(state, card_id)
_return_fortifications(state, card_id)
owned = OwnedCard(card_id=card_id)
player.cards.append(owned)
player.purchased_card_count += 1
state.turn_purchase_count += 1
state.log.append(f"{player.name} purchased {card.label}")
if card.effect.kind in {CardEffectKind.COPY_BONUS, CardEffectKind.COPY_AND_CLAIM}:
# Do not allow the new copy card to satisfy its own target requirement.
prior = player.cards.pop()
options = sorted(resource for resource, amount in bonuses(player, pack).items() if amount)
player.cards.append(prior)
if not options:
error = "This card requires an existing bonus to copy"
raise RuleError(error)
state.pending = PendingChoice(
kind="copy_bonus",
seat=player.seat,
options=options,
context={"card_id": card_id, "is_conquest": is_conquest},
)
return
if card.effect.kind == CardEffectKind.CLAIM_FREE:
_queue_free_card(state, card, pack, settings, is_conquest=is_conquest)
return
_after_purchase(state, pack, settings, is_conquest=is_conquest)
def _pay_for_purchase(
state: GameState,
player: PlayerState,
card: CardDefinition,
payload: dict[str, Any],
pack: ContentPack,
settings: GameSettings,
) -> None:
if card.alternate_cost:
discarded = payload.get("discard_cards", [])
_pay_alternate(player, card, discarded, pack)
if not settings.interactions.retain_outposts_after_discard:
_reconcile_outposts(player, pack)
else:
payment = payload.get("payment", {})
virtual_cards = payload.get("virtual_wild_cards", [])
_pay_tokens(state, player, card, payment, virtual_cards, pack, settings)
def _pay_tokens( # noqa: C901 - payment validation mirrors the rule sequence
state: GameState,
player: PlayerState,
card: CardDefinition,
payment: object,
virtual_cards: object,
pack: ContentPack,
settings: GameSettings,
) -> None:
if not isinstance(payment, dict) or any(not isinstance(value, int) or value < 0 for value in payment.values()):
error = "Provide a valid payment"
raise RuleError(error)
allowed = {*pack.resource_ids, pack.wild_resource.id}
if not set(payment) <= allowed:
error = "Payment contains an unknown resource"
raise RuleError(error)
if any(payment.get(resource, 0) > player.tokens.get(resource, 0) for resource in allowed):
error = "Payment uses resources you do not have"
raise RuleError(error)
discount = bonuses(player, pack)
if not isinstance(virtual_cards, list) or len(virtual_cards) != len(set(virtual_cards)):
error = "Virtual-wild selections must be unique"
raise RuleError(error)
owned_by_id = {item.card_id: item for item in player.cards}
virtual_value = 0
for card_id in virtual_cards:
owned = owned_by_id.get(card_id)
if owned is None:
error = "You do not own a selected virtual-wild card"
raise RuleError(error)
definition = pack.card(card_id)
if definition.effect.kind != CardEffectKind.VIRTUAL_WILD:
error = "A selected card does not grant virtual wild resources"
raise RuleError(error)
virtual_value += definition.effect.amount
wild = payment.get(pack.wild_resource.id, 0)
double = bool(_owned_outpost(player, pack, OutpostPower.DOUBLE_WILD))
wild_value = 2 if double else 1
virtual_multiplier = 2 if double and settings.interactions.virtual_wild_can_double else 1
remaining_wild_value = wild * wild_value + virtual_value * virtual_multiplier
for resource in pack.resource_ids:
due = max(0, card.cost.get(resource, 0) - discount.get(resource, 0))
normal = payment.get(resource, 0)
if normal > due:
error = "Payment overpays a normal resource"
raise RuleError(error)
remaining_wild_value -= due - normal
if remaining_wild_value < 0:
error = "Payment does not exactly cover the card cost"
raise RuleError(error)
for resource, amount in payment.items():
player.tokens[resource] -= amount
state.supply[resource] += amount
if virtual_cards:
player.cards = [item for item in player.cards if item.card_id not in virtual_cards]
def _pay_alternate(player: PlayerState, card: CardDefinition, selected: object, pack: ContentPack) -> None:
if not isinstance(selected, list) or not card.alternate_cost or len(selected) != card.alternate_cost.count:
error = "Choose the required owned cards to discard"
raise RuleError(error)
if len(selected) != len(set(selected)):
error = "A card can only be discarded once"
raise RuleError(error)
owned_by_id = {item.card_id: item for item in player.cards}
for card_id in selected:
owned = owned_by_id.get(card_id)
if owned is None:
error = "You do not own a selected discard"
raise RuleError(error)
definition = pack.card(card_id)
effective = owned.copied_resource or definition.bonus_resource
if effective != card.alternate_cost.discard_resource:
error = "A selected discard has the wrong color"
raise RuleError(error)
# Copy cards of the effective color must be discarded first.
matching_copies = [
item.card_id for item in player.cards if item.copied_resource == card.alternate_cost.discard_resource
]
if any(item not in selected for item in matching_copies[: min(len(matching_copies), len(selected))]):
error = "Copied cards of this color must be discarded first"
raise RuleError(error)
player.cards = [item for item in player.cards if item.card_id not in selected]
def _apply_pending( # noqa: C901, PLR0911, PLR0912, PLR0915 - explicit pending-choice state machine
state: GameState, command: GameCommand, pack: ContentPack, settings: GameSettings
) -> None:
pending = state.pending
if pending is None or command.type not in {"choose", "decline", "purchase"}:
error = "Resolve the pending choice"
raise RuleError(error)
player = state.players[state.current_seat]
choice = command.payload.get("choice")
if pending.kind == "reserve_keep":
if choice not in pending.options:
error = "Choose one of the drawn cards"
raise RuleError(error)
other = next(item for item in pending.options if item != choice)
state.decks[pending.context["deck"]].insert(0, other)
state.pending = None
_finish_reserve(state, choice, pack, settings)
return
if pending.kind == "copy_bonus":
if choice not in pending.options:
error = "Choose an existing bonus"
raise RuleError(error)
card_id = pending.context["card_id"]
owned = next(item for item in reversed(player.cards) if item.card_id == card_id)
owned.copied_resource = choice
definition = pack.card(card_id)
state.pending = None
if definition.effect.kind == CardEffectKind.COPY_AND_CLAIM:
_queue_free_card(state, definition, pack, settings, is_conquest=bool(pending.context.get("is_conquest")))
else:
_after_purchase(
state,
pack,
settings,
is_conquest=bool(pending.context.get("is_conquest")),
chained=bool(pending.context.get("chained")),
)
return
if pending.kind == "free_card":
if choice not in pending.options:
error = "Choose an eligible free card"
raise RuleError(error)
if settings.interactions.fortifications_block_free_claim:
_assert_not_blocked(state, choice, player.seat)
_remove_visible(state, choice)
_return_fortifications(state, choice)
claimed = pack.card(choice)
player.cards.append(OwnedCard(card_id=choice))
state.turn_chain_free_count += 1
state.log.append(f"{player.name} claimed {claimed.label} without payment")
state.pending = None
if claimed.effect.kind in {CardEffectKind.COPY_BONUS, CardEffectKind.COPY_AND_CLAIM}:
prior = player.cards.pop()
options = sorted(resource for resource, amount in bonuses(player, pack).items() if amount)
player.cards.append(prior)
if options:
state.pending = PendingChoice(
kind="copy_bonus",
seat=player.seat,
options=options,
context={
"card_id": choice,
"is_conquest": pending.context.get("is_conquest", False),
"chained": True,
},
)
return
_after_purchase(state, pack, settings, is_conquest=bool(pending.context.get("is_conquest")), chained=True)
return
if pending.kind == "resource":
if choice not in pending.options or state.supply.get(str(choice), 0) <= 0:
error = "Choose an available resource"
raise RuleError(error)
state.supply[str(choice)] -= 1
player.tokens[str(choice)] += 1
context = pending.context
state.pending = None
remaining = int(context.get("remaining", 1))
if context.get("next") == "after_purchase" and remaining > 1:
options = [item for item in pack.resource_ids if state.supply.get(item, 0) > 0]
if options:
state.pending = PendingChoice(
kind="resource",
seat=player.seat,
options=options,
context={**context, "remaining": remaining - 1},
)
return
if context.get("next") == "after_purchase":
_after_purchase(state, pack, settings, is_conquest=bool(context.get("is_conquest")), skip_resource=True)
else:
_after_standard_action(state, pack, settings, action="take_double")
return
if pending.kind == "discard_tokens":
discard = command.payload.get("tokens", {})
allowed = {*pack.resource_ids, pack.wild_resource.id}
if not isinstance(discard, dict) or not set(discard) <= allowed or sum(discard.values()) != pending.amount:
error = "Discard exactly the required number of resources"
raise RuleError(error)
for resource, amount in discard.items():
if not isinstance(amount, int) or amount <= 0 or amount > player.tokens.get(resource, 0):
error = "Invalid token discard"
raise RuleError(error)
for resource, amount in discard.items():
player.tokens[resource] -= amount
state.supply[resource] += amount
state.pending = None
_end_checks(state, pack, settings)
return
if pending.kind == "patron":
if choice not in pending.options:
error = "Choose an eligible patron"
raise RuleError(error)
player.patrons.append(choice)
state.available_patrons.remove(choice)
state.pending = None
_check_outposts_or_objectives(state, pack, settings)
return
if pending.kind == "outpost":
if choice not in pending.options:
error = "Choose an eligible outpost"
raise RuleError(error)
player.outposts.append(choice)
state.pending = None
_check_objectives(state, pack, settings)
return
if pending.kind == "fortification":
target = command.payload.get("card_id")
mode = command.payload.get("mode")
if target not in visible_cards(state):
error = "Choose a visible card"
raise RuleError(error)
if (
target in state.markets.get(deck_key("eastern", pack.card(target).tier), [])
and not settings.interactions.fortifications_on_eastern
):
error = "Fortifications cannot occupy eastern cards with this setting"
raise RuleError(error)
if mode == "place":
occupants = state.fortifications.get(target, {})
if any(seat != player.seat and count for seat, count in occupants.items()):
error = "An opponent occupies that card"
raise RuleError(error)
source = command.payload.get("from_card")
if source:
if state.fortifications.get(source, {}).get(player.seat, 0) <= 0:
error = "You have no fortification there"
raise RuleError(error)
state.fortifications[source][player.seat] -= 1
elif player.fortifications_available > 0:
player.fortifications_available -= 1
else:
error = "Move one of your placed fortifications"
raise RuleError(error)
state.fortifications.setdefault(target, {})[player.seat] = occupants.get(player.seat, 0) + 1
elif mode == "remove":
occupants = state.fortifications.get(target, {})
opponents = [(seat, count) for seat, count in occupants.items() if seat != player.seat and count == 1]
if len(opponents) != 1:
error = "Choose a card with exactly one opposing fortification"
raise RuleError(error)
seat, _ = opponents[0]
occupants[seat] = 0
state.players[seat].fortifications_available += 1
else:
error = "Choose place or remove"
raise RuleError(error)
remaining = int(pending.context.get("remaining", 1))
state.pending = None
if remaining > 1 and _queue_fortification(state, pack, settings, remaining=remaining - 1):
return
_after_fortification(state, pack, settings)
return
if pending.kind == "conquest":
if command.type == "decline":
state.pending = None
_enforce_token_limit_or_checks(state, pack, settings)
return
if command.type != "purchase":
error = "Purchase the conquest card or decline"
raise RuleError(error)
state.pending = None
_purchase(state, command.payload, pack, settings, is_conquest=True)
return
error = "Unsupported pending choice"
raise RuleError(error)
def _queue_free_card(
state: GameState, card: CardDefinition, pack: ContentPack, settings: GameSettings, *, is_conquest: bool
) -> None:
options = []
for card_id in visible_cards(state):
candidate = pack.card(card_id)
if candidate.tier != card.effect.target_tier:
continue
if settings.interactions.fortifications_block_free_claim:
occupants = state.fortifications.get(card_id, {})
if any(seat != state.current_seat and count for seat, count in occupants.items()):
continue
options.append(card_id)
if options:
state.pending = PendingChoice(
kind="free_card", seat=state.current_seat, options=sorted(options), context={"is_conquest": is_conquest}
)
else:
_after_purchase(state, pack, settings, is_conquest=is_conquest)
def _after_purchase(
state: GameState,
pack: ContentPack,
settings: GameSettings,
*,
is_conquest: bool,
chained: bool = False,
skip_resource: bool = False,
) -> None:
player = state.players[state.current_seat]
_refill_markets(state, settings)
resource_post = _owned_outpost(player, pack, OutpostPower.RESOURCE_AFTER_PURCHASE)
triggers = not is_conquest or settings.interactions.purchase_resource_on_conquest
if settings.modules.outposts and resource_post and triggers and not skip_resource:
options = [item for item in pack.resource_ids if state.supply.get(item, 0) > 0]
if options:
remaining = 1
if chained and not settings.interactions.chained_claim_is_not_purchase:
remaining += state.turn_chain_free_count
state.pending = PendingChoice(
kind="resource",
seat=player.seat,
options=options,
context={"next": "after_purchase", "is_conquest": is_conquest, "remaining": remaining},
)
return
if settings.modules.fortifications:
remaining = 1
if chained and not settings.interactions.one_fortification_decision_per_purchase_chain:
remaining += state.turn_chain_free_count
if _queue_fortification(state, pack, settings, remaining=remaining):
return
_after_fortification(state, pack, settings)
def _after_fortification(state: GameState, pack: ContentPack, settings: GameSettings) -> None:
player = state.players[state.current_seat]
conquest = [
card_id
for card_id, occupants in state.fortifications.items()
if occupants.get(player.seat, 0) >= settings.fortifications_per_player and card_id in visible_cards(state)
]
if settings.modules.fortifications and conquest:
state.turn_chain_free_count = 0
state.pending = PendingChoice(kind="conquest", seat=player.seat, options=conquest)
return
state.turn_chain_free_count = 0
_enforce_token_limit_or_checks(state, pack, settings)
def _queue_fortification(
state: GameState,
pack: ContentPack,
settings: GameSettings,
*,
remaining: int,
) -> bool:
player = state.players[state.current_seat]
options = [
card_id
for card_id in visible_cards(state)
if settings.interactions.fortifications_on_eastern or pack.card(card_id).deck != "eastern"
]
can_act = False
for card_id in options:
occupants = state.fortifications.get(card_id, {})
can_place = not any(seat != player.seat and count for seat, count in occupants.items())
can_remove = any(seat != player.seat and count == 1 for seat, count in occupants.items())
if can_place or can_remove:
can_act = True
break
if not can_act:
return False
state.pending = PendingChoice(
kind="fortification",
seat=player.seat,
options=sorted(options),
context={"remaining": remaining},
)
return True
def _after_standard_action(state: GameState, pack: ContentPack, settings: GameSettings, *, action: str) -> None:
del action
if settings.modules.fortifications and state.turn_purchase_count:
_after_purchase(state, pack, settings, is_conquest=False)
else:
_enforce_token_limit_or_checks(state, pack, settings)
def _enforce_token_limit_or_checks(state: GameState, pack: ContentPack, settings: GameSettings) -> None:
player = state.players[state.current_seat]
excess = sum(player.tokens.values()) - settings.token_limit
if excess > 0:
state.pending = PendingChoice(kind="discard_tokens", seat=player.seat, amount=excess)
return
_end_checks(state, pack, settings)
def _end_checks(state: GameState, pack: ContentPack, settings: GameSettings) -> None:
player = state.players[state.current_seat]
bonus = bonuses(player, pack)
eligible_patrons = [
patron.id
for patron in pack.patrons
if patron.id in state.available_patrons and requirements_met(patron.requirements, bonus)
]
if eligible_patrons:
state.pending = PendingChoice(kind="patron", seat=player.seat, options=eligible_patrons)
return
_check_outposts_or_objectives(state, pack, settings)
def _check_outposts_or_objectives(state: GameState, pack: ContentPack, settings: GameSettings) -> None:
if settings.modules.outposts and settings.interactions.outposts_before_objectives and _queue_outpost(state, pack):
return
_check_objectives(state, pack, settings)
def _queue_outpost(state: GameState, pack: ContentPack) -> bool:
player = state.players[state.current_seat]
bonus = bonuses(player, pack)
choices = [
outpost.id
for outpost in pack.outposts
if outpost.id not in player.outposts and requirements_met(outpost.requirements, bonus)
]
if choices:
state.pending = PendingChoice(kind="outpost", seat=player.seat, options=choices)
return True
return False
def _check_objectives(state: GameState, pack: ContentPack, settings: GameSettings) -> None:
player = state.players[state.current_seat]
if settings.modules.objectives:
bonus = bonuses(player, pack)
choices = [
objective.id
for objective in pack.objectives
if objective.id in state.available_objectives
and score(player, pack) >= objective.minimum_score
and requirements_met(objective.requirements, bonus)
]
if choices:
player.objective_met = choices[0]
if player.seat not in state.objective_qualifiers:
state.objective_qualifiers.append(player.seat)
if (
settings.modules.outposts
and not settings.interactions.outposts_before_objectives
and _queue_outpost(state, pack)
):
return
_finish_turn(state, pack, settings)
def _finish_turn(state: GameState, pack: ContentPack, settings: GameSettings) -> None:
player = state.players[state.current_seat]
has_score = score(player, pack) >= settings.target_score
has_objective = player.objective_met is not None
triggered = {
"score": has_score,
"objective": has_objective,
"either": has_score or has_objective,
"both": has_score and has_objective,
}[settings.victory_condition]
if triggered and state.finish_at_seat is None:
state.finish_at_seat = (state.first_seat - 1) % len(state.players)
if state.finish_at_seat == state.current_seat:
candidates = (
state.objective_qualifiers if settings.victory_condition == "objective" else list(range(len(state.players)))
)
if not candidates:
candidates = list(range(len(state.players)))
best_score = max(score(state.players[seat], pack) for seat in candidates)
candidates = [seat for seat in candidates if score(state.players[seat], pack) == best_score]
fewest = min(state.players[seat].purchased_card_count for seat in candidates)
state.winners = [seat for seat in candidates if state.players[seat].purchased_card_count == fewest]
state.finished = True
state.pending = None
return
state.current_seat = (state.current_seat + 1) % len(state.players)
if state.current_seat == state.first_seat:
state.round_number += 1
state.turn_purchase_count = 0
state.turn_chain_free_count = 0
def _remove_visible(state: GameState, card_id: str) -> None:
for market in state.markets.values():
if card_id in market:
market.remove(card_id)
return
error = "Card is not visible"
raise RuleError(error)
def _refill_markets(state: GameState, settings: GameSettings) -> None:
for key, market in state.markets.items():
desired = settings.eastern_market_size if key.startswith("eastern:") else settings.base_market_size
deck = state.decks[key]
while len(market) < desired and deck:
market.append(deck.pop())
def _assert_not_blocked(state: GameState, card_id: str, seat: int) -> None:
occupants = state.fortifications.get(card_id, {})
if any(owner != seat and count > 0 for owner, count in occupants.items()):
error = "An opponent's fortification protects that card"
raise RuleError(error)
def _return_fortifications(state: GameState, card_id: str) -> None:
for seat, count in state.fortifications.pop(card_id, {}).items():
state.players[seat].fortifications_available += count
def _owned_outpost(player: PlayerState, pack: ContentPack, power: OutpostPower) -> OutpostDefinition | None:
definitions = {item.id: item for item in pack.outposts}
return next((definitions[item] for item in player.outposts if definitions[item].power == power), None)
def _reconcile_outposts(player: PlayerState, pack: ContentPack) -> None:
definitions = {item.id: item for item in pack.outposts}
bonus = bonuses(player, pack)
player.outposts = [item for item in player.outposts if requirements_met(definitions[item].requirements, bonus)]
def public_state(state: GameState, viewer_seat: int | None) -> dict[str, Any]:
"""Serialize state while redacting other players' reserved cards and deck order."""
data = state.model_dump(mode="json")
for player in data["players"]:
if player["seat"] != viewer_seat:
player["reserved"] = [None] * len(player["reserved"])
data["decks"] = {key: len(value) for key, value in state.decks.items()}
if state.pending and state.pending.seat != viewer_seat:
data["pending"]["options"] = []
data["pending"]["context"] = {}
return data
+159
View File
@@ -0,0 +1,159 @@
"""Legal command generation used by bots and server-rendered controls."""
from __future__ import annotations
import itertools
import math
import uuid
from .engine import bonuses, visible_cards
from .models import ContentPack, GameCommand, GameSettings, GameState, OutpostPower
DISTINCT_TAKE_COUNT = 3
DOUBLE_TAKE_MINIMUM = 4
def command(state: GameState, type_: str, payload: dict | None = None) -> GameCommand:
"""Create a command targeting the state's current revision."""
return GameCommand(
command_id=uuid.uuid4().hex,
expected_revision=state.revision,
type=type_, # type: ignore[arg-type]
payload=payload or {},
)
def legal_commands( # noqa: C901, PLR0911, PLR0912 - mirrors pending and normal action branches
state: GameState, pack: ContentPack, settings: GameSettings, seat: int
) -> list[GameCommand]:
"""Enumerate meaningful legal commands without revealing hidden information."""
if state.finished or seat != state.current_seat:
return []
player = state.players[seat]
if state.pending:
pending = state.pending
if pending.seat != seat:
return []
if pending.kind == "discard_tokens":
available = [item for item, amount in player.tokens.items() for _ in range(amount)]
payload: dict[str, int] = {}
for item in available[: pending.amount]:
payload[item] = payload.get(item, 0) + 1
return [command(state, "choose", {"tokens": payload})]
if pending.kind == "fortification":
pending_results = []
for target in pending.options:
occupants = state.fortifications.get(target, {})
if not any(owner != seat and count for owner, count in occupants.items()):
if player.fortifications_available:
pending_results.append(command(state, "choose", {"card_id": target, "mode": "place"}))
for source, source_occupants in state.fortifications.items():
if source_occupants.get(seat, 0):
pending_results.append(
command(state, "choose", {"card_id": target, "from_card": source, "mode": "place"})
)
if any(owner != seat and count == 1 for owner, count in occupants.items()):
pending_results.append(command(state, "choose", {"card_id": target, "mode": "remove"}))
return pending_results
if pending.kind == "conquest":
conquest_results = [command(state, "decline")]
for card_id in pending.options:
payment = default_payment(state, pack, settings, card_id, seat)
if payment is not None:
conquest_results.append(command(state, "purchase", {"card_id": card_id, **payment}))
return conquest_results
return [command(state, "choose", {"choice": item}) for item in pending.options]
results: list[GameCommand] = []
available = [resource for resource in pack.resource_ids if state.supply.get(resource, 0)]
take_size = DISTINCT_TAKE_COUNT if len(available) >= DISTINCT_TAKE_COUNT else 1
sizes = [take_size] if len(available) >= DISTINCT_TAKE_COUNT else list(range(1, len(available) + 1))
for size in sizes:
results.extend(
command(state, "take_distinct", {"resources": list(items)})
for items in itertools.combinations(available, size)
)
results.extend(
command(state, "take_double", {"resource": resource})
for resource in pack.resource_ids
if state.supply.get(resource, 0) >= DOUBLE_TAKE_MINIMUM
)
if len(player.reserved) < settings.reserve_limit:
results.extend(
command(state, "reserve", {"card_id": card_id})
for card_id in visible_cards(state)
if not _blocked(state, card_id, seat)
)
results.extend(command(state, "reserve", {"deck": key}) for key, cards in state.decks.items() if cards)
for card_id in [*visible_cards(state), *player.reserved]:
if card_id not in player.reserved and _blocked(state, card_id, seat):
continue
payment = default_payment(state, pack, settings, card_id, seat)
if payment is not None:
results.append(command(state, "purchase", {"card_id": card_id, **payment}))
return results
def default_payment( # noqa: C901, PLR0912 - ordered payment rules are intentionally explicit
state: GameState,
pack: ContentPack,
settings: GameSettings,
card_id: str,
seat: int,
) -> dict | None:
"""Return one legal colored-first payment, or None when unaffordable."""
player = state.players[seat]
card = pack.card(card_id)
if card.alternate_cost:
matching = []
definitions = {item.id: item for item in pack.cards}
for owned in player.cards:
resource = owned.copied_resource or definitions[owned.card_id].bonus_resource
if resource == card.alternate_cost.discard_resource:
matching.append(owned.card_id)
copies = [
item for item in matching if next(owned for owned in player.cards if owned.card_id == item).copied_resource
]
ordered = [*copies, *(item for item in matching if item not in copies)]
if len(ordered) < card.alternate_cost.count:
return None
return {"discard_cards": ordered[: card.alternate_cost.count]}
discount = bonuses(player, pack)
payment: dict[str, int] = {}
shortage = 0
for resource in pack.resource_ids:
due = max(0, card.cost.get(resource, 0) - discount.get(resource, 0))
amount = min(due, player.tokens.get(resource, 0))
if amount:
payment[resource] = amount
shortage += due - amount
double_wild = any(
outpost.power == OutpostPower.DOUBLE_WILD and outpost.id in player.outposts for outpost in pack.outposts
)
wild_value = 2 if double_wild else 1
virtual_multiplier = 2 if double_wild and settings.interactions.virtual_wild_can_double else 1
virtual_cards: list[str] = []
virtual_total = 0
if shortage > player.tokens.get(pack.wild_resource.id, 0) * wild_value:
for owned in player.cards:
definition = pack.card(owned.card_id)
if definition.effect.kind.value != "virtual_wild":
continue
virtual_cards.append(owned.card_id)
virtual_total += definition.effect.amount * virtual_multiplier
if virtual_total + player.tokens.get(pack.wild_resource.id, 0) * wild_value >= shortage:
break
remaining = max(0, shortage - virtual_total)
wild_needed = math.ceil(remaining / wild_value)
if wild_needed > player.tokens.get(pack.wild_resource.id, 0) or virtual_total + wild_needed * wild_value < shortage:
return None
if wild_needed:
payment[pack.wild_resource.id] = wild_needed
result: dict[str, object] = {"payment": payment}
if virtual_cards:
result["virtual_wild_cards"] = virtual_cards
return result
def _blocked(state: GameState, card_id: str, seat: int) -> bool:
return any(owner != seat and count for owner, count in state.fortifications.get(card_id, {}).items())
+394
View File
@@ -0,0 +1,394 @@
"""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)
+58
View File
@@ -0,0 +1,58 @@
"""Requirement evaluation shared by patrons, objectives, outposts, and AI."""
from __future__ import annotations
from .models import Requirement, RequirementKind
def requirements_met(requirements: list[Requirement], bonuses: dict[str, int]) -> bool:
"""Return whether one assignment satisfies all fixed and wildcard clauses."""
chosen: dict[str, str] = {}
def visit(index: int) -> bool:
if index == len(requirements):
return True
requirement = requirements[index]
if requirement.kind == RequirementKind.COLOR:
if bonuses.get(requirement.resource or "", 0) < requirement.count:
return False
chosen[requirement.id] = requirement.resource or ""
return visit(index + 1)
forbidden = set(requirement.exclude)
forbidden.update(chosen[item] for item in requirement.distinct_from if item in chosen)
for resource, amount in bonuses.items():
if resource in forbidden or amount < requirement.count:
continue
chosen[requirement.id] = resource
if visit(index + 1):
return True
chosen.pop(requirement.id, None)
return False
fixed = [item for item in requirements if item.kind == RequirementKind.COLOR]
flexible = [item for item in requirements if item.kind == RequirementKind.ANY_COLOR]
return visit_ordered([*fixed, *flexible], bonuses, chosen)
def visit_ordered(requirements: list[Requirement], bonuses: dict[str, int], chosen: dict[str, str]) -> bool:
"""Backtracking evaluator kept separate for straightforward unit testing."""
if not requirements:
return True
requirement, *rest = requirements
if requirement.kind == RequirementKind.COLOR:
resource = requirement.resource or ""
if bonuses.get(resource, 0) < requirement.count:
return False
chosen[requirement.id] = resource
return visit_ordered(rest, bonuses, chosen)
forbidden = set(requirement.exclude)
forbidden.update(chosen[item] for item in requirement.distinct_from if item in chosen)
for resource, amount in bonuses.items():
if resource in forbidden or amount < requirement.count:
continue
chosen[requirement.id] = resource
if visit_ordered(rest, bonuses, chosen):
return True
chosen.pop(requirement.id, None)
return False