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