Skip to content

App

patiencepilot.app

Application orchestration between UI adapters and core game modules.

AdviceProvider

Bases: Protocol

Protocol for components that produce advice from player-known state.

Source code in src/patiencepilot/solvers/base.py
128
129
130
131
132
133
class AdviceProvider(Protocol):
    """Protocol for components that produce advice from player-known state."""

    def suggest(self, view: PlayerView, *, limit: SearchLimit | None = None) -> Advice:
        """Return move advice using only the supplied player-known view."""
        ...

suggest

suggest(
    view: PlayerView, *, limit: SearchLimit | None = None
) -> Advice

Return move advice using only the supplied player-known view.

Source code in src/patiencepilot/solvers/base.py
131
132
133
def suggest(self, view: PlayerView, *, limit: SearchLimit | None = None) -> Advice:
    """Return move advice using only the supplied player-known view."""
    ...

SerializedSession

Bases: TypedDict

JSON-compatible game-session payload.

Source code in src/patiencepilot/app.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class SerializedSession(TypedDict):
    """JSON-compatible game-session payload."""

    schema_version: int
    variant: str
    options: dict[str, object]
    seed: object
    initial_state: SerializedGameState
    current_state: SerializedGameState
    state_notation: str
    history: list[SerializedMove]
    redo: list[SerializedMove]
    seen_cards: list[str]
    player_view: SerializedPlayerView
    metadata: dict[str, object]
    ui_state: dict[str, object]

ValidationReport dataclass

Validation result suitable for UI display.

Source code in src/patiencepilot/app.py
53
54
55
56
57
58
59
60
@dataclass(frozen=True, slots=True)
class ValidationReport:
    """Validation result suitable for UI display."""

    ok: bool
    message: str | None = None
    error_type: str | None = None
    diagnostics: tuple[ValidationDiagnostic, ...] = ()

PatiencePilotApp dataclass

Small application service for UI and adapter workflows.

Source code in src/patiencepilot/app.py
 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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
@dataclass(slots=True)
class PatiencePilotApp:
    """Small application service for UI and adapter workflows."""

    variant: str = "klondike"
    options: dict[str, object] = field(default_factory=dict)
    session: GameSession | None = None
    advice_provider: AdviceProvider | None = None

    def select_variant(self, name: str, options: VariantOptions | None = None) -> Variant:
        """Select the variant used for newly created sessions.

        Args:
            name: Registered variant name.
            options: Variant-specific option values.
        """
        rules = resolve_variant(name, options)
        self.variant = rules.name
        self.options = {} if options is None else dict(options)
        return rules

    def select_solver(self, name: str = "dummy") -> AdviceProvider:
        """Select the advice provider used for future advice requests.

        Args:
            name: Registered solver name or alias.
        """
        self.advice_provider = resolve_solver(name)
        return self.advice_provider

    def new_session(
        self,
        *,
        seed: Seed = None,
        metadata: Mapping[str, object] | None = None,
        ui_state: Mapping[str, object] | None = None,
    ) -> GameSession:
        """Create, store, and return a new session for the selected variant."""
        self.session = GameSession.new(
            self.variant,
            seed=seed,
            options=self.options,
            metadata=metadata,
            ui_state=ui_state,
        )
        return self.session

    def use_session(self, session: GameSession) -> GameSession:
        """Store an existing session and sync selected variant metadata."""
        session.validate_state()
        self.session = session
        self.variant = session.state.variant
        self.options = variant_options_from_state(session.state)
        return session

    def validate(self, state: GameState | None = None) -> ValidationReport:
        """Validate a state or the active session state without raising."""
        try:
            if state is not None:
                resolve_variant(state.variant, variant_options_from_state(state)).validate_state(state)
            else:
                self._require_session().validate_state()
        except PatiencePilotError as error:
            return _validation_report("state", error)
        return ValidationReport(ok=True)

    def apply_move(self, move: Move) -> MoveResult:
        """Apply ``move`` to the active session."""
        return self._require_session().apply_move(move)

    def undo(self) -> GameSession:
        """Undo the active session's most recent move and return the session."""
        session = self._require_session()
        session.undo()
        return session

    def redo(self) -> MoveResult:
        """Redo the active session's most recently undone move."""
        return self._require_session().redo()

    def export_session(
        self,
        session: GameSession | None = None,
        *,
        seen_cards: Iterable[Card] | None = None,
    ) -> SerializedSession:
        """Return a serialized payload for ``session`` or the active session."""
        return export_session(self._require_session() if session is None else session, seen_cards=seen_cards)

    def import_session(self, data: Mapping[str, object]) -> GameSession:
        """Import, store, and return a session payload."""
        session = import_session(data)
        self.use_session(session)
        return session

    def request_advice(
        self,
        *,
        provider: AdviceProvider | None = None,
        limit: SearchLimit | None = None,
        seen_cards: Iterable[Card] | None = None,
    ) -> Advice:
        """Request advice for the active session from an advice provider.

        Args:
            provider: Optional provider to use for this request. When omitted,
                the configured ``advice_provider`` is used.
            limit: Optional solver search limits.
            seen_cards: Optional cards known from earlier observation history.
        """
        selected_provider = self.advice_provider if provider is None else provider
        if selected_provider is None:
            msg = "no advice provider configured"
            raise InvalidStateError(msg)
        session = self._require_session()
        view = PlayerView.from_state(session.state, seen_cards=_seen_cards_for_session(session, seen_cards))
        return selected_provider.suggest(view, limit=limit)

    def _require_session(self) -> GameSession:
        """Return the active session or raise a package error."""
        if self.session is None:
            msg = "no active session"
            raise InvalidStateError(msg)
        return self.session

