upwords 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|