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,1224 @@
1
+ module GitGameShow
2
+ class GameServer
3
+ attr_reader :port, :password, :rounds, :repo, :players, :current_round, :game_state
4
+
5
+ def initialize(port:, password:, rounds:, repo:)
6
+ @port = port
7
+ @password = password
8
+ @rounds = rounds
9
+ @repo = repo
10
+ @players = {} # WebSocket connections by player name
11
+ @scores = {} # Player scores
12
+ @current_round = 0
13
+ @game_state = :lobby # :lobby, :playing, :ended
14
+ @mini_games = load_mini_games
15
+ @current_mini_game = nil
16
+ @round_questions = []
17
+ @current_question_index = 0
18
+ @question_start_time = nil
19
+ @player_answers = {}
20
+ @used_mini_games = [] # Track which mini-games have been used
21
+ @available_mini_games = [] # Mini-games still available in the current cycle
22
+ end
23
+
24
+ def start
25
+ EM.run do
26
+ setup_server
27
+
28
+ # Setup console commands for the host
29
+ setup_console_commands
30
+
31
+ puts "Server running at ws://0.0.0.0:#{port}".colorize(:green)
32
+ end
33
+ end
34
+
35
+ def start_with_ui(join_link = nil)
36
+ # Display UI
37
+ @show_host_ui = true
38
+ @join_link = join_link
39
+ @message_log = []
40
+ @players = {}
41
+ @cursor = TTY::Cursor
42
+
43
+ # Get terminal dimensions
44
+ @terminal_width = `tput cols`.to_i rescue 80
45
+ @terminal_height = `tput lines`.to_i rescue 24
46
+
47
+ # Calculate layout
48
+ @main_width = (@terminal_width * 0.7).to_i
49
+ @sidebar_width = @terminal_width - @main_width - 3 # 3 for border
50
+
51
+ # The fixed line for command input (near bottom of screen)
52
+ @command_line = @terminal_height - 3
53
+
54
+ # Clear screen and hide cursor
55
+ print @cursor.clear_screen
56
+ print @cursor.hide
57
+
58
+ # Draw initial UI
59
+ draw_ui_frame
60
+ draw_welcome_banner
61
+ draw_join_link
62
+ draw_sidebar
63
+ draw_command_prompt
64
+
65
+ # Set up buffer for events
66
+ @event_buffer = []
67
+
68
+ # Start the server
69
+ EM.run do
70
+ setup_server
71
+ setup_fixed_console_commands
72
+ end
73
+ end
74
+
75
+ def draw_ui_frame
76
+ # Clear screen
77
+ print @cursor.clear_screen
78
+
79
+ # Draw horizontal divider line between main area and command area
80
+ print @cursor.move_to(0, @command_line - 1)
81
+ print "=" * @terminal_width
82
+
83
+ # Draw vertical divider line between main area and sidebar
84
+ (0...@command_line-1).each do |line|
85
+ print @cursor.move_to(@main_width, line)
86
+ print "│"
87
+ end
88
+ end
89
+
90
+ def draw_welcome_banner
91
+ # Position cursor at top left
92
+ lines = [
93
+ " ██████╗ ".colorize(:red) + " ██████╗ ".colorize(:green) + " █████╗".colorize(:blue),
94
+ "██╔════╝ ".colorize(:red) + " ██╔════╝ ".colorize(:green) + " ██╔═══╝".colorize(:blue),
95
+ "██║ ███╗".colorize(:red) + " ██║ ███╗".colorize(:green) + " ███████╗".colorize(:blue),
96
+ "██║ ██║".colorize(:red) + " ██║ ██║".colorize(:green) + " ╚════██║".colorize(:blue),
97
+ "╚██████╔╝".colorize(:red) + " ╚██████╔╝".colorize(:green) + " ██████╔╝".colorize(:blue),
98
+ " ╚═════╝ ".colorize(:red) + " ╚═════╝ ".colorize(:green) + " ╚═════╝ ".colorize(:blue),
99
+ ]
100
+
101
+ start_y = 1
102
+ lines.each_with_index do |line, i|
103
+ print @cursor.move_to((@main_width - 28) / 2, start_y + i)
104
+ print line
105
+ end
106
+ end
107
+
108
+ def draw_join_link
109
+ # Copy the join link to clipboard
110
+ Clipboard.copy(@join_link)
111
+
112
+ link_box_width = [@join_link.length + 6, @main_width - 10].min
113
+ start_x = (@main_width - link_box_width) / 2
114
+ start_y = 13
115
+
116
+ print @cursor.move_to(start_x, start_y)
117
+ print "┌" + "─" * (link_box_width - 2) + "┐"
118
+
119
+ print @cursor.move_to(start_x, start_y + 1)
120
+ print "│" + " JOIN LINK (Copied to Clipboard) ".center(link_box_width - 2).colorize(:green) + "│"
121
+
122
+ print @cursor.move_to(start_x, start_y + 2)
123
+ print "│" + @join_link.center(link_box_width - 2).colorize(:yellow) + "│"
124
+
125
+ print @cursor.move_to(start_x, start_y + 3)
126
+ print "└" + "─" * (link_box_width - 2) + "┘"
127
+
128
+ # Also log that the link was copied
129
+ log_message("Join link copied to clipboard", :green)
130
+ end
131
+
132
+ def draw_sidebar
133
+ # Draw sidebar header
134
+ print @cursor.move_to(@main_width + 2, 1)
135
+ print "PLAYERS".colorize(:cyan)
136
+
137
+ print @cursor.move_to(@main_width + 2, 2)
138
+ print "═" * (@sidebar_width - 2)
139
+
140
+ update_player_list
141
+ end
142
+
143
+ def update_player_list
144
+ # Clear player area
145
+ (3..(@command_line-3)).each do |line|
146
+ print @cursor.move_to(@main_width + 2, line)
147
+ print " " * (@sidebar_width - 2)
148
+ end
149
+
150
+ # Show player count
151
+ print @cursor.move_to(@main_width + 2, 3)
152
+ print "Total: #{@players.size} player(s)".colorize(:yellow)
153
+
154
+ # Calculate available space for the player list
155
+ max_visible_players = @command_line - 8 # Allow space for headers, counts and scrolling indicators
156
+
157
+ # List players with scrolling if needed
158
+ if @players.empty?
159
+ print @cursor.move_to(@main_width + 2, 5)
160
+ print "Waiting for players...".colorize(:light_black)
161
+ else
162
+ # Show scrolling indicator if needed
163
+ if @players.size > max_visible_players
164
+ print @cursor.move_to(@main_width + 2, 4)
165
+ print "Showing #{max_visible_players} of #{@players.size}:".colorize(:light_yellow)
166
+ end
167
+
168
+ # Determine which players to display (for now, show first N players)
169
+ visible_players = @players.keys.take(max_visible_players)
170
+
171
+ # Display visible players
172
+ visible_players.each_with_index do |name, index|
173
+ print @cursor.move_to(@main_width + 2, 5 + index)
174
+ # Truncate long names
175
+ truncated_name = name.length > (@sidebar_width - 6) ?
176
+ "#{name[0...(@sidebar_width-9)]}..." :
177
+ name
178
+
179
+ if index < 9
180
+ print "#{index + 1}. #{truncated_name}".colorize(:light_blue)
181
+ else
182
+ print "#{index + 1}. #{truncated_name}".colorize(:light_blue)
183
+ end
184
+ end
185
+
186
+ # If there are more players than can be shown, add an indicator
187
+ if @players.size > max_visible_players
188
+ print @cursor.move_to(@main_width + 2, 5 + max_visible_players)
189
+ print "... and #{@players.size - max_visible_players} more".colorize(:light_black)
190
+ end
191
+ end
192
+
193
+ # Return cursor to command prompt
194
+ draw_command_prompt
195
+ end
196
+
197
+ def log_message(message, color = :white)
198
+ # Add message to log
199
+ @message_log << {text: message, color: color}
200
+
201
+ # Keep only last few messages
202
+ @message_log = @message_log.last(15) if @message_log.size > 15
203
+
204
+ # Redraw message area
205
+ draw_message_area
206
+
207
+ # Return cursor to command prompt
208
+ draw_command_prompt
209
+ end
210
+
211
+ def draw_message_area
212
+ # Calculate message area dimensions
213
+ message_area_start = 18
214
+ message_area_height = @command_line - message_area_start - 2
215
+
216
+ # Clear message area
217
+ (message_area_start..(@command_line-2)).each do |line|
218
+ print @cursor.move_to(1, line)
219
+ print " " * (@main_width - 2)
220
+ end
221
+
222
+ # Draw most recent messages
223
+ display_messages = @message_log.last(message_area_height)
224
+ display_messages.each_with_index do |msg, index|
225
+ print @cursor.move_to(1, message_area_start + index)
226
+ # Truncate message to fit within main width to prevent overflow
227
+ truncated_text = msg[:text][0...(@main_width - 3)]
228
+ print truncated_text.colorize(msg[:color])
229
+ end
230
+
231
+ # No need to call draw_command_prompt here as it's already called by log_message
232
+ end
233
+
234
+ def draw_command_prompt
235
+ # Clear command line and two lines below to prevent commit info bleeding
236
+ print @cursor.move_to(0, @command_line)
237
+ print " " * @terminal_width
238
+ print @cursor.move_to(0, @command_line + 1)
239
+ print " " * @terminal_width
240
+ print @cursor.move_to(0, @command_line + 2)
241
+ print " " * @terminal_width
242
+
243
+ # Draw command prompt
244
+ print @cursor.move_to(0, @command_line)
245
+ print "Command> ".colorize(:green)
246
+
247
+ # Position cursor after prompt
248
+ print @cursor.move_to(9, @command_line)
249
+ print @cursor.show
250
+ end
251
+
252
+ def display_welcome_banner
253
+ puts <<-BANNER.colorize(:green)
254
+ ██████╗ ██╗████████╗ ██████╗ █████╗ ███╗ ███╗███████╗
255
+ ██╔════╝ ██║╚══██╔══╝ ██╔════╝ ██╔══██╗████╗ ████║██╔════╝
256
+ ██║ ███╗██║ ██║ ██║ ███╗███████║██╔████╔██║█████╗
257
+ ██║ ██║██║ ██║ ██║ ██║██╔══██║██║╚██╔╝██║██╔══╝
258
+ ╚██████╔╝██║ ██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║███████╗
259
+ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝
260
+ BANNER
261
+ puts "\n SERVER STARTED - PORT: #{port}\n".colorize(:light_blue)
262
+ end
263
+
264
+ private
265
+
266
+ def setup_server
267
+ WebSocket::EventMachine::Server.start(host: '0.0.0.0', port: port) do |ws|
268
+ ws.onopen do
269
+ # Connection is logged when a player successfully joins
270
+ end
271
+
272
+ ws.onmessage do |msg|
273
+ handle_message(ws, msg)
274
+ end
275
+
276
+ ws.onclose do
277
+ handle_player_disconnect(ws)
278
+ end
279
+ end
280
+ end
281
+
282
+ def handle_message(ws, msg)
283
+ begin
284
+ data = JSON.parse(msg)
285
+ case data['type']
286
+ when MessageType::JOIN_REQUEST
287
+ handle_join_request(ws, data)
288
+ when MessageType::ANSWER
289
+ handle_answer(data)
290
+ when MessageType::CHAT
291
+ broadcast_message(data)
292
+ else
293
+ puts "Unknown message type: #{data['type']}".colorize(:red)
294
+ end
295
+ rescue JSON::ParserError => e
296
+ puts "Invalid message format: #{e.message}".colorize(:red)
297
+ rescue => e
298
+ puts "Error processing message: #{e.message}".colorize(:red)
299
+ end
300
+ end
301
+
302
+ def handle_join_request(ws, data)
303
+ player_name = data['name']
304
+ sent_password = data['password']
305
+
306
+ response = {
307
+ type: MessageType::JOIN_RESPONSE
308
+ }
309
+
310
+ # Check if game is already in progress
311
+ if @game_state != :lobby
312
+ response.merge!(success: false, message: "Game is already in progress")
313
+ # Validate password
314
+ elsif sent_password != password
315
+ response.merge!(success: false, message: "Incorrect password")
316
+ # Check for duplicate names
317
+ elsif @players.key?(player_name)
318
+ response.merge!(success: false, message: "Player name already taken")
319
+ else
320
+ # Add player to the game
321
+ @players[player_name] = ws
322
+ @scores[player_name] = 0
323
+
324
+ # Include current player list in the response
325
+ response.merge!(
326
+ success: true,
327
+ message: "Successfully joined the game",
328
+ players: @players.keys
329
+ )
330
+
331
+ # Notify all existing players about the new player
332
+ broadcast_message({
333
+ type: 'player_joined',
334
+ name: player_name,
335
+ players: @players.keys
336
+ }, exclude: player_name)
337
+
338
+ # Log message for player joining
339
+ log_message("🟢 #{player_name} has joined the game", :green)
340
+ # Update player list in sidebar
341
+ update_player_list
342
+ end
343
+
344
+ ws.send(response.to_json)
345
+ end
346
+
347
+ def handle_player_disconnect(ws)
348
+ # Find the player who disconnected
349
+ player_name = @players.key(ws)
350
+ return unless player_name
351
+
352
+ # Remove the player
353
+ @players.delete(player_name)
354
+
355
+ # Log message for player leaving
356
+ log_message("🔴 #{player_name} has left the game", :yellow)
357
+ # Update player list in sidebar
358
+ update_player_list
359
+
360
+ # Notify other players
361
+ broadcast_message({
362
+ type: 'player_left',
363
+ name: player_name,
364
+ players: @players.keys
365
+ })
366
+ end
367
+
368
+ def handle_answer(data)
369
+ return unless @game_state == :playing
370
+
371
+ player_name = data['name']
372
+ answer = data['answer']
373
+ question_id = data['question_id']
374
+
375
+ # Make sure the answer is for the current question
376
+ return unless question_id == @current_question_id
377
+
378
+ # Don't allow duplicate answers
379
+ return if @player_answers.dig(player_name, :answered)
380
+
381
+ # Calculate time taken to answer
382
+ time_taken = Time.now - @question_start_time
383
+
384
+ # Get current question
385
+ current_question = @round_questions[@current_question_index]
386
+
387
+ # Handle nil answer (timeout) differently
388
+ if answer.nil?
389
+ # For timeouts, set a special "TIMEOUT" answer with 0 points
390
+ @player_answers[player_name] = {
391
+ answer: "TIMEOUT",
392
+ time_taken: time_taken,
393
+ answered: true,
394
+ correct: false,
395
+ points: 0
396
+ }
397
+
398
+ # Send timeout feedback to the player
399
+ feedback = {
400
+ type: MessageType::ANSWER_FEEDBACK,
401
+ answer: "TIMEOUT",
402
+ correct: false,
403
+ correct_answer: current_question[:correct_answer],
404
+ points: 0
405
+ }
406
+ @players[player_name]&.send(feedback.to_json)
407
+
408
+ # Log the timeout
409
+ truncated_name = player_name.length > 15 ? "#{player_name[0...12]}..." : player_name
410
+ log_message("#{truncated_name} timed out after #{time_taken.round(2)}s ⏰", :yellow)
411
+ else
412
+ # Regular answer processing
413
+ # Calculate points for this answer
414
+ correct = answer == current_question[:correct_answer]
415
+ points = 0
416
+
417
+ if correct
418
+ points = 10 # Base points for correct answer
419
+
420
+ # Bonus points for fast answers
421
+ if time_taken < 5
422
+ points += 5
423
+ elsif time_taken < 10
424
+ points += 3
425
+ end
426
+ end
427
+
428
+ # Store the answer with points pre-calculated
429
+ @player_answers[player_name] = {
430
+ answer: answer,
431
+ time_taken: time_taken,
432
+ answered: true,
433
+ correct: correct,
434
+ points: points
435
+ }
436
+
437
+ # Send immediate feedback to this player only
438
+ send_answer_feedback(player_name, answer, correct, current_question, points)
439
+
440
+ # Log this answer - ensure the name is not too long
441
+ truncated_name = player_name.length > 15 ? "#{player_name[0...12]}..." : player_name
442
+ log_message("#{truncated_name} answered in #{time_taken.round(2)}s: #{correct ? "Correct ✓" : "Wrong ✗"}", correct ? :green : :red)
443
+ end
444
+
445
+ # Check if all players have answered, regardless of timeout or manual answer
446
+ check_all_answered
447
+ end
448
+
449
+ def send_answer_feedback(player_name, answer, correct, question, points=0)
450
+ # Send feedback only to the player who answered
451
+ ws = @players[player_name]
452
+ return unless ws
453
+
454
+ feedback = {
455
+ type: MessageType::ANSWER_FEEDBACK,
456
+ answer: answer,
457
+ correct: correct,
458
+ correct_answer: question[:correct_answer],
459
+ points: points # Include points in the feedback
460
+ }
461
+
462
+ ws.send(feedback.to_json)
463
+ end
464
+
465
+ def check_all_answered
466
+ # If all players have answered, log it but WAIT for the full timeout
467
+ # This ensures consistent timing regardless of how fast people answer
468
+ if @player_answers.keys.size == @players.size
469
+ timeout_sec = GitGameShow::DEFAULT_CONFIG[:question_timeout]
470
+ log_message("All players have answered - waiting for timeout (#{timeout_sec}s)", :cyan)
471
+ # We don't immediately evaluate anymore - we wait for the timer
472
+ end
473
+ end
474
+
475
+ def evaluate_answers
476
+ return unless @current_mini_game && @current_question_index < @round_questions.size
477
+
478
+ # We can't actually cancel timers in the current EM implementation
479
+ # Just set a flag indicating that we've already evaluated this question
480
+ return if @question_already_evaluated
481
+ @question_already_evaluated = true
482
+
483
+ current_question = @round_questions[@current_question_index]
484
+
485
+ # Use our pre-calculated answers instead of running evaluation again
486
+ # This ensures consistency between immediate feedback and final results
487
+ results = {}
488
+ @player_answers.each do |player_name, answer_data|
489
+ results[player_name] = {
490
+ answer: answer_data[:answer],
491
+ correct: answer_data[:correct],
492
+ points: answer_data[:points]
493
+ }
494
+ end
495
+
496
+ # Update scores
497
+ results.each do |player, result|
498
+ @scores[player] += result[:points]
499
+ end
500
+
501
+ # Send results to all players
502
+ broadcast_message({
503
+ type: MessageType::ROUND_RESULT,
504
+ question: current_question,
505
+ results: results,
506
+ correct_answer: current_question[:correct_answer],
507
+ scores: @scores.sort_by { |_, score| -score }.to_h # Include current scores
508
+ })
509
+
510
+ # Log current scores for the host
511
+ log_message("Current scores:", :cyan)
512
+ @scores.sort_by { |_, score| -score }.each do |player, score|
513
+ # Truncate player names if too long
514
+ truncated_name = player.length > 15 ? "#{player[0...12]}..." : player
515
+ log_message("#{truncated_name}: #{score} points", :light_blue)
516
+ end
517
+
518
+ # Move to next question or round
519
+ @current_question_index += 1
520
+ @player_answers = {}
521
+ @question_already_evaluated = false
522
+
523
+ if @current_question_index >= @round_questions.size
524
+ # End of round
525
+ EM.add_timer(GitGameShow::DEFAULT_CONFIG[:transition_delay]) do
526
+ start_next_round
527
+ end
528
+ else
529
+ # Next question - use mini-game specific timing if available
530
+ display_time = @current_mini_game.class.respond_to?(:question_display_time) ?
531
+ @current_mini_game.class.question_display_time :
532
+ GitGameShow::DEFAULT_CONFIG[:question_display_time]
533
+
534
+ log_message("Next question in #{display_time} seconds...", :cyan)
535
+ EM.add_timer(display_time) do
536
+ ask_next_question
537
+ end
538
+ end
539
+ end
540
+
541
+ def start_game
542
+ # If players are in an ended state, reset them first
543
+ if @game_state == :ended
544
+ log_message("Resetting players from previous game...", :light_black)
545
+ begin
546
+ broadcast_message({
547
+ type: MessageType::GAME_RESET,
548
+ message: "Get ready! The host is starting a new game..."
549
+ })
550
+ # Give players a moment to see the reset message
551
+ sleep(1)
552
+ rescue => e
553
+ log_message("Error sending reset message: #{e.message}", :red)
554
+ end
555
+ end
556
+
557
+ # Only start if we're in lobby state (which includes after reset)
558
+ return unless @game_state == :lobby
559
+ return if @players.empty?
560
+
561
+ @game_state = :playing
562
+ @current_round = 0
563
+
564
+ # Reset the mini-game tracking for a new game
565
+ @used_mini_games = []
566
+ @available_mini_games = []
567
+
568
+ broadcast_message({
569
+ type: MessageType::GAME_START,
570
+ rounds: @rounds,
571
+ players: @players.keys
572
+ })
573
+
574
+ log_message("Game started with #{@players.size} players", :green)
575
+
576
+ start_next_round
577
+ end
578
+
579
+ def start_next_round
580
+ @current_round += 1
581
+
582
+ # Reset question evaluation flag for the new round
583
+ @question_already_evaluated = false
584
+
585
+ # Check if we've completed all rounds
586
+ if @current_round > @rounds
587
+ log_message("All rounds completed! Showing final scores...", :green)
588
+ EM.next_tick { end_game } # Use next_tick to ensure it runs after current operations
589
+ return
590
+ end
591
+
592
+ # Select mini-game for this round with better variety
593
+ @current_mini_game = select_next_mini_game.new
594
+ @round_questions = @current_mini_game.generate_questions(@repo)
595
+ @current_question_index = 0
596
+
597
+ # Announce new round
598
+ broadcast_message({
599
+ type: 'round_start',
600
+ round: @current_round,
601
+ total_rounds: @rounds,
602
+ mini_game: @current_mini_game.class.name,
603
+ description: @current_mini_game.class.description
604
+ })
605
+
606
+ log_message("Starting round #{@current_round}: #{@current_mini_game.class.name}", :cyan)
607
+
608
+ # Start the first question after a short delay
609
+ EM.add_timer(3) do
610
+ ask_next_question
611
+ end
612
+ end
613
+
614
+ def ask_next_question
615
+ return if @current_question_index >= @round_questions.size
616
+
617
+ # Log information for debugging
618
+ log_message("Preparing question #{@current_question_index + 1} of #{@round_questions.size}", :cyan)
619
+
620
+ # Reset the evaluation flag for the new question
621
+ @question_already_evaluated = false
622
+
623
+ # Save current question without printing it to console
624
+ current_question = @round_questions[@current_question_index]
625
+ @current_question_id = "#{@current_round}-#{@current_question_index}"
626
+ @question_start_time = Time.now
627
+ @player_answers = {}
628
+
629
+ # Send question to all players
630
+ # Use mini-game specific timeout if available, otherwise use default
631
+ timeout = @current_mini_game.class.respond_to?(:question_timeout) ?
632
+ @current_mini_game.class.question_timeout :
633
+ GitGameShow::DEFAULT_CONFIG[:question_timeout]
634
+
635
+ question_data = {
636
+ type: MessageType::QUESTION,
637
+ question_id: @current_question_id,
638
+ question: current_question[:question],
639
+ options: current_question[:options],
640
+ timeout: timeout,
641
+ round: @current_round,
642
+ question_number: @current_question_index + 1,
643
+ total_questions: @round_questions.size
644
+ }
645
+
646
+ # Add question_type if it's a special question type (like ordering)
647
+ if current_question[:question_type]
648
+ question_data[:question_type] = current_question[:question_type]
649
+ end
650
+
651
+ # Add commit info if available (for AuthorQuiz)
652
+ if current_question[:commit_info]
653
+ question_data[:commit_info] = current_question[:commit_info]
654
+ end
655
+
656
+ # Don't log detailed question info to prevent author lists from showing
657
+ log_message("Question #{@current_question_index + 1}/#{@round_questions.size}", :cyan)
658
+
659
+ log_message("Broadcasting question to players...", :cyan)
660
+ broadcast_message(question_data)
661
+
662
+ # Set a timer for question timeout - ALWAYS evaluate after timeout
663
+ # Use same timeout value we sent to clients
664
+ EM.add_timer(timeout) do
665
+ log_message("Question timeout (#{timeout}s) - evaluating", :yellow)
666
+ evaluate_answers unless @current_question_index >= @round_questions.size
667
+ end
668
+ end
669
+
670
+ def broadcast_scoreboard
671
+ broadcast_message({
672
+ type: MessageType::SCOREBOARD,
673
+ scores: @scores.sort_by { |_, score| -score }.to_h
674
+ })
675
+ end
676
+
677
+ def end_game
678
+ @game_state = :ended
679
+
680
+ # Safety check - make sure we have scores
681
+ if @scores.empty?
682
+ log_message("Game ended, but no scores were recorded.", :yellow)
683
+
684
+ # Reset game state for potential restart
685
+ @current_round = 0
686
+ @game_state = :lobby
687
+ @current_mini_game = nil
688
+ @round_questions = []
689
+ @current_question_index = 0
690
+ @question_already_evaluated = false
691
+ @player_answers = {}
692
+
693
+ # Update UI
694
+ update_player_list
695
+ log_message("Ready for a new game! Type 'start' when players have joined.", :green)
696
+ return
697
+ end
698
+
699
+ # Determine the winner
700
+ winner = @scores.max_by { |_, score| score }
701
+
702
+ # Safety check - ensure winner isn't nil
703
+ if winner.nil?
704
+ log_message("Error: Could not determine winner. No valid scores found.", :red)
705
+
706
+ # Reset and return early
707
+ @scores = {}
708
+ @current_round = 0
709
+ @game_state = :lobby
710
+ update_player_list
711
+ return
712
+ end
713
+
714
+ # Notify all players
715
+ begin
716
+ broadcast_message({
717
+ type: MessageType::GAME_END,
718
+ winner: winner[0],
719
+ scores: @scores.sort_by { |_, score| -score }.to_h
720
+ })
721
+ rescue => e
722
+ log_message("Error broadcasting final results: #{e.message}", :red)
723
+ end
724
+
725
+ # Display the final results on screen
726
+ display_final_results(winner)
727
+
728
+ # Reset game state for potential restart
729
+ @scores = {}
730
+ @current_round = 0
731
+ @game_state = :lobby
732
+ @current_mini_game = nil
733
+ @round_questions = []
734
+ @current_question_index = 0
735
+ @question_already_evaluated = false
736
+ @player_answers = {}
737
+
738
+ # Re-initialize player scores for existing players
739
+ @players.keys.each do |player_name|
740
+ @scores[player_name] = 0
741
+ end
742
+
743
+ # Don't reset players yet - let them stay on the leaderboard screen
744
+ # They'll be reset when a new game starts
745
+ log_message("Players will remain on the leaderboard screen until a new game starts", :light_black)
746
+
747
+ # Update UI
748
+ update_player_list
749
+ log_message("Game ended! Type 'start' to play again or 'exit' to quit.", :cyan)
750
+ end
751
+
752
+ def display_final_results(winner)
753
+ begin
754
+ # Use log messages instead of clearing screen
755
+ divider = "=" * (@main_width - 5)
756
+ log_message(divider, :yellow)
757
+ log_message("🏆 GAME OVER - FINAL SCORES 🏆", :yellow)
758
+
759
+ # Safety check for winner
760
+ if !winner || !winner[0] || !winner[1]
761
+ log_message("Error: Invalid winner data", :red)
762
+ log_message("Ready for a new game! Type 'start' when players have joined.", :green)
763
+ return
764
+ end
765
+
766
+ # Announce winner
767
+ winner_name = winner[0].to_s
768
+ winner_name = winner_name.length > 20 ? "#{winner_name[0...17]}..." : winner_name
769
+ log_message("Winner: #{winner_name} with #{winner[1]} points!", :green)
770
+
771
+ # Safety check for scores
772
+ if @scores.nil? || @scores.empty?
773
+ log_message("No scores available to display", :yellow)
774
+ log_message(divider, :yellow)
775
+ log_message("Ready for a new game! Type 'start' when players have joined.", :green)
776
+ return
777
+ end
778
+
779
+ # List players in console (but limit to avoid taking too much space)
780
+ log_message("Leaderboard:", :cyan)
781
+
782
+ leaderboard_entries = []
783
+
784
+ # Sort scores safely
785
+ begin
786
+ sorted_scores = @scores.sort_by { |_, score| -(score || 0) }
787
+ rescue => e
788
+ log_message("Error sorting scores: #{e.message}", :red)
789
+ sorted_scores = @scores.to_a
790
+ end
791
+
792
+ # Show limited entries in console
793
+ sorted_scores.take(10).each_with_index do |(name, score), index|
794
+ # Safely handle name and score
795
+ player_name = name.to_s
796
+ player_score = score || 0
797
+
798
+ # Truncate name if needed
799
+ display_name = player_name.length > 15 ? "#{player_name[0...12]}..." : player_name
800
+
801
+ # Format based on position
802
+ case index
803
+ when 0
804
+ log_message("🥇 #{display_name}: #{player_score} points", :yellow)
805
+ when 1
806
+ log_message("🥈 #{display_name}: #{player_score} points", :light_blue)
807
+ when 2
808
+ log_message("🥉 #{display_name}: #{player_score} points", :light_magenta)
809
+ else
810
+ log_message("#{(index + 1).to_s}. #{display_name}: #{player_score} points", :white)
811
+ end
812
+ end
813
+
814
+ # If there are more players than shown, add a note
815
+ if sorted_scores.size > 10
816
+ log_message("... and #{sorted_scores.size - 10} more (see full results in file)", :light_black)
817
+ end
818
+
819
+ # Build complete entries array for file
820
+ sorted_scores.each_with_index do |(name, score), index|
821
+ # Use safe values
822
+ player_name = name.to_s
823
+ player_score = score || 0
824
+
825
+ # Add medals for file format
826
+ medal = case index
827
+ when 0 then "🥇"
828
+ when 1 then "🥈"
829
+ when 2 then "🥉"
830
+ else "#{index + 1}."
831
+ end
832
+
833
+ leaderboard_entries << "#{medal} #{player_name}: #{player_score} points"
834
+ end
835
+
836
+ # Save leaderboard to file
837
+ filename = save_leaderboard_to_file(winner, leaderboard_entries)
838
+
839
+ log_message(divider, :yellow)
840
+ if filename
841
+ log_message("Leaderboard saved to: #{filename}", :cyan)
842
+ else
843
+ log_message("Failed to save leaderboard to file", :red)
844
+ end
845
+ log_message("Ready for a new game! Type 'start' when players have joined.", :green)
846
+ rescue => e
847
+ # Catch-all error handling
848
+ log_message("Error displaying final results: #{e.message}", :red)
849
+ log_message("Game has ended. Type 'start' for a new game or 'exit' to quit.", :yellow)
850
+ end
851
+ end
852
+
853
+ def save_leaderboard_to_file(winner, leaderboard_entries)
854
+ begin
855
+ # Validate parameters
856
+ if !winner || !leaderboard_entries
857
+ log_message("Error: Invalid data for leaderboard file", :red)
858
+ return nil
859
+ end
860
+
861
+ # Create a unique filename with timestamp
862
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
863
+ filename = "git_game_show_results_#{timestamp}.txt"
864
+
865
+ # Use a base path that should be writable
866
+ begin
867
+ # First try current directory
868
+ file_path = File.join(Dir.pwd, filename)
869
+
870
+ # Test if we can write there
871
+ unless File.writable?(Dir.pwd)
872
+ # If not, try user's home directory
873
+ file_path = File.join(Dir.home, filename)
874
+ filename = File.join(Dir.home, filename) # Update filename to show full path
875
+ end
876
+ rescue
877
+ # If all else fails, use /tmp (Unix) or %TEMP% (Windows)
878
+ temp_dir = ENV['TEMP'] || ENV['TMP'] || '/tmp'
879
+ file_path = File.join(temp_dir, filename)
880
+ filename = file_path # Update filename to show full path
881
+ end
882
+
883
+ # Get repo name from git directory path safely
884
+ begin
885
+ repo_name = @repo && @repo.dir ? File.basename(@repo.dir.path) : "Unknown"
886
+ rescue
887
+ repo_name = "Unknown"
888
+ end
889
+
890
+ File.open(file_path, "w") do |file|
891
+ # Write header
892
+ file.puts "GIT GAME SHOW - FINAL RESULTS"
893
+ file.puts "==========================="
894
+ file.puts "Date: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
895
+ file.puts "Repository: #{repo_name}"
896
+ file.puts "Players: #{@players.keys.size}"
897
+ file.puts ""
898
+
899
+ # Write winner safely
900
+ begin
901
+ winner_name = winner[0].to_s
902
+ winner_score = winner[1].to_i
903
+ file.puts "WINNER: #{winner_name} with #{winner_score} points!"
904
+ rescue
905
+ file.puts "WINNER: Unknown (error retrieving winner data)"
906
+ end
907
+ file.puts ""
908
+
909
+ # Write full leaderboard
910
+ file.puts "FULL LEADERBOARD:"
911
+ file.puts "---------------"
912
+ if leaderboard_entries.empty?
913
+ file.puts "No entries recorded"
914
+ else
915
+ leaderboard_entries.each do |entry|
916
+ file.puts entry.to_s
917
+ end
918
+ end
919
+
920
+ # Write footer
921
+ file.puts ""
922
+ file.puts "Thanks for playing Git Game Show!"
923
+ end
924
+
925
+ return filename
926
+ rescue => e
927
+ log_message("Error saving leaderboard: #{e.message}", :red)
928
+ return nil
929
+ end
930
+ end
931
+
932
+ # Removed old full-screen methods as we now use log_message based approach
933
+
934
+ def broadcast_message(message, exclude: nil)
935
+ @players.each do |player_name, ws|
936
+ # Skip excluded player if specified
937
+ next if exclude && player_name == exclude
938
+ ws.send(message.to_json)
939
+ end
940
+ end
941
+
942
+ def setup_console_commands
943
+ Thread.new do
944
+ prompt = TTY::Prompt.new
945
+
946
+ loop do
947
+ command = prompt.select("Host commands:", {
948
+ "Start game" => :start,
949
+ "Show players" => :players,
950
+ "Show scoreboard" => :scoreboard,
951
+ "End game" => :end,
952
+ "Exit server" => :exit
953
+ })
954
+
955
+ case command
956
+ when :start
957
+ if @players.size < 1
958
+ puts "Need at least one player to start".colorize(:red)
959
+ else
960
+ start_game
961
+ end
962
+ when :players
963
+ puts "Connected players:".colorize(:cyan)
964
+ @players.keys.each { |name| puts "- #{name}" }
965
+ when :scoreboard
966
+ puts "Current scores:".colorize(:cyan)
967
+ @scores.sort_by { |_, score| -score }.each do |name, score|
968
+ puts "- #{name}: #{score}"
969
+ end
970
+ when :end
971
+ puts "Ending game...".colorize(:yellow)
972
+ end_game
973
+ when :exit
974
+ puts "Shutting down server...".colorize(:yellow)
975
+ EM.stop_event_loop
976
+ break
977
+ end
978
+ end
979
+ end
980
+ end
981
+
982
+ def setup_fixed_console_commands
983
+ # Log initial instructions
984
+ log_message("Available commands: help, start, exit", :cyan)
985
+ log_message("Type a command and press Enter", :cyan)
986
+
987
+ # Show that the server is ready
988
+ log_message("Server is running. Waiting for players to join...", :green)
989
+
990
+ # Handle commands in a separate thread
991
+ Thread.new do
992
+ loop do
993
+ # Show command prompt and get input
994
+ draw_command_prompt
995
+ command = gets&.chomp&.downcase
996
+ next unless command
997
+
998
+ # Process commands
999
+ case command
1000
+ when 'start'
1001
+ if @players.empty?
1002
+ log_message("Need at least one player to start", :red)
1003
+ else
1004
+ log_message("Starting game with #{@players.size} players...", :green)
1005
+ start_game
1006
+ end
1007
+ # 'players' command removed - player list is always visible in sidebar
1008
+ when 'help'
1009
+ log_message("Available commands:", :cyan)
1010
+ log_message(" start - Start the game with current players", :cyan)
1011
+ log_message(" end - End current game and show final scores", :cyan)
1012
+ log_message(" reset - Manually reset players to waiting room (after game ends)", :cyan)
1013
+ log_message(" help - Show this help message", :cyan)
1014
+ log_message(" exit - Shut down the server and exit", :cyan)
1015
+ when 'end'
1016
+ if @game_state == :playing
1017
+ log_message("Ending game early...", :yellow)
1018
+ end_game
1019
+ elsif @game_state == :ended
1020
+ log_message("Game already ended. Type 'start' to begin a new game.", :yellow)
1021
+ else
1022
+ log_message("No game in progress to end", :yellow)
1023
+ end
1024
+ when 'reset'
1025
+ # Add a separate reset command for manually resetting players
1026
+ if @game_state == :ended
1027
+ log_message("Manually resetting all players to waiting room state...", :yellow)
1028
+
1029
+ # Send a game reset message to all players
1030
+ broadcast_message({
1031
+ type: MessageType::GAME_RESET,
1032
+ message: "Game has been reset by the host. Waiting for a new game to start."
1033
+ })
1034
+
1035
+ # Update game state
1036
+ @game_state = :lobby
1037
+ else
1038
+ log_message("Can only reset after a game has ended", :yellow)
1039
+ end
1040
+ when 'exit'
1041
+ # Clean up before exiting
1042
+ log_message("Shutting down server...", :yellow)
1043
+ print @cursor.show # Make sure cursor is visible
1044
+ print @cursor.clear_screen
1045
+ EM.stop_event_loop
1046
+ break
1047
+ else
1048
+ log_message("Unknown command: #{command}. Type 'help' for available commands.", :red)
1049
+ end
1050
+
1051
+ # Small delay to process any display changes
1052
+ sleep 0.1
1053
+ end
1054
+ end
1055
+ end
1056
+
1057
+ def print_players_list
1058
+ puts "\nCurrent players:"
1059
+ if @players.empty?
1060
+ puts "No players have joined yet".colorize(:yellow)
1061
+ else
1062
+ @players.keys.each_with_index do |name, i|
1063
+ puts "#{i+1}. #{name}"
1064
+ end
1065
+ end
1066
+ end
1067
+
1068
+ def refresh_host_ui
1069
+ # Only clear the screen for the first draw
1070
+ if !@ui_drawn
1071
+ system("clear") || system("cls")
1072
+
1073
+ # Header - only show on first draw
1074
+ puts <<-HEADER.colorize(:green)
1075
+ ██████╗ ██╗████████╗ ██████╗ █████╗ ███╗ ███╗███████╗
1076
+ ██╔════╝ ██║╚══██╔══╝ ██╔════╝ ██╔══██╗████╗ ████║██╔════╝
1077
+ ██║ ███╗██║ ██║ ██║ ███╗███████║██╔████╔██║█████╗
1078
+ ██║ ██║██║ ██║ ██║ ██║██╔══██║██║╚██╔╝██║██╔══╝
1079
+ ╚██████╔╝██║ ██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║███████╗
1080
+ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝
1081
+ HEADER
1082
+ else
1083
+ # Just print a separator for subsequent updates
1084
+ puts "\n\n" + ("=" * 60)
1085
+ puts "GIT GAME SHOW - STATUS UPDATE".center(60).colorize(:green)
1086
+ puts ("=" * 60)
1087
+ end
1088
+
1089
+ # Server info
1090
+ puts "\n==================== SERVER INFO ====================".colorize(:cyan)
1091
+ puts "Status: #{game_state_text}".colorize(game_state_color)
1092
+ puts "Rounds: #{@current_round}/#{rounds}".colorize(:light_blue)
1093
+ puts "Repository: #{repo.dir.path}".colorize(:light_blue)
1094
+
1095
+ # Display join link prominently
1096
+ puts "\n==================== JOIN LINK =====================".colorize(:green)
1097
+ puts @join_link.to_s.colorize(:yellow)
1098
+
1099
+ # Player list
1100
+ puts "\n==================== PLAYERS =======================".colorize(:cyan)
1101
+ if @players.empty?
1102
+ puts "No players have joined yet".colorize(:yellow)
1103
+ else
1104
+ @players.keys.each_with_index do |name, i|
1105
+ puts "#{i+1}. #{name}"
1106
+ end
1107
+ end
1108
+
1109
+ # Current game state info
1110
+ case @game_state
1111
+ when :lobby
1112
+ puts "\nWaiting for players to join. Type 'start' when ready.".colorize(:yellow)
1113
+ puts "Players can join using the link above.".colorize(:yellow)
1114
+ puts "Type 'players' to see the current list of players.".colorize(:yellow)
1115
+ when :playing
1116
+ puts "\n==================== GAME INFO =======================".colorize(:cyan)
1117
+ puts "Current round: #{@current_round}/#{rounds}".colorize(:light_blue)
1118
+ puts "Current mini-game: #{@current_mini_game&.class&.name || 'N/A'}".colorize(:light_blue)
1119
+ puts "Question: #{@current_question_index + 1}/#{@round_questions.size}".colorize(:light_blue) if @round_questions&.any?
1120
+
1121
+ # Show scoreboard
1122
+ puts "\n=================== SCOREBOARD ======================".colorize(:cyan)
1123
+ if @scores.empty?
1124
+ puts "No scores yet".colorize(:yellow)
1125
+ else
1126
+ @scores.sort_by { |_, score| -score }.each_with_index do |(name, score), i|
1127
+ case i
1128
+ when 0
1129
+ puts "🥇 #{name}: #{score} points".colorize(:light_yellow)
1130
+ when 1
1131
+ puts "🥈 #{name}: #{score} points".colorize(:light_blue)
1132
+ when 2
1133
+ puts "🥉 #{name}: #{score} points".colorize(:light_magenta)
1134
+ else
1135
+ puts "#{i+1}. #{name}: #{score} points"
1136
+ end
1137
+ end
1138
+ end
1139
+ when :ended
1140
+ puts "\nGame has ended. Type 'exit' to quit or 'start' to begin a new game.".colorize(:green)
1141
+ end
1142
+
1143
+
1144
+ # Only print command help on first draw to avoid cluttering output
1145
+ if !@ui_drawn
1146
+ puts "\nAvailable commands: help, start, players, status, end, exit".colorize(:light_black)
1147
+ puts "Type a command and press Enter".colorize(:light_black)
1148
+ end
1149
+ end
1150
+
1151
+ def print_help_message
1152
+ puts "\n==================== HELP =========================="
1153
+ puts "Available commands:"
1154
+ puts " help - Show this help message"
1155
+ puts " start - Start the game with current players"
1156
+ puts " players - Show list of connected players"
1157
+ puts " status - Refresh the status display"
1158
+ puts " end - End the current game"
1159
+ puts " exit - Shut down the server and exit"
1160
+ puts "=================================================="
1161
+ end
1162
+
1163
+ def print_status_message(message, status)
1164
+ color = case status
1165
+ when :success then :green
1166
+ when :error then :red
1167
+ when :warning then :yellow
1168
+ else :white
1169
+ end
1170
+ puts "\n> #{message}".colorize(color)
1171
+ end
1172
+
1173
+ def game_state_text
1174
+ case @game_state
1175
+ when :lobby then "Waiting for players"
1176
+ when :playing then "Game in progress"
1177
+ when :ended then "Game over"
1178
+ end
1179
+ end
1180
+
1181
+ def game_state_color
1182
+ case @game_state
1183
+ when :lobby then :yellow
1184
+ when :playing then :green
1185
+ when :ended then :light_blue
1186
+ end
1187
+ end
1188
+
1189
+ # Select the next mini-game to ensure variety and avoid repetition
1190
+ def select_next_mini_game
1191
+ # If we have no more available mini-games, reset the cycle
1192
+ if @available_mini_games.empty?
1193
+ # Repopulate with all mini-games except the last one used
1194
+ @available_mini_games = @mini_games.reject { |game| game == @used_mini_games.last }
1195
+
1196
+ # Log that we're starting a new cycle
1197
+ log_message("Starting a new cycle of mini-games", :light_black)
1198
+ end
1199
+
1200
+ # Select a random game from the available ones
1201
+ selected_game = @available_mini_games.sample
1202
+
1203
+ # Remove the selected game from available and add to used
1204
+ @available_mini_games.delete(selected_game)
1205
+ @used_mini_games << selected_game
1206
+
1207
+ # Log which mini-game was selected
1208
+ log_message("Selected #{selected_game.name} for this round", :light_black)
1209
+
1210
+ # Return the selected game class
1211
+ selected_game
1212
+ end
1213
+
1214
+ def load_mini_games
1215
+ # Enable all mini-games
1216
+ [
1217
+ GitGameShow::AuthorQuiz,
1218
+ GitGameShow::CommitMessageQuiz,
1219
+ GitGameShow::CommitMessageCompletion,
1220
+ GitGameShow::DateOrderingQuiz
1221
+ ]
1222
+ end
1223
+ end
1224
+ end