select_variant

select_variant(
    name: str, options: VariantOptions | None = None
) -> Variant

Select the variant used for newly created sessions.

Parameters:

Name Type Description Default
name str

Registered variant name.

required
options VariantOptions | None

Variant-specific option values.

None
Source code in src/patiencepilot/app.py
72
73
74
75
76
77
78
79
80
81
82
def select_variant(self, name: str, options: VariantOptions | None = None) -> Variant:
    """Select the variant used for newly created sessions.

    Args:
        name: Registered variant name.
        options: Variant-specific option values.
    """
    rules = resolve_variant(name, options)
    self.variant = rules.name
    self.options = {} if options is None else dict(options)
    return rules

select_solver

select_solver(name: str = 'dummy') -> AdviceProvider

Select the advice provider used for future advice requests.

Parameters:

Name Type Description Default
name str

Registered solver name or alias.

'dummy'
Source code in src/patiencepilot/app.py
84
85
86
87
88
89
90
91
def select_solver(self, name: str = "dummy") -> AdviceProvider:
    """Select the advice provider used for future advice requests.

    Args:
        name: Registered solver name or alias.
    """
    self.advice_provider = resolve_solver(name)
    return self.advice_provider

new_session

new_session(
    *,
    seed: Seed = None,
    metadata: Mapping[str, object] | None = None,
    ui_state: Mapping[str, object] | None = None,
) -> GameSession

Create, store, and return a new session for the selected variant.

Source code in src/patiencepilot/app.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def new_session(
    self,
    *,
    seed: Seed = None,
    metadata: Mapping[str, object] | None = None,
    ui_state: Mapping[str, object] | None = None,
) -> GameSession:
    """Create, store, and return a new session for the selected variant."""
    self.session = GameSession.new(
        self.variant,
        seed=seed,
        options=self.options,
        metadata=metadata,
        ui_state=ui_state,
    )
    return self.session

use_session

use_session(session: GameSession) -> GameSession

Store an existing session and sync selected variant metadata.

Source code in src/patiencepilot/app.py
110
111
112
113
114
115
116
def use_session(self, session: GameSession) -> GameSession:
    """Store an existing session and sync selected variant metadata."""
    session.validate_state()
    self.session = session
    self.variant = session.state.variant
    self.options = variant_options_from_state(session.state)
    return session

validate

validate(
    state: GameState | None = None,
) -> ValidationReport

Validate a state or the active session state without raising.

Source code in src/patiencepilot/app.py
118
119
120
121
122
123
124
125
126
127
def validate(self, state: GameState | None = None) -> ValidationReport:
    """Validate a state or the active session state without raising."""
    try:
        if state is not None:
            resolve_variant(state.variant, variant_options_from_state(state)).validate_state(state)
        else:
            self._require_session().validate_state()
    except PatiencePilotError as error:
        return _validation_report("state", error)
    return ValidationReport(ok=True)

apply_move

apply_move(move: Move) -> MoveResult

Apply move to the active session.

Source code in src/patiencepilot/app.py
129
130
131
def apply_move(self, move: Move) -> MoveResult:
    """Apply ``move`` to the active session."""
    return self._require_session().apply_move(move)

undo

undo() -> GameSession

