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
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:
@@ -0,0 +1 @@
|
||||
"""Domain models and rules for Gems."""
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user