git_game_show 0.1.10 → 0.2.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,392 @@
1
+ module GitGameShow
2
+ # Enable this for testing/debugging
3
+ $BLAME_GAME_DEBUG = false
4
+
5
+ class BlameGame < MiniGame
6
+ self.name = "Blame Game"
7
+ self.description = "Identify who committed the highlighted line of code! (Can be slow to load)"
8
+ self.example = <<~EXAMPLE
9
+ Who committed the highlighted line of code?
10
+
11
+ File: main.rb
12
+
13
+
14
+ 1: def initialize(options = {})
15
+ 2: @logger = options[:logger] || Logger.new(STDOUT)
16
+ 3: @config = load_configuration
17
+ \e[0;33;49m> 4: @connections = []\e[0m
18
+ 5: @active = false
19
+ 6: setup_signal_handlers
20
+ 7: end
21
+
22
+ Choose your answer?
23
+ Bob Coder
24
+ Steve Developer
25
+ \e[0;32;49m> Alice Programmer\e[0m
26
+ Sandy Engineer
27
+ EXAMPLE
28
+ self.questions_per_round = 5
29
+
30
+ # Custom timing for this mini-game
31
+ def self.question_timeout
32
+ 20 # 20 seconds per question (slightly longer than Author Quiz since more code to read)
33
+ end
34
+
35
+ def self.question_display_time
36
+ 5 # 5 seconds between questions
37
+ end
38
+
39
+ def generate_questions(repo)
40
+ questions = []
41
+
42
+ # Check if repo exists and has commits
43
+ if repo.nil? || !repo.branches.size.positive?
44
+ puts "No repository with commits found, using sample questions" if $BLAME_GAME_DEBUG
45
+ return generate_sample_questions
46
+ end
47
+
48
+ begin
49
+ puts "Starting Blame Game question generation" if $BLAME_GAME_DEBUG
50
+ # Get all files in the repo
51
+ repo_path = repo.dir.path
52
+
53
+ # Get a more limited list of files tracked by git
54
+ # 1. Use git ls-files with specific extensions to limit scope
55
+ # 2. Exclude binary files and very large files right from the start
56
+ # 3. Limit to a reasonable number of files to consider
57
+ cmd = "cd #{repo_path} && git ls-files -- '*.rb' '*.js' '*.py' '*.html' '*.css' '*.ts' '*.jsx' '*.tsx' '*.md' '*.yml' '*.yaml' '*.json' '*.c' '*.cpp' '*.h' '*.java' | grep -v -E '\\.min\\.js$|\\.min\\.css$|\\.bundle\\.js$|\\.map$|\\.png$|\\.jpg$|\\.jpeg$|\\.gif$|\\.pdf$|\\.zip$|\\.tar$|\\.gz$|\\.jar$|\\.exe$|\\.bin$' | sort -R | head -100"
58
+ all_files = `#{cmd}`.split("\n").reject(&:empty?)
59
+
60
+ puts "Found #{all_files.size} files to choose from for Blame Game" if $BLAME_GAME_DEBUG
61
+
62
+ # Skip if no files found
63
+ if all_files.empty?
64
+ puts "No suitable files found in repository, using sample questions" if $BLAME_GAME_DEBUG
65
+ return generate_sample_questions
66
+ end
67
+
68
+ # Get all authors in advance to see if we have enough
69
+ all_authors_cmd = "cd #{repo_path} && git log --pretty=format:'%an' | sort | uniq"
70
+ all_authors = `#{all_authors_cmd}`.split("\n").uniq.reject(&:empty?)
71
+
72
+ puts "Found #{all_authors.size} unique authors in repository" if $BLAME_GAME_DEBUG
73
+
74
+ if all_authors.size < 2
75
+ puts "Not enough authors found (need at least 2), using sample questions" if $BLAME_GAME_DEBUG
76
+ return generate_sample_questions
77
+ end
78
+
79
+ # Try to generate the requested number of questions
80
+ # More attempts to increase chances of getting real data
81
+ attempts = 0
82
+ max_attempts = self.class.questions_per_round * 10
83
+
84
+ puts "Starting question generation process, will try up to #{max_attempts} attempts" if $BLAME_GAME_DEBUG
85
+
86
+ while questions.size < self.class.questions_per_round && attempts < max_attempts
87
+ attempts += 1
88
+
89
+ # Select a random file
90
+ file_path = all_files.sample
91
+
92
+ # Skip if file path is invalid or file doesn't exist
93
+ full_path = File.join(repo_path, file_path)
94
+ next unless File.exist?(full_path)
95
+
96
+ # Skip very large files (over 500 KB)
97
+ next if File.size(full_path) > 500_000
98
+
99
+ # Skip binary or non-text files
100
+ begin
101
+ # Test if this file can be read as text
102
+ File.read(full_path, 100).encode('UTF-8', :invalid => :replace, :undef => :replace)
103
+ rescue
104
+ next
105
+ end
106
+
107
+ # Skip checking line count, which is slow for large files
108
+ # Instead read the first chunk to estimate if it's large enough
109
+ content_preview = File.read(full_path, 1000) rescue nil
110
+ next unless content_preview
111
+
112
+ # Rough estimate if file has enough lines by checking for newlines
113
+ newline_count = content_preview.count("\n")
114
+ next if newline_count < 7
115
+
116
+ # Estimate total lines - if preview has enough lines, we can use that section
117
+ if newline_count >= 20
118
+ # Use lines from the preview which we already have
119
+ lines = content_preview.split("\n")
120
+ # Choose a line not too close to the beginning or end
121
+ target_idx = rand(4..(lines.size - 5))
122
+ # Content is just the section we already read, no need to read the whole file
123
+ file_content = lines
124
+ # Target line number is relative to the preview
125
+ target_line_num = target_idx + 1 # 1-based line numbers
126
+ # For display purposes, show the real line number
127
+ target_line_display = target_line_num
128
+ else
129
+ # For small files, do a quick line count
130
+ # This is much faster than counting all lines in a large file
131
+ line_count = content_preview.count("\n") + 1
132
+ file_content = File.readlines(full_path) rescue nil
133
+ next unless file_content
134
+ target_line_num = rand(4..(line_count - 4))
135
+ target_line_display = target_line_num
136
+ end
137
+
138
+ # Get git blame for the selected line with better error checking
139
+ # Use a more efficient git blame approach - only get what we need
140
+ begin
141
+ # Extract both the author name and commit date
142
+ blame_cmd = "cd #{repo_path} && git blame -L #{target_line_num},#{target_line_num} --line-porcelain #{file_path}"
143
+ blame_output = `#{blame_cmd}`
144
+
145
+ # Extract author name
146
+ author_name_match = blame_output.match(/^author (.+)$/)
147
+ author_name = author_name_match ? author_name_match[1].strip : ""
148
+
149
+ # Extract commit date (author-time and author-tz)
150
+ author_time_match = blame_output.match(/^author-time (\d+)$/)
151
+ author_tz_match = blame_output.match(/^author-tz (.+)$/)
152
+
153
+ commit_date = nil
154
+ if author_time_match
155
+ timestamp = author_time_match[1].to_i
156
+ commit_date = Time.at(timestamp).strftime("%Y-%m-%d")
157
+ end
158
+
159
+ # Skip if we couldn't get a valid author
160
+ if author_name.empty? || author_name.include?("fatal:") || author_name == "Not Committed Yet"
161
+ puts "Skipping uncommitted or invalid line" if $BLAME_GAME_DEBUG
162
+ next
163
+ end
164
+
165
+ puts "Found line committed by: #{author_name} on #{commit_date}" if $BLAME_GAME_DEBUG
166
+
167
+ # We already have all authors, so we don't need to fetch them again
168
+ # But we should still check if we have enough authors to make this challenging
169
+ if all_authors.size < 4 # Need at least 3 incorrect options + 1 correct
170
+ puts "Not enough unique authors in the repository: #{all_authors.size}" if $BLAME_GAME_DEBUG
171
+ next
172
+ end
173
+ rescue => e
174
+ puts "Error getting blame info: #{e.message}" if $BLAME_GAME_DEBUG
175
+ next
176
+ end
177
+
178
+ # Get context lines (3 before, target line, 3 after)
179
+ # Use the content we already read if possible, otherwise read from file
180
+ if newline_count >= 20 && target_idx >= 3 && target_idx <= (lines.size - 4)
181
+ # We can use the lines we already have from the preview
182
+ context_lines = lines[(target_idx-3)..(target_idx+3)]
183
+ start_idx = target_idx - 3
184
+ else
185
+ # Get relevant content directly - much more efficient than reading entire file
186
+ context_cmd = "cd #{repo_path} && tail -n +#{[target_line_num-3, 1].max} #{file_path} | head -7"
187
+ context_output = `#{context_cmd}`
188
+ context_lines = context_output.split("\n")
189
+ start_idx = [target_line_num - 4, 0].max
190
+ end
191
+
192
+ next unless context_lines && context_lines.size > 0
193
+
194
+ # Add line indicators for display
195
+ display_lines = []
196
+ context_lines.each_with_index do |line, idx|
197
+ line_num = start_idx + idx + 1
198
+
199
+ # Mark the target line with > prefix
200
+ prefix = (line_num == target_line_num) ? "> " : " "
201
+
202
+ # Clean the line for display (handle tab characters, trim long lines)
203
+ clean_line = line.gsub("\t", " ").rstrip
204
+ if clean_line.length > 100
205
+ clean_line = clean_line[0..97] + "..."
206
+ end
207
+
208
+ display_lines << "#{prefix}#{line_num}: #{clean_line}"
209
+ end
210
+
211
+ # Create context string with file path and highlighted lines
212
+ context = "File: #{file_path}\n\n#{display_lines.join("\n")}"
213
+
214
+ # Get incorrect options (other authors)
215
+ incorrect_authors = all_authors.reject { |a| a == author_name }.shuffle.take(3)
216
+
217
+ # If we don't have enough distinct authors, generate some
218
+ if incorrect_authors.size < 3
219
+ sample_authors = ["Alice", "Bob", "Charlie", "David", "Emma"].reject { |a| a == author_name }
220
+ incorrect_authors += sample_authors.take(3 - incorrect_authors.size)
221
+ end
222
+
223
+ # Create options array with the correct answer and incorrect ones
224
+ all_options = ([author_name] + incorrect_authors).shuffle
225
+
226
+ # Create the question with date information
227
+ question_text = "Who committed the highlighted line of code"
228
+ question_text += commit_date ? " on #{commit_date}?" : "?"
229
+
230
+ questions << {
231
+ question: question_text,
232
+ context: context,
233
+ options: all_options,
234
+ correct_answer: author_name
235
+ }
236
+ end
237
+
238
+ rescue => e
239
+ # Silently fail and use sample questions instead
240
+ return generate_sample_questions
241
+ end
242
+
243
+ # Make sure we have enough questions or use fallback
244
+ if questions.size < self.class.questions_per_round
245
+ puts "Could only generate #{questions.size} questions, using sample questions to fill the rest" if $BLAME_GAME_DEBUG
246
+ sample_questions = generate_sample_questions
247
+ # Add enough sample questions to reach the required number
248
+ questions += sample_questions[0...(self.class.questions_per_round - questions.size)]
249
+ end
250
+
251
+ puts "Generated #{questions.size} questions for Blame Game" if $BLAME_GAME_DEBUG
252
+ questions
253
+ rescue => e
254
+ puts "Error in BlameGame#generate_questions: #{e.message}\n#{e.backtrace.join("\n")}" if $BLAME_GAME_DEBUG
255
+ generate_sample_questions
256
+ end
257
+
258
+ def evaluate_answers(question, player_answers)
259
+ results = {}
260
+
261
+ player_answers.each do |player_name, answer_data|
262
+ answered = answer_data[:answered] || false
263
+ player_answer = answer_data[:answer]
264
+ correct = player_answer == question[:correct_answer]
265
+
266
+ points = correct ? 10 : 0
267
+
268
+ # Bonus points for fast answers (if correct)
269
+ if correct
270
+ time_taken = answer_data[:time_taken] || self.class.question_timeout
271
+
272
+ if time_taken < 5
273
+ points += 5
274
+ elsif time_taken < 10
275
+ points += 3
276
+ elsif time_taken < 15
277
+ points += 1
278
+ end
279
+ end
280
+
281
+ results[player_name] = {
282
+ answer: player_answer,
283
+ correct: correct,
284
+ points: points
285
+ }
286
+ end
287
+
288
+ results
289
+ end
290
+
291
+ def generate_sample_questions
292
+ # Create sample questions in case the repo doesn't have enough data
293
+ sample_authors = ["Alice", "Bob", "Charlie", "David", "Emma"]
294
+ sample_files = ["main.rb", "utils.js", "config.py", "index.html", "styles.css"]
295
+ # Sample dates for the commits
296
+ sample_dates = ["2024-01-15", "2024-02-20", "2024-03-05", "2024-03-10", "2024-03-11"]
297
+
298
+ questions = []
299
+
300
+ self.class.questions_per_round.times do |i|
301
+ # Select a random author, file, and date for this sample
302
+ correct_author = sample_authors.sample
303
+ file_name = sample_files.sample
304
+ commit_date = sample_dates.sample
305
+
306
+ # Create sample code context
307
+ context_lines = []
308
+
309
+ case file_name
310
+ when "main.rb"
311
+ context_lines = [
312
+ "def initialize(options = {})",
313
+ " @logger = options[:logger] || Logger.new(STDOUT)",
314
+ " @config = load_configuration",
315
+ " @connections = []",
316
+ " @active = false",
317
+ " setup_signal_handlers",
318
+ "end"
319
+ ]
320
+ when "utils.js"
321
+ context_lines = [
322
+ "function formatDate(date) {",
323
+ " const day = String(date.getDate()).padStart(2, '0');",
324
+ " const month = String(date.getMonth() + 1).padStart(2, '0');",
325
+ " const year = date.getFullYear();",
326
+ " return `${year}-${month}-${day}`;",
327
+ " // TODO: Add support for different formats",
328
+ "};"
329
+ ]
330
+ when "config.py"
331
+ context_lines = [
332
+ "class Config:",
333
+ " DEBUG = False",
334
+ " TESTING = False",
335
+ " DATABASE_URI = os.environ.get('DATABASE_URI')",
336
+ " SECRET_KEY = os.environ.get('SECRET_KEY')",
337
+ " SESSION_COOKIE_SECURE = True",
338
+ " TEMPLATES_AUTO_RELOAD = True"
339
+ ]
340
+ when "index.html"
341
+ context_lines = [
342
+ "<header>",
343
+ " <nav>",
344
+ " <ul>",
345
+ " <li><a href=\"/\">Home</a></li>",
346
+ " <li><a href=\"/about\">About</a></li>",
347
+ " <li><a href=\"/contact\">Contact</a></li>",
348
+ " </ul>"
349
+ ]
350
+ when "styles.css"
351
+ context_lines = [
352
+ "body {",
353
+ " font-family: 'Helvetica', sans-serif;",
354
+ " line-height: 1.6;",
355
+ " color: #333;",
356
+ " margin: 0;",
357
+ " padding: 20px;",
358
+ " background-color: #f5f5f5;"
359
+ ]
360
+ end
361
+
362
+ # Mark a random line as the target (line 4 is index 3)
363
+ target_idx = 3 # Middle line
364
+
365
+ # Add line numbers and indicators
366
+ display_lines = []
367
+ context_lines.each_with_index do |line, idx|
368
+ line_num = idx + 1
369
+ prefix = (idx == target_idx) ? "> " : " "
370
+ display_lines << "#{prefix}#{line_num}: #{line}"
371
+ end
372
+
373
+ # Create context string
374
+ context = "File: #{file_name} (SAMPLE)\n\n#{display_lines.join("\n")}"
375
+
376
+ # Get incorrect options (other authors)
377
+ incorrect_authors = sample_authors.reject { |a| a == correct_author }.sample(3)
378
+ all_options = ([correct_author] + incorrect_authors).shuffle
379
+
380
+ # Create the sample question with date
381
+ questions << {
382
+ question: "Who committed the highlighted line of code on #{commit_date}? (SAMPLE)",
383
+ context: context,
384
+ options: all_options,
385
+ correct_answer: correct_author
386
+ }
387
+ end
388
+
389
+ questions
390
+ end
391
+ end
392
+ end
@@ -2,6 +2,19 @@ module GitGameShow
2
2
  class BranchDetective < MiniGame
3
3
  self.name = "Branch Detective"
4
4
  self.description = "Identify which branch a commit belongs to!"
5
+ self.example = <<~EXAMPLE
6
+ Which branch was this commit originally made on?
7
+
8
+ "Add pagination to users list"
9
+
10
+ abcdef7 (by Jane Developer on 2023-05-20 16:45:12)
11
+
12
+ Choose your answer:
13
+ development
14
+ \e[0;32;49m> feature/user-management\e[0m
15
+ main
16
+ hotfix/login-issue
17
+ EXAMPLE
5
18
  self.questions_per_round = 5
6
19
 
7
20
  # Custom timing for this mini-game
@@ -2,24 +2,37 @@ module GitGameShow
2
2
  class CommitMessageCompletion < MiniGame
3
3
  self.name = "Complete the Commit"
4
4
  self.description = "Complete the missing part of these commit messages! (20 seconds per question)"
5
+ self.example = <<~EXAMPLE
6
+ Complete this commit message:
7
+
8
+ "Fix memory __________ in the background worker"
9
+
10
+ 2d9a45c (Feb 25, 2025)
11
+
12
+ Choose your answer:
13
+ usage
14
+ allocation
15
+ \e[0;32;49m> leak\e[0m
16
+ error
17
+ EXAMPLE
5
18
  self.questions_per_round = 5
6
-
19
+
7
20
  # Custom timing for this mini-game (20 seconds instead of 15)
8
21
  def self.question_timeout
9
22
  20 # 20 seconds per question
10
23
  end
11
-
24
+
12
25
  def self.question_display_time
13
26
  5 # 5 seconds between questions
14
27
  end
15
-
28
+
16
29
  def generate_questions(repo)
17
30
  begin
18
31
  commits = get_all_commits(repo)
19
-
32
+
20
33
  # Filter commits with message length > 10 characters
21
34
  valid_commits = commits.select { |commit| commit.message.strip.length > 10 }
22
-
35
+
23
36
  # Fall back to sample questions if not enough valid commits
24
37
  if valid_commits.size < self.class.questions_per_round
25
38
  return generate_sample_questions
@@ -28,44 +41,44 @@ module GitGameShow
28
41
  # If there's any error, fall back to sample questions
29
42
  return generate_sample_questions
30
43
  end
31
-
44
+
32
45
  questions = []
33
46
  valid_questions = 0
34
47
  attempts = 0
35
48
  max_attempts = 100 # Prevent infinite loops
36
-
49
+
37
50
  # Keep trying until we have exactly 5 questions or reach max attempts
38
51
  while valid_questions < self.class.questions_per_round && attempts < max_attempts
39
52
  attempts += 1
40
-
53
+
41
54
  # Get a random commit
42
55
  commit = valid_commits.sample
43
56
  message = commit.message.strip
44
-
57
+
45
58
  # Split message into beginning and end parts
46
59
  words = message.split(/\s+/)
47
-
60
+
48
61
  # Skip if too few words
49
62
  if words.size < 4
50
63
  next
51
64
  end
52
-
65
+
53
66
  # Determine how much to hide (1/3 to 1/2 of words)
54
67
  hide_count = [words.size / 3, 2].max
55
68
  hide_start = rand(0..(words.size - hide_count))
56
69
  hidden_words = words[hide_start...(hide_start + hide_count)]
57
-
70
+
58
71
  # Replace hidden words with blanks
59
72
  words[hide_start...(hide_start + hide_count)] = ['________'] * hide_count
60
-
73
+
61
74
  # Create question and actual answer
62
75
  question_text = words.join(' ')
63
76
  correct_answer = hidden_words.join(' ')
64
-
77
+
65
78
  # Generate incorrect options
66
79
  other_messages = get_commit_messages(valid_commits) - [message]
67
80
  other_messages_parts = []
68
-
81
+
69
82
  # Get parts of other messages that have similar length
70
83
  other_messages.each do |other_msg|
71
84
  other_words = other_msg.split(/\s+/)
@@ -74,17 +87,17 @@ module GitGameShow
74
87
  other_messages_parts << other_words[part_start...(part_start + hide_count)].join(' ')
75
88
  end
76
89
  end
77
-
90
+
78
91
  # Only proceed if we have enough options
79
92
  if other_messages_parts.size < 3
80
93
  next
81
94
  end
82
-
95
+
83
96
  other_options = other_messages_parts.sample(3)
84
-
97
+
85
98
  # Create options array with the correct answer and incorrect ones
86
99
  all_options = ([correct_answer] + other_options).shuffle
87
-
100
+
88
101
  # Format consistently with other mini-games
89
102
  questions << {
90
103
  question: "Complete this commit message:\n\n \"#{question_text}\"",
@@ -92,22 +105,22 @@ module GitGameShow
92
105
  options: all_options,
93
106
  correct_answer: correct_answer
94
107
  }
95
-
108
+
96
109
  valid_questions += 1
97
110
  end
98
-
111
+
99
112
  # If we couldn't generate enough questions, fall back to sample questions
100
113
  if questions.size < self.class.questions_per_round
101
114
  return generate_sample_questions
102
115
  end
103
-
116
+
104
117
  questions
105
118
  end
106
-
119
+
107
120
  # Generate sample questions when there aren't enough commits
108
121
  def generate_sample_questions
109
122
  questions = []
110
-
123
+
111
124
  # Sample commit messages that are realistic
112
125
  sample_messages = [
113
126
  {
@@ -151,14 +164,14 @@ module GitGameShow
151
164
  date: "Mar 15, 2025"
152
165
  }
153
166
  ]
154
-
167
+
155
168
  # Create a question for each sample
156
169
  self.class.questions_per_round.times do |i|
157
170
  sample = sample_messages[i % sample_messages.size]
158
-
171
+
159
172
  # Create options array with the correct answer and incorrect ones
160
173
  all_options = ([sample[:missing_part]] + sample[:wrong_options]).shuffle
161
-
174
+
162
175
  # Format consistently with other mini-games
163
176
  questions << {
164
177
  question: "Complete this commit message:\n\n \"#{sample[:blank_text]}\"",
@@ -167,22 +180,22 @@ module GitGameShow
167
180
  correct_answer: sample[:missing_part]
168
181
  }
169
182
  end
170
-
183
+
171
184
  questions
172
185
  end
173
-
186
+
174
187
  def evaluate_answers(question, player_answers)
175
188
  results = {}
176
-
189
+
177
190
  player_answers.each do |player_name, answer_data|
178
191
  player_answer = answer_data[:answer]
179
192
  correct = player_answer == question[:correct_answer]
180
-
193
+
181
194
  points = 0
182
-
195
+
183
196
  if correct
184
197
  points = 10 # Base points for correct answer
185
-
198
+
186
199
  # Bonus points for fast answers, adjusted for 20-second time limit
187
200
  time_taken = answer_data[:time_taken] || 20
188
201
  if time_taken < 7 # Increased from 5 to 7 seconds
@@ -191,15 +204,15 @@ module GitGameShow
191
204
  points += 3
192
205
  end
193
206
  end
194
-
207
+
195
208
  results[player_name] = {
196
209
  answer: player_answer,
197
210
  correct: correct,
198
211
  points: points
199
212
  }
200
213
  end
201
-
214
+
202
215
  results
203
216
  end
204
217
  end
205
- end
218
+ end