Undo the active session's most recent move and return the session.

Source code in src/patiencepilot/app.py
133
134
135
136
137
def undo(self) -> GameSession:
    """Undo the active session's most recent move and return the session."""
    session = self._require_session()
    session.undo()
    return session

redo

redo() -> MoveResult

Redo the active session's most recently undone move.

Source code in src/patiencepilot/app.py
139
140
141
def redo(self) -> MoveResult:
    """Redo the active session's most recently undone move."""
    return self._require_session().redo()

export_session

export_session(
    session: GameSession | None = None,
    *,
    seen_cards: Iterable[Card] | None = None,
) -> SerializedSession

Return a serialized payload for session or the active session.

Source code in src/patiencepilot/app.py
143
144
145
146
147
148
149
150
def export_session(
    self,
    session: GameSession | None = None,
    *,
    seen_cards: Iterable[Card] | None = None,
) -> SerializedSession:
    """Return a serialized payload for ``session`` or the active session."""
    return export_session(self._require_session() if session is None else session, seen_cards=seen_cards)

import_session

import_session(data: Mapping[str, object]) -> GameSession

Import, store, and return a session payload.

Source code in src/patiencepilot/app.py
152
153
154
155
156
def import_session(self, data: Mapping[str, object]) -> GameSession:
    """Import, store, and return a session payload."""
    session = import_session(data)
    self.use_session(session)
    return session

request_advice

request_advice(
    *,
    provider: AdviceProvider | None = None,
    limit: SearchLimit | None = None,
    seen_cards: Iterable[Card] | None = None,
) -> Advice

Request advice for the active session from an advice provider.

Parameters:

Name Type Description Default
provider AdviceProvider | None

Optional provider to use for this request. When omitted, the configured advice_provider is used.

None
limit SearchLimit | None

Optional solver search limits.

None
seen_cards Iterable[Card] | None

Optional cards known from earlier observation history.

None
Source code in src/patiencepilot/app.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def request_advice(
    self,
    *,
    provider: AdviceProvider | None = None,
    limit: SearchLimit | None = None,
    seen_cards: Iterable[Card] | None = None,
) -> Advice:
    """Request advice for the active session from an advice provider.

    Args:
        provider: Optional provider to use for this request. When omitted,
            the configured ``advice_provider`` is used.
        limit: Optional solver search limits.
        seen_cards: Optional cards known from earlier observation history.
    """
    selected_provider = self.advice_provider if provider is None else provider
    if selected_provider is None:
        msg = "no advice provider configured"
        raise InvalidStateError(msg)
    session = self._require_session()
    view = PlayerView.from_state(session.state, seen_cards=_seen_cards_for_session(session, seen_cards))
    return selected_provider.suggest(view, limit=limit)

export_session

export_session(
    session: GameSession,
    *,
    seen_cards: Iterable[Card] | None = None,
) -> SerializedSession

Return a JSON-compatible session payload.

Parameters:

Name Type Description Default
session GameSession

Session to export.

required
seen_cards Iterable[Card] | None

Cards known to the player from earlier observation history. When omitted, session.metadata["seen_cards"] is used if present.

None
Source code in src/patiencepilot/app.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
def export_session(session: GameSession, *, seen_cards: Iterable[Card] | None = None) -> SerializedSession:
    """Return a JSON-compatible session payload.

    Args:
        session: Session to export.
        seen_cards: Cards known to the player from earlier observation history.
            When omitted, ``session.metadata["seen_cards"]`` is used if present.
    """
    initial_state = session.history[0].before if session.history else session.state
    known_seen_cards = _seen_cards_for_session(session, seen_cards)
    player_view = PlayerView.from_state(session.state, seen_cards=known_seen_cards)
    return {
        "schema_version": SCHEMA_VERSION,
        "variant": session.state.variant,
        "options": variant_options_from_state(session.state),
        "seed": session.seed,
        "initial_state": serialize_state(initial_state),
        "current_state": serialize_state(session.state),
        "state_notation": state_to_text(session.state),
        "history": [serialize_move(move) for move in session.move_history],
        "redo": [serialize_move(step.move) for step in session.redo_history],
        "seen_cards": [card.code for card in known_seen_cards],
        "player_view": serialize_player_view(player_view),
        "metadata": dict(session.metadata),
        "ui_state": dict(session.ui_state),
    }

import_session

import_session(data: Mapping[str, object]) -> GameSession

Return a session from a serialized payload.

Parameters:

