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
160 lines
7.2 KiB
Python
160 lines
7.2 KiB
Python
"""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())
|