textflight-client 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+
@@ -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
+
@@ -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")