demo.py

Part of Tic Tac Toe.

  1"""
  2Tic Tac Toe: Scripted demo with menu, gameplay, and verification.
  3
  4Demonstrates the full game flow: menu with score tracking, two-player
  5gameplay with UI widgets, and result overlay with Play Again / Back to Menu.
  6
  7Run modes:
  8    uv run python examples/demos/tictactoe/demo.py             # Visual demo
  9    uv run python examples/demos/tictactoe/demo.py --test      # Headless test (exit 0/1)
 10    uv run python examples/demos/tictactoe/demo.py --speed 3   # Faster visual demo
 11"""
 12
 13import argparse
 14import sys
 15from dataclasses import dataclass
 16
 17from simvx.core import Node
 18from simvx.core.scripted_demo import Assert, Click, DemoRunner, Narrate, Wait
 19
 20# ============================================================================
 21# Cell coordinate helpers: computed from TicTacToeGame layout geometry
 22# ============================================================================
 23
 24# Layout constants (must match game.py)
 25_VBOX_POS = (20, 20)
 26_VBOX_SEP = 10
 27_TITLE_H = 36
 28_STATUS_H = 24
 29_GRID_SEP = 6
 30_GRID_W = 360
 31_GRID_COLS = 3
 32_CELL_H = 100
 33_NEWGAME_H = 35
 34
 35_GRID_Y = _VBOX_POS[1] + _TITLE_H + _VBOX_SEP + _STATUS_H + _VBOX_SEP
 36_CELL_W = (_GRID_W - _GRID_SEP * (_GRID_COLS - 1)) / _GRID_COLS  # 116
 37
 38
 39def cell_center(row: int, col: int) -> tuple[float, float]:
 40    """Compute pixel center of a cell button given grid row/col."""
 41    x = _VBOX_POS[0] + col * (_CELL_W + _GRID_SEP) + _CELL_W / 2
 42    y = _GRID_Y + row * (_CELL_H + _GRID_SEP) + _CELL_H / 2
 43    return (x, y)
 44
 45
 46_GRID_H = 330  # As set in game.py
 47_NEWGAME_LOCAL_Y = _TITLE_H + _VBOX_SEP + _STATUS_H + _VBOX_SEP + _GRID_H + _VBOX_SEP  # 420
 48
 49
 50def new_game_center() -> tuple[float, float]:
 51    """Compute pixel center of the New Game button."""
 52    global_y = _VBOX_POS[1] + _NEWGAME_LOCAL_Y + _NEWGAME_H / 2
 53    return (_VBOX_POS[0] + _GRID_W / 2, global_y)
 54
 55
 56# ============================================================================
 57# Helpers
 58# ============================================================================
 59
 60
 61def _app(root: Node):
 62    """Get the TicTacToeApp from the root."""
 63    from game import TicTacToeApp
 64
 65    for c in root.children:
 66        if isinstance(c, TicTacToeApp):
 67            return c
 68    raise RuntimeError("TicTacToeApp not found")
 69
 70
 71def _widget(root: Node, path: str):
 72    """Resolve a widget below the app by node path, or None if it is not on screen."""
 73    try:
 74        return _app(root)[path]
 75    except (KeyError, ValueError):
 76        return None
 77
 78
 79# ============================================================================
 80# Node-targeted click step
 81# ============================================================================
 82
 83
 84@dataclass
 85class ClickWidget:
 86    """Click a widget addressed by node path.
 87
 88    Board cells sit at fixed offsets, so they are clicked by coordinate. The menu and
 89    the result overlay are anchor-centred and move with the window, so their buttons
 90    are located from the live layout instead of from mirrored geometry.
 91    """
 92
 93    path: str
 94
 95
 96def _handle_click_widget(runner: DemoRunner, step: ClickWidget, dt: float) -> None:
 97    """Resolve the widget's screen rect, then hand the step to the runner's own Click."""
 98    widget = _widget(runner.parent, step.path)
 99    if widget is None:
