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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +130 -0
- data/bin/mumble +6 -0
- data/lib/mumble/animations.rb +112 -0
- data/lib/mumble/box.rb +139 -0
- data/lib/mumble/colors.rb +83 -0
- data/lib/mumble/config.rb +172 -0
- data/lib/mumble/cursor.rb +82 -0
- data/lib/mumble/error_handler.rb +74 -0
- data/lib/mumble/grid.rb +182 -0
- data/lib/mumble/hangman.rb +440 -0
- data/lib/mumble/high_scores.rb +109 -0
- data/lib/mumble/input.rb +143 -0
- data/lib/mumble/layout.rb +208 -0
- data/lib/mumble/scorer.rb +78 -0
- data/lib/mumble/screen.rb +61 -0
- data/lib/mumble/screens/base.rb +142 -0
- data/lib/mumble/screens/gameplay.rb +433 -0
- data/lib/mumble/screens/high_scores.rb +126 -0
- data/lib/mumble/screens/lose.rb +108 -0
- data/lib/mumble/screens/main_menu.rb +130 -0
- data/lib/mumble/screens/name_input.rb +121 -0
- data/lib/mumble/screens/play_again.rb +97 -0
- data/lib/mumble/screens/profile.rb +154 -0
- data/lib/mumble/screens/quit_confirm.rb +103 -0
- data/lib/mumble/screens/rules.rb +102 -0
- data/lib/mumble/screens/splash.rb +130 -0
- data/lib/mumble/screens/win.rb +139 -0
- data/lib/mumble/storage.rb +85 -0
- data/lib/mumble/version.rb +5 -0
- data/lib/mumble/word_cache.rb +131 -0
- data/lib/mumble/word_service.rb +192 -0
- data/lib/mumble.rb +340 -0
- metadata +137 -0
|
@@ -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,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
|