"""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())