harness.pyΒΆ

Part of Klondike Solitaire.

  1"""Scripted-input harness: exercises the menu, deal, click-to-draw, drag-drop, undo, and win.
  2
  3Input goes through ``simvx.core.testing.InputSimulator``, so every click drives
  4the same pipeline a real mouse does: polled ``Input`` state for the table, and
  5UI events for the menu and HUD buttons.
  6
  7Captures frames at seven stages:
  8    01_deal.png        initial deal, fan visible
  9    02_drawn.png       three clicks on stock -> waste cards drawn
 10    03_drag.png        mid-drag of waste card toward foundation
 11    04_drop.png        post-drop / state after the drop resolves
 12    05_undo.png        after pressing 'U' to undo
 13    06_nearwin.png     synthesised end-game state, one click from the win
 14    07_won.png         click resolved -> "YOU WIN!" banner shown
 15"""
 16
 17from __future__ import annotations
 18
 19import sys
 20from collections.abc import Callable
 21from pathlib import Path
 22
 23_PORT_DIR = Path(__file__).parent
 24if str(_PORT_DIR) not in sys.path:
 25    sys.path.insert(0, str(_PORT_DIR))
 26
 27from main import SolitaireRoot  # noqa: E402
 28from nodes.card_textures import CardId  # noqa: E402
 29from nodes.game_state import CardState, GameState  # noqa: E402
 30from nodes.table import DESIGN_H, DESIGN_W  # noqa: E402
 31
 32from simvx.core.input.enums import Key  # noqa: E402
 33from simvx.core.testing import InputSimulator  # noqa: E402
 34from simvx.graphics import App, save_png  # noqa: E402
 35
 36OUT = _PORT_DIR / "screenshots"
 37
 38
 39def _stock_xy() -> tuple[float, float]:
 40    # Mirrors TableNode._pile_origin(STOCK, 0)
 41    return (130.0, 130.0)
 42
 43
 44def _waste_xy() -> tuple[float, float]:
 45    return (280.0, 130.0)
 46
 47
 48def _foundation_xy(idx: int) -> tuple[float, float]:
 49    return (130.0 + (3 + idx) * 150.0, 130.0)
 50
 51
 52def capture_sequence() -> None:
 53    OUT.mkdir(exist_ok=True)
 54    app = App(width=DESIGN_W, height=DESIGN_H, title="Solitaire Harness", visible=False)
 55    root = SolitaireRoot()
 56    sim = InputSimulator()
 57
 58    captures: dict[int, str] = {}
 59    actions: dict[int, Callable[[], None]] = {}
 60
 61    def press_at(x, y):
 62        return lambda: sim.press_mouse(position=(x, y))
 63
 64    def release_at(x, y):
 65        def _go():
 66            sim.move_mouse(x, y)
 67            sim.release_mouse()
 68
 69        return _go
 70
 71    def move_to(x, y):
 72        return lambda: sim.move_mouse(x, y)
 73
 74    def click_widget(name):
 75        """Click a named widget at its own centre, wherever layout put it."""
 76
 77        def _go():
 78            x, y, w, h = root.find(name).get_global_rect()
 79            sim.click((x + w * 0.5, y + h * 0.5))
 80
 81        return _go
 82
 83    # Stage 1: leave the title menu via its "New game" button, then let the deal settle
 84    actions[5] = click_widget("MenuNewGame")
 85    captures[30] = "01_deal.png"
 86
 87    # Stage 2: draw 3 cards from stock
 88    sx, sy = _stock_xy()
 89    actions[50] = press_at(sx, sy)
 90    actions[52] = release_at(sx, sy)
 91    actions[70] = press_at(sx, sy)
 92    actions[72] = release_at(sx, sy)
 93    actions[90] = press_at(sx, sy)
 94    actions[92] = release_at(sx, sy)
 95    captures[100] = "02_drawn.png"
 96
 97    # Stage 3: drag the top waste card to a foundation. Whatever rank appears,
 98    # the drag motion itself demonstrates the drop pipeline.
 99    wx, wy = _waste_xy()
100    fx, fy = _foundation_xy(0)
101    actions[120] = press_at(wx, wy)
102    drag_steps = 20
103    for k in range(drag_steps):
104        u = (k + 1) / drag_steps
105        actions[122 + k] = move_to(wx + (fx - wx) * u, wy + (fy - wy) * u)
106    captures[135] = "03_drag.png"
107    actions[150] = release_at(fx, fy)
108    captures[170] = "04_drop.png"
109
110    # Stage 4: press 'U' to undo
111    actions[190] = lambda: sim.press_key(Key.U)
112    actions[192] = lambda: sim.release_key(Key.U)
113    captures[210] = "05_undo.png"
114
115    # Stage 5: synthesise a near-win state. Every card except the king of clubs
116    # sits on the foundations, and the king of clubs sits alone on the waste;
117    # clicking it auto-moves it onto the clubs foundation and wins the game.
118    def inject_near_win():
119        from nodes.card_textures import RANKS
120
121        gs = GameState()
122        for f_idx, suit in enumerate(["S", "H", "D", "C"]):
123            ranks = RANKS if suit != "C" else RANKS[:-1]  # skip the king of clubs
124            for r in ranks:
125                gs.foundations[f_idx].append(CardState(CardId(r, suit), True))
126        gs.waste.append(CardState(CardId("K", "C"), True))
127        root.table.load_state(gs)
128
129    actions[230] = inject_near_win
130    captures[260] = "06_nearwin.png"
131
132    # Click the king of clubs -> auto-moves to its foundation -> WIN
133    actions[280] = press_at(*_waste_xy())
134    actions[282] = release_at(*_waste_xy())
135    captures[330] = "07_won.png"
136
137    total_frames = 350
138
139    def on_frame(idx, t):
140        fn = actions.get(idx)
141        if fn is not None:
142            fn()
143
144    def capture_fn(idx):
145        return idx in captures
146
147    frames = app.run_headless(
148        root,
149        frames=total_frames,
150        on_frame=on_frame,
151        capture_fn=capture_fn,
152    )
153
154    saved: list[str] = []
155    capture_indices = sorted(captures.keys())
156    for fi, img in zip(capture_indices, frames, strict=False):
157        out = OUT / captures[fi]
158        save_png(img, out)
159        saved.append(str(out))
160        print(f"saved {out}")
161    print(f"{len(saved)} screenshots written")
162
163
164if __name__ == "__main__":
165    capture_sequence()