Saltar a contenido

Referencia del API

Bots

API pública para escribir bots de Mus.

Un bot externo solo necesita importar este módulo::

from musst.bots.api import Bot, Observation, Action, PassAction

class MiBot(Bot):
    name = "mi_bot"
    def decide(self, observation: Observation) -> Action:
        return observation.legal_actions[0].default()

El bot controla un asiento. Recibe una :class:Observation (nunca el estado del motor) y devuelve una :class:Action. El motor vuelve a validar todas las acciones.

AcceptAction dataclass

Quiero.

Source code in src/musst/engine/actions.py
59
60
61
62
63
@dataclass(frozen=True)
class AcceptAction:
    """Quiero."""

    type = "accept"

AcceptNoLoseAction dataclass

"Quiero y no pierdo" (FEM VI.9-10): postre con 33 acepta un órdago a juego.

Source code in src/musst/engine/actions.py
66
67
68
69
70
@dataclass(frozen=True)
class AcceptNoLoseAction:
    """ "Quiero y no pierdo" (FEM VI.9-10): postre con 33 acepta un órdago a juego."""

    type = "accept_no_lose"

ActionSpec dataclass

Descripción de una familia de acciones legales.

Para bet/raise indica el rango de cantidades; para discard el número de cartas.

Source code in src/musst/engine/actions.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
@dataclass(frozen=True)
class ActionSpec:
    """Descripción de una familia de acciones legales.

    Para ``bet``/``raise`` indica el rango de cantidades; para ``discard`` el número de cartas.
    """

    type: str
    min_amount: int | None = None
    max_amount: int | None = None

    def default(self) -> Action:
        """Una acción concreta válida de esta familia (para bots sencillos).

        ``discard`` no tiene acción por defecto porque depende de la mano.
        """
        if self.type in _SIMPLE:
            return _SIMPLE[self.type]
        if self.type == "bet":
            return BetAction(self.min_amount or 2)
        if self.type == "raise":
            return RaiseAction(self.min_amount or 2)
        raise ValueError("discard requiere elegir cartas")

    def to_dict(self) -> dict[str, Any]:
        data: dict[str, Any] = {"type": self.type}
        if self.min_amount is not None:
            data["min"] = self.min_amount
            data["max"] = self.max_amount
        return data

default()

Una acción concreta válida de esta familia (para bots sencillos).

discard no tiene acción por defecto porque depende de la mano.

Source code in src/musst/engine/actions.py
152
153
154
155
156
157
158
159
160
161
162
163
def default(self) -> Action:
    """Una acción concreta válida de esta familia (para bots sencillos).

    ``discard`` no tiene acción por defecto porque depende de la mano.
    """
    if self.type in _SIMPLE:
        return _SIMPLE[self.type]
    if self.type == "bet":
        return BetAction(self.min_amount or 2)
    if self.type == "raise":
        return RaiseAction(self.min_amount or 2)
    raise ValueError("discard requiere elegir cartas")

BetAction dataclass

Envido de amount tantos (envido = 2).

Source code in src/musst/engine/actions.py
43
44
45
46
47
48
@dataclass(frozen=True)
class BetAction:
    """Envido de ``amount`` tantos (envido = 2)."""

    amount: int
    type = "bet"

Bot

Bases: ABC

Jugador autónomo de Mus.

Source code in src/musst/bots/api.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
class Bot(ABC):
    """Jugador autónomo de Mus."""

    name: str = "unnamed"
    author: str = ""
    version: str = "0.1"

    def on_game_start(self, info: GameStartInfo) -> None:  # noqa: B027
        """Opcional: se llama una vez al empezar la partida."""

    @abstractmethod
    def decide(self, observation: Observation) -> Action:
        """Devuelve una acción de ``observation.legal_actions``."""

    def on_game_end(self, info: GameEndInfo) -> None:  # noqa: B027
        """Opcional: se llama al terminar. Útil para aprender."""

    def signal(self, observation: Observation) -> str | None:
        """Opcional: seña que quiere hacer ahora (una de ``observation.available_signals``).

        Se le pregunta cada vez que avanza el reloj de la mesa, sea o no su turno. Solo verán
        la seña quienes le estén mirando (``observation.gazes``). ``None`` = no hacer seña.
        """
        return None

decide(observation) abstractmethod

Devuelve una acción de observation.legal_actions.

Source code in src/musst/bots/api.py
130
131
132
@abstractmethod
def decide(self, observation: Observation) -> Action:
    """Devuelve una acción de ``observation.legal_actions``."""

on_game_end(info)

Opcional: se llama al terminar. Útil para aprender.

Source code in src/musst/bots/api.py
134
135
def on_game_end(self, info: GameEndInfo) -> None:  # noqa: B027
    """Opcional: se llama al terminar. Útil para aprender."""

on_game_start(info)

Opcional: se llama una vez al empezar la partida.

Source code in src/musst/bots/api.py
127
128
def on_game_start(self, info: GameStartInfo) -> None:  # noqa: B027
    """Opcional: se llama una vez al empezar la partida."""

signal(observation)

Opcional: seña que quiere hacer ahora (una de observation.available_signals).

Se le pregunta cada vez que avanza el reloj de la mesa, sea o no su turno. Solo verán la seña quienes le estén mirando (observation.gazes). None = no hacer seña.

