teeworlds_network/lib/teeworlds-client.rb

405 lines
10 KiB
Ruby
Raw Normal View History

2022-10-25 17:42:20 +00:00
#!/usr/bin/env ruby
2022-11-05 16:48:47 +00:00
# frozen_string_literal: true
2022-10-25 17:42:20 +00:00
require 'socket'
2022-11-01 13:25:56 +00:00
require_relative 'string'
require_relative 'array'
require_relative 'bytes'
require_relative 'network'
require_relative 'packet'
require_relative 'chunk'
require_relative 'server_info'
require_relative 'net_base'
require_relative 'packer'
2022-11-04 15:26:24 +00:00
require_relative 'player'
require_relative 'game_client'
2022-10-30 10:18:15 +00:00
2022-11-04 15:26:24 +00:00
class TeeworldsClient
2022-11-05 10:07:16 +00:00
attr_reader :state, :hooks, :game_client
2022-10-25 17:42:20 +00:00
def initialize(options = {})
@verbose = options[:verbose] || false
2022-10-25 17:42:20 +00:00
@state = NET_CONNSTATE_OFFLINE
@ip = 'localhost'
@port = 8303
@packet_flags = {}
@hooks = {}
2022-11-01 14:27:39 +00:00
@thread_running = false
@signal_disconnect = false
2022-11-04 15:26:24 +00:00
@game_client = GameClient.new(self)
2022-11-04 12:22:29 +00:00
@start_info = {
2022-11-05 16:19:05 +00:00
name: 'ruby gamer',
clan: '',
2022-11-04 12:22:29 +00:00
country: -1,
2022-11-05 16:19:05 +00:00
body: 'spiky',
marking: 'duodonny',
decoration: '',
hands: 'standard',
feet: 'standard',
eyes: 'standard',
2022-11-04 12:22:29 +00:00
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
}
2022-10-25 17:42:20 +00:00
end
2022-11-05 08:39:16 +00:00
def on_chat(&block)
2022-11-01 13:25:56 +00:00
@hooks[:chat] = block
end
def on_map_change(&block)
@hooks[:map_change] = block
end
def on_client_info(&block)
@hooks[:client_info] = block
end
2022-11-05 10:59:36 +00:00
def on_client_drop(&block)
@hooks[:client_drop] = block
end
2022-11-05 10:07:16 +00:00
def on_connected(&block)
@hooks[:connected] = block
end
2022-11-06 17:26:14 +00:00
def on_rcon_line(&block)
@hooks[:rcon_line] = block
end
2022-11-04 15:57:50 +00:00
def send_chat(str)
@netbase.send_packet(
2022-11-05 16:19:05 +00:00
NetChunk.create_vital_header({ vital: true }, 4 + str.length) +
2022-11-04 15:57:50 +00:00
[
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
2022-11-05 16:19:05 +00:00
if options[:detach] && @thread_running
puts 'Error: connection thread already running call disconnect() first'
return
2022-11-01 14:27:39 +00:00
end
2022-11-04 12:04:51 +00:00
disconnect
@signal_disconnect = false
2022-11-04 09:12:23 +00:00
@ticks = 0
2022-11-04 15:26:24 +00:00
@game_client = GameClient.new(self)
# me trying to write cool code
@client_token = (1..4).to_a.map { |_| rand(0..255) }
2022-11-05 16:57:12 +00:00
@client_token = @client_token.map { |b| b.to_s(16) }.join
2022-11-04 09:12:23 +00:00
puts "client token #{@client_token}"
@netbase = NetBase.new
@netbase.client_token = @client_token
2022-11-04 11:55:01 +00:00
NetChunk.reset
2022-11-01 13:25:56 +00:00
@ip = ip
@port = port
puts "connecting to #{@ip}:#{@port} .."
2022-11-01 14:27:39 +00:00
@s = UDPSocket.new
2022-11-01 13:25:56 +00:00
@s.connect(ip, port)
2022-11-01 14:27:39 +00:00
puts "client port: #{@s.addr[1]}"
2022-11-01 13:25:56 +00:00
@netbase.connect(@s, @ip, @port)
2022-11-01 14:27:39 +00:00
@token = nil
2022-11-01 13:25:56 +00:00
send_ctrl_with_token
if options[:detach]
@thread_running = true
Thread.new do
connection_loop
2022-11-01 14:27:39 +00:00
end
else
connection_loop
2022-11-01 13:25:56 +00:00
end
end
def disconnect
2022-11-05 16:19:05 +00:00
puts 'disconnecting.'
2022-11-05 16:48:47 +00:00
@netbase&.send_packet([NET_CTRLMSG_CLOSE], 0, control: true)
@s&.close
2022-11-01 14:27:39 +00:00
@signal_disconnect = true
2022-11-01 13:25:56 +00:00
end
2022-11-04 12:22:29 +00:00
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
2022-10-25 17:42:20 +00:00
def send_msg(data)
2022-10-30 10:18:15 +00:00
@netbase.send_packet(data)
2022-10-25 17:42:20 +00:00
end
2022-11-05 16:19:05 +00:00
def send_ctrl_keepalive
@netbase.send_packet([NET_CTRLMSG_KEEPALIVE], 0, control: true)
2022-10-29 15:04:35 +00:00
end
2022-11-05 16:19:05 +00:00
def send_msg_connect
2022-11-04 09:12:23 +00:00
msg = [NET_CTRLMSG_CONNECT] + str_bytes(@client_token) + Array.new(501, 0x00)
@netbase.send_packet(msg, 0, control: true)
2022-10-25 17:42:20 +00:00
end
2022-11-05 16:19:05 +00:00
def send_ctrl_with_token
2022-10-25 17:42:20 +00:00
@state = NET_CONNSTATE_TOKEN
2022-11-04 09:12:23 +00:00
msg = [NET_CTRLMSG_TOKEN] + str_bytes(@client_token) + Array.new(512, 0x00)
@netbase.send_packet(msg, 0, control: true)
2022-10-25 17:42:20 +00:00
end
2022-11-05 16:19:05 +00:00
def send_info
data = []
data += Packer.pack_str(GAME_NETVERSION)
2022-11-05 16:19:05 +00:00
data += Packer.pack_str('password')
data += Packer.pack_int(CLIENT_VERSION)
2022-11-05 16:19:05 +00:00
msg = NetChunk.create_vital_header({ vital: true }, data.size + 1) +
[pack_msg_id(NETMSG_INFO, system: true)] +
data
@netbase.send_packet(msg, 1)
2022-10-25 17:42:20 +00:00
end
2022-11-06 17:26:14 +00:00
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_vital_header({ vital: true }, data.size + 1) +
[pack_msg_id(NETMSG_RCON_AUTH, system: true)] +
data
@netbase.send_packet(msg, 1)
end
def rcon(command)
data = []
data += Packer.pack_str(command)
msg = NetChunk.create_vital_header({ vital: true }, data.size + 1) +
[pack_msg_id(NETMSG_RCON_CMD, system: true)] +
data
@netbase.send_packet(msg, 1)
end
2022-11-05 16:19:05 +00:00
def send_msg_startinfo
data = []
2022-11-04 12:22:29 +00:00
@start_info.each do |key, value|
2022-11-05 16:19:05 +00:00
if value.instance_of?(String)
data += Packer.pack_str(value)
2022-11-05 16:19:05 +00:00
elsif value.instance_of?(Integer)
data += Packer.pack_int(value)
else
puts "Error: invalid startinfo #{key}: #{value}"
exit 1
end
end
2022-11-04 09:12:23 +00:00
@netbase.send_packet(
2022-11-05 16:19:05 +00:00
NetChunk.create_vital_header({ vital: true }, data.size + 1) +
[pack_msg_id(NETMSGTYPE_CL_STARTINFO, system: false)] +
data
)
2022-10-25 17:42:20 +00:00
end
2022-11-05 16:19:05 +00:00
def send_msg_ready
2022-11-04 09:12:23 +00:00
@netbase.send_packet(
2022-11-05 16:19:05 +00:00
NetChunk.create_vital_header({ vital: true }, 1) +
[pack_msg_id(NETMSG_READY, system: true)]
)
2022-10-25 17:42:20 +00:00
end
2022-11-05 16:19:05 +00:00
def send_enter_game
@netbase.send_packet(
2022-11-05 16:19:05 +00:00
NetChunk.create_vital_header({ vital: true }, 1) +
[pack_msg_id(NETMSG_ENTERGAME, system: true)]
)
2022-10-25 17:42:20 +00:00
end
2022-11-01 09:37:24 +00:00
##
# Turns int into network byte
#
# Takes a NETMSGTYPE_CL_* integer
# and returns a byte that can be send over
# the network
2022-11-05 16:19:05 +00:00
def pack_msg_id(msg_id, options = { system: false })
2022-11-04 09:12:23 +00:00
(msg_id << 1) | (options[:system] ? 1 : 0)
2022-11-01 09:37:24 +00:00
end
2022-10-25 17:42:20 +00:00
def send_input
2022-11-05 16:19:05 +00:00
header = [0x10, 0x0A, 0o1] + str_bytes(@token)
2022-10-25 17:42:20 +00:00
random_compressed_input = [
0x4D, 0xE9, 0x48, 0x13, 0xD0, 0x0B, 0x6B, 0xFC, 0xB7, 0x2B, 0x6E, 0x00, 0xBA
]
# this wont work we need to ack the ticks
# and then compress it
# CMsgPacker Msg(NETMSG_INPUT, true);
# Msg.AddInt(m_AckGameTick);
# Msg.AddInt(m_PredTick);
# Msg.AddInt(Size);
msg = header + random_compressed_input
@s.send(msg.pack('C*'), 0, @ip, @port)
end
def on_msg_token(data)
2022-11-05 16:19:05 +00:00
@token = bytes_to_str(data)
@netbase.server_token = @token
puts "Got token #{@token}"
send_msg_connect
2022-10-25 17:42:20 +00:00
end
def on_msg_accept
2022-11-05 16:19:05 +00:00
puts 'got accept. connection online'
2022-10-25 17:42:20 +00:00
@state = NET_CONNSTATE_ONLINE
send_info
end
def on_msg_close
2022-11-05 16:19:05 +00:00
puts 'got NET_CTRLMSG_CLOSE'
2022-10-25 17:42:20 +00:00
end
private
2022-10-25 17:42:20 +00:00
# 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
2022-11-05 16:19:05 +00:00
when NET_CTRLMSG_KEEPALIVE # silently ignore keepalive
else
2022-11-05 16:19:05 +00:00
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)
2022-11-05 10:59:36 +00:00
when NETMSGTYPE_SV_CLIENTDROP then @game_client.on_client_drop(chunk)
when NETMSGTYPE_SV_EMOTICON then @game_client.on_emoticon(chunk)
2022-11-04 15:26:24 +00:00
when NETMSGTYPE_SV_CHAT then @game_client.on_chat(chunk)
else
2022-11-05 16:19:05 +00:00
puts "todo non sys chunks. skipped msg: #{chunk.msg}" if @verbose
end
end
def process_chunk(chunk)
2022-11-05 16:19:05 +00:00
unless chunk.sys
on_message(chunk)
return
end
puts "proccess chunk with msg: #{chunk.msg}"
case chunk.msg
when NETMSG_MAP_CHANGE
@game_client.on_map_change(chunk)
when NETMSG_SERVERINFO
2022-11-05 16:19:05 +00:00
puts 'ignore server info for now'
when NETMSG_CON_READY
@game_client.on_connected
when NETMSG_NULL
# should we be in alert here?
2022-11-06 17:26:14 +00:00
when NETMSG_RCON_LINE
@game_client.on_rcon_line(chunk)
else
puts "Unsupported system msg: #{chunk.msg}"
exit(1)
end
end
def process_server_packet(packet)
data = packet.payload
if data.size.zero?
2022-11-05 16:19:05 +00:00
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
2022-10-30 18:00:13 +00:00
@netbase.ack = (@netbase.ack + 1) % NET_MAX_SEQUENCE
2022-11-05 16:19:05 +00:00
puts "got ack: #{@netbase.ack}" if @verbose
2022-10-30 18:00:13 +00:00
end
process_chunk(chunk)
end
end
2022-10-25 17:42:20 +00:00
def tick
# puts "tick"
begin
pck = @s.recvfrom_nonblock(1400)
2022-11-01 14:27:39 +00:00
rescue IO::EAGAINWaitReadable
2022-10-25 17:42:20 +00:00
pck = nil
end
2022-11-01 14:27:39 +00:00
if pck.nil? && @token.nil?
2022-11-05 16:19:05 +00:00
@wait_for_token ||= 0
2022-11-01 14:27:39 +00:00
@wait_for_token += 1
if @wait_for_token > 6
@token = nil
send_ctrl_with_token
2022-11-05 16:19:05 +00:00
puts 'retrying connection ...'
2022-11-01 14:27:39 +00:00
end
end
2022-10-25 17:42:20 +00:00
return unless pck
data = pck.first
2022-10-30 10:18:15 +00:00
packet = Packet.new(data, '<')
2022-11-05 16:19:05 +00:00
puts packet.to_s if @verbose
# process connless packets data
if packet.flags_control
2022-11-05 16:19:05 +00:00
msg = data[PACKET_HEADER_SIZE].unpack1('C*')
2022-10-29 11:17:42 +00:00
on_ctrl_message(msg, data[(PACKET_HEADER_SIZE + 1)..])
else # process non-connless packets
process_server_packet(packet)
end
2022-10-29 15:04:35 +00:00
@ticks += 1
2022-11-05 16:48:47 +00:00
send_ctrl_keepalive if (@ticks % 8).zero?
2022-11-05 10:07:16 +00:00
# if @ticks % 20 == 0
# send_chat("hello world")
# end
2022-10-25 17:42:20 +00:00
end
def connection_loop
2022-11-05 16:19:05 +00:00
until @signal_disconnect
tick
# TODO: proper tick speed sleep
sleep 0.001
end
@thread_running = false
@signal_disconnect = false
end
end