mumble_game 1.0.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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mumble
4
+ module Screens
5
+ # Animated splash screen with MUMBLE logo
6
+ class Splash < Base
7
+ LOGO_SMALL = [
8
+ "╔╦╗╦ ╦╔╦╗╔╗ ╦ ╔═╗",
9
+ "║║║║ ║║║║╠╩╗║ ║╣ ",
10
+ "╩ ╩╚═╝╩ ╩╚═╝╩═╝╚═╝"
11
+ ].freeze
12
+
13
+ LOGO_LARGE = [
14
+ "███╗ ███╗██╗ ██╗███╗ ███╗██████╗ ██╗ ███████╗",
15
+ "████╗ ████║██║ ██║████╗ ████║██╔══██╗██║ ██╔════╝",
16
+ "██╔████╔██║██║ ██║██╔████╔██║██████╔╝██║ █████╗ ",
17
+ "██║╚██╔╝██║██║ ██║██║╚██╔╝██║██╔══██╗██║ ██╔══╝ ",
18
+ "██║ ╚═╝ ██║╚██████╔╝██║ ╚═╝ ██║██████╔╝███████╗███████╗",
19
+ "╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝╚══════╝"
20
+ ].freeze
21
+
22
+ COLORS = %i[red yellow green cyan blue magenta].freeze
23
+
24
+ def show
25
+ setup_screen
26
+ animate_logo
27
+ show_tagline
28
+ show_prompt
29
+ wait_for_key
30
+ cleanup_screen
31
+ end
32
+
33
+ private
34
+
35
+ def logo
36
+ medium_screen? ? LOGO_LARGE : LOGO_SMALL
37
+ end
38
+
39
+ def tagline
40
+ if large_screen?
41
+ "═══════════════ A Word Guessing Adventure ═══════════════"
42
+ elsif medium_screen?
43
+ "══════ A Word Guessing Adventure ══════"
44
+ else
45
+ "A Word Guessing Adventure"
46
+ end
47
+ end
48
+
49
+ def prompt
50
+ if medium_screen?
51
+ ">>> Press any key to continue <<<"
52
+ else
53
+ "Press any key to continue..."
54
+ end
55
+ end
56
+
57
+ def logo_start_row
58
+ center_row_for(logo.length + 8)
59
+ end
60
+
61
+ def logo_start_col
62
+ Screen.center_col(logo.first.length)
63
+ end
64
+
65
+ def animation_speed
66
+ case size_category
67
+ when :large then 0.18
68
+ when :medium then 0.15
69
+ else 0.12
70
+ end
71
+ end
72
+
73
+ def cycle_count
74
+ case size_category
75
+ when :large then 6
76
+ when :medium then 5
77
+ else 3
78
+ end
79
+ end
80
+
81
+ # Animate the logo appearing with color cycling
82
+ def animate_logo
83
+ # First pass: draw logo line by line with color cycling
84
+ logo.each_with_index do |line, index|
85
+ color = COLORS[index % COLORS.length]
86
+ Cursor.move_to(logo_start_row + index, logo_start_col)
87
+ print Colors.send(color, line)
88
+ pause(animation_speed)
89
+ end
90
+
91
+ # Second pass: cycle colors for effect
92
+ cycle_count.times do
93
+ COLORS.each do |color|
94
+ draw_logo_in_color(color)
95
+ pause(animation_speed)
96
+ end
97
+ end
98
+
99
+ # Final: settle on cyan
100
+ draw_logo_in_color(:cyan)
101
+ end
102
+
103
+ def draw_logo_in_color(color)
104
+ logo.each_with_index do |line, index|
105
+ Cursor.move_to(logo_start_row + index, logo_start_col)
106
+ print Colors.send(color, line)
107
+ end
108
+ end
109
+
110
+ def show_tagline
111
+ row = logo_start_row + logo.length + 2
112
+ draw_centered(row, tagline, color: :dim)
113
+ end
114
+
115
+ def show_prompt
116
+ row = logo_start_row + logo.length + 5
117
+
118
+ # Blinking effect for prompt
119
+ 3.times do
120
+ draw_centered(row, prompt, color: :dim)
121
+ pause(0.4)
122
+ draw_centered(row, " " * prompt.length)
123
+ pause(0.2)
124
+ end
125
+
126
+ draw_centered(row, prompt, color: :dim)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../scorer"
5
+ require_relative "../animations"
6
+
7
+ module Mumble
8
+ module Screens
9
+ # Victory celebration screen with flashing text
10
+ class Win < Base
11
+ YOU_WIN_SMALL = [
12
+ "╦ ╦╔═╗╦ ╦ ╦ ╦╦╔╗╔",
13
+ "╚╦╝║ ║║ ║ ║║║║║║║",
14
+ " ╩ ╚═╝╚═╝ ╚╩╝╩╝╚╝"
15
+ ].freeze
16
+
17
+ YOU_WIN_LARGE = [
18
+ "██╗ ██╗ ██████╗ ██╗ ██╗ ██╗ ██╗██╗███╗ ██╗",
19
+ "╚██╗ ██╔╝██╔═══██╗██║ ██║ ██║ ██║██║████╗ ██║",
20
+ " ╚████╔╝ ██║ ██║██║ ██║ ██║ █╗ ██║██║██╔██╗ ██║",
21
+ " ╚██╔╝ ██║ ██║██║ ██║ ██║███╗██║██║██║╚██╗██║",
22
+ " ██║ ╚██████╔╝╚██████╔╝ ╚███╔███╔╝██║██║ ╚████║",
23
+ " ╚═╝ ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═╝╚═╝ ╚═══╝"
24
+ ].freeze
25
+
26
+ def initialize(guesses:, time:, level:, word:)
27
+ super()
28
+ @guesses = guesses
29
+ @time = time
30
+ @level = level
31
+ @word = word
32
+ @breakdown = Scorer.breakdown(guesses: guesses, time: time, level: level)
33
+ end
34
+
35
+ def show
36
+ Cursor.hide
37
+ draw_screen
38
+ flash_you_win
39
+ Input.wait_for_any_key
40
+ @breakdown[:final_score]
41
+ end
42
+
43
+ private
44
+
45
+ def you_win_text
46
+ Layout.size_category == :large ? YOU_WIN_LARGE : YOU_WIN_SMALL
47
+ end
48
+
49
+ def text_start_row
50
+ (Screen.height - 30) / 2
51
+ end
52
+
53
+ def text_start_col
54
+ Screen.center_col(you_win_text.first.length)
55
+ end
56
+
57
+ def draw_screen
58
+ Cursor.clear_screen
59
+ draw_you_win_text(:green)
60
+ draw_content
61
+ end
62
+
63
+ def draw_you_win_text(color)
64
+ you_win_text.each_with_index do |line, index|
65
+ Cursor.move_to(text_start_row + index, text_start_col)
66
+ print Colors.send(color, line)
67
+ end
68
+ end
69
+
70
+ def flash_you_win
71
+ # Flash between green and cyan for celebration
72
+ 6.times do
73
+ draw_you_win_text(:green)
74
+ pause(0.2)
75
+ draw_you_win_text(:cyan)
76
+ pause(0.15)
77
+ draw_you_win_text(:yellow)
78
+ pause(0.15)
79
+ end
80
+ # End on solid green
81
+ draw_you_win_text(:green)
82
+ end
83
+
84
+ def draw_content
85
+ content_row = text_start_row + you_win_text.length + 2
86
+
87
+ # Celebration emoji line
88
+ draw_centered(content_row, "🎉 🏆 🎉", color: :yellow)
89
+
90
+ # Word revealed
91
+ draw_centered(content_row + 2, "The word was: #{@word}", color: :cyan)
92
+
93
+ # Score breakdown
94
+ draw_score_breakdown(content_row + 5)
95
+
96
+ # Rating
97
+ rating = Scorer.rating(@breakdown[:final_score])
98
+ rating_row = content_row + 14
99
+ draw_centered(rating_row, rating, color: :yellow)
100
+
101
+ # Continue prompt
102
+ draw_centered(rating_row + 3, "Press any key to continue...", color: :dim)
103
+ end
104
+
105
+ def draw_score_breakdown(start_row)
106
+ col = Screen.center_col(30)
107
+
108
+ draw_at(start_row, col, "━━━ SCORE BREAKDOWN ━━━", color: :cyan)
109
+
110
+ draw_at(start_row + 2, col, "Base Score:", color: :white)
111
+ draw_at(start_row + 2, col + 20, @breakdown[:base_score].to_s.rjust(6), color: :green)
112
+
113
+ draw_at(start_row + 3, col, "Guess Penalty:", color: :white)
114
+ draw_at(start_row + 3, col + 20, @breakdown[:guess_penalty].to_s.rjust(6), color: :red)
115
+
116
+ draw_at(start_row + 4, col, "Time Penalty:", color: :white)
117
+ draw_at(start_row + 4, col + 20, @breakdown[:time_penalty].to_s.rjust(6), color: :red)
118
+
119
+ if @breakdown[:level_multiplier] > 1.0
120
+ draw_at(start_row + 5, col, "Level #{@level} Bonus:", color: :white)
121
+ draw_at(start_row + 5, col + 20, "x#{@breakdown[:level_multiplier]}", color: :yellow)
122
+ end
123
+
124
+ draw_at(start_row + 6, col, "━━━━━━━━━━━━━━━━━━━━━━━", color: :cyan)
125
+ draw_at(start_row + 7, col, "FINAL SCORE:", color: :white)
126
+
127
+ # Animate score counting up
128
+ Animations.count_up(@breakdown[:final_score], duration: 1.5) do |value|
129
+ Cursor.move_to(start_row + 7, col + 20)
130
+ print Colors.green(value.to_s.rjust(6))
131
+ end
132
+ end
133
+
134
+ def pause(seconds)
135
+ sleep(seconds)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Mumble
7
+ # Base storage functionality for persistent data
8
+ # All data is stored in ~/.mumble/ directory
9
+ module Storage
10
+ # Directory where all Mumble data is stored
11
+ DATA_DIR = File.expand_path("~/.mumble")
12
+
13
+ class << self
14
+ # Ensure the data directory exists with proper permissions
15
+ def setup
16
+ return if Dir.exist?(DATA_DIR)
17
+
18
+ FileUtils.mkdir_p(DATA_DIR)
19
+ FileUtils.chmod(0o700, DATA_DIR)
20
+ end
21
+
22
+ # Get full path for a data file
23
+ def path_for(filename)
24
+ File.join(DATA_DIR, filename)
25
+ end
26
+
27
+ # Read JSON data from a file
28
+ # Returns parsed data or nil if file doesn't exist/is invalid
29
+ def read_json(filename)
30
+ filepath = path_for(filename)
31
+ return nil unless File.exist?(filepath)
32
+
33
+ data = File.read(filepath)
34
+ JSON.parse(data, symbolize_names: true)
35
+ rescue JSON::ParserError
36
+ # File is corrupted, return nil
37
+ nil
38
+ end
39
+
40
+ # Write data to a JSON file
41
+ # Creates file with secure permissions (owner read/write only)
42
+ def write_json(filename, data)
43
+ setup
44
+ filepath = path_for(filename)
45
+
46
+ File.write(filepath, JSON.pretty_generate(data))
47
+ FileUtils.chmod(0o600, filepath)
48
+ true
49
+ rescue StandardError
50
+ false
51
+ end
52
+
53
+ # Delete a data file
54
+ def delete_file(filename)
55
+ filepath = path_for(filename)
56
+ return false unless File.exist?(filepath)
57
+
58
+ File.delete(filepath)
59
+ true
60
+ rescue StandardError
61
+ false
62
+ end
63
+
64
+ # Check if a data file exists
65
+ def file_exists?(filename)
66
+ File.exist?(path_for(filename))
67
+ end
68
+
69
+ # Delete all Mumble data (for "Delete All Data" feature)
70
+ def delete_all_data
71
+ return false unless Dir.exist?(DATA_DIR)
72
+
73
+ FileUtils.rm_rf(DATA_DIR)
74
+ true
75
+ rescue StandardError
76
+ false
77
+ end
78
+
79
+ # Get the data directory path (for display to user)
80
+ def data_directory
81
+ DATA_DIR
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mumble
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "storage"
5
+
6
+ module Mumble
7
+ # Manages cached words fetched from the API
8
+ module WordCache
9
+ FILENAME = "word_cache.json"
10
+ CACHE_DURATION = 24 * 60 * 60 # 24 hours in seconds
11
+ MIN_WORDS = 20 # Minimum words before we should refresh
12
+
13
+ class << self
14
+ # Load word cache from file
15
+ def load
16
+ data = Storage.read_json(FILENAME)
17
+ return empty_cache if data.nil?
18
+
19
+ data
20
+ end
21
+
22
+ # Save word cache to file
23
+ def save(cache)
24
+ Storage.write_json(FILENAME, cache)
25
+ end
26
+
27
+ # Get all cached words
28
+ def words
29
+ load[:words] || []
30
+ end
31
+
32
+ # Check if cache exists and has words
33
+ def exists?
34
+ cache = load
35
+ !cache[:words].nil? && !cache[:words].empty?
36
+ end
37
+
38
+ # Check if cache is stale (older than 24 hours)
39
+ def stale?
40
+ cache = load
41
+ return true if cache[:fetched_at].nil?
42
+
43
+ fetched_time = Time.parse(cache[:fetched_at])
44
+ Time.now - fetched_time > CACHE_DURATION
45
+ end
46
+
47
+ # Check if cache is running low
48
+ def low?
49
+ words.length < MIN_WORDS
50
+ end
51
+
52
+ # Check if we need to fetch new words
53
+ def needs_refresh?
54
+ !exists? || stale? || low?
55
+ end
56
+
57
+ # Store new words (replaces existing cache)
58
+ def store(new_words, source: "api")
59
+ cache = {
60
+ words: new_words.map(&:downcase).uniq,
61
+ fetched_at: Time.now.iso8601,
62
+ source: source,
63
+ original_count: new_words.length
64
+ }
65
+ save(cache)
66
+ cache
67
+ end
68
+
69
+ # Add words to existing cache
70
+ def add(new_words)
71
+ cache = load
72
+ existing = cache[:words] || []
73
+ combined = (existing + new_words.map(&:downcase)).uniq
74
+
75
+ cache[:words] = combined
76
+ cache[:updated_at] = Time.now.iso8601
77
+ save(cache)
78
+ cache
79
+ end
80
+
81
+ # Get a random word and remove it from cache
82
+ def pick_word
83
+ cache = load
84
+ return nil if cache[:words].nil? || cache[:words].empty?
85
+
86
+ word = cache[:words].sample
87
+ cache[:words].delete(word)
88
+ save(cache)
89
+
90
+ word.upcase
91
+ end
92
+
93
+ # Get a random word without removing it (for testing)
94
+ def peek_word
95
+ cache = load
96
+ return nil if cache[:words].nil? || cache[:words].empty?
97
+
98
+ cache[:words].sample.upcase
99
+ end
100
+
101
+ # Get count of remaining words
102
+ def count
103
+ words.length
104
+ end
105
+
106
+ # Clear the cache
107
+ def clear
108
+ Storage.delete_file(FILENAME)
109
+ end
110
+
111
+ # Get cache info for debugging
112
+ def info
113
+ cache = load
114
+ {
115
+ count: cache[:words]&.length || 0,
116
+ source: cache[:source],
117
+ fetched_at: cache[:fetched_at],
118
+ stale: stale?,
119
+ low: low?
120
+ }
121
+ end
122
+
123
+ private
124
+
125
+ # Return empty cache structure
126
+ def empty_cache
127
+ { words: [], fetched_at: nil, source: nil }
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "openssl"
7
+ require_relative "word_cache"
8
+ require_relative "error_handler"
9
+
10
+ module Mumble
11
+ # Handles fetching words from APIs and managing the word supply
12
+ module WordService
13
+ # API endpoints
14
+ DATAMUSE_URL = "https://api.datamuse.com/words"
15
+ RANDOM_WORD_URL = "https://random-word-api.herokuapp.com/word"
16
+
17
+ # Configuration
18
+ WORD_LENGTH = 5
19
+ FETCH_COUNT = 500
20
+ REQUEST_TIMEOUT = 10
21
+
22
+ class << self
23
+ # Get the next word for gameplay
24
+ # Returns uppercase 5-letter word, or nil if unavailable
25
+ def next_word
26
+ ensure_words_available
27
+ WordCache.pick_word
28
+ end
29
+
30
+ # Ensure we have words available, fetching if needed
31
+ # Returns true if words are available, false if offline and no cache
32
+ def ensure_words_available
33
+ return true unless WordCache.needs_refresh?
34
+
35
+ fetch_words
36
+ end
37
+
38
+ # Check if we can provide words (have cache or can fetch)
39
+ def available?
40
+ WordCache.exists? || fetch_words
41
+ end
42
+
43
+ # Force a refresh of the word cache
44
+ def refresh
45
+ fetch_words
46
+ end
47
+
48
+ # Get status info for debugging/display
49
+ def status
50
+ {
51
+ words_available: WordCache.count,
52
+ cache_exists: WordCache.exists?,
53
+ needs_refresh: WordCache.needs_refresh?,
54
+ cache_info: WordCache.info
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ # Fetch words from APIs (tries primary, then backup)
61
+ def fetch_words
62
+ words = fetch_from_datamuse
63
+ words = fetch_from_random_word_api if words.empty?
64
+
65
+ return false if words.empty?
66
+
67
+ WordCache.store(words, source: "api")
68
+ true
69
+ end
70
+
71
+ # Fetch from Datamuse API (primary)
72
+ def fetch_from_datamuse
73
+ uri = URI(DATAMUSE_URL)
74
+ uri.query = URI.encode_www_form(
75
+ sp: "?" * WORD_LENGTH,
76
+ max: FETCH_COUNT
77
+ )
78
+
79
+ response = http_get(uri.to_s)
80
+ return [] if response.nil?
81
+
82
+ words = JSON.parse(response)
83
+ filter_words(words.map { |w| w["word"] })
84
+ rescue JSON::ParserError => e
85
+ ErrorHandler.log_error(e, "Datamuse JSON parse failed")
86
+ []
87
+ rescue StandardError => e
88
+ ErrorHandler.log_error(e, "Datamuse fetch failed")
89
+ []
90
+ end
91
+
92
+ # Fetch from Random Word API (backup)
93
+ def fetch_from_random_word_api
94
+ words = []
95
+
96
+ 50.times do
97
+ word_array = fetch_single_random_word
98
+ words.concat(word_array) if word_array
99
+ end
100
+
101
+ filter_words(words)
102
+ rescue StandardError => e
103
+ ErrorHandler.log_error(e, "Random Word API fetch failed")
104
+ []
105
+ end
106
+
107
+ # Fetch a single word from Random Word API
108
+ # Returns array of words or nil on failure
109
+ def fetch_single_random_word
110
+ uri = "#{RANDOM_WORD_URL}?length=#{WORD_LENGTH}"
111
+ response = http_get(uri)
112
+ return nil if response.nil?
113
+
114
+ word_array = JSON.parse(response)
115
+ word_array.is_a?(Array) ? word_array : nil
116
+ rescue StandardError
117
+ nil
118
+ end
119
+
120
+ # Make HTTP request with timeout and error handling
121
+ def http_get(url)
122
+ uri = URI.parse(url)
123
+
124
+ http = Net::HTTP.new(uri.host, uri.port)
125
+ http.use_ssl = (uri.scheme == "https")
126
+ http.open_timeout = REQUEST_TIMEOUT
127
+ http.read_timeout = REQUEST_TIMEOUT
128
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
129
+
130
+ request = Net::HTTP::Get.new(uri.request_uri)
131
+ response = http.request(request)
132
+
133
+ return nil unless response.is_a?(Net::HTTPSuccess)
134
+
135
+ response.body
136
+ rescue StandardError => e
137
+ handle_http_error(e, url)
138
+ end
139
+
140
+ # Handle HTTP errors based on type
141
+ def handle_http_error(error, url)
142
+ case error
143
+ when Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED, Errno::ENETUNREACH
144
+ # Network unavailable or timeout - expected, no logging needed
145
+ nil
146
+ when OpenSSL::SSL::SSLError
147
+ # SSL issues - try without verification as fallback
148
+ retry_without_ssl_verify(url)
149
+ else
150
+ # Unexpected error - log it
151
+ ErrorHandler.log_error(error, "HTTP request failed: #{url}")
152
+ nil
153
+ end
154
+ end
155
+
156
+ # Retry HTTP request without SSL verification (fallback for cert issues)
157
+ def retry_without_ssl_verify(url)
158
+ uri = URI.parse(url)
159
+
160
+ http = Net::HTTP.new(uri.host, uri.port)
161
+ http.use_ssl = (uri.scheme == "https")
162
+ http.open_timeout = REQUEST_TIMEOUT
163
+ http.read_timeout = REQUEST_TIMEOUT
164
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
165
+
166
+ request = Net::HTTP::Get.new(uri.request_uri)
167
+ response = http.request(request)
168
+
169
+ response.is_a?(Net::HTTPSuccess) ? response.body : nil
170
+ rescue StandardError
171
+ nil
172
+ end
173
+
174
+ # Filter words to ensure they're valid for the game
175
+ def filter_words(words)
176
+ words
177
+ .map(&:downcase)
178
+ .select { |w| valid_word?(w) }
179
+ .uniq
180
+ end
181
+
182
+ # Check if a word is valid for the game
183
+ def valid_word?(word)
184
+ return false unless word.is_a?(String)
185
+ return false unless word.length == WORD_LENGTH
186
+ return false unless word.match?(/\A[a-z]+\z/)
187
+
188
+ true
189
+ end
190
+ end
191
+ end
192
+ end