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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "storage"
4
+ require_relative "error_handler"
5
+
6
+ module Mumble
7
+ # Manages player profile, preferences, and statistics
8
+ module Config
9
+ FILENAME = "config.json"
10
+
11
+ # Default values for a new player
12
+ DEFAULTS = {
13
+ player_name: "Player",
14
+ created_at: nil,
15
+ updated_at: nil,
16
+ preferences: {
17
+ skip_rules: false,
18
+ show_timer: true
19
+ },
20
+ stats: {
21
+ games_played: 0,
22
+ games_won: 0,
23
+ current_streak: 0,
24
+ best_streak: 0,
25
+ highest_level: 0,
26
+ total_score: 0
27
+ }
28
+ }.freeze
29
+
30
+ class << self
31
+ # Load config with corruption recovery
32
+ def load
33
+ return create_new_config unless exists?
34
+
35
+ data = Storage.read_json(FILENAME)
36
+
37
+ # Validate the loaded data
38
+ unless valid_config?(data)
39
+ backup_corrupted_config
40
+ return create_new_config
41
+ end
42
+
43
+ merge_with_defaults(data)
44
+ rescue StandardError => e
45
+ ErrorHandler.log_error(e, "Config load failed")
46
+ backup_corrupted_config
47
+ create_new_config
48
+ end
49
+
50
+ # Check if config data is valid
51
+ def valid_config?(data)
52
+ return false unless data.is_a?(Hash)
53
+ return false unless data[:player_name].is_a?(String)
54
+ return false unless data[:stats].is_a?(Hash)
55
+
56
+ true
57
+ end
58
+
59
+ # Backup corrupted config before replacing
60
+ def backup_corrupted_config
61
+ return unless exists?
62
+
63
+ backup_name = "config.backup.#{Time.now.to_i}.json"
64
+ Storage.write_json(backup_name, Storage.read_json(FILENAME))
65
+ rescue StandardError
66
+ # If backup fails, just continue
67
+ nil
68
+ end
69
+
70
+ # Save config to file
71
+ def save(config)
72
+ config[:updated_at] = Time.now.iso8601
73
+ Storage.write_json(FILENAME, config)
74
+ end
75
+
76
+ # Check if a config file exists (returning player vs new player)
77
+ def exists?
78
+ Storage.file_exists?(FILENAME)
79
+ end
80
+
81
+ # Create a new config with defaults
82
+ def create_new_config
83
+ config = deep_copy(DEFAULTS)
84
+ config[:created_at] = Time.now.iso8601
85
+ config[:updated_at] = Time.now.iso8601
86
+ config
87
+ end
88
+
89
+ # Update player name
90
+ def update_name(config, new_name)
91
+ config[:player_name] = sanitize_name(new_name)
92
+ save(config)
93
+ config
94
+ end
95
+
96
+ # Update a preference
97
+ def update_preference(config, key, value)
98
+ config[:preferences][key] = value
99
+ save(config)
100
+ config
101
+ end
102
+
103
+ # Record a game result
104
+ def record_game(config, won:, score:, level:)
105
+ stats = config[:stats]
106
+
107
+ stats[:games_played] += 1
108
+
109
+ if won
110
+ stats[:games_won] += 1
111
+ stats[:current_streak] += 1
112
+ stats[:best_streak] = [stats[:best_streak], stats[:current_streak]].max
113
+ stats[:total_score] += score
114
+ else
115
+ stats[:current_streak] = 0
116
+ end
117
+
118
+ stats[:highest_level] = [stats[:highest_level], level].max
119
+
120
+ save(config)
121
+ config
122
+ end
123
+
124
+ # Reset all statistics
125
+ def reset_stats(config)
126
+ config[:stats] = deep_copy(DEFAULTS[:stats])
127
+ save(config)
128
+ config
129
+ end
130
+
131
+ # Delete all config data
132
+ def delete
133
+ Storage.delete_file(FILENAME)
134
+ end
135
+
136
+ # Calculate win rate as a percentage
137
+ def win_rate(config)
138
+ played = config[:stats][:games_played]
139
+ return 0 if played.zero?
140
+
141
+ ((config[:stats][:games_won].to_f / played) * 100).round
142
+ end
143
+
144
+ private
145
+
146
+ # Sanitize player name
147
+ def sanitize_name(name)
148
+ sanitized = name.to_s.strip[0, 20]
149
+ sanitized.empty? ? "Player" : sanitized
150
+ end
151
+
152
+ # Deep copy a hash (so we don't modify DEFAULTS)
153
+ def deep_copy(hash)
154
+ JSON.parse(JSON.generate(hash), symbolize_names: true)
155
+ end
156
+
157
+ # Merge loaded data with defaults (handles missing keys)
158
+ def merge_with_defaults(data)
159
+ config = deep_copy(DEFAULTS)
160
+
161
+ config[:player_name] = data[:player_name] if data[:player_name]
162
+ config[:created_at] = data[:created_at] if data[:created_at]
163
+ config[:updated_at] = data[:updated_at] if data[:updated_at]
164
+
165
+ config[:preferences].merge!(data[:preferences]) if data[:preferences]
166
+ config[:stats].merge!(data[:stats]) if data[:stats]
167
+
168
+ config
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-cursor"
4
+
5
+ module Mumble
6
+ # Provides cursor control for terminal manipulation
7
+ # Uses TTY::Cursor for cross-platform support
8
+ module Cursor
9
+ class << self
10
+ # Memoized cursor instance
11
+ def cursor
12
+ @cursor ||= TTY::Cursor
13
+ end
14
+
15
+ # Move cursor to specific row and column (1-indexed)
16
+ def move_to(row, col)
17
+ print cursor.move_to(col - 1, row - 1)
18
+ end
19
+
20
+ # Move cursor up by n rows
21
+ def up(n = 1)
22
+ print cursor.up(n)
23
+ end
24
+
25
+ # Move cursor down by n rows
26
+ def down(n = 1)
27
+ print cursor.down(n)
28
+ end
29
+
30
+ # Move cursor left by n columns
31
+ def left(n = 1)
32
+ print cursor.backward(n)
33
+ end
34
+
35
+ # Move cursor right by n columns
36
+ def right(n = 1)
37
+ print cursor.forward(n)
38
+ end
39
+
40
+ # Move cursor to beginning of current line
41
+ def line_start
42
+ print cursor.column(0)
43
+ end
44
+
45
+ # Hide cursor (useful during animations)
46
+ def hide
47
+ print cursor.hide
48
+ end
49
+
50
+ # Show cursor (restore after hiding)
51
+ def show
52
+ print cursor.show
53
+ end
54
+
55
+ # Clear entire screen and move to top-left
56
+ def clear_screen
57
+ print cursor.clear_screen
58
+ print cursor.move_to(0, 0)
59
+ end
60
+
61
+ # Clear from cursor to end of line
62
+ def clear_line
63
+ print cursor.clear_line
64
+ end
65
+
66
+ # Clear from cursor to end of screen
67
+ def clear_below
68
+ print cursor.clear_screen_down
69
+ end
70
+
71
+ # Save current cursor position
72
+ def save_position
73
+ print cursor.save
74
+ end
75
+
76
+ # Restore previously saved cursor position
77
+ def restore_position
78
+ print cursor.restore
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mumble
4
+ # Centralized error handling for graceful failures
5
+ module ErrorHandler
6
+ class << self
7
+ # Wrap any operation that might fail
8
+ def safely(fallback: nil, message: nil)
9
+ yield
10
+ rescue StandardError => e
11
+ log_error(e, message)
12
+ fallback
13
+ end
14
+
15
+ # Handle fatal errors with user-friendly message
16
+ def fatal(error, context: nil)
17
+ Cursor.clear_screen
18
+ Cursor.show
19
+ Cursor.move_to(1, 1)
20
+
21
+ puts Colors.red("╔═══════════════════════════════════════╗")
22
+ puts Colors.red("║ Oops! Something broke ║")
23
+ puts Colors.red("╚═══════════════════════════════════════╝")
24
+ puts ""
25
+ puts Colors.yellow("Context: #{context}") if context
26
+ puts Colors.dim("Error: #{error.message}")
27
+ puts ""
28
+ puts Colors.white("Please try restarting the game.")
29
+ puts Colors.dim("If this keeps happening, try deleting ~/.mumble/")
30
+ puts ""
31
+
32
+ log_error(error, context)
33
+
34
+ exit(1)
35
+ end
36
+
37
+ # Log error to file for debugging
38
+ def log_error(error, context = nil)
39
+ log_file = File.join(Storage.data_dir, "error.log")
40
+
41
+ File.open(log_file, "a") do |f|
42
+ f.puts "=" * 50
43
+ f.puts "Time: #{Time.now}"
44
+ f.puts "Context: #{context}" if context
45
+ f.puts "Error: #{error.class}: #{error.message}"
46
+ f.puts "Backtrace:"
47
+ error.backtrace&.first(10)&.each { |line| f.puts " #{line}" }
48
+ f.puts ""
49
+ end
50
+ rescue StandardError
51
+ # If we can't even log, just continue
52
+ nil
53
+ end
54
+
55
+ # Check if terminal is still valid size
56
+ def check_terminal_size!
57
+ return if Screen.valid_size?
58
+
59
+ Cursor.clear_screen
60
+ Cursor.show
61
+ Cursor.move_to(1, 1)
62
+
63
+ puts Colors.yellow("Terminal too small!")
64
+ puts ""
65
+ puts "Current size: #{Screen.width}x#{Screen.height}"
66
+ puts "Required: #{Screen::MIN_WIDTH}x#{Screen::MIN_HEIGHT}"
67
+ puts ""
68
+ puts Colors.dim("Please resize your terminal and restart.")
69
+
70
+ exit(1)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "layout"
4
+ require_relative "cursor"
5
+ require_relative "colors"
6
+
7
+ module Mumble
8
+ # The 5x6 letter grid display with size variants
9
+ module Grid
10
+ # Letter states for coloring
11
+ STATE_EMPTY = :empty
12
+ STATE_TYPING = :typing # Currently being typed (white)
13
+ STATE_CORRECT = :correct # Right letter, right position (green)
14
+ STATE_PRESENT = :present # Right letter, wrong position (orange)
15
+ STATE_ABSENT = :absent # Letter not in word (red)
16
+
17
+ # Small grid dimensions (for smaller terminals)
18
+ SMALL = {
19
+ cell_width: 5,
20
+ cell_height: 3,
21
+ h_gap: 1,
22
+ v_gap: 1
23
+ }.freeze
24
+
25
+ # Large grid dimensions (for larger terminals)
26
+ LARGE = {
27
+ cell_width: 7,
28
+ cell_height: 5,
29
+ h_gap: 2,
30
+ v_gap: 1
31
+ }.freeze
32
+
33
+ class << self
34
+ # Get dimensions based on terminal size
35
+ def dimensions
36
+ case Layout.size_category
37
+ when :large
38
+ LARGE
39
+ else
40
+ SMALL
41
+ end
42
+ end
43
+
44
+ # Total width of the entire grid
45
+ def total_width
46
+ dims = dimensions
47
+ (dims[:cell_width] * 5) + (dims[:h_gap] * 4)
48
+ end
49
+
50
+ # Total height of the entire grid
51
+ def total_height
52
+ dims = dimensions
53
+ (dims[:cell_height] * 6) + (dims[:v_gap] * 5)
54
+ end
55
+
56
+ # Draw the entire grid
57
+ # guesses: array of up to 6 guesses, each guess is array of {letter:, state:}
58
+ # current_row: which row is active (0-5)
59
+ # current_input: string being typed in current row
60
+ def draw(row:, col:, guesses: [], current_row: 0, current_input: "")
61
+ 6.times do |row_index|
62
+ draw_row(
63
+ row: row + (row_index * (dimensions[:cell_height] + dimensions[:v_gap])),
64
+ col: col,
65
+ letters: guesses[row_index] || [],
66
+ is_current: row_index == current_row,
67
+ current_input: row_index == current_row ? current_input : ""
68
+ )
69
+ end
70
+ end
71
+
72
+ # Draw a single row of 5 cells
73
+ def draw_row(row:, col:, letters:, is_current: false, current_input: "")
74
+ 5.times do |col_index|
75
+ cell_col = col + (col_index * (dimensions[:cell_width] + dimensions[:h_gap]))
76
+
77
+ # Determine what to show in this cell
78
+ if letters[col_index]
79
+ # Already guessed - show letter with state color
80
+ letter = letters[col_index][:letter]
81
+ state = letters[col_index][:state]
82
+ elsif is_current && current_input[col_index]
83
+ # Currently typing - show input letter
84
+ letter = current_input[col_index]
85
+ state = STATE_TYPING
86
+ else
87
+ # Empty cell
88
+ letter = nil
89
+ state = STATE_EMPTY
90
+ end
91
+
92
+ draw_cell(row: row, col: cell_col, letter: letter, state: state)
93
+ end
94
+ end
95
+
96
+ # Draw a single cell with letter
97
+ def draw_cell(row:, col:, letter:, state:)
98
+ dims = dimensions
99
+ color = color_for_state(state)
100
+
101
+ if dims[:cell_height] == 3
102
+ draw_cell_small(row: row, col: col, letter: letter, color: color)
103
+ else
104
+ draw_cell_large(row: row, col: col, letter: letter, color: color)
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ # Small cell (3 lines tall, 5 wide)
111
+ # ┌───┐
112
+ # │ A │
113
+ # └───┘
114
+ def draw_cell_small(row:, col:, letter:, color:)
115
+ display_letter = letter&.upcase || " "
116
+
117
+ # Top border
118
+ Cursor.move_to(row, col)
119
+ print colorize("┌───┐", color)
120
+
121
+ # Middle with letter
122
+ Cursor.move_to(row + 1, col)
123
+ print colorize("│ #{display_letter} │", color)
124
+
125
+ # Bottom border
126
+ Cursor.move_to(row + 2, col)
127
+ print colorize("└───┘", color)
128
+ end
129
+
130
+ # Large cell (5 lines tall, 7 wide)
131
+ # ┌─────┐
132
+ # │ │
133
+ # │ A │
134
+ # │ │
135
+ # └─────┘
136
+ def draw_cell_large(row:, col:, letter:, color:)
137
+ display_letter = letter&.upcase || " "
138
+
139
+ # Top border
140
+ Cursor.move_to(row, col)
141
+ print colorize("┌─────┐", color)
142
+
143
+ # Empty line
144
+ Cursor.move_to(row + 1, col)
145
+ print colorize("│ │", color)
146
+
147
+ # Middle with letter
148
+ Cursor.move_to(row + 2, col)
149
+ print colorize("│ #{display_letter} │", color)
150
+
151
+ # Empty line
152
+ Cursor.move_to(row + 3, col)
153
+ print colorize("│ │", color)
154
+
155
+ # Bottom border
156
+ Cursor.move_to(row + 4, col)
157
+ print colorize("└─────┘", color)
158
+ end
159
+
160
+ # Get color method name for state
161
+ def color_for_state(state)
162
+ case state
163
+ when STATE_CORRECT
164
+ :green
165
+ when STATE_PRESENT
166
+ :orange
167
+ when STATE_ABSENT
168
+ :red
169
+ when STATE_TYPING
170
+ :white
171
+ else
172
+ :dim
173
+ end
174
+ end
175
+
176
+ # Apply color to text
177
+ def colorize(text, color)
178
+ Colors.send(color, text)
179
+ end
180
+ end
181
+ end
182
+ end