minecraftctl 1.0.0 → 1.1.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.
- data/Gemfile +3 -0
- data/Gemfile.lock +12 -0
- data/README.rdoc +9 -0
- data/VERSION +1 -1
- data/bin/minecraftctl +13 -4
- data/bin/minecraftctlserver +89 -195
- data/lib/message_collector.rb +21 -0
- data/lib/minecraft.rb +281 -0
- data/lib/spawn.rb +78 -0
- data/minecraftctl.gemspec +16 -5
- data/spec/message_collector_spec.rb +61 -0
- data/spec/minecraft_spec.rb +80 -0
- data/spec/minecraftctlserver_spec.rb +92 -0
- data/spec/spec_helper.rb +0 -1
- data/spec/stub_server/minecraft +100 -0
- metadata +43 -6
- data/lib/minecraftctl.rb +0 -0
- data/spec/minecraftctl_spec.rb +0 -7
@@ -0,0 +1,21 @@
|
|
1
|
+
class MessageCollector
|
2
|
+
def initialize(&operation)
|
3
|
+
@operation = operation
|
4
|
+
end
|
5
|
+
|
6
|
+
def self.for(minecraft, &operations)
|
7
|
+
self.new do |collector|
|
8
|
+
minecraft.with_message_collector(collector, &operations)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def collect(msg)
|
13
|
+
@collector.call(msg)
|
14
|
+
end
|
15
|
+
|
16
|
+
def each(&collector)
|
17
|
+
@collector = collector
|
18
|
+
@operation.call(method(:collect))
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
data/lib/minecraft.rb
ADDED
@@ -0,0 +1,281 @@
|
|
1
|
+
require 'timeout'
|
2
|
+
require 'spawn'
|
3
|
+
Thread.abort_on_exception = true
|
4
|
+
|
5
|
+
class Minecraft
|
6
|
+
class HistoryQueue < Queue
|
7
|
+
def initialize
|
8
|
+
@history = []
|
9
|
+
super
|
10
|
+
end
|
11
|
+
|
12
|
+
def flush
|
13
|
+
until empty?
|
14
|
+
msg = pop(true)
|
15
|
+
yield msg if block_given?
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def history
|
20
|
+
flush
|
21
|
+
@history
|
22
|
+
end
|
23
|
+
|
24
|
+
def pop(blocking = false)
|
25
|
+
msg = super(blocking)
|
26
|
+
@history << msg
|
27
|
+
msg
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class MessageQueue < HistoryQueue
|
32
|
+
class Message
|
33
|
+
def initialize(msg)
|
34
|
+
@msg = msg
|
35
|
+
end
|
36
|
+
|
37
|
+
attr_reader :msg
|
38
|
+
|
39
|
+
def to_s
|
40
|
+
@msg
|
41
|
+
end
|
42
|
+
|
43
|
+
class Internal < Message
|
44
|
+
def initialize(msg)
|
45
|
+
super(msg)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
class InternalError < Message
|
50
|
+
def initialize(msg)
|
51
|
+
super(msg)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class Out < Message
|
56
|
+
def initialize(msg)
|
57
|
+
super(msg)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class Err < Message
|
62
|
+
def initialize(line)
|
63
|
+
x, @time, @level, msg = *line.match(/^([^ ]* [^ ]*) \[([^\]]*)\] (.*)/)
|
64
|
+
msg = line unless @time and @level and msg
|
65
|
+
super(msg)
|
66
|
+
end
|
67
|
+
|
68
|
+
attr_reader :time, :level
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def initialize
|
73
|
+
super
|
74
|
+
end
|
75
|
+
|
76
|
+
def out(msg)
|
77
|
+
push Message::Out.new(msg)
|
78
|
+
end
|
79
|
+
|
80
|
+
def err(msg)
|
81
|
+
push Message::Err.new(msg)
|
82
|
+
end
|
83
|
+
|
84
|
+
def log(msg)
|
85
|
+
push Message::Internal.new(msg)
|
86
|
+
end
|
87
|
+
|
88
|
+
def error(msg)
|
89
|
+
push Message::InternalError.new(msg)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
class StartupFailedError < RuntimeError
|
94
|
+
def initialize(command)
|
95
|
+
super "failed to start process: #{command}"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def initialize(cmd)
|
100
|
+
@cmd = cmd
|
101
|
+
@in_queue = Queue.new
|
102
|
+
@message_queue = MessageQueue.new
|
103
|
+
|
104
|
+
@server_pid = nil
|
105
|
+
|
106
|
+
@collector = nil
|
107
|
+
end
|
108
|
+
|
109
|
+
attr_reader :server_pid
|
110
|
+
|
111
|
+
def with_message_collector(collector, &operations)
|
112
|
+
@message_queue.flush
|
113
|
+
@collector = collector
|
114
|
+
begin
|
115
|
+
instance_eval &operations
|
116
|
+
rescue Timeout::Error
|
117
|
+
error "Command timed out"
|
118
|
+
ensure
|
119
|
+
@message_queue.flush do |msg|
|
120
|
+
collect(msg)
|
121
|
+
end
|
122
|
+
@collector = nil
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def running?
|
127
|
+
@out_reader and @in_writter.alive?
|
128
|
+
end
|
129
|
+
|
130
|
+
def log(msg)
|
131
|
+
@message_queue.log(msg)
|
132
|
+
end
|
133
|
+
|
134
|
+
def error(msg)
|
135
|
+
@message_queue.error(msg)
|
136
|
+
end
|
137
|
+
|
138
|
+
def start
|
139
|
+
if running?
|
140
|
+
log "Server already running"
|
141
|
+
else
|
142
|
+
time_operation("Server start") do
|
143
|
+
begin
|
144
|
+
log "Starting minecraft: #{@cmd}"
|
145
|
+
|
146
|
+
pid, @stdin, @stdout, @stderr = Spawn::spawn(@cmd)
|
147
|
+
@server_pid = pid
|
148
|
+
|
149
|
+
log "Started server process with pid: #{@server_pid}"
|
150
|
+
|
151
|
+
@in_writter = Thread.new do
|
152
|
+
begin
|
153
|
+
loop do
|
154
|
+
msg = @in_queue.pop
|
155
|
+
@stdin.write(msg)
|
156
|
+
end
|
157
|
+
rescue IOError, Errno::EPIPE
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
@err_reader = Thread.new do
|
162
|
+
begin
|
163
|
+
@stderr.each do |line|
|
164
|
+
@message_queue.err(line.strip)
|
165
|
+
end
|
166
|
+
rescue IOError
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
@out_reader = Thread.new do
|
171
|
+
begin
|
172
|
+
@stdout.each do |line|
|
173
|
+
@message_queue.out(line.strip)
|
174
|
+
end
|
175
|
+
rescue IOError
|
176
|
+
ensure
|
177
|
+
@in_writter.kill
|
178
|
+
@err_reader.kill
|
179
|
+
|
180
|
+
@stdin.close unless @stdin.closed?
|
181
|
+
@stderr.close unless @stderr.closed?
|
182
|
+
@stdout.close unless @stdout.closed?
|
183
|
+
|
184
|
+
log "Minecraft exits"
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
wait_msg do |m|
|
189
|
+
m.msg =~ /Done \(([^n]*)ns\)!/ or m.msg =~ /Minecraft exits/
|
190
|
+
end
|
191
|
+
|
192
|
+
unless running?
|
193
|
+
Process.wait(@server_pid)
|
194
|
+
raise StartupFailedError, @cmd
|
195
|
+
end
|
196
|
+
rescue Errno::ENOENT
|
197
|
+
raise StartupFailedError, @cmd
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def stop
|
204
|
+
unless running?
|
205
|
+
log "Server already stopped"
|
206
|
+
else
|
207
|
+
command('stop') do
|
208
|
+
time_operation("Server stop") do
|
209
|
+
Process.wait(@server_pid)
|
210
|
+
log "Server stopped"
|
211
|
+
end
|
212
|
+
|
213
|
+
wait_msg{|m| m.msg == "Server stopped"}
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def save_all
|
219
|
+
command('save-all') do
|
220
|
+
time_operation("Save") do
|
221
|
+
wait_msg{|m| m.msg =~ /Save complete/}
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
def list
|
227
|
+
command('list') do
|
228
|
+
wait_msg{|m| m.msg =~ /Connected players:/}
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def method_missing(m, *args)
|
233
|
+
command(([m.to_s.tr('_', '-')] + args).join(' '))
|
234
|
+
end
|
235
|
+
|
236
|
+
def command(cmd)
|
237
|
+
raise RuntimeError, "server not running" unless running?
|
238
|
+
@in_queue << "#{cmd}\n"
|
239
|
+
if block_given?
|
240
|
+
yield
|
241
|
+
else
|
242
|
+
active_wait
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def history
|
247
|
+
@message_queue.history
|
248
|
+
end
|
249
|
+
|
250
|
+
private
|
251
|
+
|
252
|
+
def collect(msg)
|
253
|
+
@collector.call(msg) if @collector
|
254
|
+
end
|
255
|
+
|
256
|
+
def time_operation(name)
|
257
|
+
start = Time.now
|
258
|
+
yield
|
259
|
+
log "#{name} finished in #{(Time.now - start).to_f}"
|
260
|
+
end
|
261
|
+
|
262
|
+
def wait_msg(discard = false, timeout = 20)
|
263
|
+
Timeout::timeout(timeout) do
|
264
|
+
loop do
|
265
|
+
msg = @message_queue.pop
|
266
|
+
if yield msg
|
267
|
+
collect(msg) unless discard
|
268
|
+
break
|
269
|
+
end
|
270
|
+
|
271
|
+
collect(msg)
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
def active_wait
|
277
|
+
@in_queue << "list\n"
|
278
|
+
wait_msg(true){|m| m.msg =~ /Connected players:/}
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
data/lib/spawn.rb
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'fcntl'
|
2
|
+
|
3
|
+
class Spawn
|
4
|
+
def self.spawn(*cmd, &b)
|
5
|
+
pw, pr, pe, ps = IO.pipe, IO.pipe, IO.pipe, IO.pipe
|
6
|
+
|
7
|
+
ps.first.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
|
8
|
+
ps.last.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
|
9
|
+
|
10
|
+
cid = fork {
|
11
|
+
pw.last.close
|
12
|
+
STDIN.reopen pw.first
|
13
|
+
pw.first.close
|
14
|
+
|
15
|
+
pr.first.close
|
16
|
+
STDOUT.reopen pr.last
|
17
|
+
pr.last.close
|
18
|
+
|
19
|
+
pe.first.close
|
20
|
+
STDERR.reopen pe.last
|
21
|
+
pe.last.close
|
22
|
+
|
23
|
+
STDOUT.sync = STDERR.sync = true
|
24
|
+
|
25
|
+
begin
|
26
|
+
# WARNING: detect max open fd no - is thre a better way?
|
27
|
+
r, w = IO.pipe
|
28
|
+
max_fd = r.to_i - 1
|
29
|
+
r.close
|
30
|
+
w.close
|
31
|
+
|
32
|
+
#puts "setting close on exec for fd's from 3 to #{max_fd}"
|
33
|
+
(3..max_fd).each do |fd|
|
34
|
+
begin
|
35
|
+
IO.for_fd(fd).fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
|
36
|
+
#puts "fd #{fd} set for closure on exec"
|
37
|
+
rescue Errno::EBADF
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
exec(*cmd)
|
42
|
+
raise 'forty-two'
|
43
|
+
rescue Exception => e
|
44
|
+
Marshal.dump(e, ps.last)
|
45
|
+
ps.last.flush
|
46
|
+
end
|
47
|
+
ps.last.close unless (ps.last.closed?)
|
48
|
+
exit!
|
49
|
+
}
|
50
|
+
|
51
|
+
[pw.first, pr.last, pe.last, ps.last].each{|fd| fd.close}
|
52
|
+
|
53
|
+
begin
|
54
|
+
e = Marshal.load ps.first
|
55
|
+
raise(Exception === e ? e : "unknown failure!")
|
56
|
+
rescue EOFError # If we get an EOF error, then the exec was successful
|
57
|
+
42
|
58
|
+
ensure
|
59
|
+
ps.first.close
|
60
|
+
end
|
61
|
+
|
62
|
+
pw.last.sync = true
|
63
|
+
|
64
|
+
pi = [pw.last, pr.first, pe.first]
|
65
|
+
|
66
|
+
if b
|
67
|
+
begin
|
68
|
+
b[cid, *pi]
|
69
|
+
Process.waitpid2(cid).last
|
70
|
+
ensure
|
71
|
+
pi.each{|fd| fd.close unless fd.closed?}
|
72
|
+
end
|
73
|
+
else
|
74
|
+
[cid, pw.last, pr.first, pe.first]
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
data/minecraftctl.gemspec
CHANGED
@@ -5,11 +5,11 @@
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |s|
|
7
7
|
s.name = "minecraftctl"
|
8
|
-
s.version = "1.
|
8
|
+
s.version = "1.1.0"
|
9
9
|
|
10
10
|
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
11
|
s.authors = ["Jakub Pastuszek"]
|
12
|
-
s.date = "2011-09-
|
12
|
+
s.date = "2011-09-22"
|
13
13
|
s.description = "Allows to send messages, start and stop Minecraft server"
|
14
14
|
s.email = "jpastuszek@gmail.com"
|
15
15
|
s.executables = ["minecraftctl", "minecraftctlserver", "minecraftctl", "minecraftctlserver"]
|
@@ -28,10 +28,15 @@ Gem::Specification.new do |s|
|
|
28
28
|
"VERSION",
|
29
29
|
"bin/minecraftctl",
|
30
30
|
"bin/minecraftctlserver",
|
31
|
-
"lib/
|
31
|
+
"lib/message_collector.rb",
|
32
|
+
"lib/minecraft.rb",
|
33
|
+
"lib/spawn.rb",
|
32
34
|
"minecraftctl.gemspec",
|
33
|
-
"spec/
|
34
|
-
"spec/
|
35
|
+
"spec/message_collector_spec.rb",
|
36
|
+
"spec/minecraft_spec.rb",
|
37
|
+
"spec/minecraftctlserver_spec.rb",
|
38
|
+
"spec/spec_helper.rb",
|
39
|
+
"spec/stub_server/minecraft"
|
35
40
|
]
|
36
41
|
s.homepage = "http://github.com/jpastuszek/minecraftctl"
|
37
42
|
s.licenses = ["MIT"]
|
@@ -53,6 +58,8 @@ Gem::Specification.new do |s|
|
|
53
58
|
s.add_development_dependency(%q<main>, [">= 4.7.3"])
|
54
59
|
s.add_development_dependency(%q<haml>, [">= 3.1.3"])
|
55
60
|
s.add_development_dependency(%q<httpclient>, [">= 2.2.1"])
|
61
|
+
s.add_development_dependency(%q<open4>, [">= 1.1.0"])
|
62
|
+
s.add_development_dependency(%q<mongrel>, [">= 1.1.5"])
|
56
63
|
else
|
57
64
|
s.add_dependency(%q<rspec>, ["~> 2.3.0"])
|
58
65
|
s.add_dependency(%q<bundler>, ["~> 1.0.0"])
|
@@ -64,6 +71,8 @@ Gem::Specification.new do |s|
|
|
64
71
|
s.add_dependency(%q<main>, [">= 4.7.3"])
|
65
72
|
s.add_dependency(%q<haml>, [">= 3.1.3"])
|
66
73
|
s.add_dependency(%q<httpclient>, [">= 2.2.1"])
|
74
|
+
s.add_dependency(%q<open4>, [">= 1.1.0"])
|
75
|
+
s.add_dependency(%q<mongrel>, [">= 1.1.5"])
|
67
76
|
end
|
68
77
|
else
|
69
78
|
s.add_dependency(%q<rspec>, ["~> 2.3.0"])
|
@@ -76,6 +85,8 @@ Gem::Specification.new do |s|
|
|
76
85
|
s.add_dependency(%q<main>, [">= 4.7.3"])
|
77
86
|
s.add_dependency(%q<haml>, [">= 3.1.3"])
|
78
87
|
s.add_dependency(%q<httpclient>, [">= 2.2.1"])
|
88
|
+
s.add_dependency(%q<open4>, [">= 1.1.0"])
|
89
|
+
s.add_dependency(%q<mongrel>, [">= 1.1.5"])
|
79
90
|
end
|
80
91
|
end
|
81
92
|
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
require 'message_collector'
|
3
|
+
require 'minecraft'
|
4
|
+
|
5
|
+
describe MessageCollector do
|
6
|
+
it 'should collect messages via passed proc call' do
|
7
|
+
mc = MessageCollector.new do |collector|
|
8
|
+
collector.call(:test1)
|
9
|
+
collector.call(:test2)
|
10
|
+
end
|
11
|
+
|
12
|
+
msgs = []
|
13
|
+
mc.each do |msg|
|
14
|
+
msgs << msg
|
15
|
+
end
|
16
|
+
|
17
|
+
msgs.should == [:test1, :test2]
|
18
|
+
end
|
19
|
+
|
20
|
+
describe 'with minecraft running' do
|
21
|
+
before :all do
|
22
|
+
@m = Minecraft.new(File.dirname(__FILE__) + '/stub_server/minecraft')
|
23
|
+
@m.start
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'should collect messages generated by minecraft commands' do
|
27
|
+
msgs = []
|
28
|
+
|
29
|
+
MessageCollector.new do |collector|
|
30
|
+
@m.with_message_collector(collector) do
|
31
|
+
list
|
32
|
+
command(:help)
|
33
|
+
end
|
34
|
+
end.each do |msg|
|
35
|
+
msgs << msg.msg
|
36
|
+
end
|
37
|
+
|
38
|
+
msgs.first.should == 'Connected players: kazuya'
|
39
|
+
msgs.should include 'Console commands:'
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'should provide #for method that shortens the code' do
|
43
|
+
msgs = []
|
44
|
+
|
45
|
+
MessageCollector.for(@m) do
|
46
|
+
list
|
47
|
+
command(:help)
|
48
|
+
end.each do |msg|
|
49
|
+
msgs << msg.msg
|
50
|
+
end
|
51
|
+
|
52
|
+
msgs.first.should == 'Connected players: kazuya'
|
53
|
+
msgs.should include 'Console commands:'
|
54
|
+
end
|
55
|
+
|
56
|
+
after :all do
|
57
|
+
@m.stop
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|