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,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())
|
||||
Reference in New Issue
Block a user