git_game_show 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1145 @@
1
+ require 'websocket-client-simple'
2
+ require 'securerandom'
3
+ require 'timeout'
4
+
5
+ module GitGameShow
6
+ class PlayerClient
7
+ attr_reader :host, :port, :password, :name
8
+
9
+ def initialize(host:, port:, password:, name:)
10
+ @host = host
11
+ @port = port
12
+ @password = password
13
+ @name = name
14
+ @ws = nil
15
+ @prompt = TTY::Prompt.new
16
+ @players = []
17
+ @game_state = :lobby # :lobby, :playing, :ended
18
+ @current_timer_id = nil
19
+ end
20
+
21
+ def connect
22
+ begin
23
+ client = self # Store reference to the client instance
24
+ @ws = WebSocket::Client::Simple.connect("ws://#{host}:#{port}")
25
+
26
+ @ws.on :open do
27
+ puts "Connected to server".colorize(:green)
28
+ # Use the stored client reference
29
+ client.send_join_request
30
+ end
31
+
32
+ @ws.on :message do |msg|
33
+ client.handle_message(msg)
34
+ end
35
+
36
+ @ws.on :error do |e|
37
+ puts "Error: #{e.message}".colorize(:red)
38
+ end
39
+
40
+ @ws.on :close do |e|
41
+ puts "Connection closed (#{e.code}: #{e.reason})".colorize(:yellow)
42
+ exit(1)
43
+ end
44
+
45
+ # Keep the client running
46
+ loop do
47
+ sleep(1)
48
+
49
+ # Check if connection is still alive
50
+ if @ws.nil? || @ws.closed?
51
+ puts "Connection lost. Exiting...".colorize(:red)
52
+ exit(1)
53
+ end
54
+ end
55
+ rescue => e
56
+ puts "Failed to connect: #{e.message}".colorize(:red)
57
+ end
58
+ end
59
+
60
+ # Make these methods public so they can be called from the WebSocket callbacks
61
+ def send_join_request
62
+ send_message({
63
+ type: MessageType::JOIN_REQUEST,
64
+ name: name,
65
+ password: password
66
+ })
67
+ end
68
+
69
+ # Make public for WebSocket callback
70
+ def handle_message(msg)
71
+ begin
72
+ data = JSON.parse(msg.data)
73
+
74
+ # Remove debug print to reduce console noise
75
+
76
+ case data['type']
77
+ when MessageType::JOIN_RESPONSE
78
+ handle_join_response(data)
79
+ when MessageType::GAME_START
80
+ handle_game_start(data)
81
+ when MessageType::GAME_RESET # New handler for game reset
82
+ handle_game_reset(data)
83
+ when 'player_joined', 'player_left'
84
+ handle_player_update(data)
85
+ when 'round_start'
86
+ handle_round_start(data)
87
+ when MessageType::QUESTION
88
+ handle_question(data)
89
+ when MessageType::ROUND_RESULT
90
+ handle_round_result(data)
91
+ when MessageType::SCOREBOARD
92
+ handle_scoreboard(data)
93
+ when MessageType::GAME_END
94
+ handle_game_end(data)
95
+ when MessageType::ANSWER_FEEDBACK
96
+ handle_answer_feedback(data)
97
+ when MessageType::CHAT
98
+ handle_chat(data)
99
+ else
100
+ puts "Unknown message type: #{data['type']}".colorize(:yellow)
101
+ end
102
+ rescue JSON::ParserError => e
103
+ puts "Invalid message format: #{e.message}".colorize(:red)
104
+ rescue => e
105
+ puts "Error processing message: #{e.message}".colorize(:red)
106
+ end
107
+ end
108
+
109
+ def handle_join_response(data)
110
+ if data['success']
111
+ @players = data['players'] # Get the full player list from server
112
+ display_waiting_room
113
+ else
114
+ puts "Failed to join: #{data['message']}".colorize(:red)
115
+ exit(1)
116
+ end
117
+ end
118
+
119
+ def display_waiting_room
120
+ clear_screen
121
+
122
+ # Draw header with fancy box
123
+ terminal_width = `tput cols`.to_i rescue 80
124
+ terminal_height = `tput lines`.to_i rescue 24
125
+
126
+ # Create title box
127
+ puts "┏#{"━" * (terminal_width - 2)}┓".colorize(:green)
128
+ puts "┃#{" GIT GAME SHOW - WAITING ROOM ".center(terminal_width - 2)}┃".colorize(:green)
129
+ puts "┗#{"━" * (terminal_width - 2)}┛".colorize(:green)
130
+
131
+ # Left column width (2/3 of terminal) for main content
132
+ left_width = (terminal_width * 0.65).to_i
133
+
134
+ # Display instructions and welcome information
135
+ puts "\n"
136
+ puts " Welcome to Git Game Show!".colorize(:yellow)
137
+ puts " Test your knowledge about Git and your team's commits through fun mini-games.".colorize(:light_white)
138
+ puts "\n"
139
+ puts " 🔹 INSTRUCTIONS:".colorize(:cyan)
140
+ puts " • The game consists of multiple rounds with different question types".colorize(:light_white)
141
+ puts " • Each round has a theme based on Git commit history".colorize(:light_white)
142
+ puts " • Answer questions as quickly as possible for maximum points".colorize(:light_white)
143
+ puts " • The player with the most points at the end wins!".colorize(:light_white)
144
+ puts "\n"
145
+ puts " 🔹 STATUS: Waiting for the host to start the game...".colorize(:light_yellow)
146
+ puts "\n"
147
+
148
+ # Draw player section in a box
149
+ player_box_width = terminal_width - 4
150
+ puts " ┏#{"━" * player_box_width}┓".colorize(:cyan)
151
+ puts " ┃#{" PLAYERS ".center(player_box_width)}┃".colorize(:cyan)
152
+ puts " ┗#{"━" * player_box_width}┛".colorize(:cyan)
153
+
154
+ # Display list of players in a nicer format
155
+ if @players.empty?
156
+ puts " (No other players yet)".colorize(:light_black)
157
+ else
158
+ # Calculate number of columns based on terminal width and name lengths
159
+ max_name_length = @players.map(&:length).max + 10 # Extra space for number and "(You)" text
160
+
161
+ # Add more spacing between players - increase padding from 4 to 10
162
+ column_width = max_name_length + 12 # More generous spacing
163
+ num_cols = [terminal_width / column_width, 3].min # Cap at 3 columns max
164
+ num_cols = 1 if num_cols < 1
165
+
166
+ # Use fewer columns for better spacing
167
+ if num_cols > 1 && @players.size > 6
168
+ # If we have many players, prefer fewer columns with more space
169
+ num_cols = [num_cols, 2].min
170
+ end
171
+
172
+ # Split players into rows for multi-column display
173
+ player_rows = @players.each_slice(((@players.size + num_cols - 1) / num_cols).ceil).to_a
174
+
175
+ puts "\n"
176
+ player_rows.each do |row_players|
177
+ row_str = " "
178
+ row_players.each_with_index do |player, idx|
179
+ col_idx = player_rows.index { |rp| rp.include?(player) }
180
+ player_num = col_idx * player_rows[0].length + idx + 1
181
+
182
+ # Apply different color for current player
183
+ if player == @name
184
+ row_str += "#{player_num}. #{player} (You)".colorize(:green).ljust(column_width)
185
+ else
186
+ row_str += "#{player_num}. #{player}".colorize(:light_blue).ljust(column_width)
187
+ end
188
+ end
189
+ # Add a blank line between rows for vertical spacing too
190
+ puts row_str
191
+ puts ""
192
+ end
193
+ end
194
+
195
+ puts "\n"
196
+ puts " When the game starts, you'll see questions appear automatically.".colorize(:light_black)
197
+ puts " Get ready to test your Git knowledge!".colorize(:light_yellow)
198
+ puts "\n"
199
+ end
200
+
201
+ def clear_screen
202
+ # Reset cursor and clear entire screen
203
+ print "\033[H\033[2J" # Move to home position and clear screen
204
+ print "\033[3J" # Clear scrollback buffer
205
+
206
+ # Reserve bottom line for timer status
207
+ term_height = `tput lines`.to_i rescue 24
208
+
209
+ # Move to bottom of screen and clear status line
210
+ print "\e[#{term_height};1H"
211
+ print "\e[K"
212
+ print "\e[H" # Move cursor back to home position
213
+
214
+ STDOUT.flush
215
+ end
216
+
217
+
218
+ # Helper method to print a countdown timer status in the window title
219
+ # This doesn't interfere with the terminal content
220
+ def update_title_timer(seconds)
221
+ # Use terminal escape sequence to update window title
222
+ # This is widely supported and doesn't interfere with content
223
+ print "\033]0;Git Game Show - #{seconds} seconds remaining\007"
224
+ STDOUT.flush
225
+ end
226
+
227
+ # Super simple ordering implementation with minimal screen updates
228
+ def handle_ordering_question(options, question_text = nil)
229
+ # Create a copy of the options that we can modify
230
+ current_order = options.dup
231
+ cursor_index = 0
232
+ selected_index = nil
233
+ num_options = current_order.size
234
+ question_text ||= "Put these commits in chronological order (oldest to newest)"
235
+
236
+ # Extract question data if available
237
+ data = Thread.current[:question_data] || {}
238
+ question_number = data['question_number']
239
+ total_questions = data['total_questions']
240
+
241
+ # Draw the initial screen once
242
+ # system('clear') || system('cls')
243
+
244
+ # Draw question header once
245
+ if question_number && total_questions
246
+ puts "\n ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓".colorize(:cyan)
247
+ puts " ┃#{"QUESTION #{question_number} of #{total_questions}".center(45)}┃".colorize(:cyan)
248
+ puts " ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛".colorize(:cyan)
249
+ end
250
+
251
+ # Draw the main question text once
252
+ puts "\n #{question_text}".colorize(:light_blue)
253
+ puts " Put in order from oldest (1) to newest (#{num_options})".colorize(:light_blue)
254
+
255
+ # Draw instructions once
256
+ puts "\n INSTRUCTIONS:".colorize(:yellow)
257
+ puts " • Use ↑/↓ arrows to move cursor".colorize(:white)
258
+ puts " • Press ENTER to select/deselect an item to move".colorize(:white)
259
+ puts " • Selected items move with cursor when you press ↑/↓".colorize(:white)
260
+ puts " • Navigate to Submit and press ENTER when finished".colorize(:white)
261
+
262
+ # Draw the header for the list content once
263
+ puts "\n CURRENT ORDER:".colorize(:light_blue)
264
+
265
+ # Calculate where the list content starts on screen
266
+ content_start_line = question_number ? 15 : 12
267
+
268
+ # Draw the list content (this will be redrawn repeatedly)
269
+ draw_ordering_list(current_order, cursor_index, selected_index, content_start_line, num_options)
270
+
271
+ # Initialize variables
272
+
273
+ # Main interaction loop
274
+ loop do
275
+ # Read a single keypress
276
+ char = read_char
277
+
278
+ # Clear any message on this line
279
+ move_cursor_to(content_start_line + num_options + 2, 0)
280
+ print "\r\033[K"
281
+
282
+ # Check if the timer has expired
283
+ if @timer_expired
284
+ # If timer expired, just return the current ordering
285
+ return current_order
286
+ end
287
+
288
+ # Now char is an integer (ASCII code)
289
+ case char
290
+ when 13, 10 # Enter key (CR or LF)
291
+ if cursor_index == num_options
292
+ # Submit the answer
293
+ # Move to end of list and print a message
294
+ move_cursor_to(content_start_line + num_options + 3, 0)
295
+ print "\r\033[K"
296
+ print " Submitting your answer...".colorize(:green)
297
+ return current_order
298
+ elsif selected_index == cursor_index
299
+ # Deselect the currently selected item
300
+ selected_index = nil
301
+ else
302
+ # Select the item at cursor position
303
+ selected_index = cursor_index
304
+ end
305
+ when 65, 107, 119 # Up arrow (65='A'), k (107), w (119)
306
+ # Move cursor up
307
+ if selected_index == cursor_index && cursor_index > 0
308
+ # Move the selected item up in the order
309
+ current_order[cursor_index], current_order[cursor_index - 1] =
310
+ current_order[cursor_index - 1], current_order[cursor_index]
311
+ cursor_index -= 1
312
+ selected_index = cursor_index
313
+ elsif cursor_index > 0
314
+ # Just move the cursor up
315
+ cursor_index -= 1
316
+ end
317
+ when 66, 106, 115 # Down arrow (66='B'), j (106), s (115)
318
+ if selected_index == cursor_index && cursor_index < num_options - 1
319
+ # Move the selected item down in the order
320
+ current_order[cursor_index], current_order[cursor_index + 1] =
321
+ current_order[cursor_index + 1], current_order[cursor_index]
322
+ cursor_index += 1
323
+ selected_index = cursor_index
324
+ elsif cursor_index < num_options
325
+ # Just move the cursor down
326
+ cursor_index += 1
327
+ end
328
+ end
329
+
330
+ # Redraw just the list portion of the screen
331
+ draw_ordering_list(current_order, cursor_index, selected_index, content_start_line, num_options)
332
+ end
333
+ end
334
+
335
+ # Helper method to draw just the list portion of the ordering UI
336
+ def draw_ordering_list(items, cursor_index, selected_index, start_line, num_options)
337
+ # Clear the line above the list (was used for debugging)
338
+ debug_line = start_line - 1
339
+ move_cursor_to(debug_line, 0)
340
+ print "\r\033[K" # Clear debug line
341
+
342
+ # Move cursor to the start position for the list
343
+ move_cursor_to(start_line, 0)
344
+
345
+ # Clear all lines that will contain list items and the submit button
346
+ (num_options + 2).times do |i|
347
+ move_cursor_to(start_line + i, 0)
348
+ print "\r\033[K" # Clear current line without moving cursor
349
+ end
350
+
351
+ # Draw each item with appropriate highlighting
352
+ items.each_with_index do |item, idx|
353
+ # Calculate the line for this item
354
+ item_line = start_line + idx
355
+ move_cursor_to(item_line, 0)
356
+
357
+ if selected_index == idx
358
+ # Selected item (being moved)
359
+ print " → #{idx + 1}. #{item}".colorize(:light_green)
360
+ elsif cursor_index == idx
361
+ # Cursor is on this item
362
+ print " → #{idx + 1}. #{item}".colorize(:light_blue)
363
+ else
364
+ # Normal item
365
+ print " #{idx + 1}. #{item}".colorize(:white)
366
+ end
367
+ end
368
+
369
+ # Add the Submit option at the bottom
370
+ move_cursor_to(start_line + num_options, 0)
371
+ if cursor_index == num_options
372
+ print " → Submit Answer".colorize(:yellow)
373
+ else
374
+ print " Submit Answer".colorize(:white)
375
+ end
376
+
377
+ # Move cursor after the list
378
+ move_cursor_to(start_line + num_options + 1, 0)
379
+
380
+ # Ensure output is visible
381
+ STDOUT.flush
382
+ end
383
+
384
+ # Helper to position cursor at a specific row/column
385
+ def move_cursor_to(row, col)
386
+ print "\033[#{row};#{col}H"
387
+ end
388
+
389
+ # Simplified key input reader that uses numbers for arrow keys
390
+ def read_char
391
+ begin
392
+ system("stty raw -echo")
393
+
394
+ # Read a character
395
+ c = STDIN.getc
396
+
397
+ # Special handling for escape sequences
398
+ if c == "\e"
399
+ # Could be an arrow key - read more
400
+ begin
401
+ # Check if there's more data to read
402
+ if IO.select([STDIN], [], [], 0.1)
403
+ c2 = STDIN.getc
404
+ if c2 == "["
405
+ # This is an arrow key or similar control sequence
406
+ if IO.select([STDIN], [], [], 0.1)
407
+ c3 = STDIN.getc
408
+ case c3
409
+ when 'A' then return 65 # Up arrow (ASCII 'A')
410
+ when 'B' then return 66 # Down arrow (ASCII 'B')
411
+ when 'C' then return 67 # Right arrow
412
+ when 'D' then return 68 # Left arrow
413
+ else
414
+ return c3.ord # Other control character
415
+ end
416
+ end
417
+ else
418
+ return c2.ord # ESC followed by another key
419
+ end
420
+ end
421
+ rescue => e
422
+ # Just return ESC if there's an error
423
+ return 27 # ESC key
424
+ end
425
+ end
426
+
427
+ # Just return the ASCII value for the key
428
+ return c.ord
429
+ ensure
430
+ system("stty -raw echo")
431
+ end
432
+ end
433
+
434
+ # Non-blocking key input reader that supports timeouts
435
+ def read_char_with_timeout
436
+ begin
437
+ # Check if there's input data available
438
+ if IO.select([STDIN], [], [], 0.1)
439
+ # Read a character
440
+ c = STDIN.getc
441
+
442
+ # Handle nil case (EOF)
443
+ return nil if c.nil?
444
+
445
+ # Special handling for escape sequences
446
+ if c == "\e"
447
+ # Could be an arrow key - read more
448
+ begin
449
+ # Check if there's more data to read
450
+ if IO.select([STDIN], [], [], 0.1)
451
+ c2 = STDIN.getc
452
+ if c2 == "["
453
+ # This is an arrow key or similar control sequence
454
+ if IO.select([STDIN], [], [], 0.1)
455
+ c3 = STDIN.getc
456
+ case c3
457
+ when 'A' then return 65 # Up arrow (ASCII 'A')
458
+ when 'B' then return 66 # Down arrow (ASCII 'B')
459
+ when 'C' then return 67 # Right arrow
460
+ when 'D' then return 68 # Left arrow
461
+ else
462
+ return c3.ord # Other control character
463
+ end
464
+ end
465
+ else
466
+ return c2.ord # ESC followed by another key
467
+ end
468
+ end
469
+ rescue => e
470
+ # Just return ESC if there's an error
471
+ return 27 # ESC key
472
+ end
473
+ end
474
+
475
+ # Just return the ASCII value for the key
476
+ return c.ord
477
+ end
478
+
479
+ # No input available - return nil for timeout
480
+ return nil
481
+ rescue => e
482
+ # In case of error, return nil
483
+ return nil
484
+ end
485
+ end
486
+
487
+ # Helper method to display countdown using a status bar at the bottom of the screen
488
+ def update_countdown_display(seconds, original_seconds)
489
+ # Get terminal dimensions
490
+ term_height = `tput lines`.to_i rescue 24
491
+
492
+ # Calculate a simple progress bar
493
+ total_width = 30
494
+ progress_width = ((seconds.to_f / original_seconds) * total_width).to_i
495
+ remaining_width = total_width - progress_width
496
+
497
+ # Choose color based on time remaining
498
+ color = if seconds <= 5
499
+ :red
500
+ elsif seconds <= 10
501
+ :light_yellow
502
+ else
503
+ :green
504
+ end
505
+
506
+ # Create status bar with progress indicator
507
+ bar = "[#{"█" * progress_width}#{" " * remaining_width}]"
508
+ status_text = " ⏱️ Time remaining: #{seconds.to_s.rjust(2)} seconds ".colorize(color) + bar
509
+
510
+ # Save cursor position
511
+ print "\e7"
512
+
513
+ # Move to bottom of screen (status line)
514
+ print "\e[#{term_height};1H"
515
+
516
+ # Clear the line
517
+ print "\e[K"
518
+
519
+ # Print status bar at bottom of screen
520
+ print status_text
521
+
522
+ # Restore cursor position
523
+ print "\e8"
524
+ STDOUT.flush
525
+ end
526
+
527
+ def handle_game_start(data)
528
+ @game_state = :playing
529
+ @players = data['players']
530
+ @total_rounds = data['rounds']
531
+
532
+ clear_screen
533
+
534
+ # Display a fun "Game Starting" animation
535
+ puts "\n\n"
536
+ puts " ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓".colorize(:green)
537
+ puts " ┃ GAME STARTING... ┃".colorize(:green)
538
+ puts " ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛".colorize(:green)
539
+ puts "\n\n"
540
+
541
+ puts " Total rounds: #{@total_rounds}".colorize(:cyan)
542
+ puts " Players: #{@players.join(', ')}".colorize(:cyan)
543
+ puts "\n\n"
544
+ puts " Get ready for the first round!".colorize(:yellow)
545
+ puts "\n\n"
546
+ end
547
+
548
+ def handle_player_update(data)
549
+ # Update the players list
550
+ @players = data['players']
551
+
552
+ if @game_state == :lobby
553
+ # If we're in the lobby, refresh the waiting room UI with updated player list
554
+ display_waiting_room
555
+
556
+ # Show notification at the bottom
557
+ if data['type'] == 'player_joined'
558
+ puts "\n 🟢 #{data['name']} has joined the game".colorize(:green)
559
+ else
560
+ puts "\n 🔴 #{data['name']} has left the game".colorize(:yellow)
561
+ end
562
+ else
563
+ # During gameplay, just show a notification without disrupting the game UI
564
+ terminal_width = `tput cols`.to_i rescue 80
565
+
566
+ # Create a notification box that won't interfere with ongoing gameplay
567
+ puts "\n┏#{"━" * (terminal_width - 2)}┓".colorize(:cyan)
568
+
569
+ if data['type'] == 'player_joined'
570
+ puts "┃#{" 🟢 #{data['name']} has joined the game ".center(terminal_width - 2)}┃".colorize(:green)
571
+ else
572
+ puts "┃#{" 🔴 #{data['name']} has left the game ".center(terminal_width - 2)}┃".colorize(:yellow)
573
+ end
574
+
575
+ # Don't show all players during gameplay - can be too disruptive
576
+ # Just show the total count
577
+ puts "┃#{" Total players: #{data['players'].size} ".center(terminal_width - 2)}┃".colorize(:cyan)
578
+ puts "┗#{"━" * (terminal_width - 2)}┛".colorize(:cyan)
579
+ end
580
+ end
581
+
582
+ def handle_round_start(data)
583
+ clear_screen
584
+
585
+ # Draw a fancy round header
586
+ round_num = data['round']
587
+ total_rounds = data['total_rounds']
588
+ mini_game = data['mini_game']
589
+ description = data['description']
590
+
591
+ puts "\n\n"
592
+
593
+ # Box is drawn with exactly 45 "━" characters for the top and bottom borders
594
+ # The top and bottom including borders are 48 characters wide
595
+ box_top = " ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓"
596
+ box_bottom = " ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛"
597
+
598
+ # Get the text to center
599
+ round_text = "ROUND #{round_num} of #{total_rounds}"
600
+
601
+ # Find exact box width by measuring the top border
602
+ box_width = box_top.length # Should be 48 with Unicode characters
603
+
604
+ # The inner width is the box width minus the borders
605
+ inner_width = box_width - (" ┃".length + "┃".length)
606
+
607
+ # Simply use Ruby's built-in center method for reliable centering
608
+ box_middle = " ┃" + round_text.center(inner_width) + "┃"
609
+
610
+ # Output the box
611
+ puts box_top.colorize(:green)
612
+ puts box_middle.colorize(:green)
613
+ puts box_bottom.colorize(:green)
614
+ puts "\n"
615
+ puts " Mini-game: #{mini_game}".colorize(:cyan)
616
+ puts " #{description}".colorize(:light_blue)
617
+ puts "\n"
618
+
619
+ # Count down to the start - don't sleep here as we're waiting for the server
620
+ # to send us the questions after a fixed delay
621
+ puts " Get ready for the first question...".colorize(:yellow)
622
+ puts " Questions will appear automatically when the game begins.".colorize(:yellow)
623
+ puts " The host is controlling the timing of all questions.".colorize(:light_blue)
624
+ puts "\n\n"
625
+ end
626
+
627
+ def handle_question(data)
628
+ # Invalidate any previous timer
629
+ @current_timer_id = SecureRandom.uuid
630
+
631
+ # Clear the screen completely
632
+ clear_screen
633
+
634
+ question_num = data['question_number']
635
+ total_questions = data['total_questions']
636
+ question = data['question']
637
+ timeout = data['timeout']
638
+
639
+ # Store question data in thread-local storage for access in other methods
640
+ Thread.current[:question_data] = data
641
+
642
+ # No need to reserve space for timer - it will be at the bottom of the screen
643
+
644
+ # Display question header
645
+ puts "\n"
646
+
647
+ # Draw a simple box for the question header
648
+ box_top = " ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓"
649
+ box_bottom = " ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛"
650
+ question_text = "QUESTION #{question_num} of #{total_questions}"
651
+ inner_width = box_top.length - (" ┃".length + "┃".length)
652
+ box_middle = " ┃" + question_text.center(inner_width) + "┃"
653
+
654
+ # Output the question box
655
+ puts box_top.colorize(:cyan)
656
+ puts box_middle.colorize(:cyan)
657
+ puts box_bottom.colorize(:cyan)
658
+ puts "\n"
659
+
660
+ # Display question
661
+ puts " #{question}".colorize(:light_blue)
662
+
663
+ # Display commit info if available
664
+ if data['commit_info']
665
+ puts "\n Commit: #{data['commit_info']}".colorize(:yellow)
666
+ end
667
+ puts "\n"
668
+
669
+ # Create a unique timer ID for this question
670
+ timer_id = SecureRandom.uuid
671
+ @current_timer_id = timer_id
672
+ start_time = Time.now
673
+ end_time = start_time + timeout
674
+
675
+ # Static deadline info
676
+ puts " Deadline: #{end_time.strftime('%I:%M:%S %p')}".colorize(:light_blue)
677
+ puts "\n"
678
+
679
+ # Initialize remaining time for scoring
680
+ @time_remaining = timeout
681
+
682
+ # Update the timer display immediately
683
+ update_countdown_display(timeout, timeout)
684
+
685
+ # Variable to track if the timer has expired
686
+ @timer_expired = false
687
+
688
+ # Start countdown in a background thread with new approach
689
+ countdown_thread = Thread.new do
690
+ begin
691
+ remaining = timeout
692
+
693
+ while remaining > 0 && @current_timer_id == timer_id
694
+ # Update both window title and fixed position display
695
+ update_title_timer(remaining)
696
+ update_countdown_display(remaining, timeout)
697
+
698
+ # Sound alert when time is almost up (< 5 seconds)
699
+ if remaining < 5 && remaining > 0
700
+ print "\a" if remaining % 2 == 0 # Beep on even seconds
701
+ end
702
+
703
+ # Store time for scoring
704
+ @time_remaining = remaining
705
+
706
+ # Wait one second
707
+ sleep 1
708
+ remaining -= 1
709
+ end
710
+
711
+ # Final update when timer reaches zero
712
+ if @current_timer_id == timer_id
713
+ update_countdown_display(0, timeout)
714
+
715
+ # IMPORTANT: Send a timeout answer when time expires
716
+ # without waiting for user input
717
+ @timer_expired = true
718
+
719
+ # Clear the screen to break out of any prompt/UI state
720
+ clear_screen
721
+
722
+ puts "\n ⏰ TIME'S UP! Timeout answer submitted.".colorize(:red)
723
+ puts " Waiting for the next question...".colorize(:yellow)
724
+
725
+ # Force terminal back to normal mode in case something is waiting for input
726
+ system("stty sane") rescue nil
727
+ system("tput cnorm") rescue nil # Re-enable cursor
728
+
729
+ # Send a timeout answer to the server
730
+ send_message({
731
+ type: MessageType::ANSWER,
732
+ name: name,
733
+ answer: nil, # nil indicates timeout
734
+ question_id: data['question_id']
735
+ })
736
+
737
+ # Force kill other input methods by returning directly from handle_question
738
+ # This breaks out of the entire method, bypassing any pending input operations
739
+ return
740
+ end
741
+ rescue => e
742
+ # Silent failure for robustness
743
+ end
744
+ end
745
+
746
+ # Handle different question types - but wrap in a separate thread
747
+ # so that timeouts can interrupt the UI
748
+ input_thread = Thread.new do
749
+ if data['question_type'] == 'ordering'
750
+ # Special UI for ordering questions
751
+ answer = handle_ordering_question(data['options'], data['question'])
752
+ elsif data['options'] && !data['options'].empty?
753
+ # Regular multiple choice question - with interrupt check
754
+ begin
755
+ # Configure prompt to be interruptible
756
+ answer = @prompt.select(" Choose your answer:", data['options'], per_page: 10) do |menu|
757
+ # Check for timeout periodically during menu interactions
758
+ menu.help ""
759
+ menu.default 1
760
+ end
761
+ rescue TTY::Reader::InputInterrupt
762
+ # If interrupted, just return nil
763
+ nil
764
+ end
765
+ else
766
+ # Free text answer - with interrupt check
767
+ begin
768
+ answer = @prompt.ask(" Your answer:") do |q|
769
+ # Check for timeout periodically
770
+ q.help ""
771
+ end
772
+ rescue TTY::Reader::InputInterrupt
773
+ # If interrupted, just return nil
774
+ nil
775
+ end
776
+ end
777
+ end
778
+
779
+ # Wait for input but with timeout
780
+ answer = nil
781
+ begin
782
+ # Try to join the thread but allow for interruption
783
+ Timeout.timeout(timeout + 0.5) do
784
+ answer = input_thread.value
785
+ end
786
+ rescue Timeout::Error
787
+ # If timeout occurs during join, kill the thread
788
+ input_thread.kill if input_thread.alive?
789
+ end
790
+
791
+ # Only send user answer if timer hasn't expired
792
+ unless @timer_expired
793
+ # Send answer back to server
794
+ send_message({
795
+ type: MessageType::ANSWER,
796
+ name: name,
797
+ answer: answer,
798
+ question_id: data['question_id']
799
+ })
800
+
801
+ puts "\n Answer submitted! Waiting for feedback...".colorize(:green)
802
+ end
803
+
804
+ # Stop the timer by invalidating its ID and terminating the thread
805
+ @current_timer_id = SecureRandom.uuid # Change timer ID to signal thread to stop
806
+ countdown_thread.kill if countdown_thread.alive? # Force kill the thread
807
+
808
+ # Reset window title
809
+ print "\033]0;Git Game Show\007"
810
+
811
+ # Clear the timer status line at bottom
812
+ term_height = `tput lines`.to_i rescue 24
813
+ print "\e7" # Save cursor position
814
+ print "\e[#{term_height};1H" # Move to bottom line
815
+ print "\e[K" # Clear line
816
+ print "\e8" # Restore cursor position
817
+
818
+ # The server will send ANSWER_FEEDBACK message right away, then we'll see feedback
819
+ end
820
+
821
+ # Handle immediate feedback after submitting an answer
822
+ def handle_answer_feedback(data)
823
+ # Invalidate any running timer and reset window title
824
+ @current_timer_id = SecureRandom.uuid
825
+ print "\033]0;Git Game Show\007" # Reset window title
826
+
827
+ # Clear the timer status line at bottom
828
+ term_height = `tput lines`.to_i rescue 24
829
+ print "\e7" # Save cursor position
830
+ print "\e[#{term_height};1H" # Move to bottom line
831
+ print "\e[K" # Clear line
832
+ print "\e8" # Restore cursor position
833
+
834
+ # Don't clear screen, just display the feedback under the question
835
+ # This keeps the context of the question while showing the result
836
+
837
+ # Add a visual separator
838
+ puts "\n #{"─" * 40}".colorize(:light_black)
839
+ puts "\n"
840
+
841
+ # Show immediate feedback
842
+ if data['answer'] == "TIMEOUT"
843
+ # Special handling for timeouts
844
+ puts " ⏰ TIME'S UP! You didn't answer in time.".colorize(:red)
845
+ puts " The correct answer was: #{data['correct_answer']}".colorize(:yellow)
846
+ puts " (0 points)".colorize(:light_black)
847
+ elsif data['correct']
848
+ # Correct answer
849
+ points_text = data['points'] > 0 ? " (+#{data['points']} points)" : ""
850
+ puts " ✅ CORRECT! Your answer was correct: #{data['answer']}#{points_text}".colorize(:green)
851
+
852
+ # Show bonus points details if applicable
853
+ if data['points'] > 10 # More than base points
854
+ bonus = data['points'] - 10
855
+ puts " 🎉 SPEED BONUS: +#{bonus} points for fast answer!".colorize(:light_yellow)
856
+ end
857
+ else
858
+ # Incorrect answer
859
+ puts " ❌ INCORRECT! The correct answer was: #{data['correct_answer']}".colorize(:red)
860
+ puts " You answered: #{data['answer']} (0 points)".colorize(:yellow)
861
+ end
862
+
863
+ puts "\n Waiting for the round to complete. Please wait for the next question...".colorize(:light_blue)
864
+ end
865
+
866
+ # Handle round results showing all players' answers
867
+ def handle_round_result(data)
868
+ # Invalidate any running timer and reset window title
869
+ @current_timer_id = SecureRandom.uuid
870
+ print "\033]0;Git Game Show - Round Results\007" # Reset window title with context
871
+
872
+ # Start with a clean screen
873
+ clear_screen
874
+
875
+ puts "\n"
876
+
877
+ # Box is drawn with exactly 45 "━" characters for the top and bottom borders
878
+ # The top and bottom including borders are 48 characters wide
879
+ box_top = " ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓"
880
+ box_bottom = " ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛"
881
+
882
+ # Get the text to center
883
+ result_text = "ROUND RESULTS"
884
+
885
+ # Find exact box width by measuring the top border
886
+ box_width = box_top.length # Should be 48 with Unicode characters
887
+
888
+ # The inner width is the box width minus 2 characters for the borders
889
+ inner_width = box_width - (" ┃".length + "┃".length)
890
+
891
+ # Simply use Ruby's built-in center method for reliable centering
892
+ box_middle = " ┃" + result_text.center(inner_width) + "┃"
893
+
894
+ # Output the box
895
+ puts box_top.colorize(:cyan)
896
+ puts box_middle.colorize(:cyan)
897
+ puts box_bottom.colorize(:cyan)
898
+ puts "\n"
899
+
900
+ # Show question again
901
+ puts " Question: #{data['question'][:question]}".colorize(:light_blue)
902
+ puts " Correct answer: #{data['correct_answer']}".colorize(:green)
903
+
904
+ puts "\n All player results:".colorize(:cyan)
905
+
906
+ # Debug data temporarily removed
907
+
908
+ # Handle results based on structure
909
+ if data['results'].is_a?(Hash)
910
+ data['results'].each do |player, result|
911
+ # Ensure result is a hash with the expected keys
912
+ if result.is_a?(Hash)
913
+ # Check if 'correct' is a boolean or check string equality if it's a string
914
+ correct = result[:correct] || result['correct'] || false
915
+ answer = result[:answer] || result['answer'] || "No answer"
916
+ points = result[:points] || result['points'] || 0
917
+
918
+ status = correct ? "✓" : "✗"
919
+ points_str = "(+#{points} points)"
920
+ player_str = player == name ? "#{player} (You)" : player
921
+
922
+ player_output = " #{player_str.ljust(20)} #{points_str.ljust(15)} #{answer} #{status}"
923
+ if correct
924
+ puts player_output.colorize(:green)
925
+ else
926
+ puts player_output.colorize(:red)
927
+ end
928
+ else
929
+ # Fallback for unexpected result format
930
+ puts " #{player}: #{result.inspect}".colorize(:yellow)
931
+ end
932
+ end
933
+ else
934
+ # Fallback message if results isn't a hash
935
+ puts " No detailed results available".colorize(:yellow)
936
+ end
937
+
938
+ # Display current scoreboard
939
+ if data['scores']
940
+ puts "\n Current Standings:".colorize(:yellow)
941
+ data['scores'].each_with_index do |(player, score), index|
942
+ player_str = player == name ? "#{player} (You)" : player
943
+ rank = index + 1
944
+
945
+ # Add medal emoji for top 3
946
+ rank_display = case rank
947
+ when 1 then "🥇"
948
+ when 2 then "🥈"
949
+ when 3 then "🥉"
950
+ else "#{rank}."
951
+ end
952
+
953
+ output = " #{rank_display} #{player_str.ljust(20)} #{score} points"
954
+
955
+ if player == name
956
+ puts output.colorize(:light_yellow)
957
+ else
958
+ puts output.colorize(:light_blue)
959
+ end
960
+ end
961
+ end
962
+
963
+ puts "\n Next question coming up automatically...".colorize(:yellow)
964
+ end
965
+
966
+ def handle_scoreboard(data)
967
+ # Invalidate any running timer and reset window title
968
+ @current_timer_id = SecureRandom.uuid
969
+ print "\033]0;Git Game Show - Scoreboard\007" # Reset window title with context
970
+
971
+ # Always start with a clean screen for the scoreboard
972
+ clear_screen
973
+
974
+ puts "\n"
975
+ puts " ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓".colorize(:yellow)
976
+ puts " ┃ SCOREBOARD ┃".colorize(:yellow)
977
+ puts " ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛".colorize(:yellow)
978
+ puts "\n"
979
+
980
+ # Get player positions
981
+ position = 1
982
+ last_score = nil
983
+
984
+ data['scores'].each do |player, score|
985
+ # Determine position (handle ties)
986
+ position = data['scores'].values.index(score) + 1 if last_score != score
987
+ last_score = score
988
+
989
+ # Highlight current player
990
+ player_str = player == name ? "#{player} (You)" : player
991
+
992
+ # Format with position
993
+ position_str = "#{position}."
994
+ score_str = "#{score} points"
995
+
996
+ # Add emoji for top 3
997
+ case position
998
+ when 1
999
+ position_str = "🥇 #{position_str}"
1000
+ puts " #{position_str.ljust(5)} #{player_str.ljust(25)} #{score_str}".colorize(:light_yellow)
1001
+ when 2
1002
+ position_str = "🥈 #{position_str}"
1003
+ puts " #{position_str.ljust(5)} #{player_str.ljust(25)} #{score_str}".colorize(:light_blue)
1004
+ when 3
1005
+ position_str = "🥉 #{position_str}"
1006
+ puts " #{position_str.ljust(5)} #{player_str.ljust(25)} #{score_str}".colorize(:light_magenta)
1007
+ else
1008
+ puts " #{position_str.ljust(5)} #{player_str.ljust(25)} #{score_str}"
1009
+ end
1010
+ end
1011
+
1012
+ puts "\n Next round coming up soon...".colorize(:cyan)
1013
+ end
1014
+
1015
+ def handle_game_end(data)
1016
+ # Invalidate any running timer and reset window title
1017
+ @current_timer_id = SecureRandom.uuid
1018
+ print "\033]0;Git Game Show - Game Over\007" # Reset window title with context
1019
+
1020
+ # Clear any timer status line at the bottom
1021
+ term_height = `tput lines`.to_i rescue 24
1022
+ print "\e7" # Save cursor position
1023
+ print "\e[#{term_height};1H" # Move to bottom line
1024
+ print "\e[K" # Clear line
1025
+ print "\e8" # Restore cursor position
1026
+
1027
+ # Completely clear the screen
1028
+ clear_screen
1029
+ @game_state = :ended
1030
+
1031
+ winner = data['winner']
1032
+
1033
+ # ASCII trophy art
1034
+ trophy = <<-TROPHY
1035
+ ___________
1036
+ '._==_==_=_.'
1037
+ .-\\: /-.
1038
+ | (|:. |) |
1039
+ '-|:. |-'
1040
+ \\::. /
1041
+ '::. .'
1042
+ ) (
1043
+ _.' '._
1044
+ TROPHY
1045
+
1046
+ puts "\n\n"
1047
+ puts trophy.colorize(:yellow)
1048
+ puts "\n"
1049
+ puts " ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓".colorize(:green)
1050
+ puts " ┃ GAME OVER ┃".colorize(:green)
1051
+ puts " ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛".colorize(:green)
1052
+ puts "\n"
1053
+
1054
+ winner_is_you = winner == name
1055
+ if winner_is_you
1056
+ puts " 🎉 Congratulations! You won! 🎉".colorize(:light_yellow)
1057
+ else
1058
+ puts " Winner: #{winner}! 🏆".colorize(:light_yellow)
1059
+ end
1060
+
1061
+ puts "\n Final Scores:".colorize(:cyan)
1062
+
1063
+ # Get player positions
1064
+ position = 1
1065
+ last_score = nil
1066
+
1067
+ data['scores'].each do |player, score|
1068
+ # Determine position (handle ties)
1069
+ position = data['scores'].values.index(score) + 1 if last_score != score
1070
+ last_score = score
1071
+
1072
+ # Highlight current player
1073
+ player_str = player == name ? "#{player} (You)" : player
1074
+
1075
+ # Format with position
1076
+ position_str = "#{position}."
1077
+ score_str = "#{score} points"
1078
+
1079
+ # Add emoji for top 3
1080
+ case position
1081
+ when 1
1082
+ position_str = "🥇 #{position_str}"
1083
+ puts " #{position_str.ljust(5)} #{player_str.ljust(25)} #{score_str}".colorize(:light_yellow)
1084
+ when 2
1085
+ position_str = "🥈 #{position_str}"
1086
+ puts " #{position_str.ljust(5)} #{player_str.ljust(25)} #{score_str}".colorize(:light_blue)
1087
+ when 3
1088
+ position_str = "🥉 #{position_str}"
1089
+ puts " #{position_str.ljust(5)} #{player_str.ljust(25)} #{score_str}".colorize(:light_magenta)
1090
+ else
1091
+ puts " #{position_str.ljust(5)} #{player_str.ljust(25)} #{score_str}"
1092
+ end
1093
+ end
1094
+
1095
+ puts "\n\n Thanks for playing Git Game Show!".colorize(:green)
1096
+ puts " Waiting for the host to start a new game...".colorize(:cyan)
1097
+ puts " Press Ctrl+C to exit, or wait for the next game".colorize(:light_black)
1098
+
1099
+ # Keep client ready to receive a new game start or reset message
1100
+ @game_over_timer = Thread.new do
1101
+ begin
1102
+ loop do
1103
+ # Just keep waiting for host to start a new game
1104
+ # The client will receive GAME_START or GAME_RESET when the host takes action
1105
+ sleep 1
1106
+ end
1107
+ rescue => e
1108
+ # Silence any errors in the waiting thread
1109
+ end
1110
+ end
1111
+ end
1112
+
1113
+ # Add a special method to handle game reset notifications
1114
+ def handle_game_reset(data)
1115
+ # Stop the game over timer if it's running
1116
+ @game_over_timer&.kill if @game_over_timer&.alive?
1117
+
1118
+ # Reset game state
1119
+ @game_state = :lobby
1120
+
1121
+ # Clear any lingering state
1122
+ @players = @players || [] # Keep existing players list if we have one
1123
+
1124
+ # Show the waiting room again
1125
+ clear_screen
1126
+ display_waiting_room
1127
+
1128
+ # Show a prominent message that we're back in waiting room mode
1129
+ puts "\n 🔄 The game has been reset by the host. Waiting for a new game to start...".colorize(:cyan)
1130
+ puts " You can play again or press Ctrl+C to exit.".colorize(:cyan)
1131
+ end
1132
+
1133
+ def handle_chat(data)
1134
+ puts "[#{data['sender']}]: #{data['message']}".colorize(:light_blue)
1135
+ end
1136
+
1137
+ def send_message(message)
1138
+ begin
1139
+ @ws.send(message.to_json)
1140
+ rescue => e
1141
+ puts "Error sending message: #{e.message}".colorize(:red)
1142
+ end
1143
+ end
1144
+ end
1145
+ end