Name Type Description Default
data Mapping[str, object]

Session payload created by :func:export_session.

required

Raises:

Type Description
NotationError

If the payload cannot be imported.

Source code in src/patiencepilot/app.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
def import_session(data: Mapping[str, object]) -> GameSession:
    """Return a session from a serialized payload.

    Args:
        data: Session payload created by :func:`export_session`.

    Raises:
        NotationError: If the payload cannot be imported.
    """
    migrated = migrate_session_payload(data)
    variant = _str_value(migrated, "variant")
    options = _dict_value(migrated, "options")
    initial_state = deserialize_state(_mapping_value(migrated, "initial_state"))
    current_state = deserialize_state(_mapping_value(migrated, "current_state"))
    state_notation = migrated.get("state_notation")
    if isinstance(state_notation, str) and state_from_text(state_notation) != current_state:
        msg = "state_notation does not match current_state"
        raise NotationError(msg)

    history = _deserialize_move_list(migrated, "history")
    redo = _deserialize_move_list(migrated, "redo")
    metadata = _dict_value(migrated, "metadata")
    ui_state = _dict_value(migrated, "ui_state")
    seen_cards = _cards_from_codes(_list_value(migrated, "seen_cards"), "seen_cards")
    if seen_cards and "seen_cards" not in metadata:
        metadata["seen_cards"] = [card.code for card in seen_cards]

    resolve_variant(variant, options).validate_state(initial_state)
    session = GameSession(
        state=initial_state,
        variant=variant,
        seed=_seed_value(migrated.get("seed")),
        metadata=metadata,
        ui_state=ui_state,
    )
    for move in history:
        session.apply_move(move)

    if session.state != current_state:
        msg = "session history does not reproduce current_state"
        raise NotationError(msg)

    for move in redo:
        session.apply_move(move)
    for _ in redo:
        session.undo()

    return session

migrate_session_payload

migrate_session_payload(
    data: Mapping[str, object],
) -> dict[str, object]

Return a best-effort alpha migration to session schema version 1.

Source code in src/patiencepilot/app.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
def migrate_session_payload(data: Mapping[str, object]) -> dict[str, object]:
    """Return a best-effort alpha migration to session schema version 1."""
    migrated = dict(data)
    schema_version = migrated.get("schema_version", SCHEMA_VERSION)
    if schema_version != SCHEMA_VERSION:
        msg = f"unsupported session schema_version: {schema_version!r}"
        raise NotationError(msg)

    if "current_state" not in migrated and "state" in migrated:
        migrated["current_state"] = migrated["state"]
    if "initial_state" not in migrated and "current_state" in migrated:
        migrated["initial_state"] = migrated["current_state"]

    current_state = migrated.get("current_state")
    if isinstance(current_state, Mapping):
        migrated.setdefault("variant", current_state.get("variant", "klondike"))
        migrated.setdefault("options", current_state.get("options", {}))
    else:
        migrated.setdefault("variant", "klondike")
        migrated.setdefault("options", {})

    migrated["schema_version"] = SCHEMA_VERSION
    migrated.setdefault("seed", None)
    migrated.setdefault("history", [])
    migrated.setdefault("redo", [])
    migrated.setdefault("metadata", {})
    migrated.setdefault("ui_state", {})
    migrated.setdefault("seen_cards", [])
    return migrated

validate_session_payload

validate_session_payload(
    data: Mapping[str, object],
) -> ValidationReport

Validate a serialized session payload without raising.

Source code in src/patiencepilot/app.py
298
299
300
301
302
303
304
def validate_session_payload(data: Mapping[str, object]) -> ValidationReport:
    """Validate a serialized session payload without raising."""
    try:
        import_session(data)
    except PatiencePilotError as error:
        return _validation_report("session", error)
    return ValidationReport(ok=True)

validate_state_payload

validate_state_payload(
    data: Mapping[str, object],
) -> ValidationReport

Validate a serialized state payload without raising.

Source code in src/patiencepilot/app.py
307
308
309
310
311
312
313
314
315
316
317
318
def validate_state_payload(data: Mapping[str, object]) -> ValidationReport:
    """Validate a serialized state payload without raising."""
    result = validate_serialized_state(data)
    if result.ok:
        return ValidationReport(ok=True)
    diagnostic = result.diagnostics[0]
    return ValidationReport(
        ok=False,
        message=diagnostic.message,
        error_type=diagnostic.error_type,
        diagnostics=result.diagnostics,
    )