Source code in src/musst/bots/api.py
137
138
139
140
141
142
143
def signal(self, observation: Observation) -> str | None:
    """Opcional: seña que quiere hacer ahora (una de ``observation.available_signals``).

    Se le pregunta cada vez que avanza el reloj de la mesa, sea o no su turno. Solo verán
    la seña quienes le estén mirando (``observation.gazes``). ``None`` = no hacer seña.
    """
    return None

Card dataclass

Un naipe. Inmutable y serializable como "12O" (rey de oros).

Source code in src/musst/engine/cards.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@dataclass(frozen=True, order=True)
class Card:
    """Un naipe. Inmutable y serializable como ``"12O"`` (rey de oros)."""

    rank: int
    suit: Suit

    def __post_init__(self) -> None:
        if self.rank not in RANKS:
            raise ValueError(f"Número de naipe inválido: {self.rank}")

    @property
    def code(self) -> str:
        return f"{self.rank}{self.suit.value}"

    @classmethod
    def parse(cls, code: str) -> Card:
        """Crea un naipe desde su código, p. ej. ``"3E"`` o ``"12o"``."""
        code = code.strip().upper()
        if len(code) < 2:
            raise ValueError(f"Código de naipe inválido: {code!r}")
        return cls(int(code[:-1]), Suit(code[-1]))

    def __str__(self) -> str:
        return self.code

    def __repr__(self) -> str:
        return f"Card({self.code})"

parse(code) classmethod

Crea un naipe desde su código, p. ej. "3E" o "12o".

Source code in src/musst/engine/cards.py
57
58
59
60
61
62
63
@classmethod
def parse(cls, code: str) -> Card:
    """Crea un naipe desde su código, p. ej. ``"3E"`` o ``"12o"``."""
    code = code.strip().upper()
    if len(code) < 2:
        raise ValueError(f"Código de naipe inválido: {code!r}")
    return cls(int(code[:-1]), Suit(code[-1]))

CutMusAction dataclass

Cortar el mus ("no hay mus").

Source code in src/musst/engine/actions.py
21
22
23
24
25
@dataclass(frozen=True)
class CutMusAction:
    """Cortar el mus ("no hay mus")."""

    type = "cut"

DiscardAction dataclass

Descartar las cartas indicadas (de min_discard a 4).

Source code in src/musst/engine/actions.py
28
29
30
31
32
33
@dataclass(frozen=True)
class DiscardAction:
    """Descartar las cartas indicadas (de ``min_discard`` a 4)."""

    cards: tuple[Card, ...]
    type = "discard"

GameEndInfo dataclass

Resultado final. history incluye las manos reveladas de cada jugada.

