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.
@@ -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")