textflight-client 1.0.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/CHANGELOG.md +10 -0
- data/Gemfile +4 -0
- data/LICENSE.md +675 -0
- data/README.md +160 -0
- data/Rakefile +29 -0
- data/bin/client.rb +291 -0
- data/lib/textflight-client.rb +33 -0
- data/lib/textflight-client/command_parser.rb +42 -0
- data/lib/textflight-client/dot_dir.rb +21 -0
- data/lib/textflight-client/environment.rb +55 -0
- data/lib/textflight-client/logging.rb +58 -0
- data/lib/textflight-client/models/local.rb +69 -0
- data/lib/textflight-client/models/model.rb +55 -0
- data/lib/textflight-client/models/nav.rb +293 -0
- data/lib/textflight-client/models/scan.rb +222 -0
- data/lib/textflight-client/models/status.rb +191 -0
- data/lib/textflight-client/models/status_report.rb +26 -0
- data/lib/textflight-client/response_parser.rb +200 -0
- data/lib/textflight-client/string_utils.rb +15 -0
- data/lib/textflight-client/tfprompt.rb +30 -0
- data/lib/textflight-client/version.rb +3 -0
- metadata +325 -0
data/README.md
ADDED
@@ -0,0 +1,160 @@
|
|
1
|
+
## TextFlight Client
|
2
|
+
|
3
|
+
A command-line client for the TextFlight.
|
4
|
+
|
5
|
+
TextFlight is a space-based text adventure MMO.
|
6
|
+
|
7
|
+
* https://leagueh.xyz/tf/
|
8
|
+
* https://leagueh.xyz/git/textflight/
|
9
|
+
|
10
|
+
```
|
11
|
+
# TextFlight character
|
12
|
+
jmoody
|
13
|
+
faction: nibiru
|
14
|
+
```
|
15
|
+
|
16
|
+
One of the first computer games I played was Zork I on an Apple IIc. The first
|
17
|
+
program of any significance that I wrote was a text-based adventure in C# based
|
18
|
+
on my experiences as student worker at the Physical Sciences library at UMass
|
19
|
+
Amherst. I played way too much BatMUD in the early nineties and early 2000s.
|
20
|
+
|
21
|
+
### Requirements
|
22
|
+
|
23
|
+
I develop and test on macOS. The GitHub Actions run rspec tests on Ubuntu.
|
24
|
+
|
25
|
+
I install dependencies with Homebrew.
|
26
|
+
|
27
|
+
* Ruby >= 2.7.0
|
28
|
+
* sqlite3
|
29
|
+
* readline
|
30
|
+
* socat (essential for debugging)
|
31
|
+
|
32
|
+
### Developing
|
33
|
+
|
34
|
+
To run integration tests and debug, a TextFlight server needs to be running
|
35
|
+
locally. There are two options: building the server yourself or using the
|
36
|
+
docker container. Find the instructions for building the server yourself at the
|
37
|
+
bottom of this document.
|
38
|
+
|
39
|
+
#### Docker
|
40
|
+
|
41
|
+
The client has a Docker container that starts the server with SSL enabled.
|
42
|
+
|
43
|
+
```
|
44
|
+
$ bundle exec rake server
|
45
|
+
docker-compose up --build --remove-orphans
|
46
|
+
...
|
47
|
+
ssl_1 | 2020-12-09 06:48:58,318 INFO:Loaded quest 'Refueling'.
|
48
|
+
ssl_1 | 2020-12-09 06:48:58,318 INFO:Loaded quest 'Base Building'.
|
49
|
+
ssl_1 | 2020-12-09 06:48:58,319 INFO:Loaded quest 'Starting Colonies'.
|
50
|
+
```
|
51
|
+
|
52
|
+
|
53
|
+
The certificate is self-signed, so the client should not try to verify the
|
54
|
+
Certificate Authority.
|
55
|
+
|
56
|
+
```
|
57
|
+
# Connect to the native client: socat
|
58
|
+
$ socat readline ssl:localhost:10000,verify=0
|
59
|
+
|
60
|
+
^ also a rake task: bundle exec rake socat
|
61
|
+
|
62
|
+
# Connect with bin/client.rb
|
63
|
+
# --dev flag turns off certificate validation.
|
64
|
+
$ bundle exec bin/client.rb --dev
|
65
|
+
```
|
66
|
+
|
67
|
+
### Test
|
68
|
+
|
69
|
+
```
|
70
|
+
$ bundle install
|
71
|
+
$ bundle exec spec/lib
|
72
|
+
```
|
73
|
+
|
74
|
+
Integration tests require standing up the TextFlight server locally (see the
|
75
|
+
Developing section above).
|
76
|
+
|
77
|
+
```
|
78
|
+
$ bundle install
|
79
|
+
$ bundle exec spec/integration
|
80
|
+
```
|
81
|
+
|
82
|
+
### TODO
|
83
|
+
|
84
|
+
- [ ] stand up thor to improve cli
|
85
|
+
- [ ] improve 'set' command
|
86
|
+
- engines {off | on}
|
87
|
+
- mining {off | on}
|
88
|
+
- prepare to launch
|
89
|
+
- prepare to land
|
90
|
+
- [ ] improve the prompt
|
91
|
+
- [ ] run 'nav' automatically after jump
|
92
|
+
- [ ] handle the craft response
|
93
|
+
- [ ] improve craft command (craft all [recipe])
|
94
|
+
- [ ] improve the load command (mv all <index> to <structure>)
|
95
|
+
|
96
|
+
### Server
|
97
|
+
|
98
|
+
#### Build It Yourself
|
99
|
+
|
100
|
+
```
|
101
|
+
$ git clone https://leagueh.xyz/git/textflight/.git/
|
102
|
+
```
|
103
|
+
|
104
|
+
The server requires Python 3.x; I use 3.9.0. I use pyenv to manage Python
|
105
|
+
versions. Other than that, I don't know anything about Python (my Algorithms
|
106
|
+
class at Smith in 2001 was in Python...) or how to configure Python on macOS.
|
107
|
+
There is a little documentation in the textflight/README.md
|
108
|
+
|
109
|
+
```
|
110
|
+
# Run the server like this:
|
111
|
+
$ cd textflight
|
112
|
+
$ pip3 install bcrypt
|
113
|
+
$ src/main.py
|
114
|
+
...
|
115
|
+
2020-12-04 23:17:25,110 INFO:Loaded quest 'Base Building'.
|
116
|
+
2020-12-04 23:17:25,110 INFO:Loaded quest 'Starting Colonies'.
|
117
|
+
|
118
|
+
# In another terminal, connect to the server like this:
|
119
|
+
$ socat readline tcp:leagueh.xyz:10000
|
120
|
+
```
|
121
|
+
|
122
|
+
#### OpenSSL
|
123
|
+
|
124
|
+
If you want to use openssl, install with `brew openssl` and use
|
125
|
+
`/usr/local/opt/openssl/bin/openssl` instead of Apple's LibreSSL.
|
126
|
+
|
127
|
+
|
128
|
+
```
|
129
|
+
1. In the server directory, generate a self-sign cert.
|
130
|
+
|
131
|
+
$ cd textflight
|
132
|
+
$ mkdir -p certs
|
133
|
+
# change the -subj
|
134
|
+
$ /usr/local/opt/openssl/bin/openssl \
|
135
|
+
req -x509 \
|
136
|
+
-newkey rsa:4096 \
|
137
|
+
-keyout certs/key.pem \
|
138
|
+
-out certs/cert.pem \
|
139
|
+
-days 365 \
|
140
|
+
-nodes \
|
141
|
+
-subj "/C=DE/ST=BW/L=Konstanz/O=nibiru/OU=Org/CN=localhost"
|
142
|
+
|
143
|
+
2. Install a textflight.conf and update the conf to use SSL
|
144
|
+
$ cp textflight.conf.example textflight.conf
|
145
|
+
SSL = true
|
146
|
+
SSLCert = certs/cert.pem
|
147
|
+
SSLKey = certs/key.pem
|
148
|
+
|
149
|
+
3. Start the server.
|
150
|
+
$ src/main.py
|
151
|
+
|
152
|
+
4. Connect the client. Without verify=0, the client will refuse
|
153
|
+
to connect with an error "unknown ca" (certificate authority).
|
154
|
+
# Test connection with socat
|
155
|
+
$ socat readline ssl:localhost:10000,verify=0
|
156
|
+
|
157
|
+
# Or with this client
|
158
|
+
$ bin/client.rb --dev
|
159
|
+
```
|
160
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
2
|
+
require "rspec/core/rake_task"
|
3
|
+
require 'bundler/audit/task'
|
4
|
+
|
5
|
+
Bundler::Audit::Task.new
|
6
|
+
|
7
|
+
RSpec::Core::RakeTask.new(:spec) do |task|
|
8
|
+
Rake::Task["bundle:audit"].invoke
|
9
|
+
task.pattern = "spec/lib/**{,/*/**}/*_spec.rb"
|
10
|
+
end
|
11
|
+
|
12
|
+
RSpec::Core::RakeTask.new(:unit) do |task|
|
13
|
+
Rake::Task["bundle:audit"].invoke
|
14
|
+
task.pattern = "spec/lib/**{,/*/**}/*_spec.rb"
|
15
|
+
end
|
16
|
+
|
17
|
+
RSpec::Core::RakeTask.new(:integration) do |task|
|
18
|
+
Rake::Task["bundle:audit"].invoke
|
19
|
+
task.pattern = "spec/integration/**{,/*/**}/*_spec.rb"
|
20
|
+
end
|
21
|
+
|
22
|
+
task :server do
|
23
|
+
sh "docker-compose up --build --remove-orphans --detach"
|
24
|
+
end
|
25
|
+
|
26
|
+
task :socat do
|
27
|
+
sh "socat readline ssl:localhost:10000,verify=0"
|
28
|
+
end
|
29
|
+
|
data/bin/client.rb
ADDED
@@ -0,0 +1,291 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
module TextFlight
|
4
|
+
require "openssl"
|
5
|
+
require "socket"
|
6
|
+
require "readline"
|
7
|
+
require "pry"
|
8
|
+
|
9
|
+
class CLI
|
10
|
+
|
11
|
+
def self.write_command(socket:, command:)
|
12
|
+
timeout = 5.0
|
13
|
+
ready = IO.select(nil, [socket], nil, timeout)
|
14
|
+
|
15
|
+
if !ready
|
16
|
+
message = "Timed out waiting for socket to be ready for writes after #{timeout} seconds"
|
17
|
+
socket.close
|
18
|
+
raise message
|
19
|
+
end
|
20
|
+
|
21
|
+
begin
|
22
|
+
socket.puts(command)
|
23
|
+
rescue StandardError, IOError => e
|
24
|
+
message = <<~EOM
|
25
|
+
Caught error while writing to socket
|
26
|
+
|
27
|
+
#{e.message}
|
28
|
+
|
29
|
+
after reading #{buffer.bytesize} from socket:
|
30
|
+
|
31
|
+
#{buffer}
|
32
|
+
EOM
|
33
|
+
socket.close
|
34
|
+
raise(e.class, message)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.read_response(socket:)
|
39
|
+
timeout = 5.0
|
40
|
+
ready = IO.select([socket], nil, nil, timeout)
|
41
|
+
|
42
|
+
if !ready
|
43
|
+
message = "Timed out waiting for socket to response after #{timeout} seconds"
|
44
|
+
socket.close
|
45
|
+
raise message
|
46
|
+
end
|
47
|
+
|
48
|
+
buffer = ""
|
49
|
+
max_tries = 3
|
50
|
+
tries = 1
|
51
|
+
begin
|
52
|
+
loop do
|
53
|
+
response = socket.read_nonblock(4096, exception: false)
|
54
|
+
|
55
|
+
if response == :wait_readable
|
56
|
+
if tries < max_tries
|
57
|
+
TFClient.debug(
|
58
|
+
"received :wait_readable on try: #{tries} of #{max_tries}; retrying"
|
59
|
+
)
|
60
|
+
tries = tries + 1
|
61
|
+
sleep(0.2)
|
62
|
+
next
|
63
|
+
else
|
64
|
+
TFClient.debug(
|
65
|
+
"received :wait_readable on try: #{tries} of #{max_tries}; breaking"
|
66
|
+
)
|
67
|
+
# could be we have to exit here
|
68
|
+
break
|
69
|
+
end
|
70
|
+
elsif response == nil
|
71
|
+
TFClient.error(
|
72
|
+
"received 'nil' on try: #{tries} of #{max_tries}; exiting"
|
73
|
+
)
|
74
|
+
raise("Server returned nil, possibly because of rate limiting")
|
75
|
+
end
|
76
|
+
|
77
|
+
TFClient.debug(
|
78
|
+
"received #{response.bytesize} bytes; pushing onto buffer"
|
79
|
+
)
|
80
|
+
tries = 1
|
81
|
+
response.delete_prefix!("> ")
|
82
|
+
response.delete_suffix!("> ")
|
83
|
+
response = TFClient::StringUtils.remove_terminal_control_chars(string: response)
|
84
|
+
response = TFClient::StringUtils.remove_color_control_chars(string: response)
|
85
|
+
buffer = buffer + response
|
86
|
+
|
87
|
+
sleep(0.2)
|
88
|
+
end
|
89
|
+
rescue StandardError, IOError => e
|
90
|
+
message = <<~EOM
|
91
|
+
Caught error while reading from socket:
|
92
|
+
|
93
|
+
#{e.message}
|
94
|
+
|
95
|
+
after reading #{buffer.bytesize} bytes from socket:
|
96
|
+
|
97
|
+
#{buffer}
|
98
|
+
EOM
|
99
|
+
socket.close
|
100
|
+
raise(e.class, message)
|
101
|
+
end
|
102
|
+
|
103
|
+
buffer
|
104
|
+
end
|
105
|
+
|
106
|
+
def self.parse_response(response:)
|
107
|
+
response.each do |line|
|
108
|
+
puts "#{line}"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def self.register(socket:, user:, pass:)
|
113
|
+
TFClient.debug("=== REGISTER ===")
|
114
|
+
TFClient.info("registering user: #{user} pass: #{pass[0..3]}***")
|
115
|
+
sleep(0.5)
|
116
|
+
self.write_command(socket: socket, command: "register #{user} #{pass}")
|
117
|
+
|
118
|
+
response = self.read_response(socket: socket)
|
119
|
+
puts response
|
120
|
+
end
|
121
|
+
|
122
|
+
def self.login(socket:, user:, pass:)
|
123
|
+
TFClient.debug("=== LOGIN ===")
|
124
|
+
TFClient.info("logging in user: #{user} pass: #{pass[0..3]}***")
|
125
|
+
sleep(0.5)
|
126
|
+
self.write_command(socket: socket, command: "login #{user} #{pass}")
|
127
|
+
|
128
|
+
response = self.read_response(socket: socket)
|
129
|
+
lines = response.lines(chomp: true)
|
130
|
+
if lines[0] && lines[0].chomp == "Incorrect username or password."
|
131
|
+
TFClient.error("#{response[0].chomp}")
|
132
|
+
socket.close
|
133
|
+
exit(1)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def self.enable_client_mode(socket:)
|
138
|
+
TFClient.debug("=== ENABLE CLIENT MODE ===")
|
139
|
+
sleep(0.5)
|
140
|
+
self.write_command(socket: socket, command: "language client")
|
141
|
+
response = self.read_response(socket: socket)
|
142
|
+
puts response
|
143
|
+
end
|
144
|
+
|
145
|
+
def self.status(socket:)
|
146
|
+
sleep(0.5)
|
147
|
+
TextFlight::CLI.write_command(socket: socket, command: "status")
|
148
|
+
sleep(0.5)
|
149
|
+
response = TextFlight::CLI.read_response(socket: socket)
|
150
|
+
TFClient::ResponseParser.new(command: "status",
|
151
|
+
textflight_command: "status",
|
152
|
+
response: response).parse
|
153
|
+
end
|
154
|
+
|
155
|
+
def self.nav(socket: @socket)
|
156
|
+
sleep(0.5)
|
157
|
+
TextFlight::CLI.write_command(socket: socket, command: "nav")
|
158
|
+
sleep(0.5)
|
159
|
+
response = TextFlight::CLI.read_response(socket: socket)
|
160
|
+
TFClient::ResponseParser.new(command: "nav",
|
161
|
+
textflight_command: "nav",
|
162
|
+
response: response).parse
|
163
|
+
end
|
164
|
+
|
165
|
+
attr_reader :socket, :user, :pass, :host, :port, :tcp, :state, :dev
|
166
|
+
attr_reader :local_db
|
167
|
+
|
168
|
+
def initialize(host:, port:, tcp:, user:, pass:, dev:)
|
169
|
+
db_path = TFClient::DotDir.local_database_file(dev: dev)
|
170
|
+
@local_db = TFClient::Models::Local::Database.new(path: db_path)
|
171
|
+
@state = { }
|
172
|
+
@user = user
|
173
|
+
@pass = pass
|
174
|
+
@host = host
|
175
|
+
@port = port
|
176
|
+
@tcp = tcp
|
177
|
+
@socket = connect(host: @host, port: @port, tcp: @tcp, dev: dev)
|
178
|
+
TextFlight::CLI.read_response(socket: @socket)
|
179
|
+
|
180
|
+
if dev
|
181
|
+
TextFlight::CLI.register(socket: @socket, user: @user, pass: @pass)
|
182
|
+
end
|
183
|
+
|
184
|
+
TextFlight::CLI.login(socket: @socket, user: @user, pass: @pass)
|
185
|
+
TextFlight::CLI.enable_client_mode(socket: @socket)
|
186
|
+
|
187
|
+
update_prompt!
|
188
|
+
read_eval_print
|
189
|
+
end
|
190
|
+
|
191
|
+
def connect(host:, port:, tcp:, dev:)
|
192
|
+
puts "try to connect to #{host}:#{port} with #{tcp ? "tcp" : "ssl"}"
|
193
|
+
if tcp
|
194
|
+
socket = TCPSocket.new(host, port)
|
195
|
+
else
|
196
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
197
|
+
if dev
|
198
|
+
ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
199
|
+
end
|
200
|
+
tcp_socket = TCPSocket.new(host, port)
|
201
|
+
socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
|
202
|
+
socket.sync_close = true
|
203
|
+
socket.connect
|
204
|
+
end
|
205
|
+
socket
|
206
|
+
end
|
207
|
+
|
208
|
+
def update_prompt!
|
209
|
+
TextFlight::CLI.write_command(socket: @socket, command: "status")
|
210
|
+
response = TextFlight::CLI.read_response(socket: @socket)
|
211
|
+
status = TFClient::ResponseParser.new(command: "status-for-prompt",
|
212
|
+
textflight_command: "status",
|
213
|
+
response: response).parse
|
214
|
+
|
215
|
+
@prompt = TFClient::TFPrompt.new(operator:@user,
|
216
|
+
status_report: status.status_report)
|
217
|
+
|
218
|
+
system_id = status.status_report.hash[:sys_id]
|
219
|
+
system = @local_db.system_for_id(id: system_id)
|
220
|
+
if system.count == 0
|
221
|
+
TextFlight::CLI.write_command(socket: socket, command: "nav")
|
222
|
+
response = TextFlight::CLI.read_response(socket: @socket)
|
223
|
+
nav = TFClient::ResponseParser.new(command: "nav-for-prompt",
|
224
|
+
textflight_command: "nav",
|
225
|
+
response: response).parse
|
226
|
+
system = @local_db.create_system(id: system_id, nav: nav)
|
227
|
+
@prompt.x = system[:x]
|
228
|
+
@prompt.y = system[:y]
|
229
|
+
else
|
230
|
+
@prompt.x = system.first[:x]
|
231
|
+
@prompt.y = system.first[:y]
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def read_eval_print
|
236
|
+
begin
|
237
|
+
loop do
|
238
|
+
command = Readline.readline("#{@prompt.to_s}", true)
|
239
|
+
if command.strip == ""
|
240
|
+
update_prompt!
|
241
|
+
next
|
242
|
+
end
|
243
|
+
parsed_command = TFClient::CommandParser.new(command: command).parse
|
244
|
+
|
245
|
+
if parsed_command == "exit"
|
246
|
+
TextFlight::CLI.write_command(socket: socket, command: parsed_command)
|
247
|
+
socket.close
|
248
|
+
puts "Goodbye."
|
249
|
+
exit(0)
|
250
|
+
end
|
251
|
+
|
252
|
+
TextFlight::CLI.write_command(socket: socket, command: parsed_command)
|
253
|
+
|
254
|
+
# dock, set, jump reply with STATUSREPORT
|
255
|
+
response = TextFlight::CLI.read_response(socket: @socket)
|
256
|
+
TFClient::ResponseParser.new(command: command,
|
257
|
+
textflight_command: parsed_command,
|
258
|
+
response: response).parse
|
259
|
+
|
260
|
+
update_prompt!
|
261
|
+
end
|
262
|
+
rescue IOError => e
|
263
|
+
puts e.message
|
264
|
+
# e.backtrace
|
265
|
+
@socket.close
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
require "dotenv/load" # load from .env
|
272
|
+
require "textflight-client"
|
273
|
+
|
274
|
+
env = ARGV.include?("--dev") ? "DEV" : "TF"
|
275
|
+
tcp = ARGV.include?("--tcp")
|
276
|
+
host = ENV["#{env}_HOST"] || "localhost"
|
277
|
+
port = ENV["#{env}_PORT"] || "10000"
|
278
|
+
|
279
|
+
if ARGV[0][/--/]
|
280
|
+
user = ENV["#{env}_USER"] || "abc"
|
281
|
+
else
|
282
|
+
user = ARGV[0]
|
283
|
+
end
|
284
|
+
pass = ENV["#{env}_PASS"] || "1234"
|
285
|
+
|
286
|
+
TextFlight::CLI.new(host: host,
|
287
|
+
port: port,
|
288
|
+
tcp: tcp,
|
289
|
+
user: user,
|
290
|
+
pass: pass,
|
291
|
+
dev: env == "DEV")
|