ChillerDragon
bddc48ef82
I was missing it for a 'Wrong password' error but it is also nice in the non error case or all other disconnects triggered by the server like kick etc
503 lines
12 KiB
Ruby
503 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'socket'
|
|
|
|
require_relative 'string'
|
|
require_relative 'array'
|
|
require_relative 'bytes'
|
|
require_relative 'network'
|
|
require_relative 'packet'
|
|
require_relative 'chunk'
|
|
require_relative 'messages/server_info'
|
|
require_relative 'net_base'
|
|
require_relative 'packer'
|
|
require_relative 'models/player'
|
|
require_relative 'game_client'
|
|
|
|
class TeeworldsClient
|
|
attr_reader :state, :hooks, :game_client
|
|
attr_accessor :rcon_authed, :local_client_id
|
|
|
|
def initialize(options = {})
|
|
@verbose = options[:verbose] || false
|
|
@state = NET_CONNSTATE_OFFLINE
|
|
@ip = 'localhost'
|
|
@port = 8303
|
|
@local_client_id = 0
|
|
@hooks = {
|
|
chat: [],
|
|
map_change: [],
|
|
client_info: [],
|
|
client_drop: [],
|
|
connected: [],
|
|
disconnect: [],
|
|
rcon_line: [],
|
|
snapshot: [],
|
|
input_timing: [],
|
|
auth_on: [],
|
|
auth_off: [],
|
|
rcon_cmd_add: [],
|
|
rcon_cmd_rem: [],
|
|
maplist_entry_add: [],
|
|
maplist_entry_rem: []
|
|
}
|
|
@thread_running = false
|
|
@signal_disconnect = false
|
|
@game_client = GameClient.new(self)
|
|
@start_info = {
|
|
name: 'ruby gamer',
|
|
clan: '',
|
|
country: -1,
|
|
body: 'spiky',
|
|
marking: 'duodonny',
|
|
decoration: '',
|
|
hands: 'standard',
|
|
feet: 'standard',
|
|
eyes: 'standard',
|
|
custom_color_body: 0,
|
|
custom_color_marking: 0,
|
|
custom_color_decoration: 0,
|
|
custom_color_hands: 0,
|
|
custom_color_feet: 0,
|
|
custom_color_eyes: 0,
|
|
color_body: 0,
|
|
color_marking: 0,
|
|
color_decoration: 0,
|
|
color_hands: 0,
|
|
color_feet: 0,
|
|
color_eyes: 0
|
|
}
|
|
@rcon_authed = false
|
|
end
|
|
|
|
def rcon_authed?
|
|
@rcon_authed
|
|
end
|
|
|
|
def on_auth_on(&block)
|
|
@hooks[:auth_on].push(block)
|
|
end
|
|
|
|
def on_auth_off(&block)
|
|
@hooks[:auth_off].push(block)
|
|
end
|
|
|
|
def on_rcon_cmd_add(&block)
|
|
@hooks[:rcon_cmd_add].push(block)
|
|
end
|
|
|
|
def on_rcon_cmd_rem(&block)
|
|
@hooks[:rcon_cmd_rem].push(block)
|
|
end
|
|
|
|
def on_maplist_entry_add(&block)
|
|
@hooks[:maplist_entry_add].push(block)
|
|
end
|
|
|
|
def on_maplist_entry_rem(&block)
|
|
@hooks[:maplist_entry_rem].push(block)
|
|
end
|
|
|
|
def on_chat(&block)
|
|
@hooks[:chat].push(block)
|
|
end
|
|
|
|
def on_map_change(&block)
|
|
@hooks[:map_change].push(block)
|
|
end
|
|
|
|
def on_client_info(&block)
|
|
@hooks[:client_info].push(block)
|
|
end
|
|
|
|
def on_client_drop(&block)
|
|
@hooks[:client_drop].push(block)
|
|
end
|
|
|
|
def on_connected(&block)
|
|
@hooks[:connected].push(block)
|
|
end
|
|
|
|
def on_disconnect(&block)
|
|
@hooks[:disconnect].push(block)
|
|
end
|
|
|
|
def on_rcon_line(&block)
|
|
@hooks[:rcon_line].push(block)
|
|
end
|
|
|
|
def on_snapshot(&block)
|
|
@hooks[:snapshot].push(block)
|
|
end
|
|
|
|
def on_input_timing(&block)
|
|
@hooks[:input_timing].push(block)
|
|
end
|
|
|
|
def send_chat(str)
|
|
@netbase.send_packet(
|
|
NetChunk.create_header(vital: true, size: 4 + str.length) +
|
|
[
|
|
pack_msg_id(NETMSGTYPE_CL_SAY),
|
|
CHAT_ALL,
|
|
64 # should use TARGET_SERVER (-1) instead of hacking 64 in here
|
|
] +
|
|
Packer.pack_str(str)
|
|
)
|
|
end
|
|
|
|
def connect(ip, port, options = {})
|
|
options[:detach] = options[:detach] || false
|
|
if options[:detach] && @thread_running
|
|
puts 'Error: connection thread already running call disconnect() first'
|
|
return
|
|
end
|
|
disconnect
|
|
@signal_disconnect = false
|
|
@ticks = 0
|
|
@game_client = GameClient.new(self)
|
|
# me trying to write cool code
|
|
@client_token = (1..4).to_a.map { |_| rand(0..255) }
|
|
@client_token = @client_token.map { |b| b.to_s(16).rjust(2, '0') }.join
|
|
puts "client token #{@client_token}"
|
|
@netbase = NetBase.new(verbose: @verbose)
|
|
NetChunk.reset
|
|
@ip = ip
|
|
@port = port
|
|
puts "connecting to #{@ip}:#{@port} .."
|
|
@s = UDPSocket.new
|
|
@s.connect(ip, port)
|
|
puts "client port: #{@s.addr[1]}"
|
|
@netbase.connect(@s, @ip, @port)
|
|
@token = nil
|
|
send_ctrl_with_token
|
|
if options[:detach]
|
|
@thread_running = true
|
|
Thread.new do
|
|
connection_loop
|
|
end
|
|
else
|
|
connection_loop
|
|
end
|
|
end
|
|
|
|
# TODO: this is same in client and server
|
|
# move to NetBase???
|
|
def send_ctrl_close
|
|
@netbase&.send_packet([NET_CTRLMSG_CLOSE], chunks: 0, control: true)
|
|
end
|
|
|
|
def disconnect
|
|
puts 'disconnecting.'
|
|
send_ctrl_close
|
|
@s&.close
|
|
@signal_disconnect = true
|
|
end
|
|
|
|
def set_startinfo(info)
|
|
info.each do |key, value|
|
|
unless @start_info.key?(key)
|
|
puts "Error: invalid start info key '#{key}'"
|
|
puts " valid keys: #{@start_info.keys}"
|
|
exit 1
|
|
end
|
|
@start_info[key] = value
|
|
end
|
|
end
|
|
|
|
def send_msg(data)
|
|
@netbase.send_packet(data)
|
|
end
|
|
|
|
def send_ctrl_keepalive
|
|
@netbase.send_packet([NET_CTRLMSG_KEEPALIVE], chunks: 0, control: true)
|
|
end
|
|
|
|
def send_msg_connect
|
|
msg = [NET_CTRLMSG_CONNECT] + str_bytes(@client_token) + Array.new(501, 0x00)
|
|
@netbase.send_packet(msg, chunks: 0, control: true)
|
|
end
|
|
|
|
def send_ctrl_with_token
|
|
@state = NET_CONNSTATE_TOKEN
|
|
msg = [NET_CTRLMSG_TOKEN] + str_bytes(@client_token) + Array.new(512, 0x00)
|
|
@netbase.send_packet(msg, chunks: 0, control: true)
|
|
end
|
|
|
|
def send_info
|
|
data = []
|
|
data += Packer.pack_str(GAME_NETVERSION)
|
|
data += Packer.pack_str('password')
|
|
data += Packer.pack_int(CLIENT_VERSION)
|
|
msg = NetChunk.create_header(vital: true, size: data.size + 1) +
|
|
[pack_msg_id(NETMSG_INFO, system: true)] +
|
|
data
|
|
|
|
@netbase.send_packet(msg)
|
|
end
|
|
|
|
def rcon_auth(name, password = nil)
|
|
if name.instance_of?(Hash)
|
|
password = name[:password]
|
|
name = name[:name]
|
|
end
|
|
if password.nil?
|
|
raise "Error: password can not be empty\n" \
|
|
" provide two strings: name, password\n" \
|
|
" or a hash with the key :password\n" \
|
|
"\n" \
|
|
" rcon_auth('', '123')\n" \
|
|
" rcon_auth(password: '123')\n"
|
|
end
|
|
data = []
|
|
if name.nil? || name == ''
|
|
data += Packer.pack_str(password)
|
|
else # ddnet auth using name, password and some int?
|
|
data += Packer.pack_str(name)
|
|
data += Packer.pack_str(password)
|
|
data += Packer.pack_int(1)
|
|
end
|
|
msg = NetChunk.create_header(vital: true, size: data.size + 1) +
|
|
[pack_msg_id(NETMSG_RCON_AUTH, system: true)] +
|
|
data
|
|
@netbase.send_packet(msg)
|
|
end
|
|
|
|
def rcon(command)
|
|
data = []
|
|
data += Packer.pack_str(command)
|
|
msg = NetChunk.create_header(vital: true, size: data.size + 1) +
|
|
[pack_msg_id(NETMSG_RCON_CMD, system: true)] +
|
|
data
|
|
@netbase.send_packet(msg)
|
|
end
|
|
|
|
def send_msg_start_info
|
|
data = []
|
|
|
|
@start_info.each do |key, value|
|
|
if value.instance_of?(String)
|
|
data += Packer.pack_str(value)
|
|
elsif value.instance_of?(Integer)
|
|
data += Packer.pack_int(value)
|
|
else
|
|
puts "Error: invalid start info #{key}: #{value}"
|
|
exit 1
|
|
end
|
|
end
|
|
|
|
@netbase.send_packet(
|
|
NetChunk.create_header(vital: true, size: data.size + 1) +
|
|
[pack_msg_id(NETMSGTYPE_CL_STARTINFO, system: false)] +
|
|
data
|
|
)
|
|
end
|
|
|
|
def send_msg_ready
|
|
@netbase.send_packet(
|
|
NetChunk.create_header(vital: true, size: 1) +
|
|
[pack_msg_id(NETMSG_READY, system: true)]
|
|
)
|
|
end
|
|
|
|
def send_enter_game
|
|
@netbase.send_packet(
|
|
NetChunk.create_header(vital: true, size: 1) +
|
|
[pack_msg_id(NETMSG_ENTERGAME, system: true)]
|
|
)
|
|
end
|
|
|
|
def send_input
|
|
inp = {
|
|
direction: -1,
|
|
target_x: 10,
|
|
target_y: 10,
|
|
jump: rand(0..1),
|
|
fire: 0,
|
|
hook: 0,
|
|
player_flags: 0,
|
|
wanted_weapon: 0,
|
|
next_weapon: 0,
|
|
prev_weapon: 0
|
|
}
|
|
|
|
data = []
|
|
data += Packer.pack_int(@game_client.ack_game_tick)
|
|
data += Packer.pack_int(@game_client.pred_game_tick)
|
|
data += Packer.pack_int(40) # magic size 40
|
|
data += Packer.pack_int(inp[:direction])
|
|
data += Packer.pack_int(inp[:target_x])
|
|
data += Packer.pack_int(inp[:target_y])
|
|
data += Packer.pack_int(inp[:jump])
|
|
data += Packer.pack_int(inp[:fire])
|
|
data += Packer.pack_int(inp[:hook])
|
|
data += Packer.pack_int(inp[:player_flags])
|
|
data += Packer.pack_int(inp[:wanted_weapon])
|
|
data += Packer.pack_int(inp[:next_weapon])
|
|
data += Packer.pack_int(inp[:prev_weapon])
|
|
msg = NetChunk.create_header(vital: false, size: data.size + 1) +
|
|
[pack_msg_id(NETMSG_INPUT, system: true)] +
|
|
data
|
|
@netbase.send_packet(msg)
|
|
end
|
|
|
|
def on_msg_token(data)
|
|
@token = bytes_to_str(data)
|
|
@netbase.set_peer_token(@token)
|
|
puts "Got token #{@token}"
|
|
send_msg_connect
|
|
end
|
|
|
|
def on_msg_accept
|
|
puts 'got accept. connection online'
|
|
@state = NET_CONNSTATE_ONLINE
|
|
send_info
|
|
end
|
|
|
|
def on_msg_close(data)
|
|
@game_client.on_disconnect(data)
|
|
end
|
|
|
|
private
|
|
|
|
# CClient::ProcessConnlessPacket
|
|
def on_ctrl_message(msg, data)
|
|
case msg
|
|
when NET_CTRLMSG_TOKEN then on_msg_token(data)
|
|
when NET_CTRLMSG_ACCEPT then on_msg_accept
|
|
when NET_CTRLMSG_CLOSE then on_msg_close(data)
|
|
when NET_CTRLMSG_KEEPALIVE # silently ignore keepalive
|
|
else
|
|
puts "Uknown control message #{msg}"
|
|
exit(1)
|
|
end
|
|
end
|
|
|
|
def on_message(chunk)
|
|
case chunk.msg
|
|
when NETMSGTYPE_SV_READYTOENTER then @game_client.on_ready_to_enter(chunk)
|
|
when NETMSGTYPE_SV_CLIENTINFO then @game_client.on_client_info(chunk)
|
|
when NETMSGTYPE_SV_CLIENTDROP then @game_client.on_client_drop(chunk)
|
|
when NETMSGTYPE_SV_EMOTICON then @game_client.on_emoticon(chunk)
|
|
when NETMSGTYPE_SV_CHAT then @game_client.on_chat(chunk)
|
|
else
|
|
puts "todo non sys chunks. skipped msg: #{chunk.msg}" if @verbose
|
|
end
|
|
end
|
|
|
|
def process_chunk(chunk)
|
|
unless chunk.sys
|
|
on_message(chunk)
|
|
return
|
|
end
|
|
puts "proccess chunk with msg: #{chunk.msg}" if @verbose
|
|
case chunk.msg
|
|
when NETMSG_MAP_CHANGE
|
|
@game_client.on_map_change(chunk)
|
|
when NETMSG_SERVERINFO
|
|
puts 'ignore server info for now'
|
|
when NETMSG_CON_READY
|
|
@game_client.on_connected
|
|
when NETMSG_NULL
|
|
# should we be in alert here?
|
|
when NETMSG_RCON_LINE
|
|
@game_client.on_rcon_line(chunk)
|
|
when NETMSG_SNAP, NETMSG_SNAPSINGLE, NETMSG_SNAPEMPTY
|
|
@game_client.on_snapshot(chunk)
|
|
when NETMSG_INPUTTIMING
|
|
@game_client.on_input_timing(chunk)
|
|
when NETMSG_RCON_AUTH_ON
|
|
@game_client.on_auth_on
|
|
when NETMSG_RCON_AUTH_OFF
|
|
@game_client.on_auth_off
|
|
when NETMSG_RCON_CMD_ADD
|
|
@game_client.on_rcon_cmd_add(chunk)
|
|
when NETMSG_RCON_CMD_REM
|
|
@game_client.on_rcon_cmd_rem(chunk)
|
|
when NETMSG_MAPLIST_ENTRY_ADD
|
|
@game_client.on_maplist_entry_add(chunk)
|
|
when NETMSG_MAPLIST_ENTRY_REM
|
|
@game_client.on_maplist_entry_rem(chunk)
|
|
else
|
|
puts "Unsupported system msg: #{chunk.msg}"
|
|
p str_hex(chunk.full_raw)
|
|
exit(1)
|
|
end
|
|
end
|
|
|
|
def process_server_packet(packet)
|
|
data = packet.payload
|
|
if data.size.zero?
|
|
puts 'Error: packet payload is empty'
|
|
puts packet.to_s
|
|
return
|
|
end
|
|
chunks = BigChungusTheChunkGetter.get_chunks(data)
|
|
chunks.each do |chunk|
|
|
if chunk.flags_vital && !chunk.flags_resend
|
|
@netbase.ack = (@netbase.ack + 1) % NET_MAX_SEQUENCE
|
|
puts "got ack: #{@netbase.ack}" if @verbose
|
|
end
|
|
process_chunk(chunk)
|
|
end
|
|
end
|
|
|
|
def tick
|
|
# puts "tick"
|
|
begin
|
|
pck = @s.recvfrom_nonblock(1400)
|
|
rescue Errno::ECONNREFUSED
|
|
puts 'connection problems ...'
|
|
pck = nil
|
|
rescue IO::EAGAINWaitReadable
|
|
pck = nil
|
|
end
|
|
if pck.nil? && @token.nil?
|
|
@wait_for_token ||= 0
|
|
@wait_for_token += 1
|
|
if @wait_for_token > 6
|
|
@token = nil
|
|
send_ctrl_with_token
|
|
puts 'retrying connection ...'
|
|
end
|
|
end
|
|
return unless pck
|
|
|
|
data = pck.first
|
|
|
|
packet = Packet.new(data, '<')
|
|
puts packet.to_s if @verbose
|
|
|
|
# process connless packets data
|
|
if packet.flags_control
|
|
msg = data[PACKET_HEADER_SIZE].unpack1('C*')
|
|
on_ctrl_message(msg, data[(PACKET_HEADER_SIZE + 1)..])
|
|
else # process non-connless packets
|
|
process_server_packet(packet)
|
|
end
|
|
|
|
send_ctrl_keepalive if (@ticks % 8).zero?
|
|
if @game_client.ack_game_tick.positive?
|
|
now = Time.now
|
|
@@last_pred ||= now
|
|
diff = now - @@last_pred
|
|
# @Swarfey does js setInterval(20) in his lib
|
|
# not sure if it makes sense to do a diff > 0.2 here then xd
|
|
@game_client.pred_game_tick += 1 if diff > 0.2
|
|
end
|
|
|
|
@ticks += 1
|
|
send_input if (@ticks % 20).zero?
|
|
end
|
|
|
|
def connection_loop
|
|
until @signal_disconnect
|
|
tick
|
|
# TODO: proper tick speed sleep
|
|
sleep 0.001
|
|
end
|
|
@thread_running = false
|
|
@signal_disconnect = false
|
|
end
|
|
end
|