Source code in src/musst/bots/api.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
@dataclass(frozen=True)
class GameEndInfo:
    """Resultado final. ``history`` incluye las manos reveladas de cada jugada."""

    winner: int
    score: tuple[int, int]
    reason: str
    history: tuple[dict[str, Any], ...]

    def to_dict(self) -> dict[str, Any]:
        return {
            "winner": self.winner,
            "score": list(self.score),
            "reason": self.reason,
            "history": list(self.history),
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> GameEndInfo:
        score = data["score"]
        return cls(data["winner"], (score[0], score[1]), data["reason"], tuple(data["history"]))

GameStartInfo dataclass

Información pública al empezar una partida.

Source code in src/musst/bots/api.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
@dataclass(frozen=True)
class GameStartInfo:
    """Información pública al empezar una partida."""

    seat: int
    partner: int
    team: int
    target: int
    player_names: tuple[str, str, str, str]
    context: dict[str, Any] = field(default_factory=dict)
    """Datos opcionales del servidor, p. ej. perfiles históricos públicos de los rivales."""

    def to_dict(self) -> dict[str, Any]:
        return {
            "seat": self.seat,
            "partner": self.partner,
            "team": self.team,
            "target": self.target,
            "player_names": list(self.player_names),
            "context": self.context,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> GameStartInfo:
        names = data["player_names"]
        return cls(
            data["seat"],
            data["partner"],
            data["team"],
            data["target"],
            (names[0], names[1], names[2], names[3]),
            data.get("context", {}),
        )

context = field(default_factory=dict) class-attribute instance-attribute

Datos opcionales del servidor, p. ej. perfiles históricos públicos de los rivales.

MusAction dataclass

Dar mus (pedir descarte).

Source code in src/musst/engine/actions.py
14
15
16
17
18
@dataclass(frozen=True)
class MusAction:
    """Dar mus (pedir descarte)."""

    type = "mus"

Observation dataclass

Todo lo que un asiento puede saber legítimamente en este momento.

Source code in src/musst/engine/observation.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
@dataclass(frozen=True)
class Observation:
    """Todo lo que un asiento puede saber legítimamente en este momento."""

    seat: int
    partner: int
    team: int
    hand: tuple[Card, ...]
    phase: str
    turn: int | None
    dealer: int
    mano: int
    round_no: int
    score: tuple[int, int]
    target: int
    declarations: dict[str, dict[int, bool]]
    current_bet: BetView | None
    lance_participants: tuple[int, ...]
    lance_statuses: dict[str, str]
    cards_in_deck: int
    mus_rounds: int
    legal_actions: tuple[ActionSpec, ...]
    history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
    revealed_hands: dict[int, tuple[Card, ...]] | None = None
    finished: bool = False
    winner: int | None = None
    gazes: tuple[int | None, ...] = ()
    """A quién mira cada asiento (público). Vacío si las señas están desactivadas."""
    available_signals: tuple[str, ...] = ()
    """Señas verdaderas que puedo hacer ahora."""
    signals_seen: dict[int, tuple[str, ...]] = field(default_factory=dict)
    """Señas de esta jugada que he visto (de otros asientos) o que he hecho yo."""

    @property
    def is_my_turn(self) -> bool:
        return self.turn == self.seat

    @property
    def opponents(self) -> tuple[int, int]:
        return ((self.seat + 1) % NUM_SEATS, (self.seat + 3) % NUM_SEATS)

    def legal_types(self) -> set[str]:
        return {spec.type for spec in self.legal_actions}

    def spec(self, kind: str) -> ActionSpec | None:
        for spec in self.legal_actions:
            if spec.type == kind:
                return spec
        return None

    def to_dict(self) -> dict[str, Any]:
        """Serialización JSON (para web y sandbox)."""
        bet = self.current_bet
        return {
            "seat": self.seat,
            "partner": self.partner,
            "team": self.team,
            "hand": [c.code for c in self.hand],
            "phase": self.phase,
            "turn": self.turn,
            "dealer": self.dealer,
            "mano": self.mano,
            "round": self.round_no,
            "score": list(self.score),
            "target": self.target,
            "declarations": {
                k: {str(s): v for s, v in d.items()} for k, d in self.declarations.items()
            },
            "current_bet": None
            if bet is None
            else {
                "lance": bet.lance,
                "bettor_team": bet.bettor_team,
                "stake": bet.stake,
                "previous": bet.previous,
                "is_ordago": bet.is_ordago,
                "responders": list(bet.responders),
            },
            "lance_participants": list(self.lance_participants),
            "lance_statuses": dict(self.lance_statuses),
            "cards_in_deck": self.cards_in_deck,
            "mus_rounds": self.mus_rounds,
            "legal_actions": [s.to_dict() for s in self.legal_actions],
            "history": list(self.history),
            "revealed_hands": None
            if self.revealed_hands is None
            else {str(s): [c.code for c in h] for s, h in self.revealed_hands.items()},
            "finished": self.finished,
            "winner": self.winner,
            "gazes": list(self.gazes),
            "available_signals": list(self.available_signals),
            "signals_seen": {str(s): list(v) for s, v in self.signals_seen.items()},
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Observation:
        bet = data.get("current_bet")
        revealed = data.get("revealed_hands")
        return cls(
            seat=data["seat"],
            partner=data["partner"],
            team=data["team"],
            hand=tuple(Card.parse(c) for c in data["hand"]),
            phase=data["phase"],
            turn=data["turn"],
            dealer=data["dealer"],
            mano=data["mano"],
            round_no=data["round"],
            score=(data["score"][0], data["score"][1]),
            target=data["target"],
            declarations={
                k: {int(s): v for s, v in d.items()} for k, d in data["declarations"].items()
            },
            current_bet=None
            if bet is None
            else BetView(
                bet["lance"],
                bet["bettor_team"],
                bet["stake"],
                bet["previous"],
                bet["is_ordago"],
                tuple(bet["responders"]),
            ),
            lance_participants=tuple(data["lance_participants"]),
            lance_statuses=dict(data["lance_statuses"]),
            cards_in_deck=data["cards_in_deck"],
            mus_rounds=data["mus_rounds"],
            legal_actions=tuple(
                ActionSpec(s["type"], s.get("min"), s.get("max")) for s in data["legal_actions"]
            ),
            history=tuple(data.get("history", ())),
            revealed_hands=None
            if revealed is None
            else {int(s): tuple(Card.parse(c) for c in h) for s, h in revealed.items()},
            finished=data["finished"],
            winner=data["winner"],
            gazes=tuple(data.get("gazes", ())),
            available_signals=tuple(data.get("available_signals", ())),
            signals_seen={int(s): tuple(v) for s, v in data.get("signals_seen", {}).items()},
        )

available_signals = () class-attribute instance-attribute

Señas verdaderas que puedo hacer ahora.

gazes = () class-attribute instance-attribute

A quién mira cada asiento (público). Vacío si las señas están desactivadas.

signals_seen = field(default_factory=dict) class-attribute instance-attribute

Señas de esta jugada que he visto (de otros asientos) o que he hecho yo.

to_dict()

Serialización JSON (para web y sandbox).

Source code in src/musst/engine/observation.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def to_dict(self) -> dict[str, Any]:
    """Serialización JSON (para web y sandbox)."""
    bet = self.current_bet
    return {
        "seat": self.seat,
        "partner": self.partner,
        "team": self.team,
        "hand": [c.code for c in self.hand],
        "phase": self.phase,
        "turn": self.turn,
        "dealer": self.dealer,
        "mano": self.mano,
        "round": self.round_no,
        "score": list(self.score),
        "target": self.target,
        "declarations": {
            k: {str(s): v for s, v in d.items()} for k, d in self.declarations.items()
        },
        "current_bet": None
        if bet is None
        else {
            "lance": bet.lance,
            "bettor_team": bet.bettor_team,
            "stake": bet.stake,
            "previous": bet.previous,
            "is_ordago": bet.is_ordago,
            "responders": list(bet.responders),
        },
        "lance_participants": list(self.lance_participants),
        "lance_statuses": dict(self.lance_statuses),
        "cards_in_deck": self.cards_in_deck,
        "mus_rounds": self.mus_rounds,
        "legal_actions": [s.to_dict() for s in self.legal_actions],
        "history": list(self.history),
        "revealed_hands": None
        if self.revealed_hands is None
        else {str(s): [c.code for c in h] for s, h in self.revealed_hands.items()},
        "finished": self.finished,
        "winner": self.winner,
        "gazes": list(self.gazes),
        "available_signals": list(self.available_signals),
        "signals_seen": {str(s): list(v) for s, v in self.signals_seen.items()},
    }

OrdagoAction dataclass

Órdago: apostar el juego completo.

Source code in src/musst/engine/actions.py
80
81
82
83
84
@dataclass(frozen=True)
class OrdagoAction:
    """Órdago: apostar el juego completo."""

    type = "ordago"

PassAction dataclass

Paso: no envidar en el turno.

Source code in src/musst/engine/actions.py
36
37
38
39
40
@dataclass(frozen=True)
class PassAction:
    """Paso: no envidar en el turno."""

    type = "pass"

RaiseAction dataclass

Reenvite: subir amount tantos sobre la apuesta pendiente.

Source code in src/musst/engine/actions.py
51
52
53
54
55
56
@dataclass(frozen=True)
class RaiseAction:
    """Reenvite: subir ``amount`` tantos sobre la apuesta pendiente."""

    amount: int
    type = "raise"

RejectAction dataclass

No quiero.

Source code in src/musst/engine/actions.py
73
74
75
76
77
@dataclass(frozen=True)
class RejectAction:
    """No quiero."""

    type = "reject"

Suit

Bases: str, Enum

Palos de la baraja española.

Source code in src/musst/engine/cards.py
29
30
31
32
33
34
35
36
37
38
39
class Suit(str, Enum):
    """Palos de la baraja española."""

    OROS = "O"
    COPAS = "C"
    ESPADAS = "E"
    BASTOS = "B"

    @property
    def label(self) -> str:
        return self.name.capitalize()

Utilidades para bots

Utilidades públicas para bots. Trabajan solo sobre la :class:Observation.

Incluye muestreo de manos rivales compatibles con la información pública (Monte Carlo) y heurísticas de fuerza por lance.

WinEstimate dataclass

Source code in src/musst/bots/toolkit.py
110
111
112
113
114
115
116
117
118
119
120
121
@dataclass(frozen=True)
class WinEstimate:
    p_win: float
    samples: int

    @property
    def confidence(self) -> float:
        """Error estándar → confianza en [0, 1]."""
        if self.samples == 0:
            return 0.0
        se: float = (self.p_win * (1 - self.p_win) / self.samples) ** 0.5
        return max(0.0, 1.0 - 4 * se)

confidence property

Error estándar → confianza en [0, 1].

discard_options(hand, min_discard=1)

Todos los descartes posibles (de min_discard a 4 cartas).

Source code in src/musst/bots/toolkit.py
187
188
189
def discard_options(hand: Sequence[Card], min_discard: int = 1) -> list[tuple[Card, ...]]:
    """Todos los descartes posibles (de ``min_discard`` a 4 cartas)."""
    return [combo for n in range(min_discard, 5) for combo in itertools.combinations(hand, n)]

estimate_lance_win(obs, lance, rng, samples=200, weight=None)

P(mi pareja gana lance) por Monte Carlo sobre las cartas no vistas.

weight permite reponderar cada mundo muestreado (p. ej. con un modelo de rival).

Source code in src/musst/bots/toolkit.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def estimate_lance_win(
    obs: Observation,
    lance: str,
    rng: random.Random,
    samples: int = 200,
    weight: Callable[[dict[int, tuple[Card, ...]]], float] | None = None,
) -> WinEstimate:
    """P(mi pareja gana ``lance``) por Monte Carlo sobre las cartas no vistas.

    ``weight`` permite reponderar cada mundo muestreado (p. ej. con un modelo de rival).
    """
    wins = 0.0
    total = 0.0
    for _ in range(samples):
        world = sample_world(obs, rng)
        w = 1.0 if weight is None else weight(world)
        winner = lance_winner(lance, world, obs.mano)
        total += w
        if winner is not None and winner % 2 == obs.team:
            wins += w
    return WinEstimate(wins / total if total else 0.0, samples)

hand_strength(hand, lance)

Fuerza heurística de una mano en un lance, en [0, 1]. Rápida y explicable.

Source code in src/musst/bots/toolkit.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def hand_strength(hand: Sequence[Card], lance: str) -> float:
    """Fuerza heurística de una mano en un lance, en [0, 1]. Rápida y explicable."""
    ranks = sorted((r for r in (ev.grande_key(hand))), reverse=True)
    if lance == "grande":
        kings = ranks.count(12)
        return min(1.0, 0.15 + 0.25 * kings + (0.08 if ranks[0] == 12 and ranks[1] >= 11 else 0))
    if lance == "chica":
        aces = ranks.count(1)
        low = sum(1 for r in ranks if r <= 4)
        return min(1.0, 0.1 + 0.25 * aces + 0.06 * low)
    if lance == "pares":
        p = ev.pairs(hand)
        if p.kind is ev.PairsKind.DUPLES:
            return 0.85 + 0.15 * (p.ranks[0] / 12)
        if p.kind is ev.PairsKind.MEDIAS:
            return 0.6 + 0.25 * (p.ranks[0] / 12)
        if p.kind is ev.PairsKind.PAR:
            return 0.25 + 0.35 * (p.ranks[0] / 12)
        return 0.0
    total = ev.total_points(hand)
    if lance == "juego":
        if total < 31:
            return 0.0
        order = ev.JUEGO_ORDER.index(total)
        return 1.0 - order * 0.11
    return min(1.0, max(0.0, (total - 20) / 10))

keep_heuristic_discard(hand)

Descarte clásico: guardar reyes, ases útiles y parejas; tirar el resto (mín. 1).

Source code in src/musst/bots/toolkit.py
192
193
194
195
196
197
198
199
200
def keep_heuristic_discard(hand: Sequence[Card]) -> tuple[Card, ...]:
    """Descarte clásico: guardar reyes, ases útiles y parejas; tirar el resto (mín. 1)."""
    norm = [ev.grande_key([c])[0] for c in hand]
    counts = {r: norm.count(r) for r in norm}
    discard = [c for c, r in zip(hand, norm, strict=True) if r != 12 and counts[r] < 2]
    if not discard:
        lowest = min(zip(hand, norm, strict=True), key=lambda t: (counts[t[1]] >= 2, t[1]))
        discard = [lowest[0]]
    return tuple(discard)

sample_world(obs, rng, max_tries=50)

Muestra manos para los otros tres asientos compatibles con lo que sé.

Restricciones: declaraciones públicas de pares/juego y señas que he visto (verdaderas). Se reparte primero a los asientos más restringidos (los que han hecho señas), cada uno con varios intentos, para que una seña rara (p. ej. 31) no obligue a descartar mundos enteros.

Source code in src/musst/bots/toolkit.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def sample_world(
    obs: Observation, rng: random.Random, max_tries: int = 50
) -> dict[int, tuple[Card, ...]]:
    """Muestra manos para los otros tres asientos compatibles con lo que sé.

    Restricciones: declaraciones públicas de pares/juego y señas que he visto (verdaderas).
    Se reparte primero a los asientos más restringidos (los que han hecho señas), cada uno con
    varios intentos, para que una seña rara (p. ej. 31) no obligue a descartar mundos enteros.
    """
    pool = unseen_cards(obs)
    others = sorted(
        (s for s in range(4) if s != obs.seat), key=lambda s: -len(obs.signals_seen.get(s, ()))
    )
    world: dict[int, tuple[Card, ...]] = {obs.seat: obs.hand}
    for _ in range(max_tries):
        remaining = list(pool)
        ok = True
        for seat in others:
            for _ in range(max_tries):
                hand = tuple(rng.sample(remaining, 4))
                if _consistent(hand, seat, obs):
                    break
            else:
                ok = False
                break
            world[seat] = hand
            for card in hand:
                remaining.remove(card)
        if ok:
            return world
    rng.shuffle(pool)
    for i, seat in enumerate(others):  # se relaja la restricción si es muy improbable
        world[seat] = tuple(pool[i * 4 : i * 4 + 4])
    return world

strength_percentile(hand, lance, samples=300, seed=0)

Percentil de la mano frente a manos aleatorias (con la misma heurística).

Source code in src/musst/bots/toolkit.py
176
177
178
179
180
181
182
183
184
def strength_percentile(
    hand: Sequence[Card], lance: str, samples: int = 300, seed: int = 0
) -> float:
    """Percentil de la mano frente a manos aleatorias (con la misma heurística)."""
    rng = random.Random(seed)
    deck = [c for c in full_deck() if c not in hand]
    mine = hand_strength(hand, lance)
    below = sum(1 for _ in range(samples) if hand_strength(rng.sample(deck, 4), lance) < mine)
    return below / samples

unseen_cards(obs)

Cartas que pueden estar en manos ajenas: todas menos las mías y mis descartes de la jugada.

Source code in src/musst/bots/toolkit.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def unseen_cards(obs: Observation) -> list[Card]:
    """Cartas que pueden estar en manos ajenas: todas menos las mías y mis descartes de la jugada."""
    mine = set(obs.hand)
    discarded: set[Card] = set()
    for event in obs.history:
        if event["kind"] == "DeckReshuffled" and event["round"] == obs.round_no:
            discarded.clear()  # FEM III.15: los descartes vuelven al mazo
        elif (
            event["kind"] == "CardsDiscarded"
            and event["round"] == obs.round_no
            and "cards" in event["payload"]
        ):
            discarded.update(Card.parse(c) for c in event["payload"]["cards"])
    return [c for c in full_deck() if c not in mine and c not in discarded]

Observación

Todo lo que un asiento puede saber legítimamente en este momento.

Source code in src/musst/engine/observation.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
@dataclass(frozen=True)
class Observation:
    """Todo lo que un asiento puede saber legítimamente en este momento."""

    seat: int
    partner: int
    team: int
    hand: tuple[Card, ...]
    phase: str
    turn: int | None
    dealer: int
    mano: int
    round_no: int
    score: tuple[int, int]
    target: int
    declarations: dict[str, dict[int, bool]]
    current_bet: BetView | None
    lance_participants: tuple[int, ...]
    lance_statuses: dict[str, str]
    cards_in_deck: int
    mus_rounds: int
    legal_actions: tuple[ActionSpec, ...]
    history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
    revealed_hands: dict[int, tuple[Card, ...]] | None = None
    finished: bool = False
    winner: int | None = None
    gazes: tuple[int | None, ...] = ()
    """A quién mira cada asiento (público). Vacío si las señas están desactivadas."""
    available_signals: tuple[str, ...] = ()
    """Señas verdaderas que puedo hacer ahora."""
    signals_seen: dict[int, tuple[str, ...]] = field(default_factory=dict)
    """Señas de esta jugada que he visto (de otros asientos) o que he hecho yo."""

    @property
    def is_my_turn(self) -> bool:
        return self.turn == self.seat

    @property
    def opponents(self) -> tuple[int, int]:
        return ((self.seat + 1) % NUM_SEATS, (self.seat + 3) % NUM_SEATS)

    def legal_types(self) -> set[str]:
        return {spec.type for spec in self.legal_actions}

    def spec(self, kind: str) -> ActionSpec | None:
        for spec in self.legal_actions:
            if spec.type == kind:
                return spec
        return None

    def to_dict(self) -> dict[str, Any]:
        """Serialización JSON (para web y sandbox)."""
        bet = self.current_bet
        return {
            "seat": self.seat,
            "partner": self.partner,
            "team": self.team,
            "hand": [c.code for c in self.hand],
            "phase": self.phase,
            "turn": self.turn,
            "dealer": self.dealer,
            "mano": self.mano,
            "round": self.round_no,
            "score": list(self.score),
            "target": self.target,
            "declarations": {
                k: {str(s): v for s, v in d.items()} for k, d in self.declarations.items()
            },
            "current_bet": None
            if bet is None
            else {
                "lance": bet.lance,
                "bettor_team": bet.bettor_team,
                "stake": bet.stake,
                "previous": bet.previous,
                "is_ordago": bet.is_ordago,
                "responders": list(bet.responders),
            },
            "lance_participants": list(self.lance_participants),
            "lance_statuses": dict(self.lance_statuses),
            "cards_in_deck": self.cards_in_deck,
            "mus_rounds": self.mus_rounds,
            "legal_actions": [s.to_dict() for s in self.legal_actions],
            "history": list(self.history),
            "revealed_hands": None
            if self.revealed_hands is None
            else {str(s): [c.code for c in h] for s, h in self.revealed_hands.items()},
            "finished": self.finished,
            "winner": self.winner,
            "gazes": list(self.gazes),
            "available_signals": list(self.available_signals),
            "signals_seen": {str(s): list(v) for s, v in self.signals_seen.items()},
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Observation:
        bet = data.get("current_bet")
        revealed = data.get("revealed_hands")
        return cls(
            seat=data["seat"],
            partner=data["partner"],
            team=data["team"],
            hand=tuple(Card.parse(c) for c in data["hand"]),
            phase=data["phase"],
            turn=data["turn"],
            dealer=data["dealer"],
            mano=data["mano"],
            round_no=data["round"],
            score=(data["score"][0], data["score"][1]),
            target=data["target"],
            declarations={
                k: {int(s): v for s, v in d.items()} for k, d in data["declarations"].items()
            },
            current_bet=None
            if bet is None
            else BetView(
                bet["lance"],
                bet["bettor_team"],
                bet["stake"],
                bet["previous"],
                bet["is_ordago"],
                tuple(bet["responders"]),
            ),
            lance_participants=tuple(data["lance_participants"]),
            lance_statuses=dict(data["lance_statuses"]),
            cards_in_deck=data["cards_in_deck"],
            mus_rounds=data["mus_rounds"],
            legal_actions=tuple(
                ActionSpec(s["type"], s.get("min"), s.get("max")) for s in data["legal_actions"]
            ),
            history=tuple(data.get("history", ())),
            revealed_hands=None
            if revealed is None
            else {int(s): tuple(Card.parse(c) for c in h) for s, h in revealed.items()},
            finished=data["finished"],
            winner=data["winner"],
            gazes=tuple(data.get("gazes", ())),
            available_signals=tuple(data.get("available_signals", ())),
            signals_seen={int(s): tuple(v) for s, v in data.get("signals_seen", {}).items()},
        )

available_signals = () class-attribute instance-attribute

Señas verdaderas que puedo hacer ahora.

gazes = () class-attribute instance-attribute

A quién mira cada asiento (público). Vacío si las señas están desactivadas.

signals_seen = field(default_factory=dict) class-attribute instance-attribute

Señas de esta jugada que he visto (de otros asientos) o que he hecho yo.

to_dict()

Serialización JSON (para web y sandbox).

Source code in src/musst/engine/observation.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def to_dict(self) -> dict[str, Any]:
    """Serialización JSON (para web y sandbox)."""
    bet = self.current_bet
    return {
        "seat": self.seat,
        "partner": self.partner,
        "team": self.team,
        "hand": [c.code for c in self.hand],
        "phase": self.phase,
        "turn": self.turn,
        "dealer": self.dealer,
        "mano": self.mano,
        "round": self.round_no,
        "score": list(self.score),
        "target": self.target,
        "declarations": {
            k: {str(s): v for s, v in d.items()} for k, d in self.declarations.items()
        },
        "current_bet": None
        if bet is None
        else {
            "lance": bet.lance,
            "bettor_team": bet.bettor_team,
            "stake": bet.stake,
            "previous": bet.previous,
            "is_ordago": bet.is_ordago,
            "responders": list(bet.responders),
        },
        "lance_participants": list(self.lance_participants),
        "lance_statuses": dict(self.lance_statuses),
        "cards_in_deck": self.cards_in_deck,
        "mus_rounds": self.mus_rounds,
        "legal_actions": [s.to_dict() for s in self.legal_actions],
        "history": list(self.history),
        "revealed_hands": None
        if self.revealed_hands is None
        else {str(s): [c.code for c in h] for s, h in self.revealed_hands.items()},
        "finished": self.finished,
        "winner": self.winner,
        "gazes": list(self.gazes),
        "available_signals": list(self.available_signals),
        "signals_seen": {str(s): list(v) for s, v in self.signals_seen.items()},
    }

Acciones

Acciones tipadas que un jugador puede proponer al motor.

Una acción es una intención. Solo el :class:~musst.engine.rules.RulesEngine decide si es legal.

AcceptAction dataclass

Quiero.

Source code in src/musst/engine/actions.py
59
60
61
62
63
@dataclass(frozen=True)
class AcceptAction:
    """Quiero."""

    type = "accept"

AcceptNoLoseAction dataclass

"Quiero y no pierdo" (FEM VI.9-10): postre con 33 acepta un órdago a juego.

Source code in src/musst/engine/actions.py
66
67
68
69
70
@dataclass(frozen=True)
class AcceptNoLoseAction:
    """ "Quiero y no pierdo" (FEM VI.9-10): postre con 33 acepta un órdago a juego."""

    type = "accept_no_lose"

ActionSpec dataclass

Descripción de una familia de acciones legales.

Para bet/raise indica el rango de cantidades; para discard el número de cartas.

Source code in src/musst/engine/actions.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
@dataclass(frozen=True)
class ActionSpec:
    """Descripción de una familia de acciones legales.

    Para ``bet``/``raise`` indica el rango de cantidades; para ``discard`` el número de cartas.
    """

    type: str
    min_amount: int | None = None
    max_amount: int | None = None

    def default(self) -> Action:
        """Una acción concreta válida de esta familia (para bots sencillos).

        ``discard`` no tiene acción por defecto porque depende de la mano.
        """
        if self.type in _SIMPLE:
            return _SIMPLE[self.type]
        if self.type == "bet":
            return BetAction(self.min_amount or 2)
        if self.type == "raise":
            return RaiseAction(self.min_amount or 2)
        raise ValueError("discard requiere elegir cartas")

    def to_dict(self) -> dict[str, Any]:
        data: dict[str, Any] = {"type": self.type}
        if self.min_amount is not None:
            data["min"] = self.min_amount
            data["max"] = self.max_amount
        return data

default()

Una acción concreta válida de esta familia (para bots sencillos).

discard no tiene acción por defecto porque depende de la mano.

Source code in src/musst/engine/actions.py
152
153
154
155
156
157
158
159
160
161
162
163
def default(self) -> Action:
    """Una acción concreta válida de esta familia (para bots sencillos).

    ``discard`` no tiene acción por defecto porque depende de la mano.
    """
    if self.type in _SIMPLE:
        return _SIMPLE[self.type]
    if self.type == "bet":
        return BetAction(self.min_amount or 2)
    if self.type == "raise":
        return RaiseAction(self.min_amount or 2)
    raise ValueError("discard requiere elegir cartas")

BetAction dataclass

Envido de amount tantos (envido = 2).

Source code in src/musst/engine/actions.py
43
44
45
46
47
48
@dataclass(frozen=True)
class BetAction:
    """Envido de ``amount`` tantos (envido = 2)."""

    amount: int
    type = "bet"

CutMusAction dataclass

Cortar el mus ("no hay mus").

Source code in src/musst/engine/actions.py
21
22
23
24
25
@dataclass(frozen=True)
class CutMusAction:
    """Cortar el mus ("no hay mus")."""

    type = "cut"

DiscardAction dataclass

Descartar las cartas indicadas (de min_discard a 4).

Source code in src/musst/engine/actions.py
28
29
30
31
32
33
@dataclass(frozen=True)
class DiscardAction:
    """Descartar las cartas indicadas (de ``min_discard`` a 4)."""

    cards: tuple[Card, ...]
    type = "discard"

MusAction dataclass

Dar mus (pedir descarte).

Source code in src/musst/engine/actions.py
14
15
16
17
18
@dataclass(frozen=True)
class MusAction:
    """Dar mus (pedir descarte)."""

    type = "mus"

OrdagoAction dataclass

Órdago: apostar el juego completo.

Source code in src/musst/engine/actions.py
80
81
82
83
84
@dataclass(frozen=True)
class OrdagoAction:
    """Órdago: apostar el juego completo."""

    type = "ordago"

PassAction dataclass

Paso: no envidar en el turno.

Source code in src/musst/engine/actions.py
36
37
38
39
40
@dataclass(frozen=True)
class PassAction:
    """Paso: no envidar en el turno."""

    type = "pass"

RaiseAction dataclass

Reenvite: subir amount tantos sobre la apuesta pendiente.

Source code in src/musst/engine/actions.py
51
52
53
54
55
56
@dataclass(frozen=True)
class RaiseAction:
    """Reenvite: subir ``amount`` tantos sobre la apuesta pendiente."""

    amount: int
    type = "raise"

RejectAction dataclass

No quiero.

Source code in src/musst/engine/actions.py
73
74
75
76
77
@dataclass(frozen=True)
class RejectAction:
    """No quiero."""

    type = "reject"

action_from_dict(data)

Deserializa y valida la forma (no la legalidad) de una acción.

Source code in src/musst/engine/actions.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def action_from_dict(data: dict[str, Any]) -> Action:
    """Deserializa y valida la *forma* (no la legalidad) de una acción."""
    if not isinstance(data, dict) or "type" not in data:
        raise ValueError("La acción debe ser un objeto con campo 'type'")
    kind = data["type"]
    if kind in _SIMPLE:
        return _SIMPLE[kind]
    if kind == "discard":
        cards = data.get("cards")
        if not isinstance(cards, list) or not all(isinstance(c, str) for c in cards):
            raise ValueError("'cards' debe ser una lista de códigos de naipe")
        return DiscardAction(tuple(Card.parse(c) for c in cards))
    if kind in ("bet", "raise"):
        amount = data.get("amount")
        if not isinstance(amount, int) or isinstance(amount, bool):
            raise ValueError("'amount' debe ser un entero")
        return BetAction(amount) if kind == "bet" else RaiseAction(amount)
    raise ValueError(f"Tipo de acción desconocido: {kind!r}")

action_to_dict(action)

Serializa una acción a JSON con discriminador type.

Source code in src/musst/engine/actions.py
111
112
113
114
115
116
117
118
def action_to_dict(action: Action) -> dict[str, Any]:
    """Serializa una acción a JSON con discriminador ``type``."""
    data: dict[str, Any] = {"type": action.type}
    if isinstance(action, DiscardAction):
        data["cards"] = [c.code for c in action.cards]
    elif isinstance(action, BetAction | RaiseAction):
        data["amount"] = action.amount
    return data

Partidas sin interfaz

Juega una partida completa. bots[i] ocupa el asiento i.

Pareja 0 = asientos 0 y 2; pareja 1 = asientos 1 y 3.

Source code in src/musst/runtime/runner.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def play_game(
    bots: Sequence[Bot],
    seed: int | None = None,
    config: RulesConfig | None = None,
    mode: Literal["casual", "tournament"] = "casual",
    time_limit_s: float = 5.0,
    names: Sequence[str] | None = None,
    contexts: Sequence[dict[str, object]] | None = None,
    on_event: Callable[[GameEvent], None] | None = None,
    max_actions: int = 20000,
) -> MatchResult:
    """Juega una partida completa. ``bots[i]`` ocupa el asiento ``i``.

    Pareja 0 = asientos 0 y 2; pareja 1 = asientos 1 y 3.
    """
    if len(bots) != 4:
        raise ValueError("Se necesitan exactamente 4 bots (uno por asiento)")
    game = MusGame(seed=seed, config=config)
    player_names = (
        tuple(names) if names else tuple(getattr(b, "name", f"bot{i}") for i, b in enumerate(bots))
    )
    seats: list[Bot] = list(bots)
    match = MatchResult(game.result or GameResult(0, (0, 0), "", 0), game.seed, game.events)
    match.decision_times = {i: [] for i in range(4)}

    for i, bot in enumerate(seats):
        info = GameStartInfo(
            seat=i,
            partner=partner_of(i),
            team=team_of(i),
            target=game.config.target_points,
            player_names=(player_names[0], player_names[1], player_names[2], player_names[3]),
            context=dict(contexts[i]) if contexts else {},
        )
        try:
            bot.on_game_start(info)
        except Exception as exc:
            if _handle_failure(game, match, seats, i, BotFailure("exception", repr(exc)), mode):
                break

    seen = len(game.events)
    steps = 0
    while not game.finished:
        steps += 1
        if steps > max_actions:
            game.forfeit(0, "action_limit")  # salvaguarda: nunca debería ocurrir
            break
        seat = game.turn
        assert seat is not None
        obs = build_observation(game, seat)
        start = time.perf_counter()
        try:
            action = seats[seat].decide(obs)
            elapsed = time.perf_counter() - start
            match.decision_times[seat].append(elapsed)
            if elapsed > time_limit_s:
                raise BotFailure("timeout", f"{elapsed:.2f}s > {time_limit_s}s")
            try:
                game.apply(seat, action)
            except IllegalAction as exc:
                raise BotFailure("illegal", f"{action!r}: {exc}") from exc
            except Exception as exc:  # acción malformada (tipo incorrecto, etc.)
                raise BotFailure("illegal", f"{action!r}: {exc!r}") from exc
            match.actions.append((seat, action))
            for s, sig in offer_signals(game, dict(enumerate(seats))):
                match.signals.append((len(match.actions), s, sig))
        except BotFailure as failure:
            if _handle_failure(game, match, seats, seat, failure, mode):
                break
        except Exception as exc:
            if _handle_failure(game, match, seats, seat, BotFailure("exception", repr(exc)), mode):
                break
        if on_event is not None:
            for event in game.events[seen:]:
                on_event(event)
        seen = len(game.events)

    assert game.result is not None
    match.result = game.result
    history = tuple(e.to_dict() for e in game.events if e.visible_to(None))
    end = GameEndInfo(game.result.winner, game.result.score, game.result.reason, history)
    for bot in seats:
        try:
            bot.on_game_end(end)
        except Exception as exc:  # el aprendizaje de un bot no puede romper la partida
            log.warning("on_game_end falló en %s: %r", getattr(bot, "name", "?"), exc)
    return match