100        raise AssertionError(f"ClickWidget: no widget at {step.path!r}")
101    x, y, w, h = widget.get_global_rect()
102    runner._steps[runner.current_step_index] = Click(x + w / 2, y + h / 2)
103    runner._step_time = 0.0
104
105
106DemoRunner.register_step_handler(ClickWidget, _handle_click_widget)
107
108
109# ============================================================================
110# Widget paths
111# ============================================================================
112
113_PLAY_BTN = "MainMenu/MenuLayout/PlayBtn"
114_SCORE_LABEL = "MainMenu/MenuLayout/Scores"
115_NEW_GAME_BTN = "TicTacToeGame/Root/NewGame"
116_PLAY_AGAIN_BTN = "ResultOverlay/ResultLayout/PlayAgain"
117_BACK_TO_MENU_BTN = "ResultOverlay/ResultLayout/BackToMenu"
118
119
120# ============================================================================
121# Demo steps
122# ============================================================================
123
124
125def build_steps() -> list:
126    """Build the full demo: menu → X wins → menu → draw → Play Again → X wins → menu."""
127    steps: list = []
128
129    # ── Phase 1: Menu ──────────────────────────────────────────────────
130    steps.append(Wait(0.3))
131    steps.append(Narrate("Welcome to Tic Tac Toe!", duration=2.0))
132
133    # Verify menu is showing
134    steps.append(Assert(lambda r: _app(r).state == "menu", "Should be in menu state"))
135    steps.append(Assert(lambda r: _app(r).menu is not None, "Menu node exists"))
136
137    def _check_menu_structure(root):
138        title = _widget(root, "MainMenu/MenuLayout/Title")
139        play = _widget(root, _PLAY_BTN)
140        return title is not None and play is not None and title.text == "TIC TAC TOE" and play.text == "Play"
141
142    steps.append(Assert(_check_menu_structure, "Menu has Title and Play button"))
143    steps.append(Assert(lambda r: _app(r).scores.total == 0, "Initial score total is 0"))
144
145    steps.append(Narrate("Scores start at zero. Let's play!", duration=1.5))
146
147    # Click Play
148    steps.append(ClickWidget(_PLAY_BTN))
149    steps.append(Wait(0.3))
150
151    # ── Phase 2: Game 1: X wins (center column) ──────────────────────
152    steps.append(Assert(lambda r: _app(r).state == "game", "Should be in game state"))
153    steps.append(Assert(lambda r: _app(r).game is not None, "Game exists"))
154
155    steps.append(Narrate("X goes first!", duration=1.0))
156
157    # X plays center (1,1)
158    cx, cy = cell_center(1, 1)
159    steps.append(Narrate("X plays center.", duration=0.8))
160    steps.append(Click(cx, cy))
161    steps.append(Wait(0.1))
162    steps.append(Assert(lambda r: _app(r).game.board[1][1] == "X", "Center should be X"))
163
164    # O plays top-left (0,0)
165    cx, cy = cell_center(0, 0)
166    steps.append(Narrate("O plays top-left.", duration=0.8))
167    steps.append(Click(cx, cy))
168    steps.append(Wait(0.1))
169    steps.append(Assert(lambda r: _app(r).game.board[0][0] == "O", "Top-left should be O"))
170
171    # X plays (0,1)
172    cx, cy = cell_center(0, 1)
173    steps.append(Narrate("X plays top-center.", duration=0.8))
174    steps.append(Click(cx, cy))
175    steps.append(Wait(0.1))
176    steps.append(Assert(lambda r: _app(r).game.board[0][1] == "X", "Top-center should be X"))
177
178    # O plays (2,2)
179    cx, cy = cell_center(2, 2)
180    steps.append(Narrate("O plays bottom-right.", duration=0.8))
181    steps.append(Click(cx, cy))
182    steps.append(Wait(0.1))
183    steps.append(Assert(lambda r: _app(r).game.board[2][2] == "O", "Bottom-right should be O"))
184
185    # X plays (2,1) → X wins column 1
186    cx, cy = cell_center(2, 1)
187    steps.append(Narrate("X plays bottom-center: wins column!", duration=1.5))
188    steps.append(Click(cx, cy))
189    steps.append(Wait(0.1))
190    steps.append(Assert(lambda r: _app(r).game.board[2][1] == "X", "Bottom-center should be X"))
191    steps.append(Assert(lambda r: _app(r).game.winner == "X", "X should have won"))
192
193    # Score updated
194    steps.append(Assert(lambda r: _app(r).scores.x_wins == 1, "X has 1 win"))
195    steps.append(Assert(lambda r: _app(r).scores.total == 1, "Total games == 1"))
196
197    # Result overlay showing
198    steps.append(Assert(lambda r: _app(r).state == "result", "In result state"))
199    steps.append(Assert(lambda r: _widget(r, _PLAY_AGAIN_BTN) is not None, "Result overlay exists"))
200
201    # The finished board must not accept a restart behind the overlay.
202    steps.append(Narrate("The board locks: only the overlay can start the next round.", duration=1.5))
203    steps.append(Assert(lambda r: _app(r).game.new_game_btn.disabled, "New Game is locked during the result"))
204    steps.append(ClickWidget(_NEW_GAME_BTN))
205    steps.append(Wait(0.1))
206    steps.append(Assert(lambda r: _app(r).state == "result", "Still in result state after clicking New Game"))
207    steps.append(Assert(lambda r: _app(r).game.board[1][1] == "X", "Finished board is untouched"))
208
209    steps.append(Narrate("X wins! Back to menu to check scores...", duration=1.5))
210
211    # Go back to menu
212    steps.append(ClickWidget(_BACK_TO_MENU_BTN))
213    steps.append(Wait(0.3))
214
215    # Verify menu shows updated scores
216    steps.append(Assert(lambda r: _app(r).state == "menu", "Back in menu"))
217
218    def _check_updated_scores(root):
219        label = _widget(root, _SCORE_LABEL)
220        return label is not None and "X: 1" in label.text
221
222    steps.append(Assert(_check_updated_scores, "Menu shows X: 1"))
223
224    # ── Phase 3: Game 2: Draw ────────────────────────────────────────
225    steps.append(Narrate("Let's play again: this time to a draw!", duration=1.5))
226
227    steps.append(ClickWidget(_PLAY_BTN))
228    steps.append(Wait(0.3))
229
230    steps.append(Assert(lambda r: _app(r).state == "game", "In game state for game 2"))
231
232    # Draw sequence
233    draw_moves = [
234        (0, 0, "X"),
235        (1, 1, "O"),
236        (2, 0, "X"),
237        (1, 0, "O"),
238        (1, 2, "X"),
239        (0, 2, "O"),
240        (0, 1, "X"),
241        (2, 1, "O"),
242        (2, 2, "X"),
243    ]
244
245    steps.append(Narrate("Playing all 9 moves...", duration=1.0))
246
247    for row, col, player in draw_moves:
248        cx, cy = cell_center(row, col)
249        steps.append(Click(cx, cy))
250        steps.append(Wait(0.05))
251
252        def _chk(r, rr=row, cc=col, pp=player):
253            game = _app(r).game
254            return game and game.board[rr][cc] == pp
255
256        steps.append(Assert(_chk, f"Cell ({row},{col}) should be {player}"))
257
258    steps.append(Wait(0.1))
259    steps.append(Assert(lambda r: _app(r).game.winner == "draw", "Game should be a draw"))
260    steps.append(Assert(lambda r: _app(r).scores.draws == 1, "1 draw recorded"))
261    steps.append(Assert(lambda r: _app(r).scores.total == 2, "Total games == 2"))
262
263    # Use Play Again from result overlay
264    steps.append(Narrate("Testing Play Again...", duration=1.0))
265
266    steps.append(ClickWidget(_PLAY_AGAIN_BTN))
267    steps.append(Wait(0.3))
268
269    steps.append(Assert(lambda r: _app(r).state == "game", "In game state for game 3"))
270    steps.append(Assert(lambda r: _widget(r, _PLAY_AGAIN_BTN) is None, "Result overlay cleared"))
271
272    def _check_empty_board(root):
273        game = _app(root).game
274        return game and all(game.board[r][c] is None for r in range(3) for c in range(3))
275
276    steps.append(Assert(_check_empty_board, "All cells empty after Play Again"))
277    steps.append(Assert(lambda r: not _app(r).game.new_game_btn.disabled, "New Game is live again"))
278
279    # ── Phase 4: Game 3: X wins the top row, then back to the menu ────
280    steps.append(Narrate("One more round, then back to the menu.", duration=1.2))
281
282    for row, col in [(0, 0), (1, 0), (0, 1), (1, 1), (0, 2)]:
283        cx, cy = cell_center(row, col)
284        steps.append(Click(cx, cy))
285        steps.append(Wait(0.05))
286
287    steps.append(Assert(lambda r: _app(r).game.winner == "X", "X wins the top row"))
288    steps.append(Assert(lambda r: _app(r).state == "result", "Result state after game 3"))
289
290    steps.append(ClickWidget(_BACK_TO_MENU_BTN))
291    steps.append(Wait(0.3))
292
293    steps.append(Assert(lambda r: _app(r).state == "menu", "Final menu state"))
294
295    def _check_final_scores(root):
296        s = _app(root).scores
297        return s.x_wins == 2 and s.draws == 1 and s.total == 3
298
299    steps.append(Assert(_check_final_scores, "Final scores: X=2, Draws=1, Total=3"))
300
301    def _check_final_label(root):
302        label = _widget(root, _SCORE_LABEL)
303        return label is not None and "X: 2" in label.text
304
305    steps.append(Assert(_check_final_label, "Menu shows X: 2"))
306    steps.append(Narrate("All tests passed! Demo complete.", duration=2.0))
307
308    return steps
309
310
311# ============================================================================
312# Headless test runner
313# ============================================================================
314
315
316def run_headless(speed: float = 10.0) -> bool:
317    """Run the demo headlessly via SceneRunner. Returns True on success."""
318    from game import SCREEN_H, SCREEN_W, TicTacToeApp
319
320    root = Node(name="DemoRoot")
321    root.add_child(TicTacToeApp())
322    return DemoRunner.run_headless(
323        root,
324        build_steps(),
325        speed=speed,
326        screen_size=(SCREEN_W, SCREEN_H),
327        delay_between_steps=0.0,
328    )
329
330
331# ============================================================================
332# Visual demo (with App)
333# ============================================================================
334
335
336def run_visual(speed: float = 1.0, backend: str | None = None):
337    """Run the demo visually with the Vulkan backend."""
338    from game import SCREEN_H, SCREEN_W, TicTacToeApp
339
340    from simvx.graphics import App
341
342    root = Node(name="DemoRoot")
343    root.add_child(TicTacToeApp())
344    steps = build_steps()
345    runner = DemoRunner(steps, test_mode=False, speed=speed)
346    root.add_child(runner)
347
348    App(title="Tic Tac Toe Demo", width=SCREEN_W, height=SCREEN_H, backend=backend).run(root)
349
350
351# ============================================================================
352# Entry point
353# ============================================================================
354
355if __name__ == "__main__":
356    parser = argparse.ArgumentParser(description="Tic Tac Toe scripted demo")
357    parser.add_argument("--test", action="store_true", help="Headless test mode (exit 0/1)")
358    parser.add_argument("--speed", type=float, default=None, help="Playback speed multiplier")
359    parser.add_argument("--backend", type=str, default=None, choices=["glfw", "sdl3"], help="Windowing backend")
360    args = parser.parse_args()
361
362    if args.test:
363        speed = args.speed if args.speed is not None else 10.0
364        ok = run_headless(speed)
365        sys.exit(0 if ok else 1)
366    else:
367        speed = args.speed if args.speed is not None else 1.0
368        run_visual(speed, backend=args.backend)