upwords 0.1.1
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/.gitignore +12 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +250 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/data/ospd.txt +79339 -0
- data/exe/upwords +5 -0
- data/lib/upwords.rb +48 -0
- data/lib/upwords/board.rb +131 -0
- data/lib/upwords/cursor.rb +39 -0
- data/lib/upwords/dictionary.rb +31 -0
- data/lib/upwords/game.rb +325 -0
- data/lib/upwords/graphics.rb +149 -0
- data/lib/upwords/letter_bank.rb +22 -0
- data/lib/upwords/letter_rack.rb +51 -0
- data/lib/upwords/move.rb +98 -0
- data/lib/upwords/move_manager.rb +64 -0
- data/lib/upwords/player.rb +159 -0
- data/lib/upwords/shape.rb +98 -0
- data/lib/upwords/version.rb +3 -0
- data/lib/upwords/word.rb +50 -0
- data/upwords.gemspec +28 -0
- metadata +158 -0
@@ -0,0 +1,149 @@
|
|
1
|
+
# require 'colored'
|
2
|
+
|
3
|
+
module Upwords
|
4
|
+
class Graphics < Curses::Window
|
5
|
+
|
6
|
+
def initialize(game)
|
7
|
+
super(0,0,0,0)
|
8
|
+
@game = game
|
9
|
+
@board = @game.board
|
10
|
+
@cursor = @game.cursor
|
11
|
+
@message = ''
|
12
|
+
@rack_visibility = false
|
13
|
+
end
|
14
|
+
|
15
|
+
def refresh
|
16
|
+
clear
|
17
|
+
self << self.to_s
|
18
|
+
super
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_s
|
22
|
+
(draw_board + draw_message).zip(draw_stats).map do |board_row, stats_row|
|
23
|
+
(board_row.to_s) + (stats_row.to_s)
|
24
|
+
end.join("\n")
|
25
|
+
end
|
26
|
+
|
27
|
+
def message=(new_message)
|
28
|
+
if new_message.nil?
|
29
|
+
@message = ""
|
30
|
+
else
|
31
|
+
@message = new_message
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def show_rack
|
36
|
+
@rack_visibility = true
|
37
|
+
end
|
38
|
+
|
39
|
+
def hide_rack
|
40
|
+
@rack_visibility = false
|
41
|
+
end
|
42
|
+
|
43
|
+
def toggle_rack_visibility
|
44
|
+
@rack_visibility = !@rack_visibility
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def format_letter(row, col)
|
50
|
+
letter = @board.top_letter(row, col)
|
51
|
+
if letter.nil?
|
52
|
+
letter = " "
|
53
|
+
# add blank space after all other letters except Qu
|
54
|
+
elsif letter != "Qu"
|
55
|
+
letter += " "
|
56
|
+
end
|
57
|
+
letter
|
58
|
+
end
|
59
|
+
|
60
|
+
def draw_player_name
|
61
|
+
"#{@game.current_player.name}'s turn"
|
62
|
+
end
|
63
|
+
|
64
|
+
def draw_score(player)
|
65
|
+
"#{player.name}'s Score: #{player.score}"
|
66
|
+
end
|
67
|
+
|
68
|
+
def draw_last_turn(player)
|
69
|
+
"Last Move: #{player.last_turn}"
|
70
|
+
end
|
71
|
+
|
72
|
+
def draw_letter_rack
|
73
|
+
"Letters: #{@game.current_player.show_rack(!@rack_visibility)} "
|
74
|
+
end
|
75
|
+
|
76
|
+
def draw_space(row, col, cursor_posn)
|
77
|
+
cursor = cursor_posn == [row, col] ? "*" : " "
|
78
|
+
"#{cursor}#{format_letter(row, col)} "
|
79
|
+
end
|
80
|
+
|
81
|
+
def draw_row(row, cursor_posn)
|
82
|
+
["|",
|
83
|
+
(0...@board.num_columns).map do |col|
|
84
|
+
draw_space(row, col, cursor_posn)
|
85
|
+
end.join("|"),
|
86
|
+
"|"].join
|
87
|
+
end
|
88
|
+
|
89
|
+
# This will also display the stack height for now
|
90
|
+
def draw_divider(row, col, show_height=true)
|
91
|
+
height = @board.stack_height(row, col)
|
92
|
+
if show_height && height > 0
|
93
|
+
height = height.to_s
|
94
|
+
else
|
95
|
+
height = "-"
|
96
|
+
end
|
97
|
+
"---#{height}"
|
98
|
+
end
|
99
|
+
|
100
|
+
def draw_row_divider(row, show_height=true)
|
101
|
+
["+",
|
102
|
+
(0...@board.num_columns).map do |col|
|
103
|
+
draw_divider(row, col, show_height)
|
104
|
+
end.join("+"),
|
105
|
+
"+"].join
|
106
|
+
end
|
107
|
+
|
108
|
+
# print grid of top letter on each stack and stack height
|
109
|
+
def draw_board
|
110
|
+
b = [draw_row_divider(0, false)]
|
111
|
+
|
112
|
+
(0...@board.num_rows).each do |i|
|
113
|
+
b << draw_row(i, @cursor.posn)
|
114
|
+
b << draw_row_divider(i)
|
115
|
+
end
|
116
|
+
|
117
|
+
b.to_a
|
118
|
+
end
|
119
|
+
|
120
|
+
def draw_message
|
121
|
+
["", @message.to_s]
|
122
|
+
end
|
123
|
+
|
124
|
+
def draw_stats
|
125
|
+
["--------------------",
|
126
|
+
draw_score(@game.players[0]),
|
127
|
+
"",
|
128
|
+
draw_letter_rack,
|
129
|
+
"--------------------",
|
130
|
+
"",
|
131
|
+
@game.player_count > 1 ?draw_score(@game.players[1]) : "",
|
132
|
+
"",
|
133
|
+
@game.player_count > 2 ? draw_score(@game.players[2]) : "",
|
134
|
+
"",
|
135
|
+
@game.player_count > 3 ? draw_score(@game.players[3]) : "",
|
136
|
+
"",
|
137
|
+
"----------------------",
|
138
|
+
"| Controls |",
|
139
|
+
"----------------------",
|
140
|
+
"Show Letters [SPACE]",
|
141
|
+
"Undo Last Move [DEL]",
|
142
|
+
"Submit Move [ENTER]",
|
143
|
+
"Swap Letter [+]",
|
144
|
+
"Skip Turn [-]",
|
145
|
+
"Quit Game [SHIFT+Q]"].map{|s| " #{s}"} # Left padding
|
146
|
+
end
|
147
|
+
|
148
|
+
end
|
149
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Upwords
|
2
|
+
class LetterBank
|
3
|
+
|
4
|
+
def initialize(letters=[])
|
5
|
+
@bank = letters.dup
|
6
|
+
end
|
7
|
+
|
8
|
+
def empty?
|
9
|
+
@bank.empty?
|
10
|
+
end
|
11
|
+
|
12
|
+
def draw
|
13
|
+
unless self.empty?
|
14
|
+
@bank.delete_at(rand(@bank.size))
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def deposit(letter)
|
19
|
+
@bank << letter
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Upwords
|
2
|
+
class LetterRack
|
3
|
+
|
4
|
+
attr_reader :capacity, :letters
|
5
|
+
|
6
|
+
def initialize(capacity=7)
|
7
|
+
@letters = []
|
8
|
+
@capacity = capacity
|
9
|
+
end
|
10
|
+
|
11
|
+
def size
|
12
|
+
@letters.size
|
13
|
+
end
|
14
|
+
|
15
|
+
def full?
|
16
|
+
size == capacity
|
17
|
+
end
|
18
|
+
|
19
|
+
def empty?
|
20
|
+
@letters.empty?
|
21
|
+
end
|
22
|
+
|
23
|
+
def has_letter?(letter)
|
24
|
+
@letters.include? letter
|
25
|
+
end
|
26
|
+
|
27
|
+
def add(letter)
|
28
|
+
if full?
|
29
|
+
raise IllegalMove, "Rack is full!"
|
30
|
+
else
|
31
|
+
@letters << letter
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def remove(letter)
|
36
|
+
if has_letter?(letter)
|
37
|
+
@letters.delete_at(@letters.index(letter))
|
38
|
+
else
|
39
|
+
raise IllegalMove, "You don't have this letter!"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def show
|
44
|
+
@letters.join(' ')
|
45
|
+
end
|
46
|
+
|
47
|
+
def show_masked
|
48
|
+
@letters.map {'*'}.join(' ')
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/lib/upwords/move.rb
ADDED
@@ -0,0 +1,98 @@
|
|
1
|
+
module Upwords
|
2
|
+
class Move
|
3
|
+
|
4
|
+
def initialize(tiles = [])
|
5
|
+
@shape = Shape.new(tiles.map {|(row, col), letter| [row, col]})
|
6
|
+
@move = tiles.to_h
|
7
|
+
end
|
8
|
+
|
9
|
+
# TODO: remove dict from word class
|
10
|
+
# TODO: move score and new word methods to board class?
|
11
|
+
def score(board, player)
|
12
|
+
new_words(board).reduce(player.rack_capacity == @move.size ? 20 : 0) do |total, word|
|
13
|
+
total += word.score
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# TODO: Add the following legal move checks:
|
18
|
+
# - Move is not a simple pluralization? (e.g. Cat -> Cats is NOT a legal move)
|
19
|
+
def legal?(board, dict, raise_exception = false)
|
20
|
+
legal_shape?(board, raise_exception) && legal_words?(board, dict, raise_exception)
|
21
|
+
end
|
22
|
+
|
23
|
+
def legal_shape?(board, raise_exception = false)
|
24
|
+
@shape.legal?(board, raise_exception)
|
25
|
+
end
|
26
|
+
|
27
|
+
def can_play_letters?(board, raise_exception = false)
|
28
|
+
@move.all? do |(row, col), letter|
|
29
|
+
board.can_play_letter?(letter, row, col, raise_exception)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# TODO: Add the following legal move checks:
|
34
|
+
# - Move is not a simple pluralization? (e.g. Cat -> Cats is NOT a legal move)
|
35
|
+
def legal_words?(board, dict, raise_exception = false)
|
36
|
+
|
37
|
+
if can_play_letters?(board, raise_exception)
|
38
|
+
bad_words = self.new_illegal_words(board, dict)
|
39
|
+
if bad_words.empty?
|
40
|
+
return true
|
41
|
+
else
|
42
|
+
raise IllegalMove, "#{bad_words.join(', ')} #{bad_words.size==1 ? 'is not a legal word' : 'are not legal words'}!" if raise_exception
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
return false
|
47
|
+
end
|
48
|
+
|
49
|
+
def position?(row, col)
|
50
|
+
@move.key?([row, col])
|
51
|
+
end
|
52
|
+
|
53
|
+
def [](row, col)
|
54
|
+
@move[[row, col]]
|
55
|
+
end
|
56
|
+
|
57
|
+
def play(board)
|
58
|
+
@move.reduce(board) do |b, (posn, letter)|
|
59
|
+
b.play_letter(letter, *posn)
|
60
|
+
b
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# TODO: consider move main subroutine to Shape class?
|
65
|
+
def remove_from(board)
|
66
|
+
if @move.any? {|(row, col), letter| board.top_letter(row, col) != letter}
|
67
|
+
raise IllegalMove, "Move does not exist on board and therefore cannot be removed!"
|
68
|
+
else
|
69
|
+
(@move.each_key).reduce(board) do |b, posn|
|
70
|
+
b.remove_top_letter(*posn)
|
71
|
+
b
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# TODO: handle exceptions when board cannot be updated with new move
|
77
|
+
# TODO: move score and new word methods to board class?
|
78
|
+
def new_words(board)
|
79
|
+
# HACK: update board with new move
|
80
|
+
words = (board.play_move(self).word_positions).select do |word_posns|
|
81
|
+
word_posns.any? {|row, col| position?(row, col)}
|
82
|
+
|
83
|
+
end.map do |word_posns|
|
84
|
+
Word.new(word_posns, board)
|
85
|
+
end
|
86
|
+
|
87
|
+
# HACK: remove move from board
|
88
|
+
remove_from(board)
|
89
|
+
|
90
|
+
words
|
91
|
+
end
|
92
|
+
|
93
|
+
def new_illegal_words(board, dict)
|
94
|
+
new_words(board).reject {|word| dict.legal_word?(word.to_s)}
|
95
|
+
end
|
96
|
+
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module Upwords
|
2
|
+
class MoveManager
|
3
|
+
|
4
|
+
def initialize(board, dictionary)
|
5
|
+
@board = board
|
6
|
+
@dict = dictionary
|
7
|
+
@pending_move = []
|
8
|
+
@move_history = [] # TODO: Add filled board spaces as first move if board is not empty
|
9
|
+
end
|
10
|
+
|
11
|
+
# --------------------------------
|
12
|
+
# Player-Board Interaction Methods
|
13
|
+
# --------------------------------
|
14
|
+
def add(player, letter, row, col)
|
15
|
+
# TODO: remove the need for @pending_move.map
|
16
|
+
if (@pending_move.map {|m| m[0]}).include?([row, col])
|
17
|
+
raise IllegalMove, "You can't stack on a space more than once in a single turn!"
|
18
|
+
elsif
|
19
|
+
@pending_move << player.play_letter(@board, letter, row, col)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def undo_last(player)
|
24
|
+
if @pending_move.empty?
|
25
|
+
raise IllegalMove, "No moves to undo!"
|
26
|
+
else
|
27
|
+
player.take_from(@board, *@pending_move.pop[0]) # TODO: make Tile class
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def undo_all(player)
|
32
|
+
until @pending_move.empty? do
|
33
|
+
undo_last(player)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def submit(player)
|
38
|
+
if @pending_move.empty?
|
39
|
+
raise IllegalMove, "You haven't played any letters!"
|
40
|
+
elsif legal?
|
41
|
+
player.score += pending_score(player)
|
42
|
+
@move_history << Move.new(@pending_move)
|
43
|
+
@pending_move.clear
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# TODO: cache prev board in local variable...
|
48
|
+
def pending_words
|
49
|
+
prev_board = Board.build(@move_history, @board.size, @board.max_height)
|
50
|
+
Move.new(@pending_move).new_words(prev_board)
|
51
|
+
end
|
52
|
+
|
53
|
+
def pending_score(player)
|
54
|
+
prev_board = Board.build(@move_history, @board.size, @board.max_height)
|
55
|
+
Move.new(@pending_move).score(prev_board, player)
|
56
|
+
end
|
57
|
+
|
58
|
+
def legal?
|
59
|
+
prev_board = Board.build(@move_history, @board.size, @board.max_height)
|
60
|
+
Move.new(@pending_move).legal?(prev_board, @dict, raise_exception = true)
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,159 @@
|
|
1
|
+
module Upwords
|
2
|
+
class Player
|
3
|
+
|
4
|
+
attr_reader :name, :cpu
|
5
|
+
attr_accessor :score, :last_turn
|
6
|
+
|
7
|
+
def initialize(name, rack_capacity=7, cpu=false)
|
8
|
+
@name = name
|
9
|
+
@rack = LetterRack.new(rack_capacity)
|
10
|
+
@score = 0
|
11
|
+
@last_turn = nil
|
12
|
+
@cpu = cpu
|
13
|
+
end
|
14
|
+
|
15
|
+
def cpu?
|
16
|
+
@cpu
|
17
|
+
end
|
18
|
+
|
19
|
+
def letters
|
20
|
+
@rack.letters.dup
|
21
|
+
end
|
22
|
+
|
23
|
+
def show_rack(masked = false)
|
24
|
+
masked ? @rack.show_masked : @rack.show
|
25
|
+
end
|
26
|
+
|
27
|
+
def rack_full?
|
28
|
+
@rack.full?
|
29
|
+
end
|
30
|
+
|
31
|
+
def rack_empty?
|
32
|
+
@rack.empty?
|
33
|
+
end
|
34
|
+
|
35
|
+
def rack_capacity
|
36
|
+
@rack.capacity
|
37
|
+
end
|
38
|
+
|
39
|
+
def take_letter(letter)
|
40
|
+
@rack.add(letter)
|
41
|
+
end
|
42
|
+
|
43
|
+
def take_from(board, row, col)
|
44
|
+
if board.stack_height(row, col) == 0
|
45
|
+
raise IllegalMove, "No letters in #{row}, #{col}!"
|
46
|
+
else
|
47
|
+
take_letter(board.remove_top_letter(row, col))
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def play_letter(board, letter, row, col)
|
52
|
+
rack_letter = @rack.remove(letter)
|
53
|
+
begin
|
54
|
+
board.play_letter(rack_letter, row, col)
|
55
|
+
rescue IllegalMove => exn
|
56
|
+
take_letter(rack_letter)
|
57
|
+
raise IllegalMove, exn
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def swap_letter(letter, letter_bank)
|
62
|
+
if letter_bank.empty?
|
63
|
+
raise IllegalMove, "Letter bank is empty!"
|
64
|
+
else
|
65
|
+
trade_letter = @rack.remove(letter)
|
66
|
+
take_letter(letter_bank.draw)
|
67
|
+
letter_bank.deposit(trade_letter)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def refill_rack(letter_bank)
|
72
|
+
until rack_full? || letter_bank.empty? do
|
73
|
+
take_letter(letter_bank.draw)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# ----------------
|
78
|
+
# CPU Move Methods
|
79
|
+
# ----------------
|
80
|
+
|
81
|
+
def straight_moves(board)
|
82
|
+
rows = board.num_rows
|
83
|
+
cols = board.num_columns
|
84
|
+
|
85
|
+
# Get single-position moves
|
86
|
+
one_space_moves = board.coordinates.map {|posn| [posn]}
|
87
|
+
|
88
|
+
# Get board positions grouped by rows
|
89
|
+
(0...rows).map do |row|
|
90
|
+
(0...cols).map {|col| [row, col]}
|
91
|
+
|
92
|
+
# Get horizontal multi-position moves
|
93
|
+
end.flat_map do |posns|
|
94
|
+
(2..(letters.size)).flat_map {|sz| posns.combination(sz).to_a}
|
95
|
+
|
96
|
+
# Collect all possible straight moves
|
97
|
+
end.reduce(one_space_moves) do |all_moves, move|
|
98
|
+
all_moves << move << move.map {|posn| posn.rotate}
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# TODO: Strip out move filters and have the client provide them in a block
|
103
|
+
def legal_move_shapes(board, &filter)
|
104
|
+
straight_moves(board).select(&filter)
|
105
|
+
end
|
106
|
+
|
107
|
+
def standard_legal_shape_filter(board)
|
108
|
+
proc do |move_arr|
|
109
|
+
Shape.new(move_arr).legal?(board)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def legal_shape_letter_permutations(board, &filter)
|
114
|
+
# Cache result of letter permutation computation for each move size
|
115
|
+
letter_perms = Hash.new {|ps, sz| ps[sz] = letters.permutation(sz).to_a}
|
116
|
+
|
117
|
+
legal_move_shapes(board, &filter).reduce([]) do |all_moves, move|
|
118
|
+
letter_perms[move.size].reduce(all_moves) do |move_perms, perm|
|
119
|
+
move_perms << move.zip(perm)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def cpu_move(board, dict, sample_size = 1000, min_score = 0)
|
125
|
+
all_possible_moves = (self.legal_shape_letter_permutations(board, &self.standard_legal_shape_filter(board)))
|
126
|
+
all_possible_moves.shuffle!
|
127
|
+
|
128
|
+
top_score = 0
|
129
|
+
top_score_move = nil
|
130
|
+
|
131
|
+
# TODO: write test for this method
|
132
|
+
while top_score_move.nil? || (top_score < min_score) do
|
133
|
+
|
134
|
+
([sample_size, all_possible_moves.size].min).times do
|
135
|
+
|
136
|
+
move_arr = all_possible_moves.pop
|
137
|
+
move = Move.new(move_arr)
|
138
|
+
|
139
|
+
if move.legal_words?(board, dict)
|
140
|
+
|
141
|
+
move_score = move.score(board, self)
|
142
|
+
|
143
|
+
if move_score >= top_score
|
144
|
+
top_score = move_score
|
145
|
+
top_score_move = move_arr
|
146
|
+
end
|
147
|
+
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Decrement minimum required score after each cycle to help prevent long searches
|
152
|
+
min_score = [(min_score - 1), 0].max
|
153
|
+
end
|
154
|
+
|
155
|
+
top_score_move
|
156
|
+
end
|
157
|
+
|
158
|
+
end
|
159
|
+
end
|