From 05eb6ce14185c6f12ca50fa94ac31fee25caffe1 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 15 Oct 2026 16:33:44 -0700 Subject: [PATCH 1/2] feat(booth): send idle and finished players back to the attract screen [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] At a booth, a lost game offers the same player unlimited retries while the line waits, and the idle return always goes to the hub. Booth mode only; outside it every game keeps its own retry screen. - --attract= (default hub) is where the idle return and finished players go. Unknown names fall back to the hub with a warning. - Games call AppRouter.booth_game_over(score_line) at game over. A booth player gets --retries= retries (default 0); after that the call returns true, the game skips its retry screen, and the router lays app/next_player_card.gd over it: GAME OVER, the game's score, "Thanks for playing!" and a six-second countdown, then the attract route opens. The count resets whenever the attract route opens. The session log records those endings as game_over. - One hook per game-over path, in Juan's and Trey's scripts: Heroes vs. Villains and Code City Brawl game_over(), the platformer's _reveal_results() on a loss, and the runner's game-over screen. - tools/booth/macos/setup.sh passes anything after -- to the game, so staff can set --retries or --attract in the login item. - tests/booth_routing.gd drives each game's real game-over path in and out of booth mode, the retry allowance, the card, the attract and idle routes and the session-log endings. Child launches go through tests/support/child_game.gd, which kills a child that overruns. --- README.md | 2 +- docs/booth-runbook.md | 5 +- docs/builds.md | 5 +- hoppys-adventure-arcade/app/app_router.gd | 83 ++++++++- hoppys-adventure-arcade/app/booth_log.gd | 3 +- .../app/next_player_card.gd | 44 +++++ .../app/next_player_card.gd.uid | 1 + .../modes/beatem_up/game.gd | 1 + .../modes/beatem_up/reinvent/game.gd | 1 + .../modes/platformer/level.gd | 1 + .../scenes/runner/ui/game_over.gd | 4 + .../tests/booth_routing.gd | 174 ++++++++++++++++++ .../tests/booth_routing.gd.uid | 1 + .../tests/support/child_game.gd | 28 +++ .../tests/support/child_game.gd.uid | 1 + tools/booth/macos/setup.sh | 6 + tools/booth/test_macos_setup.py | 6 + 17 files changed, 355 insertions(+), 11 deletions(-) create mode 100644 hoppys-adventure-arcade/app/next_player_card.gd create mode 100644 hoppys-adventure-arcade/app/next_player_card.gd.uid create mode 100644 hoppys-adventure-arcade/tests/booth_routing.gd create mode 100644 hoppys-adventure-arcade/tests/booth_routing.gd.uid create mode 100644 hoppys-adventure-arcade/tests/support/child_game.gd create mode 100644 hoppys-adventure-arcade/tests/support/child_game.gd.uid diff --git a/README.md b/README.md index 259193da..daac46bc 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ HoppysAdventureArcade.exe -- --booth --route=heroes_vs_villains ./HoppysAdventureArcade.x86_64 -- --booth --route=heroes_vs_villains # Linux ``` -Booth mode runs fullscreen and ignores the menu's **Quit Game**, so staff close the window instead. After 90 seconds without input, any game returns to the hub's attract screen. [Run it at a booth](docs/builds.md#run-it-at-a-booth) lists every route and flag. +Booth mode runs fullscreen and ignores the menu's **Quit Game**, so staff close the window instead. After 90 seconds without input, any game returns to the hub's attract screen, and a lost game sends the player there after a thank-you card instead of offering endless retries. [Run it at a booth](docs/builds.md#run-it-at-a-booth) lists every route and flag. For a machine that runs unattended all day, follow the [booth runbook](docs/booth-runbook.md). It sets up a watchdog that restarts the game if it closes or freezes, along with the Mac's kiosk settings, and covers the staff checklist. diff --git a/docs/booth-runbook.md b/docs/booth-runbook.md index 4f405890..47c0aed8 100644 --- a/docs/booth-runbook.md +++ b/docs/booth-runbook.md @@ -17,7 +17,7 @@ This runbook is for the staff who run Hoppy’s Adventure Arcade unattended at a ```sh tools/booth/macos/setup.sh --app "/Applications/Hoppy’s Adventure Arcade.app" --route heroes_vs_villains ``` - Nothing changes yet. To apply it, run the same command with `--apply` and enter the administrator password when asked. The game starts. + Nothing changes yet. To apply it, run the same command with `--apply` and enter the administrator password when asked. The game starts. To give each player a retry or change the attract screen, add game flags after `--`, for example `-- --retries=1 --attract=beatem_up` ([booth flags](builds.md#run-it-at-a-booth)). 4. Do the manual steps the script lists at the end: automatic login, Do Not Disturb, the display, the volume and the controllers. 5. Restart the Mac. The game should start on its own within a minute. @@ -27,7 +27,7 @@ The setup does the following: - It keeps the Mac awake and turns it back on after a power cut. - It turns off the screen saver, automatic updates and crash dialogs. -Booth mode itself runs fullscreen, ignores the menu's **Quit Game**, and sends a game nobody has touched for 90 seconds back to the hub's attract screen ([details](builds.md#run-it-at-a-booth)). +Booth mode itself runs fullscreen and ignores the menu's **Quit Game**. A game nobody has touched for 90 seconds goes back to the hub's attract screen, and so does a lost game, after a thank-you card for the player ([details](builds.md#run-it-at-a-booth)). ## Every morning @@ -40,6 +40,7 @@ Booth mode itself runs fullscreen, ignores the menu's **Quit Game**, and sends a | What happens | What you'll see | |---|---| | A player walks away mid-game | After 90 seconds the game returns to the hub. | +| A player loses | A **GAME OVER** card thanks them and shows their score, and after six seconds the hub opens for the next player. | | The game crashes or closes | The watchdog opens it again within a few seconds. | | The game freezes | The watchdog restarts it within about a minute. | | Someone presses Cmd+Q | The game closes, and the watchdog opens it again. | diff --git a/docs/builds.md b/docs/builds.md index 63e551f8..b07e6bdf 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -33,11 +33,12 @@ open -a "Hoppy’s Adventure Arcade.app" --args -- --booth --route=heroes_vs_vil ``` - **Fullscreen.** The game starts fullscreen without changing the saved settings. -- **Idle reset.** After 90 seconds with no keyboard, mouse, touch or controller input, any game returns to the hub, whose rotating previews act as the attract screen. +- **Idle reset.** After 90 seconds with no keyboard, mouse, touch or controller input, any game returns to the attract route. That is the hub, whose rotating previews act as the attract screen, unless `--attract=` names another route. +- **Game over.** A lost game doesn't offer unlimited retries. A player gets `--retries=` retries (default 0); after that, the game shows no retry screen. Instead a **GAME OVER** card thanks the player, shows their score and counts down six seconds, then the attract route opens for the next player. The count starts again whenever the attract route opens. Every game takes part: Heroes vs. Villains, Code City Brawl, the platformer levels and the Endless Runner. Without `--booth`, every game keeps its own retry screen. - **No quitting from the menu.** The menu's **Quit Game** does nothing in booth mode; staff close the window (Cmd+Q, Alt+F4). - **Launch route.** `--route=` opens a route at launch (`heroes_vs_villains`, `code_city_brawl`, `bay_area_blitz`, `orbital_ops`, `enchanted_repo`, `runner`, `platformer`, `beatem_up`, `hub`), with or without `--booth`. An unknown name logs an error and opens the hub. - **Keep it running.** `tools/booth/watchdog.sh` relaunches the game whenever it exits and restarts it when it hangs. The watchdog passes `--heartbeat=`, which makes the game rewrite that file with the current time every two seconds; a file that stops changing means the game is stuck. On a Mac, `tools/booth/macos/setup.sh` installs the watchdog as a login item and applies the kiosk settings. The [booth runbook](booth-runbook.md) walks staff through setup, the show days and recovery. -- **Session log.** Booth mode records each game session's start and end in `user://booth/`, one file per run of the game (`sessions--.jsonl`). Each entry records the game, the local start and end times, the duration, how the session ended (`menu`, `idle` or `quit`) and how many times the player retried. It records nothing about players and never uses the network. A start with no end means the game stopped without quitting: a crash, a power cut or a forced kill. The game prints its file's full path at startup. `--session-log=` writes to another folder and works without `--booth`, for playtests. +- **Session log.** Booth mode records each game session's start and end in `user://booth/`, one file per run of the game (`sessions--.jsonl`). Each entry records the game, the local start and end times, the duration, how the session ended (`menu`, `idle`, `game_over` or `quit`) and how many times the player retried. It records nothing about players and never uses the network. A start with no end means the game stopped without quitting: a crash, a power cut or a forced kill. The game prints its file's full path at startup. `--session-log=` writes to another folder and works without `--booth`, for playtests. `user://` is `~/Library/Application Support/Godot/app_userdata/Hoppy’s Adventure Arcade/` on macOS, `%APPDATA%\Godot\app_userdata\Hoppy’s Adventure Arcade\` on Windows and `~/.local/share/godot/app_userdata/Hoppy’s Adventure Arcade/` on Linux. To summarize the logs, copy each machine's `booth` folder and run: diff --git a/hoppys-adventure-arcade/app/app_router.gd b/hoppys-adventure-arcade/app/app_router.gd index db337c71..4af62266 100644 --- a/hoppys-adventure-arcade/app/app_router.gd +++ b/hoppys-adventure-arcade/app/app_router.gd @@ -1,6 +1,7 @@ extends CanvasLayer const BoothLog := preload("res://app/booth_log.gd") const BoothHeartbeat := preload("res://app/booth_heartbeat.gd") +const NextPlayerCard := preload("res://app/next_player_card.gd") const ROUTES := { "heroes_vs_villains": "res://scenes/beatem_up/reinvent/heroes_vs_villains.tscn", "code_city_brawl": "res://scenes/beatem_up/code_city_brawl.tscn", @@ -18,9 +19,9 @@ const ROUTES := { const SMOKE_FLAG := "--smoke-routes" const SMOKE_FRAMES := 30 ## Booth mode (` -- --booth`) is for unattended kiosks: the game runs fullscreen, the menu's -## Quit does nothing, and after IDLE_SECONDS without input any route returns to the hub, whose -## rotating previews are the attract screen. `--route=` opens a route at launch, with or -## without --booth. Both are off unless passed. +## Quit does nothing, and after IDLE_SECONDS without input any route returns to the attract route +## (the hub, whose rotating previews are the attract screen, unless --attract names another). +## `--route=` opens a route at launch, with or without --booth. Both are off unless passed. const BOOTH_FLAG := "--booth" const ROUTE_PREFIX := "--route=" const IDLE_SECONDS := 90.0 @@ -35,6 +36,13 @@ const SESSION_LOG_DEFAULT := "user://booth" ## time every two seconds, so tools/booth/watchdog.sh can restart a game that hangs. Off unless ## passed; the watchdog passes it. const HEARTBEAT_PREFIX := "--heartbeat=" +## Booth game over: games call booth_game_over() when the player loses. A booth player gets +## `--retries=` retries (default 0); after that the router lays NextPlayerCard over the game +## and, NEXT_PLAYER_SECONDS later, opens the attract route for the next player. `--attract=` +## picks the attract route for this and the idle return. Outside booth mode games are unchanged. +const ATTRACT_PREFIX := "--attract=" +const RETRIES_PREFIX := "--retries=" +const NEXT_PLAYER_SECONDS := 6.0 var busy := false var veil: ColorRect var booth := false @@ -42,6 +50,12 @@ var idle_limit := IDLE_SECONDS var idle_seconds := 0.0 var session_log: BoothLog var heartbeat: BoothHeartbeat +var attract := "hub" +var retry_limit := 0 +## Game overs since the attract route last opened: one player's, as far as a booth can tell. +var game_overs := 0 +var next_player: NextPlayerCard +var next_player_left := 0.0 func _ready() -> void: # The veil tween must keep running while a microgame has the tree paused. process_mode = Node.PROCESS_MODE_ALWAYS @@ -57,7 +71,11 @@ func _ready() -> void: if SMOKE_FLAG in args: smoke_routes.call_deferred() return + attract = attract_route(args) + retry_limit = retry_count(args) set_booth(BOOTH_FLAG in args) + if booth: + print("AppRouter: booth attract route %s, %d retries per player" % [attract, retry_limit]) var log_path := session_log_path(args, booth) if log_path != "": start_session_log(log_path) @@ -86,6 +104,10 @@ func go(route: String, reason := "menu") -> void: get_tree().paused = false if session_log: session_log.route_changed(route, reason) + if route == attract: + game_overs = 0 + if next_player: + next_player.hide() if route == "hub": AudioService.play_default_music() await create_tween().tween_property(veil, "modulate:a", 0.0, 0.25).finished @@ -143,6 +165,44 @@ static func heartbeat_path(args: PackedStringArray) -> String: return arg.trim_prefix(HEARTBEAT_PREFIX) return "" +## The booth's attract route from `--attract=`: "hub" when absent or unknown (with a warning). +static func attract_route(args: PackedStringArray) -> String: + for arg in args: + if arg.begins_with(ATTRACT_PREFIX): + var route := arg.trim_prefix(ATTRACT_PREFIX) + if ROUTES.has(route): + return route + push_warning("Unknown --attract %s; the booth returns to the hub" % route) + return "hub" + +## Retries a booth player gets, from `--retries=`: 0 when absent or not a whole number. +static func retry_count(args: PackedStringArray) -> int: + for arg in args: + if arg.begins_with(RETRIES_PREFIX): + var value := arg.trim_prefix(RETRIES_PREFIX) + if value.is_valid_int() and value.to_int() >= 0: + return value.to_int() + push_warning("--retries needs a whole number, not %s; using 0" % value) + return 0 + +## Games call this when the player loses, before showing their own retry screen. Returns true when +## the booth takes over: the player has used their retries, so the game shows nothing and the +## router thanks them and sends the booth to the attract route. Always false outside booth mode. +## `score_line` is shown on the card, in the game's own style ("SCORE 01234"). +func booth_game_over(score_line := "") -> bool: + if not booth: + return false + game_overs += 1 + if game_overs <= retry_limit: + return false + if next_player == null: + next_player = NextPlayerCard.new() + add_child(next_player) + move_child(next_player, 0) # Under the veil, so the route fade covers it. + next_player_left = NEXT_PLAYER_SECONDS + next_player.present(score_line, next_player_left) + return true + func _launch(route: String) -> void: await go(route) var scene := get_tree().current_scene @@ -153,6 +213,9 @@ func set_booth(enabled: bool) -> void: booth = enabled idle_seconds = 0.0 AudioService.set_attract(enabled) + game_overs = 0 + if next_player: + next_player.hide() set_process(enabled) set_process_input(enabled) if enabled: @@ -166,13 +229,23 @@ func _input(event: InputEvent) -> void: idle_seconds = 0.0 func _process(delta: float) -> void: + # The game-over card owns the screen until its countdown sends the booth to the attract route. + if next_player and next_player.visible: + next_player_left -= delta + next_player.set_seconds(next_player_left) + if next_player_left <= 0.0 and not busy: + # The route change hides the card; if the attract scene fails to load, try again after + # another countdown rather than every frame. + next_player_left = NEXT_PLAYER_SECONDS + go(attract, "game_over") + return idle_seconds += delta if idle_seconds < idle_limit or busy: return idle_seconds = 0.0 var scene := get_tree().current_scene - if scene == null or scene.scene_file_path != ROUTES["hub"]: - go("hub", "idle") + if scene == null or scene.scene_file_path != ROUTES[attract]: + go(attract, "idle") func smoke_routes() -> void: # Let the launch scene finish its opening frames, as a player would, before switching. diff --git a/hoppys-adventure-arcade/app/booth_log.gd b/hoppys-adventure-arcade/app/booth_log.gd index 83bb799e..4e016d66 100644 --- a/hoppys-adventure-arcade/app/booth_log.gd +++ b/hoppys-adventure-arcade/app/booth_log.gd @@ -12,7 +12,8 @@ extends RefCounted ## "time":"2026-11-30T10:04:26","duration_s":143.2,"ended_by":"idle","retries":0} ## A session is one visit to a game route. Re-entering the same game (the runner's or a platformer ## level's Retry) counts as a retry within it. `ended_by` is "menu" (the player left through the -## game's own menu), "idle" (booth mode's idle return) or "quit". A start with no end means the game +## game's own menu), "idle" (booth mode's idle return), "game_over" (booth mode sent a player who had +## used their retries back to the attract route) or "quit". A start with no end means the game ## stopped without quitting: a crash, a power cut or a forced kill. ## tools/booth/sessions_report.py summarizes a folder of these files. diff --git a/hoppys-adventure-arcade/app/next_player_card.gd b/hoppys-adventure-arcade/app/next_player_card.gd new file mode 100644 index 00000000..69f516da --- /dev/null +++ b/hoppys-adventure-arcade/app/next_player_card.gd @@ -0,0 +1,44 @@ +extends Control +## The booth's game-over card. AppRouter lays it over a finished game when the booth player has +## used their retries: GAME OVER, the game's score line, a thank-you, and a countdown to the next +## player. It takes no input; the router opens the attract route when the countdown ends. +const Style = preload("res://scenes/main_menu/menu_style.gd") +var summary: Label +var countdown: Label + +func _init() -> void: + name = "NextPlayerCard" + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + # Stop clicks reaching the game underneath. + mouse_filter = Control.MOUSE_FILTER_STOP + theme = Style.theme() + var dim := ColorRect.new() + dim.color = Color(0.01, 0.02, 0.04, 0.84) + dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + dim.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(dim) + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + center.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(center) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 16) + center.add_child(column) + var title := Style.label("GAME OVER", 48) + summary = Style.label("", 24, Style.MINT) + var thanks := Style.label("Thanks for playing!", 26) + countdown = Style.label("", 18, Color("afbdc9")) + for label in [title, summary, thanks, countdown]: + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + column.add_child(label) + hide() + +## Show the card with the game's score line ("" for none) and the seconds left. +func present(score_line: String, seconds: float) -> void: + summary.text = score_line + summary.visible = score_line != "" + set_seconds(seconds) + show() + +func set_seconds(seconds: float) -> void: + countdown.text = "Next player in %d" % ceili(maxf(seconds, 0.0)) diff --git a/hoppys-adventure-arcade/app/next_player_card.gd.uid b/hoppys-adventure-arcade/app/next_player_card.gd.uid new file mode 100644 index 00000000..5a7f2339 --- /dev/null +++ b/hoppys-adventure-arcade/app/next_player_card.gd.uid @@ -0,0 +1 @@ +uid://c5a3iwsssrkab diff --git a/hoppys-adventure-arcade/modes/beatem_up/game.gd b/hoppys-adventure-arcade/modes/beatem_up/game.gd index 9d9b7853..f98756de 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/game.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/game.gd @@ -603,6 +603,7 @@ func game_over() -> void: playing = false state = "game_over" _update_hud() + if AppRouter.booth_game_over("SCORE %05d" % score): return # Booth: the next player's turn. show_overlay("REVIEW FAILED", "Retry %s with 3 HP and fresh equipment." % Catalog.TITLES[stage_index],"Retry current stage",retry_stage) func boss_defeated() -> void: diff --git a/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd b/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd index 121f78a1..7ecdf18c 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd @@ -576,6 +576,7 @@ func game_over() -> void: playing = false state = "game_over" _update_hud() + if AppRouter.booth_game_over("SCORE %05d" % score): return # Booth: the next player's turn. show_overlay("REVIEW FAILED", "Retry %s with 3 HP and fresh equipment." % Catalog.TITLES[stage_index],"Retry current stage",retry_stage) func boss_defeated() -> void: diff --git a/hoppys-adventure-arcade/modes/platformer/level.gd b/hoppys-adventure-arcade/modes/platformer/level.gd index 490987af..e8ed58ff 100644 --- a/hoppys-adventure-arcade/modes/platformer/level.gd +++ b/hoppys-adventure-arcade/modes/platformer/level.gd @@ -297,6 +297,7 @@ func _cancel_knockout() -> void: ## The cutaway holds its final pose behind the existing results fade. func _reveal_results(delay: float) -> void: + if not won_run and AppRouter.booth_game_over("%06d POINTS" % score): return # Booth: next player. if not won_run: knockout.callout.hide() overlay.color = RESULTS_SHADE overlay.modulate.a = 0.0 diff --git a/hoppys-adventure-arcade/scenes/runner/ui/game_over.gd b/hoppys-adventure-arcade/scenes/runner/ui/game_over.gd index ddbc61cf..6bb1c5be 100644 --- a/hoppys-adventure-arcade/scenes/runner/ui/game_over.gd +++ b/hoppys-adventure-arcade/scenes/runner/ui/game_over.gd @@ -102,6 +102,10 @@ func bind(runner: Node) -> void: game.run_finished.connect(_finished) func _finished(value: Dictionary) -> void: + # Booth mode: once the player has used their retries, AppRouter shows the next-player card. + var router := get_node_or_null(^"/root/AppRouter") + if router != null and router.booth_game_over("SCORE %08d" % int(value.get("score", 0))): + return show_result(value, game.get_leaderboard()) func show_result(value: Dictionary, leaderboard: Array) -> void: diff --git a/hoppys-adventure-arcade/tests/booth_routing.gd b/hoppys-adventure-arcade/tests/booth_routing.gd new file mode 100644 index 00000000..d6af5deb --- /dev/null +++ b/hoppys-adventure-arcade/tests/booth_routing.gd @@ -0,0 +1,174 @@ +extends SceneTree +## Booth routing: --attract picks where idle and finished players go; a game over in booth mode +## sends the player on once their --retries are used, with a next-player card, and ends their +## session as game_over; outside booth mode every game's own retry screen is unchanged. Each game +## (Heroes vs. Villains, Code City Brawl, a platformer level, the runner) is driven through its +## real game-over path. Run headless with --script res://tests/booth_routing.gd. +const ChildGame := preload("res://tests/support/child_game.gd") +const LOG_DIR := "user://test_booth_routing_sessions" +const RUNNER_GAME_OVER := "res://scenes/runner/ui/game_over.tscn" +var failures := 0 +var router: Node + +func _initialize() -> void: + call_deferred("run") + +func check(ok: bool, message: String) -> void: + print("PASS: " if ok else "FAIL: ", message) + if not ok: + failures += 1 + +func wait(seconds: float) -> void: + await create_timer(seconds, true).timeout + +func settle_route() -> void: + for i in 300: + if not router.busy: + break + await process_frame + await process_frame + +func visit(route: String) -> void: + router.go(route) + await settle_route() + await wait(0.2) + +func on(route: String) -> bool: + return current_scene != null and current_scene.scene_file_path == router.ROUTES[route] + +func card_up() -> bool: + return router.next_player != null and router.next_player.visible + +## Wait out the card's countdown (shortened) and the route change it starts. +func finish_card() -> void: + router.next_player_left = 0.2 + for i in 50: + await wait(0.1) + if not card_up() and not router.busy: + break + await settle_route() + +## Launch the game headless with user args for one simulated second; return its output. +func launch(user_args: Array[String]) -> String: + var args: Array[String] = ["--fixed-fps", "60", "--quit-after", "60", "--"] + args.append_array(user_args) + var child: Dictionary = await ChildGame.run(self, args) + return child.log + +func ended_by() -> Array: + var reasons := [] + var file := FileAccess.open(router.session_log.path, FileAccess.READ) + while file != null and not file.eof_reached(): + var line := file.get_line() + if line.is_empty(): + continue + var record: Variant = JSON.parse_string(line) + if record is Dictionary and record.event == "end": + reasons.append(record.ended_by) + return reasons + +func clean() -> void: + var dir := DirAccess.open(LOG_DIR) + if dir == null: + return + for file_name in dir.get_files(): + dir.remove(file_name) + DirAccess.remove_absolute(LOG_DIR) + +func run() -> void: + router = root.get_node("AppRouter") + clean() + await wait(0.3) + check(router.attract_route(PackedStringArray()) == "hub", "The hub is the default attract route") + check(router.attract_route(PackedStringArray(["--attract=beatem_up"])) == "beatem_up", + "--attract names the attract route") + check(router.attract_route(PackedStringArray(["--attract=nowhere"])) == "hub", + "An unknown --attract falls back to the hub") + check(router.retry_count(PackedStringArray()) == 0, "Booth players get no retries by default") + check(router.retry_count(PackedStringArray(["--retries=2"])) == 2, "--retries sets the retries") + check(router.retry_count(PackedStringArray(["--retries=-1"])) == 0 + and router.retry_count(PackedStringArray(["--retries=lots"])) == 0, + "A bad --retries means none") + var kiosk: String = await launch(["--booth", "--attract=beatem_up", "--retries=1"]) + check(kiosk.contains("AppRouter: booth attract route beatem_up, 1 retries per player"), + "The launch flags reach the router") + + # Outside booth mode nothing changes: the game shows its own retry screen. + await visit("heroes_vs_villains") + current_scene.game_over() + await process_frame + check(current_scene.overlay.visible and not card_up(), + "Without booth mode, Heroes vs. Villains shows its own retry screen") + check(not router.booth_game_over(), "booth_game_over() is false outside booth mode") + + router.start_session_log(LOG_DIR) + router.set_booth(true) + router.retry_limit = 1 + router.attract = "beatem_up" + await visit("heroes_vs_villains") + current_scene.game_over() + await process_frame + check(current_scene.overlay.visible and not card_up(), "With a retry left, the game offers it") + current_scene.retry_stage() + await wait(0.2) + current_scene.game_over() + await process_frame + check(not current_scene.overlay.visible and card_up(), + "With the retries used, the game shows no retry screen and the next-player card appears") + check(router.next_player.summary.text == "SCORE %05d" % current_scene.score, + "The card shows the game's score") + await finish_card() + check(on("beatem_up") and not card_up() and router.game_overs == 0, + "After the countdown the attract route opens for the next player") + + router.retry_limit = 0 + await visit("code_city_brawl") + current_scene.game_over() + await process_frame + check(not current_scene.overlay.visible and card_up(), + "Code City Brawl hands its game over to the booth") + await finish_card() + check(on("beatem_up"), "Code City Brawl's next player starts at the attract route") + + await visit("bay_area_blitz") + var level := current_scene + level._finish(false) + for i in 100: + await wait(0.1) + if card_up(): + break + check(card_up() and not level.overlay.visible, + "A platformer level hands its game over to the booth after the knockout") + await finish_card() + check(on("beatem_up") and not paused, "The platformer's next player starts unpaused") + + var runner_screen: CanvasLayer = load(RUNNER_GAME_OVER).instantiate() + root.add_child(runner_screen) + runner_screen._finished({"score": 4242}) + check(not runner_screen.visible and card_up() + and router.next_player.summary.text == "SCORE 00004242", + "The runner's game over hands off to the booth with its score") + runner_screen.queue_free() + await finish_card() + + router.attract = "platformer" + await visit("orbital_ops") + router.idle_limit = 0.4 + await wait(0.8) + await settle_route() + check(on("platformer"), "An idle game returns to the attract route") + router.idle_limit = router.IDLE_SECONDS + + router.session_log.end("quit") + var reasons := ended_by() + check(reasons.slice(0, 3) == ["game_over", "game_over", "game_over"] and "idle" in reasons, + "Booth game overs end their sessions as game_over (got %s)" % [reasons]) + + router.set_booth(false) + router.attract = "hub" + router.retry_limit = 0 + check(not card_up(), "Leaving booth mode clears the card") + clean() + await root.get_node("AudioService").settle() + print("Booth routing failures: ", failures) + quit(1 if failures else 0) diff --git a/hoppys-adventure-arcade/tests/booth_routing.gd.uid b/hoppys-adventure-arcade/tests/booth_routing.gd.uid new file mode 100644 index 00000000..1c312427 --- /dev/null +++ b/hoppys-adventure-arcade/tests/booth_routing.gd.uid @@ -0,0 +1 @@ +uid://euq2goejn7sv diff --git a/hoppys-adventure-arcade/tests/support/child_game.gd b/hoppys-adventure-arcade/tests/support/child_game.gd new file mode 100644 index 00000000..79023674 --- /dev/null +++ b/hoppys-adventure-arcade/tests/support/child_game.gd @@ -0,0 +1,28 @@ +extends RefCounted +## Run this project in a child Godot process with a wall-clock limit, collecting its output. +## OS.execute() blocks with no limit, so a child that never quits would hang a suite run directly +## (outside tools/tests/run_tests.py's timeout). This kills the child at the deadline instead. +const DEFAULT_SECONDS := 120.0 + +## Start `godot --headless --path ` and wait for it. Returns {"code": its exit code, +## or -1 when it was killed at the deadline, "log": what it printed}. +static func run(tree: SceneTree, args: Array[String], seconds := DEFAULT_SECONDS) -> Dictionary: + var full := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://")]) + full.append_array(args) + var info := OS.execute_with_pipe(OS.get_executable_path(), full, false) + if info.is_empty(): + return {"code": -1, "log": "child_game: could not start Godot"} + var pid: int = info.pid + var text := "" + var deadline := Time.get_ticks_msec() + int(seconds * 1000.0) + while OS.is_process_running(pid): + text += _drain(info.stdio) + _drain(info.stderr) + if Time.get_ticks_msec() > deadline: + OS.kill(pid) + return {"code": -1, "log": text + "\nchild_game: killed after %d s" % seconds} + await tree.create_timer(0.05, true).timeout + text += _drain(info.stdio) + _drain(info.stderr) + return {"code": OS.get_process_exit_code(pid), "log": text} + +static func _drain(pipe: FileAccess) -> String: + return pipe.get_buffer(1 << 20).get_string_from_utf8() diff --git a/hoppys-adventure-arcade/tests/support/child_game.gd.uid b/hoppys-adventure-arcade/tests/support/child_game.gd.uid new file mode 100644 index 00000000..a8c8fc78 --- /dev/null +++ b/hoppys-adventure-arcade/tests/support/child_game.gd.uid @@ -0,0 +1 @@ +uid://bw0fxgem63pts diff --git a/tools/booth/macos/setup.sh b/tools/booth/macos/setup.sh index aaa75c4b..88a0a94d 100755 --- a/tools/booth/macos/setup.sh +++ b/tools/booth/macos/setup.sh @@ -12,6 +12,9 @@ # before setup are restored exactly. Without that record it changes no system setting and # lists what to restore by hand. # +# Anything after "--" goes to the game after --booth, for example +# ... --route heroes_vs_villains -- --retries=1 --attract=beatem_up +# # --apply does this: # - copies tools/booth/watchdog.sh to ~/Library/Application Support/HoppyBooth/, and records the # settings below as they were, for --undo (only the first time) @@ -45,6 +48,7 @@ PMSET_KEYS="sleep displaysleep disksleep powernap autorestart" here="$(cd "$(dirname "$0")" && pwd)" app="" route="" +extra=() apply=0 undo=0 @@ -66,6 +70,7 @@ while [ $# -gt 0 ]; do ;; --apply) apply=1; shift ;; --undo) undo=1; shift ;; + --) shift; extra=("$@"); break ;; -h | --help) usage; exit 0 ;; *) die "unknown option '$1'" ;; esac @@ -242,6 +247,7 @@ fi game_args=(-- --booth) [ -z "$route" ] || game_args+=("--route=$route") +[ "${#extra[@]}" -eq 0 ] || game_args+=("${extra[@]}") watchdog=("/bin/bash" "$support/watchdog.sh" --log "$logs/watchdog.log" --heartbeat "$support/heartbeat" --stop-file "$support/stop" -- "$binary" "${game_args[@]}") plist_arguments="" diff --git a/tools/booth/test_macos_setup.py b/tools/booth/test_macos_setup.py index 080d3dbf..580a4209 100644 --- a/tools/booth/test_macos_setup.py +++ b/tools/booth/test_macos_setup.py @@ -102,6 +102,12 @@ def test_agent_runs_the_watchdog_and_the_game_in_booth_mode(self) -> None: self.assertEqual(agent["KeepAlive"], {"SuccessfulExit": False}) self.assertEqual(agent["LimitLoadToSessionType"], "Aqua") + def test_arguments_after_the_separator_go_to_the_game(self) -> None: + result = self.setup("--app", str(self.app), "--route", "heroes_vs_villains", "--", "--retries=1", + "--attract=beatem_up") + self.assertEqual(self.agent(result.stdout)["ProgramArguments"][-5:], + ["--", "--booth", "--route=heroes_vs_villains", "--retries=1", "--attract=beatem_up"]) + def test_route_is_optional(self) -> None: agent = self.agent(self.setup("--app", str(self.app)).stdout) self.assertEqual(agent["ProgramArguments"][-2:], ["--", "--booth"]) From 7f2da17ed13e433f76a75019b448c6ecb537d3d8 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 15 Oct 2026 18:13:26 -0700 Subject: [PATCH 2/2] fix(booth): limit attract routes to menus, count losses per session, finish the card on exit [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Addresses CodeRabbit review 5541793941 on #81: - --attract accepts only hub, beatem_up and runner: menus that wait for a player and start their own music. A game route would play by itself, collide with native retries and same-game sessions, and the platformer menu would be silent after a beat 'em up. - booth_game_over() declines while a route change is under way, which would otherwise hide the fresh card. - Turning booth mode off while the card counts down hands off to the attract route at once instead of stranding a paused game. - The session log counts each session's losses (game_overs), so stage retries that stay in the game show up; the report adds the column. - setup.sh appends the arguments left after -- directly. - tests/booth_routing.gd drives the real runner game (isolated leaderboard) through run_finished, checks the six-second start, a takeover during a route change, booth mode off mid-countdown and the loss count, asserts the child's exit code, and gives the child its own session-log folder. The runbook row covers configured retries. --- docs/booth-runbook.md | 2 +- docs/builds.md | 4 +- hoppys-adventure-arcade/app/app_router.gd | 29 +++- hoppys-adventure-arcade/app/booth_log.gd | 19 ++- .../tests/booth_routing.gd | 128 ++++++++++++------ .../tests/booth_session_log.gd | 2 +- tools/booth/macos/setup.sh | 6 +- tools/booth/sessions_report.py | 20 ++- tools/booth/test_sessions_report.py | 14 +- 9 files changed, 149 insertions(+), 75 deletions(-) diff --git a/docs/booth-runbook.md b/docs/booth-runbook.md index 47c0aed8..f6a2bb7b 100644 --- a/docs/booth-runbook.md +++ b/docs/booth-runbook.md @@ -40,7 +40,7 @@ Booth mode itself runs fullscreen and ignores the menu's **Quit Game**. A game n | What happens | What you'll see | |---|---| | A player walks away mid-game | After 90 seconds the game returns to the hub. | -| A player loses | A **GAME OVER** card thanks them and shows their score, and after six seconds the hub opens for the next player. | +| A player loses | Once they have used any retries you set (none by default), a **GAME OVER** card thanks them and shows their score. After six seconds the attract screen (the hub unless you set another) opens for the next player. | | The game crashes or closes | The watchdog opens it again within a few seconds. | | The game freezes | The watchdog restarts it within about a minute. | | Someone presses Cmd+Q | The game closes, and the watchdog opens it again. | diff --git a/docs/builds.md b/docs/builds.md index b07e6bdf..9b124fc2 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -33,12 +33,12 @@ open -a "Hoppy’s Adventure Arcade.app" --args -- --booth --route=heroes_vs_vil ``` - **Fullscreen.** The game starts fullscreen without changing the saved settings. -- **Idle reset.** After 90 seconds with no keyboard, mouse, touch or controller input, any game returns to the attract route. That is the hub, whose rotating previews act as the attract screen, unless `--attract=` names another route. +- **Idle reset.** After 90 seconds with no keyboard, mouse, touch or controller input, any game returns to the attract route. That is the hub, whose rotating previews act as the attract screen, unless `--attract=` names `beatem_up` or `runner`, the other menus that start their own music. - **Game over.** A lost game doesn't offer unlimited retries. A player gets `--retries=` retries (default 0); after that, the game shows no retry screen. Instead a **GAME OVER** card thanks the player, shows their score and counts down six seconds, then the attract route opens for the next player. The count starts again whenever the attract route opens. Every game takes part: Heroes vs. Villains, Code City Brawl, the platformer levels and the Endless Runner. Without `--booth`, every game keeps its own retry screen. - **No quitting from the menu.** The menu's **Quit Game** does nothing in booth mode; staff close the window (Cmd+Q, Alt+F4). - **Launch route.** `--route=` opens a route at launch (`heroes_vs_villains`, `code_city_brawl`, `bay_area_blitz`, `orbital_ops`, `enchanted_repo`, `runner`, `platformer`, `beatem_up`, `hub`), with or without `--booth`. An unknown name logs an error and opens the hub. - **Keep it running.** `tools/booth/watchdog.sh` relaunches the game whenever it exits and restarts it when it hangs. The watchdog passes `--heartbeat=`, which makes the game rewrite that file with the current time every two seconds; a file that stops changing means the game is stuck. On a Mac, `tools/booth/macos/setup.sh` installs the watchdog as a login item and applies the kiosk settings. The [booth runbook](booth-runbook.md) walks staff through setup, the show days and recovery. -- **Session log.** Booth mode records each game session's start and end in `user://booth/`, one file per run of the game (`sessions--.jsonl`). Each entry records the game, the local start and end times, the duration, how the session ended (`menu`, `idle`, `game_over` or `quit`) and how many times the player retried. It records nothing about players and never uses the network. A start with no end means the game stopped without quitting: a crash, a power cut or a forced kill. The game prints its file's full path at startup. `--session-log=` writes to another folder and works without `--booth`, for playtests. +- **Session log.** Booth mode records each game session's start and end in `user://booth/`, one file per run of the game (`sessions--.jsonl`). Each entry records the game, the local start and end times, the duration, how the session ended (`menu`, `idle`, `game_over` or `quit`), how many times the player re-entered the game, and how many times they lost (`game_overs`, stage retries included). It records nothing about players and never uses the network. A start with no end means the game stopped without quitting: a crash, a power cut or a forced kill. The game prints its file's full path at startup. `--session-log=` writes to another folder and works without `--booth`, for playtests. `user://` is `~/Library/Application Support/Godot/app_userdata/Hoppy’s Adventure Arcade/` on macOS, `%APPDATA%\Godot\app_userdata\Hoppy’s Adventure Arcade\` on Windows and `~/.local/share/godot/app_userdata/Hoppy’s Adventure Arcade/` on Linux. To summarize the logs, copy each machine's `booth` folder and run: diff --git a/hoppys-adventure-arcade/app/app_router.gd b/hoppys-adventure-arcade/app/app_router.gd index 4af62266..fecfeab9 100644 --- a/hoppys-adventure-arcade/app/app_router.gd +++ b/hoppys-adventure-arcade/app/app_router.gd @@ -39,8 +39,13 @@ const HEARTBEAT_PREFIX := "--heartbeat=" ## Booth game over: games call booth_game_over() when the player loses. A booth player gets ## `--retries=` retries (default 0); after that the router lays NextPlayerCard over the game ## and, NEXT_PLAYER_SECONDS later, opens the attract route for the next player. `--attract=` -## picks the attract route for this and the idle return. Outside booth mode games are unchanged. +## picks the attract route for this and the idle return, from ATTRACT_ROUTES. Outside booth mode +## games are unchanged. const ATTRACT_PREFIX := "--attract=" +## Menus that can wait for the next player: each starts its own music (the hub through +## AudioService). Game routes would start playing by themselves, and the platformer menu would be +## silent after a beat 'em up stops the shared music. +const ATTRACT_ROUTES: Array[String] = ["hub", "beatem_up", "runner"] const RETRIES_PREFIX := "--retries=" const NEXT_PLAYER_SECONDS := 6.0 var busy := false @@ -165,14 +170,16 @@ static func heartbeat_path(args: PackedStringArray) -> String: return arg.trim_prefix(HEARTBEAT_PREFIX) return "" -## The booth's attract route from `--attract=`: "hub" when absent or unknown (with a warning). +## The booth's attract route from `--attract=`: "hub" when absent or not one of +## ATTRACT_ROUTES (with a warning). static func attract_route(args: PackedStringArray) -> String: for arg in args: if arg.begins_with(ATTRACT_PREFIX): var route := arg.trim_prefix(ATTRACT_PREFIX) - if ROUTES.has(route): + if route in ATTRACT_ROUTES: return route - push_warning("Unknown --attract %s; the booth returns to the hub" % route) + push_warning("--attract %s is not one of %s; the booth returns to the hub" + % [route, ", ".join(ATTRACT_ROUTES)]) return "hub" ## Retries a booth player gets, from `--retries=`: 0 when absent or not a whole number. @@ -187,10 +194,14 @@ static func retry_count(args: PackedStringArray) -> int: ## Games call this when the player loses, before showing their own retry screen. Returns true when ## the booth takes over: the player has used their retries, so the game shows nothing and the -## router thanks them and sends the booth to the attract route. Always false outside booth mode. +## router thanks them and sends the booth to the attract route. Always false outside booth mode, +## and while a route change is already under way (it takes the player elsewhere anyway). The +## session log counts every game over, booth or not. ## `score_line` is shown on the card, in the game's own style ("SCORE 01234"). func booth_game_over(score_line := "") -> bool: - if not booth: + if session_log: + session_log.count_game_over() + if not booth or busy: return false game_overs += 1 if game_overs <= retry_limit: @@ -210,11 +221,15 @@ func _launch(route: String) -> void: print("AppRouter: launched route %s (%s)" % [route, path]) func set_booth(enabled: bool) -> void: + # A card counting down over a paused, finished game would strand it: hand off right away. + var finishing := not enabled and next_player != null and next_player.visible booth = enabled idle_seconds = 0.0 AudioService.set_attract(enabled) game_overs = 0 - if next_player: + if finishing: + go(attract, "game_over") # The route change hides the card. + elif next_player: next_player.hide() set_process(enabled) set_process_input(enabled) diff --git a/hoppys-adventure-arcade/app/booth_log.gd b/hoppys-adventure-arcade/app/booth_log.gd index 4e016d66..6b9540eb 100644 --- a/hoppys-adventure-arcade/app/booth_log.gd +++ b/hoppys-adventure-arcade/app/booth_log.gd @@ -9,11 +9,13 @@ extends RefCounted ## {"event":"start","launch":"2026-11-30T09:58:12.345","session":1,"game":"heroes_vs_villains", ## "time":"2026-11-30T10:02:03"} ## {"event":"end","launch":"2026-11-30T09:58:12.345","session":1,"game":"heroes_vs_villains", -## "time":"2026-11-30T10:04:26","duration_s":143.2,"ended_by":"idle","retries":0} +## "time":"2026-11-30T10:04:26","duration_s":143.2,"ended_by":"idle","retries":0, +## "game_overs":1} ## A session is one visit to a game route. Re-entering the same game (the runner's or a platformer -## level's Retry) counts as a retry within it. `ended_by` is "menu" (the player left through the -## game's own menu), "idle" (booth mode's idle return), "game_over" (booth mode sent a player who had -## used their retries back to the attract route) or "quit". A start with no end means the game +## level's Retry) counts as a retry within it. `game_overs` counts the session's losses, stage +## retries that stay in the game included. `ended_by` is "menu" (the player left through the +## game's own menu), "idle" (booth mode's idle return), "game_over" (booth mode sent a player who +## had used their retries back to the attract route) or "quit". A start with no end means the game ## stopped without quitting: a crash, a power cut or a forced kill. ## tools/booth/sessions_report.py summarizes a folder of these files. @@ -29,6 +31,7 @@ var launch := "" var game := "" var session := 0 var retries := 0 +var game_overs := 0 var started_msec := 0 ## Set after the first failed write: the game keeps running, unlogged, rather than retrying. var failed := false @@ -58,6 +61,7 @@ func route_changed(route: String, reason: String) -> void: game = route session += 1 retries = 0 + game_overs = 0 started_msec = Time.get_ticks_msec() _write({"event": "start", "session": session, "game": game, "time": Time.get_datetime_string_from_system()}) @@ -70,9 +74,14 @@ func end(reason: String) -> void: var seconds := (Time.get_ticks_msec() - started_msec) / 1000.0 _write({"event": "end", "session": session, "game": game, "time": Time.get_datetime_string_from_system(), "duration_s": snappedf(seconds, 0.1), - "ended_by": reason, "retries": retries}) + "ended_by": reason, "retries": retries, "game_overs": game_overs}) game = "" +## AppRouter.booth_game_over() calls this for every loss in the session in progress. +func count_game_over() -> void: + if game != "": + game_overs += 1 + ## Open, append and close per event, so a crash loses at most the line being written. func _write(fields: Dictionary) -> void: if failed: diff --git a/hoppys-adventure-arcade/tests/booth_routing.gd b/hoppys-adventure-arcade/tests/booth_routing.gd index d6af5deb..525f33fb 100644 --- a/hoppys-adventure-arcade/tests/booth_routing.gd +++ b/hoppys-adventure-arcade/tests/booth_routing.gd @@ -1,12 +1,13 @@ extends SceneTree -## Booth routing: --attract picks where idle and finished players go; a game over in booth mode -## sends the player on once their --retries are used, with a next-player card, and ends their +## Booth routing: --attract picks the menu where idle and finished players go; a game over in booth +## mode sends the player on once their --retries are used, with a next-player card, and ends their ## session as game_over; outside booth mode every game's own retry screen is unchanged. Each game ## (Heroes vs. Villains, Code City Brawl, a platformer level, the runner) is driven through its ## real game-over path. Run headless with --script res://tests/booth_routing.gd. const ChildGame := preload("res://tests/support/child_game.gd") const LOG_DIR := "user://test_booth_routing_sessions" -const RUNNER_GAME_OVER := "res://scenes/runner/ui/game_over.tscn" +const CHILD_LOG_DIR := "user://test_booth_routing_child_sessions" +const RUNNER_SCORES := "user://test_booth_routing_leaderboard.cfg" var failures := 0 var router: Node @@ -48,15 +49,24 @@ func finish_card() -> void: break await settle_route() -## Launch the game headless with user args for one simulated second; return its output. -func launch(user_args: Array[String]) -> String: - var args: Array[String] = ["--fixed-fps", "60", "--quit-after", "60", "--"] +## Wait, at most `seconds`, until the next-player card is up. +func card_within(seconds: float) -> bool: + for i in int(seconds * 10): + if card_up(): + return true + await wait(0.1) + return card_up() + +## Launch the game headless with user args for one simulated second, its session log kept in a +## folder this suite removes. Returns the child's exit code and output. +func launch(user_args: Array[String]) -> Dictionary: + var args: Array[String] = ["--fixed-fps", "60", "--quit-after", "60", "--", + "--session-log=" + ProjectSettings.globalize_path(CHILD_LOG_DIR)] args.append_array(user_args) - var child: Dictionary = await ChildGame.run(self, args) - return child.log + return await ChildGame.run(self, args) -func ended_by() -> Array: - var reasons := [] +func ends() -> Array[Dictionary]: + var result: Array[Dictionary] = [] var file := FileAccess.open(router.session_log.path, FileAccess.READ) while file != null and not file.eof_reached(): var line := file.get_line() @@ -64,34 +74,42 @@ func ended_by() -> Array: continue var record: Variant = JSON.parse_string(line) if record is Dictionary and record.event == "end": - reasons.append(record.ended_by) - return reasons + result.append(record) + return result func clean() -> void: - var dir := DirAccess.open(LOG_DIR) - if dir == null: - return - for file_name in dir.get_files(): - dir.remove(file_name) - DirAccess.remove_absolute(LOG_DIR) + for folder in [LOG_DIR, CHILD_LOG_DIR]: + var dir := DirAccess.open(folder) + if dir == null: + continue + for file_name in dir.get_files(): + dir.remove(file_name) + DirAccess.remove_absolute(folder) + for suffix in ["", ".tmp", ".bak"]: + if FileAccess.file_exists(RUNNER_SCORES + suffix): + DirAccess.remove_absolute(RUNNER_SCORES + suffix) func run() -> void: router = root.get_node("AppRouter") clean() await wait(0.3) check(router.attract_route(PackedStringArray()) == "hub", "The hub is the default attract route") - check(router.attract_route(PackedStringArray(["--attract=beatem_up"])) == "beatem_up", - "--attract names the attract route") - check(router.attract_route(PackedStringArray(["--attract=nowhere"])) == "hub", - "An unknown --attract falls back to the hub") + check(router.attract_route(PackedStringArray(["--attract=beatem_up"])) == "beatem_up" + and router.attract_route(PackedStringArray(["--attract=runner"])) == "runner", + "--attract picks one of the attract menus") + check(router.attract_route(PackedStringArray(["--attract=heroes_vs_villains"])) == "hub" + and router.attract_route(PackedStringArray(["--attract=platformer"])) == "hub" + and router.attract_route(PackedStringArray(["--attract=nowhere"])) == "hub", + "A game, a menu without its own music, or an unknown name falls back to the hub") check(router.retry_count(PackedStringArray()) == 0, "Booth players get no retries by default") check(router.retry_count(PackedStringArray(["--retries=2"])) == 2, "--retries sets the retries") check(router.retry_count(PackedStringArray(["--retries=-1"])) == 0 and router.retry_count(PackedStringArray(["--retries=lots"])) == 0, "A bad --retries means none") - var kiosk: String = await launch(["--booth", "--attract=beatem_up", "--retries=1"]) - check(kiosk.contains("AppRouter: booth attract route beatem_up, 1 retries per player"), - "The launch flags reach the router") + var kiosk: Dictionary = await launch(["--booth", "--attract=beatem_up", "--retries=1"]) + check(kiosk.code == 0 + and kiosk.log.contains("AppRouter: booth attract route beatem_up, 1 retries per player"), + "The launch flags reach the router (exit %d)" % kiosk.code) # Outside booth mode nothing changes: the game shows its own retry screen. await visit("heroes_vs_villains") @@ -117,6 +135,8 @@ func run() -> void: "With the retries used, the game shows no retry screen and the next-player card appears") check(router.next_player.summary.text == "SCORE %05d" % current_scene.score, "The card shows the game's score") + check(router.next_player_left > router.NEXT_PLAYER_SECONDS - 1.0, + "The card starts its countdown from six seconds") await finish_card() check(on("beatem_up") and not card_up() and router.game_overs == 0, "After the countdown the attract route opens for the next player") @@ -133,41 +153,61 @@ func run() -> void: await visit("bay_area_blitz") var level := current_scene level._finish(false) - for i in 100: - await wait(0.1) - if card_up(): - break - check(card_up() and not level.overlay.visible, + check(await card_within(10.0) and not level.overlay.visible, "A platformer level hands its game over to the booth after the knockout") await finish_card() check(on("beatem_up") and not paused, "The platformer's next player starts unpaused") - var runner_screen: CanvasLayer = load(RUNNER_GAME_OVER).instantiate() - root.add_child(runner_screen) - runner_screen._finished({"score": 4242}) - check(not runner_screen.visible and card_up() - and router.next_player.summary.text == "SCORE 00004242", - "The runner's game over hands off to the booth with its score") - runner_screen.queue_free() + # The real runner: its game-over screen hears run_finished and hands off. + await visit("runner_game") + var runner := current_scene + runner.leaderboard_path = RUNNER_SCORES + runner._finish_run(&"caught") + var screen: CanvasLayer = null + for child in runner.find_children("*", "CanvasLayer", true, false): + if child.has_method("show_result"): + screen = child + check(await card_within(5.0) and screen != null and not screen.visible + and router.next_player.summary.text.begins_with("SCORE "), + "The runner's game-over screen hands off to the booth with its score") await finish_card() + check(on("beatem_up"), "The runner's next player starts at the attract route") + + # A game over while a route change is already under way leaves it to that change. + await visit("code_city_brawl") + router.go("beatem_up") + check(not router.booth_game_over(), "No takeover while a route change is under way") + await settle_route() + + # Turning booth mode off mid-countdown hands off at once instead of stranding the game. + await visit("bay_area_blitz") + current_scene._finish(false) + check(await card_within(10.0), "The card is up before booth mode is turned off") + router.set_booth(false) + await settle_route() + check(not card_up() and on("beatem_up") and not paused, + "Leaving booth mode mid-countdown clears the card and opens the attract route") + router.set_booth(true) + router.retry_limit = 0 - router.attract = "platformer" + router.attract = "runner" await visit("orbital_ops") router.idle_limit = 0.4 await wait(0.8) await settle_route() - check(on("platformer"), "An idle game returns to the attract route") + check(on("runner"), "An idle game returns to the attract route") router.idle_limit = router.IDLE_SECONDS - router.session_log.end("quit") - var reasons := ended_by() - check(reasons.slice(0, 3) == ["game_over", "game_over", "game_over"] and "idle" in reasons, - "Booth game overs end their sessions as game_over (got %s)" % [reasons]) + var records := ends() + var reasons := records.map(func(record: Dictionary) -> String: return record.ended_by) + check(reasons.slice(0, 4) == ["game_over", "game_over", "game_over", "game_over"] + and "idle" in reasons, "Booth game overs end their sessions as game_over (got %s)" % [reasons]) + check(not records.is_empty() and int(records[0].game_overs) == 2, + "The session counts both of the player's losses, the retried one included") router.set_booth(false) router.attract = "hub" router.retry_limit = 0 - check(not card_up(), "Leaving booth mode clears the card") clean() await root.get_node("AudioService").settle() print("Booth routing failures: ", failures) diff --git a/hoppys-adventure-arcade/tests/booth_session_log.gd b/hoppys-adventure-arcade/tests/booth_session_log.gd index 86836f96..68d06d2f 100644 --- a/hoppys-adventure-arcade/tests/booth_session_log.gd +++ b/hoppys-adventure-arcade/tests/booth_session_log.gd @@ -16,7 +16,7 @@ const ISOLATED := ["HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "APPDATA", "LOCAL "USERPROFILE"] ## Every field the log may contain. Anything else would be new data about players: review it first. const FIELDS := ["event", "launch", "time", "version", "session", "game", "duration_s", "ended_by", - "retries"] + "retries", "game_overs"] var failures := 0 var router: Node diff --git a/tools/booth/macos/setup.sh b/tools/booth/macos/setup.sh index 88a0a94d..e21365df 100755 --- a/tools/booth/macos/setup.sh +++ b/tools/booth/macos/setup.sh @@ -48,7 +48,6 @@ PMSET_KEYS="sleep displaysleep disksleep powernap autorestart" here="$(cd "$(dirname "$0")" && pwd)" app="" route="" -extra=() apply=0 undo=0 @@ -70,7 +69,7 @@ while [ $# -gt 0 ]; do ;; --apply) apply=1; shift ;; --undo) undo=1; shift ;; - --) shift; extra=("$@"); break ;; + --) shift; break ;; -h | --help) usage; exit 0 ;; *) die "unknown option '$1'" ;; esac @@ -247,7 +246,8 @@ fi game_args=(-- --booth) [ -z "$route" ] || game_args+=("--route=$route") -[ "${#extra[@]}" -eq 0 ] || game_args+=("${extra[@]}") +# What's left after "--" goes to the game. +[ $# -eq 0 ] || game_args+=("$@") watchdog=("/bin/bash" "$support/watchdog.sh" --log "$logs/watchdog.log" --heartbeat "$support/heartbeat" --stop-file "$support/stop" -- "$binary" "${game_args[@]}") plist_arguments="" diff --git a/tools/booth/sessions_report.py b/tools/booth/sessions_report.py index 5fe387ba..b5e49caf 100644 --- a/tools/booth/sessions_report.py +++ b/tools/booth/sessions_report.py @@ -35,6 +35,7 @@ class Session: duration_s: float | None = None ended_by: str = "" retries: int = 0 + game_overs: int = 0 @dataclass @@ -76,9 +77,11 @@ def read(paths: list[Path]) -> Log: if not (isinstance(end, str) and end and isinstance(ended_by, str) and ended_by): raise ValueError("time and ended_by must be non-empty strings") duration_s, retries = float(event["duration_s"]), int(event.get("retries", 0)) + game_overs = int(event.get("game_overs", 0)) if not math.isfinite(duration_s) or duration_s < 0: raise ValueError(f"duration_s must be a finite, non-negative number, not {duration_s}") - session.end, session.duration_s, session.ended_by, session.retries = end, duration_s, ended_by, retries + session.end, session.duration_s, session.ended_by = end, duration_s, ended_by + session.retries, session.game_overs = retries, game_overs else: skipped.append(f"{where}: unknown event {kind!r}") except (json.JSONDecodeError, KeyError, TypeError, ValueError, OverflowError) as error: @@ -108,16 +111,19 @@ def summary(log: Log) -> str: f"Sessions: {len(log.sessions)} ({len(finished)} finished, {unfinished} unfinished)", ] if log.sessions: - # Sessions and Unfinished count every visit; lengths and retries come from finished ones. - lines += ["", f"{'Game':<20} {'Sessions':>8} {'Unfinished':>10} {'Median':>7} {'Mean':>7} {'P90':>7} {'Retries':>8}"] + # Sessions and Unfinished count every visit; lengths, retries and losses come from finished ones. + lines += ["", f"{'Game':<20} {'Sessions':>8} {'Unfinished':>10} {'Median':>7} {'Mean':>7} {'P90':>7}" + f" {'Retries':>8} {'Game overs':>10}"] for game in sorted({s.game for s in log.sessions}): visits = [s for s in log.sessions if s.game == game] durations = [s.duration_s for s in visits if s.duration_s is not None] lengths = (clock(statistics.median(durations)), clock(statistics.fmean(durations)), clock(p90(durations))) \ if durations else ("-", "-", "-") + finished_visits = [s for s in visits if s.duration_s is not None] lines.append( f"{game:<20} {len(visits):>8} {len(visits) - len(durations):>10} {lengths[0]:>7} {lengths[1]:>7}" - f" {lengths[2]:>7} {sum(s.retries for s in visits if s.duration_s is not None):>8}" + f" {lengths[2]:>7} {sum(s.retries for s in finished_visits):>8}" + f" {sum(s.game_overs for s in finished_visits):>10}" ) if finished: ended = Counter(s.ended_by for s in finished) @@ -134,10 +140,12 @@ def write_csv(log: Log, target: Path) -> None: """One row per session, for spreadsheets and the post-event report.""" with target.open("w", newline="", encoding="utf-8") as handle: writer = csv.writer(handle) - writer.writerow(["source", "launch", "session", "game", "start", "end", "duration_s", "ended_by", "retries"]) + writer.writerow(["source", "launch", "session", "game", "start", "end", "duration_s", "ended_by", "retries", + "game_overs"]) for s in log.sessions: duration = "" if s.duration_s is None else f"{s.duration_s:.1f}" - writer.writerow([s.source, s.launch, s.number, s.game, s.start, s.end, duration, s.ended_by or "unfinished", s.retries]) + writer.writerow([s.source, s.launch, s.number, s.game, s.start, s.end, duration, s.ended_by or "unfinished", + s.retries, s.game_overs]) def expand(paths: list[Path]) -> list[Path]: diff --git a/tools/booth/test_sessions_report.py b/tools/booth/test_sessions_report.py index b4583d0a..18e4213f 100644 --- a/tools/booth/test_sessions_report.py +++ b/tools/booth/test_sessions_report.py @@ -25,8 +25,9 @@ def start(session: int, game: str, time: str) -> str: return event("start", session=session, game=game, time=time) -def end(session: int, game: str, time: str, duration: float, ended_by: str, retries: int = 0) -> str: - return event("end", session=session, game=game, time=time, duration_s=duration, ended_by=ended_by, retries=retries) +def end(session: int, game: str, time: str, duration: float, ended_by: str, retries: int = 0, game_overs: int = 0) -> str: + return event("end", session=session, game=game, time=time, duration_s=duration, ended_by=ended_by, retries=retries, + game_overs=game_overs) class SessionsReportTest(unittest.TestCase): @@ -45,7 +46,7 @@ def sample(self) -> Path: start(1, "heroes_vs_villains", "2026-11-30T10:02:03"), end(1, "heroes_vs_villains", "2026-11-30T10:04:26", 143.2, "idle"), start(2, "heroes_vs_villains", "2026-11-30T10:05:00"), - end(2, "heroes_vs_villains", "2026-11-30T10:08:00", 180.0, "menu", retries=2), + end(2, "heroes_vs_villains", "2026-11-30T10:08:00", 180.0, "menu", retries=2, game_overs=3), start(3, "runner_game", "2026-11-30T11:00:00"), end(3, "runner_game", "2026-11-30T11:01:00", 60.0, "quit"), start(4, "code_city_brawl", "2026-11-30T11:30:00"), @@ -68,16 +69,16 @@ def test_summary_reports_games_reasons_and_hours(self) -> None: self.assertIn("Sessions: 5 (4 finished, 1 unfinished)", text) rows = {line.split()[0]: line.split() for line in text.splitlines() if line.split() and "_" in line.split()[0]} # Two sessions: 143.2 s and 180 s. Median and mean 161.6 s; P90 interpolates to 176.3 s. - self.assertEqual(rows["heroes_vs_villains"], ["heroes_vs_villains", "2", "0", "2:42", "2:42", "2:56", "2"]) + self.assertEqual(rows["heroes_vs_villains"], ["heroes_vs_villains", "2", "0", "2:42", "2:42", "2:56", "2", "3"]) # One finished (90 s) and one unfinished visit: both count, the lengths come from the finished one. - self.assertEqual(rows["code_city_brawl"], ["code_city_brawl", "2", "1", "1:30", "1:30", "1:30", "0"]) + self.assertEqual(rows["code_city_brawl"], ["code_city_brawl", "2", "1", "1:30", "1:30", "1:30", "0", "0"]) self.assertIn("Ended by: idle 1, menu 2, quit 1", text) self.assertIn("Starts by hour: 10:00 2, 11:00 3", text) def test_a_game_with_only_unfinished_sessions_is_listed(self) -> None: path = self.write("crash.jsonl", [start(1, "orbital_ops", "2026-11-30T10:00:00")]) rows = [line.split() for line in sessions_report.summary(sessions_report.read([path])).splitlines()] - self.assertIn(["orbital_ops", "1", "1", "-", "-", "-", "0"], rows) + self.assertIn(["orbital_ops", "1", "1", "-", "-", "-", "0", "0"], rows) def test_same_launch_in_two_files_stays_separate(self) -> None: first = self.write("a.jsonl", [start(1, "runner_game", "2026-11-30T10:00:00"), end(1, "runner_game", "2026-11-30T10:01:00", 60, "menu")]) @@ -162,6 +163,7 @@ def test_csv_has_one_row_per_session(self) -> None: self.assertEqual(rows[0]["duration_s"], "143.2") self.assertEqual(rows[3]["ended_by"], "unfinished") self.assertEqual(rows[3]["duration_s"], "") + self.assertEqual(rows[1]["game_overs"], "3") def test_missing_log_is_an_error(self) -> None: with redirect_stderr(io.StringIO()) as err: