termfront 0.1.3 → 0.1.5
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 +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +14 -1
- data/data/events/final_push.json +0 -11
- data/lib/termfront/audio_manager.rb +6 -2
- data/lib/termfront/config.rb +2 -1
- data/lib/termfront/game.rb +2 -1
- data/lib/termfront/mission/final_push.rb +1 -1
- data/lib/termfront/network/client.rb +28 -55
- data/lib/termfront/network/connection.rb +8 -0
- data/lib/termfront/network/server.rb +489 -95
- data/lib/termfront/network/wavesfight_client.rb +104 -55
- data/lib/termfront/renderer.rb +128 -73
- data/lib/termfront/terminal_output.rb +1 -6
- data/lib/termfront/version.rb +1 -1
- metadata +1 -1
|
@@ -3,11 +3,35 @@
|
|
|
3
3
|
require "socket"
|
|
4
4
|
require "openssl"
|
|
5
5
|
require "json"
|
|
6
|
+
require "set"
|
|
6
7
|
|
|
7
8
|
module Termfront
|
|
8
9
|
module Network
|
|
9
10
|
class Server
|
|
10
11
|
TEAM_SIZES = [1, 2, 4].freeze
|
|
12
|
+
MAX_QUEUE_PER_MODE = 64
|
|
13
|
+
QUEUE_HANDSHAKE_TIMEOUT = 5
|
|
14
|
+
MAX_MSG_BYTES = 16 * 1024
|
|
15
|
+
MATCH_MAX_DURATION = 30 * 60
|
|
16
|
+
MATCH_IDLE_TIMEOUT = 5 * 60
|
|
17
|
+
ALLOWED_MP_WEAPONS = %w[pistol ar shock_pistol shock_rifle].freeze
|
|
18
|
+
INITIAL_OBTAINED_WEAPONS = %i[pistol ar].freeze
|
|
19
|
+
MAX_STATE_DT = 0.5
|
|
20
|
+
POSITION_DELTA_MARGIN = 1.5
|
|
21
|
+
RATE_LIMITS = {
|
|
22
|
+
"state" => 60,
|
|
23
|
+
"hit" => 20,
|
|
24
|
+
"fire" => 20,
|
|
25
|
+
"pickup" => 5,
|
|
26
|
+
"ping" => 5,
|
|
27
|
+
"dead" => 5
|
|
28
|
+
}.freeze
|
|
29
|
+
DEFAULT_RATE_LIMIT = 10
|
|
30
|
+
MAX_DROPPED_MSGS = 200
|
|
31
|
+
MAX_PVP_RANGE = 30.0
|
|
32
|
+
STATE_BROADCAST_HZ = 30
|
|
33
|
+
STATE_BROADCAST_DT = 1.0 / STATE_BROADCAST_HZ
|
|
34
|
+
SELECT_TIMEOUT = 1.0 / 60.0
|
|
11
35
|
PVP_MAP = [
|
|
12
36
|
"####################",
|
|
13
37
|
"#........##........#",
|
|
@@ -43,11 +67,12 @@ module Termfront
|
|
|
43
67
|
end
|
|
44
68
|
|
|
45
69
|
def run
|
|
46
|
-
cert, key, chain =
|
|
70
|
+
cert, key, chain = load_cert
|
|
47
71
|
|
|
48
72
|
ctx = OpenSSL::SSL::SSLContext.new
|
|
49
73
|
ctx.cert = cert
|
|
50
74
|
ctx.key = key
|
|
75
|
+
ctx.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
51
76
|
ctx.extra_chain_cert = chain unless chain.empty?
|
|
52
77
|
|
|
53
78
|
tcp_server = TCPServer.new("0.0.0.0", @port)
|
|
@@ -60,11 +85,20 @@ module Termfront
|
|
|
60
85
|
begin
|
|
61
86
|
client = ssl_server.accept
|
|
62
87
|
configure_client(client)
|
|
63
|
-
|
|
88
|
+
Thread.new(client) do |c|
|
|
89
|
+
enqueue_player(c)
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
puts "Connection handler error: #{e.class}"
|
|
92
|
+
begin
|
|
93
|
+
c.close
|
|
94
|
+
rescue StandardError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
64
98
|
rescue OpenSSL::SSL::SSLError => e
|
|
65
|
-
puts "SSL handshake failed: #{e.
|
|
99
|
+
puts "SSL handshake failed: #{e.class}"
|
|
66
100
|
rescue StandardError => e
|
|
67
|
-
puts "Accept error: #{e.
|
|
101
|
+
puts "Accept error: #{e.class}"
|
|
68
102
|
end
|
|
69
103
|
end
|
|
70
104
|
end
|
|
@@ -85,61 +119,77 @@ module Termfront
|
|
|
85
119
|
client.close
|
|
86
120
|
return
|
|
87
121
|
end
|
|
88
|
-
peer = begin
|
|
89
|
-
client.peeraddr[2]
|
|
90
|
-
rescue StandardError
|
|
91
|
-
"unknown"
|
|
92
|
-
end
|
|
93
122
|
if request[:mode] == :wavesfight
|
|
94
|
-
enqueue_wavesfight_player(client,
|
|
123
|
+
enqueue_wavesfight_player(client, request)
|
|
95
124
|
else
|
|
96
|
-
enqueue_pvp_player(client,
|
|
125
|
+
enqueue_pvp_player(client, request[:team_size])
|
|
97
126
|
end
|
|
98
127
|
end
|
|
99
128
|
|
|
100
|
-
def enqueue_pvp_player(client,
|
|
101
|
-
puts "Player connected from #{peer}, queued for #{team_size}v#{team_size}"
|
|
102
|
-
|
|
129
|
+
def enqueue_pvp_player(client, team_size)
|
|
103
130
|
match_players = nil
|
|
131
|
+
rejected = false
|
|
104
132
|
@queue_mutex.synchronize do
|
|
105
|
-
@queues[team_size]
|
|
106
|
-
|
|
107
|
-
if @queues[team_size].size >= required
|
|
108
|
-
match_players = @queues[team_size].shift(required)
|
|
133
|
+
if @queues[team_size].size >= MAX_QUEUE_PER_MODE
|
|
134
|
+
rejected = true
|
|
109
135
|
else
|
|
110
|
-
|
|
111
|
-
|
|
136
|
+
@queues[team_size] << { socket: client }
|
|
137
|
+
required = team_size * 2
|
|
138
|
+
if @queues[team_size].size >= required
|
|
139
|
+
match_players = @queues[team_size].shift(required)
|
|
140
|
+
else
|
|
141
|
+
waiting = @queues[team_size].size
|
|
142
|
+
puts "Queue #{team_size}v#{team_size}: #{waiting}/#{required}"
|
|
143
|
+
end
|
|
112
144
|
end
|
|
113
145
|
end
|
|
146
|
+
if rejected
|
|
147
|
+
close_socket(client)
|
|
148
|
+
return
|
|
149
|
+
end
|
|
114
150
|
return unless match_players
|
|
115
151
|
|
|
116
|
-
Thread.new { run_match(team_size, match_players) }
|
|
152
|
+
Thread.new { supervise_match(match_players) { run_match(team_size, match_players) } }
|
|
117
153
|
end
|
|
118
154
|
|
|
119
|
-
def enqueue_wavesfight_player(client,
|
|
155
|
+
def enqueue_wavesfight_player(client, request)
|
|
120
156
|
mission_id = request[:mission_id]
|
|
121
157
|
difficulty = request[:difficulty]
|
|
122
158
|
key = [mission_id, difficulty]
|
|
123
|
-
puts "Player connected from #{peer}, queued for wavesfight #{mission_id} diff=#{difficulty}"
|
|
124
159
|
|
|
125
160
|
match_players = nil
|
|
161
|
+
rejected = false
|
|
126
162
|
@queue_mutex.synchronize do
|
|
127
|
-
@wavesfight_queues[key]
|
|
128
|
-
|
|
129
|
-
match_players = @wavesfight_queues[key].shift(2)
|
|
163
|
+
if @wavesfight_queues[key].size >= MAX_QUEUE_PER_MODE
|
|
164
|
+
rejected = true
|
|
130
165
|
else
|
|
131
|
-
|
|
132
|
-
|
|
166
|
+
@wavesfight_queues[key] << { socket: client }
|
|
167
|
+
if @wavesfight_queues[key].size >= 2
|
|
168
|
+
match_players = @wavesfight_queues[key].shift(2)
|
|
169
|
+
else
|
|
170
|
+
waiting = @wavesfight_queues[key].size
|
|
171
|
+
puts "Queue wavesfight #{mission_id}: #{waiting}/2"
|
|
172
|
+
end
|
|
133
173
|
end
|
|
134
174
|
end
|
|
175
|
+
if rejected
|
|
176
|
+
close_socket(client)
|
|
177
|
+
return
|
|
178
|
+
end
|
|
135
179
|
return unless match_players
|
|
136
180
|
|
|
137
|
-
Thread.new { run_wavesfight_match(mission_id, difficulty, match_players) }
|
|
181
|
+
Thread.new { supervise_match(match_players) { run_wavesfight_match(mission_id, difficulty, match_players) } }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def close_socket(client)
|
|
185
|
+
client.close
|
|
186
|
+
rescue StandardError
|
|
187
|
+
nil
|
|
138
188
|
end
|
|
139
189
|
|
|
140
190
|
def read_queue_request(client)
|
|
141
191
|
buf = +""
|
|
142
|
-
deadline = Time.now +
|
|
192
|
+
deadline = Time.now + QUEUE_HANDSHAKE_TIMEOUT
|
|
143
193
|
|
|
144
194
|
while Time.now < deadline
|
|
145
195
|
readable, = IO.select([client], nil, nil, 0.5)
|
|
@@ -151,6 +201,8 @@ module Termfront
|
|
|
151
201
|
next
|
|
152
202
|
end
|
|
153
203
|
|
|
204
|
+
return nil if buf.bytesize > MAX_MSG_BYTES
|
|
205
|
+
|
|
154
206
|
while (nl = buf.index("\n"))
|
|
155
207
|
line = buf.slice!(0, nl + 1)
|
|
156
208
|
begin
|
|
@@ -159,11 +211,15 @@ module Termfront
|
|
|
159
211
|
next
|
|
160
212
|
end
|
|
161
213
|
next unless msg[:t] == "queue"
|
|
214
|
+
return nil unless queue_token_acceptable?(msg)
|
|
162
215
|
|
|
163
216
|
if msg[:mode].to_s == "wavesfight"
|
|
217
|
+
mission_id = msg[:mission_id].to_s
|
|
218
|
+
return nil unless wavesfight_mission_ids.include?(mission_id)
|
|
219
|
+
|
|
164
220
|
return {
|
|
165
221
|
mode: :wavesfight,
|
|
166
|
-
mission_id:
|
|
222
|
+
mission_id: mission_id,
|
|
167
223
|
difficulty: [[msg[:difficulty].to_i, 0].max, Enemy::Base::DIFFICULTIES.size - 1].min
|
|
168
224
|
}
|
|
169
225
|
end
|
|
@@ -173,7 +229,7 @@ module Termfront
|
|
|
173
229
|
end
|
|
174
230
|
end
|
|
175
231
|
|
|
176
|
-
|
|
232
|
+
nil
|
|
177
233
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
178
234
|
nil
|
|
179
235
|
end
|
|
@@ -184,12 +240,22 @@ module Termfront
|
|
|
184
240
|
|
|
185
241
|
roster = players.each_with_index.map do |entry, idx|
|
|
186
242
|
team = idx < team_size ? 0 : 1
|
|
243
|
+
spawn = pvp_spawns[idx]
|
|
187
244
|
{
|
|
188
245
|
id: idx,
|
|
189
246
|
team: team,
|
|
190
247
|
socket: entry[:socket],
|
|
191
|
-
|
|
192
|
-
|
|
248
|
+
spawn: spawn,
|
|
249
|
+
x: spawn[0],
|
|
250
|
+
y: spawn[1],
|
|
251
|
+
angle: spawn[2],
|
|
252
|
+
last_state_at: nil,
|
|
253
|
+
shield: Config::SHIELD_MAX.to_f,
|
|
254
|
+
health: Config::HEALTH_MAX.to_f,
|
|
255
|
+
last_damage: -Config::SHIELD_DELAY,
|
|
256
|
+
weapon: :ar,
|
|
257
|
+
last_hit_at: nil,
|
|
258
|
+
obtained_weapons: Set.new(INITIAL_OBTAINED_WEAPONS),
|
|
193
259
|
buf: +"",
|
|
194
260
|
alive: true
|
|
195
261
|
}
|
|
@@ -206,6 +272,11 @@ module Termfront
|
|
|
206
272
|
})
|
|
207
273
|
end
|
|
208
274
|
|
|
275
|
+
match_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
276
|
+
last_activity = match_start
|
|
277
|
+
last_tick_at = match_start
|
|
278
|
+
last_state_flush_at = match_start
|
|
279
|
+
|
|
209
280
|
loop do
|
|
210
281
|
sockets = roster.filter_map do |player|
|
|
211
282
|
sock = player[:socket]
|
|
@@ -215,20 +286,50 @@ module Termfront
|
|
|
215
286
|
end
|
|
216
287
|
break if sockets.empty?
|
|
217
288
|
|
|
218
|
-
readable, = IO.select(sockets, nil, nil,
|
|
289
|
+
readable, = IO.select(sockets, nil, nil, SELECT_TIMEOUT)
|
|
290
|
+
|
|
291
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
292
|
+
if (reason = match_timeout_reason(now, match_start, last_activity))
|
|
293
|
+
broadcast(roster, { t: "match_end", reason: reason })
|
|
294
|
+
close_players(roster)
|
|
295
|
+
puts "Match ended (#{reason})."
|
|
296
|
+
return
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
dt = now - last_tick_at
|
|
300
|
+
roster.each { |player| regen_player(player, dt, now) }
|
|
301
|
+
last_tick_at = now
|
|
302
|
+
|
|
303
|
+
if now - last_state_flush_at >= STATE_BROADCAST_DT
|
|
304
|
+
flush_pending_states(roster)
|
|
305
|
+
last_state_flush_at = now
|
|
306
|
+
end
|
|
307
|
+
|
|
219
308
|
next unless readable
|
|
220
309
|
|
|
310
|
+
last_activity = now
|
|
311
|
+
|
|
221
312
|
readable.each do |sock|
|
|
222
313
|
player = roster.find { |entry| entry[:socket] == sock }
|
|
223
314
|
next unless player
|
|
224
315
|
|
|
225
316
|
begin
|
|
226
317
|
player[:buf] << sock.read_nonblock(4096)
|
|
227
|
-
|
|
318
|
+
if player[:buf].bytesize > MAX_MSG_BYTES
|
|
319
|
+
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
320
|
+
close_players(roster)
|
|
321
|
+
puts "Match aborted."
|
|
322
|
+
return
|
|
323
|
+
end
|
|
324
|
+
if consume_messages(roster, player) == :rate_limit_exceeded
|
|
325
|
+
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
326
|
+
close_players(roster)
|
|
327
|
+
puts "Match aborted (rate limit)."
|
|
328
|
+
return
|
|
329
|
+
end
|
|
228
330
|
rescue IO::WaitReadable
|
|
229
331
|
next
|
|
230
332
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
231
|
-
puts "Player #{player[:id]} disconnected from #{player[:peer]}"
|
|
232
333
|
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
233
334
|
close_players(roster)
|
|
234
335
|
puts "Match aborted."
|
|
@@ -255,13 +356,51 @@ module Termfront
|
|
|
255
356
|
next
|
|
256
357
|
end
|
|
257
358
|
|
|
359
|
+
unless allow_message(player, msg[:t].to_s, Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
360
|
+
player[:dropped_msgs] = (player[:dropped_msgs] || 0) + 1
|
|
361
|
+
return :rate_limit_exceeded if player[:dropped_msgs] > MAX_DROPPED_MSGS
|
|
362
|
+
|
|
363
|
+
next
|
|
364
|
+
end
|
|
365
|
+
|
|
258
366
|
case msg[:t]
|
|
259
367
|
when "ping"
|
|
260
368
|
send_json(player[:socket], { t: "pong", ts: msg[:ts] })
|
|
261
369
|
when "state"
|
|
262
|
-
|
|
370
|
+
next unless valid_position?(msg, pvp_map)
|
|
371
|
+
|
|
372
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
373
|
+
new_x = msg[:x].to_f
|
|
374
|
+
new_y = msg[:y].to_f
|
|
375
|
+
next unless position_delta_acceptable?(player[:x], player[:y], player[:last_state_at],
|
|
376
|
+
new_x, new_y, now)
|
|
377
|
+
|
|
378
|
+
player[:x] = new_x
|
|
379
|
+
player[:y] = new_y
|
|
380
|
+
player[:angle] = msg[:a].to_f
|
|
381
|
+
player[:last_state_at] = now
|
|
382
|
+
|
|
383
|
+
if msg.key?(:w)
|
|
384
|
+
weapon = normalize_weapon(msg[:w], player)
|
|
385
|
+
if weapon
|
|
386
|
+
player[:weapon] = weapon
|
|
387
|
+
msg = msg.merge(w: weapon.to_s)
|
|
388
|
+
else
|
|
389
|
+
msg = msg.except(:w)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
if msg.key?(:am)
|
|
393
|
+
ammo = validate_int(msg[:am], min: -1, max: 999)
|
|
394
|
+
msg = ammo ? msg.merge(am: ammo) : msg.except(:am)
|
|
395
|
+
end
|
|
396
|
+
if msg.key?(:ff)
|
|
397
|
+
ff = validate_int(msg[:ff], min: 0, max: 10)
|
|
398
|
+
msg = msg.merge(ff: ff || 0)
|
|
399
|
+
end
|
|
400
|
+
msg = msg.merge(s: player[:shield].round(1), h: player[:health].round(1))
|
|
401
|
+
player[:pending_state] = msg.merge(from: player[:id])
|
|
263
402
|
when "hit"
|
|
264
|
-
route_hit(roster, player, msg)
|
|
403
|
+
route_hit(roster, player, msg, Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
265
404
|
when "dead"
|
|
266
405
|
player[:alive] = false
|
|
267
406
|
broadcast(roster, { t: "dead", from: player[:id] }, except: player[:id])
|
|
@@ -269,14 +408,49 @@ module Termfront
|
|
|
269
408
|
end
|
|
270
409
|
end
|
|
271
410
|
|
|
272
|
-
def route_hit(roster, attacker,
|
|
273
|
-
|
|
274
|
-
|
|
411
|
+
def route_hit(roster, attacker, _msg, clock)
|
|
412
|
+
return unless attacker[:alive]
|
|
413
|
+
|
|
414
|
+
weapon = Weapon::Base.build(attacker[:weapon] || :ar)
|
|
415
|
+
return if attacker[:last_hit_at] && (clock - attacker[:last_hit_at]) < weapon.cooldown
|
|
416
|
+
|
|
417
|
+
target = pvp_target_from_raycast(roster, attacker, weapon)
|
|
275
418
|
return unless target
|
|
276
|
-
return unless target[:alive] && attacker[:alive]
|
|
277
|
-
return if target[:team] == attacker[:team]
|
|
278
419
|
|
|
279
|
-
|
|
420
|
+
attacker[:last_hit_at] = clock
|
|
421
|
+
apply_damage_to_player(target, Config::PVP_HIT_DMG, clock)
|
|
422
|
+
send_json(target[:socket],
|
|
423
|
+
{ t: "hit", from: attacker[:id], d: Config::PVP_HIT_DMG,
|
|
424
|
+
s: target[:shield].round(1), h: target[:health].round(1) })
|
|
425
|
+
broadcast(roster, { t: "dead", from: target[:id] }, except: target[:id]) unless target[:alive]
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def pvp_target_from_raycast(roster, attacker, weapon)
|
|
429
|
+
dx = Math.cos(attacker[:angle])
|
|
430
|
+
dy = Math.sin(attacker[:angle])
|
|
431
|
+
best = nil
|
|
432
|
+
best_dot = Float::INFINITY
|
|
433
|
+
|
|
434
|
+
roster.each do |other|
|
|
435
|
+
next if other[:id] == attacker[:id]
|
|
436
|
+
next unless other[:alive]
|
|
437
|
+
next if other[:team] == attacker[:team]
|
|
438
|
+
|
|
439
|
+
ox = other[:x] - attacker[:x]
|
|
440
|
+
oy = other[:y] - attacker[:y]
|
|
441
|
+
dot = ox * dx + oy * dy
|
|
442
|
+
next if dot < 0.1
|
|
443
|
+
next if dot > MAX_PVP_RANGE
|
|
444
|
+
next unless dot < best_dot
|
|
445
|
+
|
|
446
|
+
perp = (ox * (-dy) + oy * dx).abs
|
|
447
|
+
next if perp > weapon.hit_width
|
|
448
|
+
next unless pvp_map.line_of_sight?(attacker[:x], attacker[:y], other[:x], other[:y])
|
|
449
|
+
|
|
450
|
+
best = other
|
|
451
|
+
best_dot = dot
|
|
452
|
+
end
|
|
453
|
+
best
|
|
280
454
|
end
|
|
281
455
|
|
|
282
456
|
def winning_team(roster)
|
|
@@ -287,15 +461,30 @@ module Termfront
|
|
|
287
461
|
end
|
|
288
462
|
|
|
289
463
|
def broadcast(roster, msg, except: nil)
|
|
464
|
+
line = JSON.generate(msg) + "\n"
|
|
290
465
|
roster.each do |player|
|
|
291
466
|
next if player[:id] == except
|
|
292
467
|
|
|
293
|
-
|
|
468
|
+
write_line(player[:socket], line)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def flush_pending_states(roster)
|
|
473
|
+
roster.each do |player|
|
|
474
|
+
state = player[:pending_state]
|
|
475
|
+
next unless state
|
|
476
|
+
|
|
477
|
+
broadcast(roster, state, except: player[:id])
|
|
478
|
+
player[:pending_state] = nil
|
|
294
479
|
end
|
|
295
480
|
end
|
|
296
481
|
|
|
297
482
|
def send_json(socket, msg)
|
|
298
|
-
socket
|
|
483
|
+
write_line(socket, JSON.generate(msg) + "\n")
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def write_line(socket, line)
|
|
487
|
+
socket.write(line)
|
|
299
488
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError, OpenSSL::SSL::SSLError
|
|
300
489
|
nil
|
|
301
490
|
end
|
|
@@ -308,6 +497,117 @@ module Termfront
|
|
|
308
497
|
end
|
|
309
498
|
end
|
|
310
499
|
|
|
500
|
+
def valid_position?(msg, map)
|
|
501
|
+
x = msg[:x]
|
|
502
|
+
y = msg[:y]
|
|
503
|
+
a = msg[:a]
|
|
504
|
+
return false unless x.is_a?(Numeric) && y.is_a?(Numeric) && a.is_a?(Numeric)
|
|
505
|
+
|
|
506
|
+
fx = x.to_f
|
|
507
|
+
fy = y.to_f
|
|
508
|
+
fa = a.to_f
|
|
509
|
+
return false unless fx.finite? && fy.finite? && fa.finite?
|
|
510
|
+
return false if fx < 0 || fx >= map.width
|
|
511
|
+
return false if fy < 0 || fy >= map.height
|
|
512
|
+
|
|
513
|
+
true
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def validate_int(value, min:, max:)
|
|
517
|
+
return nil unless value.is_a?(Numeric) && value.to_f.finite?
|
|
518
|
+
|
|
519
|
+
int = value.to_i
|
|
520
|
+
return nil if int < min || int > max
|
|
521
|
+
|
|
522
|
+
int
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def allow_message(player, msg_type, now)
|
|
526
|
+
limit = RATE_LIMITS[msg_type] || DEFAULT_RATE_LIMIT
|
|
527
|
+
buckets = (player[:rate_buckets] ||= {})
|
|
528
|
+
bucket = (buckets[msg_type] ||= { tokens: limit.to_f, last_refill: now })
|
|
529
|
+
|
|
530
|
+
elapsed = now - bucket[:last_refill]
|
|
531
|
+
bucket[:tokens] = [bucket[:tokens] + elapsed * limit, limit.to_f].min
|
|
532
|
+
bucket[:last_refill] = now
|
|
533
|
+
|
|
534
|
+
return false if bucket[:tokens] < 1.0
|
|
535
|
+
|
|
536
|
+
bucket[:tokens] -= 1.0
|
|
537
|
+
true
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def position_delta_acceptable?(prev_x, prev_y, prev_at, new_x, new_y, now)
|
|
541
|
+
dt = if prev_at.nil?
|
|
542
|
+
0.1
|
|
543
|
+
else
|
|
544
|
+
[now - prev_at, MAX_STATE_DT].min
|
|
545
|
+
end
|
|
546
|
+
return true if dt <= 0
|
|
547
|
+
|
|
548
|
+
max_step = Config::MOVE_SPEED * dt * POSITION_DELTA_MARGIN
|
|
549
|
+
delta_sq = (new_x - prev_x)**2 + (new_y - prev_y)**2
|
|
550
|
+
delta_sq <= max_step * max_step
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def validate_float(value, min:, max:)
|
|
554
|
+
return nil unless value.is_a?(Numeric) && value.to_f.finite?
|
|
555
|
+
|
|
556
|
+
f = value.to_f
|
|
557
|
+
return nil if f < min || f > max
|
|
558
|
+
|
|
559
|
+
f
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def expected_pvp_token
|
|
563
|
+
token = ENV["TERMFRONT_PVP_TOKEN"]
|
|
564
|
+
token.nil? || token.empty? ? nil : token
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def queue_token_acceptable?(msg)
|
|
568
|
+
expected = expected_pvp_token
|
|
569
|
+
return true if expected.nil?
|
|
570
|
+
|
|
571
|
+
provided = msg[:token]
|
|
572
|
+
return false unless provided.is_a?(String)
|
|
573
|
+
return false unless provided.bytesize == expected.bytesize
|
|
574
|
+
|
|
575
|
+
OpenSSL.fixed_length_secure_compare(provided, expected)
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def normalize_weapon(value, player = nil)
|
|
579
|
+
return nil unless value.is_a?(String) || value.is_a?(Symbol)
|
|
580
|
+
|
|
581
|
+
name = value.to_s
|
|
582
|
+
return nil unless ALLOWED_MP_WEAPONS.include?(name)
|
|
583
|
+
|
|
584
|
+
sym = name.to_sym
|
|
585
|
+
if player && player[:obtained_weapons] && !player[:obtained_weapons].include?(sym)
|
|
586
|
+
return nil
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
sym
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def match_timeout_reason(now, match_start, last_activity)
|
|
593
|
+
return "match_ttl" if now - match_start > MATCH_MAX_DURATION
|
|
594
|
+
return "idle" if now - last_activity > MATCH_IDLE_TIMEOUT
|
|
595
|
+
|
|
596
|
+
nil
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def supervise_match(match_players)
|
|
600
|
+
yield
|
|
601
|
+
rescue StandardError => e
|
|
602
|
+
puts "Match thread crashed: #{e.class}"
|
|
603
|
+
ensure
|
|
604
|
+
match_players.each do |entry|
|
|
605
|
+
entry[:socket].close
|
|
606
|
+
rescue StandardError
|
|
607
|
+
nil
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
311
611
|
def run_wavesfight_match(mission_id, difficulty, players)
|
|
312
612
|
mission_klass = Mission::Base.wavesfight.find { |klass| klass.new.id == mission_id }
|
|
313
613
|
unless mission_klass
|
|
@@ -323,15 +623,17 @@ module Termfront
|
|
|
323
623
|
{
|
|
324
624
|
id: idx,
|
|
325
625
|
socket: entry[:socket],
|
|
326
|
-
peer: entry[:peer],
|
|
327
626
|
buf: +"",
|
|
328
627
|
x: spawn[0],
|
|
329
628
|
y: spawn[1],
|
|
330
629
|
angle: spawn[2],
|
|
331
630
|
shield: Config::SHIELD_MAX,
|
|
332
631
|
health: Config::HEALTH_MAX,
|
|
632
|
+
last_damage: -Config::SHIELD_DELAY,
|
|
633
|
+
last_state_at: nil,
|
|
333
634
|
weapon: :ar,
|
|
334
635
|
ammo: 60,
|
|
636
|
+
obtained_weapons: Set.new(INITIAL_OBTAINED_WEAPONS),
|
|
335
637
|
fire_flash: 0,
|
|
336
638
|
alive: true
|
|
337
639
|
}
|
|
@@ -344,9 +646,11 @@ module Termfront
|
|
|
344
646
|
wave: 0,
|
|
345
647
|
enemies: [],
|
|
346
648
|
projectiles: [],
|
|
649
|
+
drops: [],
|
|
650
|
+
next_drop_id: 0,
|
|
347
651
|
clock: Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
348
652
|
}
|
|
349
|
-
start_wavesfight_wave(session)
|
|
653
|
+
start_wavesfight_wave(session, roster)
|
|
350
654
|
|
|
351
655
|
roster.each do |player|
|
|
352
656
|
send_json(player[:socket], {
|
|
@@ -358,12 +662,20 @@ module Termfront
|
|
|
358
662
|
})
|
|
359
663
|
end
|
|
360
664
|
|
|
665
|
+
match_start = session[:clock]
|
|
666
|
+
last_activity = match_start
|
|
361
667
|
last_broadcast = session[:clock]
|
|
362
668
|
loop do
|
|
363
669
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
364
670
|
dt = now - session[:clock]
|
|
365
671
|
session[:clock] = now
|
|
366
672
|
|
|
673
|
+
if (reason = match_timeout_reason(now, match_start, last_activity))
|
|
674
|
+
broadcast(roster, { t: "match_end", reason: reason })
|
|
675
|
+
close_players(roster)
|
|
676
|
+
return
|
|
677
|
+
end
|
|
678
|
+
|
|
367
679
|
sockets = roster.filter_map do |player|
|
|
368
680
|
sock = player[:socket]
|
|
369
681
|
sock unless sock.closed?
|
|
@@ -374,13 +686,23 @@ module Termfront
|
|
|
374
686
|
|
|
375
687
|
readable, = IO.select(sockets, nil, nil, 0.01)
|
|
376
688
|
if readable
|
|
689
|
+
last_activity = now
|
|
377
690
|
readable.each do |sock|
|
|
378
691
|
player = roster.find { |entry| entry[:socket] == sock }
|
|
379
692
|
next unless player
|
|
380
693
|
|
|
381
694
|
begin
|
|
382
695
|
player[:buf] << sock.read_nonblock(4096)
|
|
383
|
-
|
|
696
|
+
if player[:buf].bytesize > MAX_MSG_BYTES
|
|
697
|
+
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
698
|
+
close_players(roster)
|
|
699
|
+
return
|
|
700
|
+
end
|
|
701
|
+
if consume_wavesfight_messages(roster, session, player) == :rate_limit_exceeded
|
|
702
|
+
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
703
|
+
close_players(roster)
|
|
704
|
+
return
|
|
705
|
+
end
|
|
384
706
|
rescue IO::WaitReadable
|
|
385
707
|
next
|
|
386
708
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
@@ -414,26 +736,74 @@ module Termfront
|
|
|
414
736
|
next
|
|
415
737
|
end
|
|
416
738
|
|
|
739
|
+
unless allow_message(player, msg[:t].to_s, session[:clock])
|
|
740
|
+
player[:dropped_msgs] = (player[:dropped_msgs] || 0) + 1
|
|
741
|
+
return :rate_limit_exceeded if player[:dropped_msgs] > MAX_DROPPED_MSGS
|
|
742
|
+
|
|
743
|
+
next
|
|
744
|
+
end
|
|
745
|
+
|
|
417
746
|
case msg[:t]
|
|
418
747
|
when "ping"
|
|
419
748
|
send_json(player[:socket], { t: "pong", ts: msg[:ts] })
|
|
420
749
|
when "state"
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
player[:
|
|
426
|
-
|
|
750
|
+
next unless valid_position?(msg, session[:map])
|
|
751
|
+
|
|
752
|
+
new_x = msg[:x].to_f
|
|
753
|
+
new_y = msg[:y].to_f
|
|
754
|
+
next unless position_delta_acceptable?(player[:x], player[:y], player[:last_state_at],
|
|
755
|
+
new_x, new_y, session[:clock])
|
|
756
|
+
|
|
757
|
+
player[:x] = new_x
|
|
758
|
+
player[:y] = new_y
|
|
759
|
+
player[:angle] = msg[:a].to_f
|
|
760
|
+
player[:last_state_at] = session[:clock]
|
|
761
|
+
weapon = normalize_weapon(msg[:w], player)
|
|
762
|
+
player[:weapon] = weapon if weapon
|
|
763
|
+
if msg.key?(:am)
|
|
764
|
+
ammo = validate_int(msg[:am], min: 0, max: 999)
|
|
765
|
+
player[:ammo] = ammo if ammo
|
|
766
|
+
end
|
|
767
|
+
ff = validate_int(msg[:ff], min: 0, max: 10)
|
|
768
|
+
player[:fire_flash] = ff || 0
|
|
427
769
|
when "fire"
|
|
428
770
|
player[:fire_flash] = 4
|
|
429
771
|
process_wavesfight_fire(session, player)
|
|
772
|
+
when "pickup"
|
|
773
|
+
process_pickup(session, player, msg)
|
|
430
774
|
end
|
|
431
775
|
end
|
|
432
776
|
end
|
|
433
777
|
|
|
778
|
+
def process_pickup(session, player, msg)
|
|
779
|
+
drop_id = msg[:id]
|
|
780
|
+
return unless drop_id.is_a?(Numeric)
|
|
781
|
+
|
|
782
|
+
drop = session[:drops].find { |d| d[:id] == drop_id }
|
|
783
|
+
return unless drop
|
|
784
|
+
|
|
785
|
+
dx = drop[:x] - player[:x]
|
|
786
|
+
dy = drop[:y] - player[:y]
|
|
787
|
+
return if (dx * dx + dy * dy) > Config::PICKUP_RADIUS**2
|
|
788
|
+
return unless ALLOWED_MP_WEAPONS.include?(drop[:type].to_s)
|
|
789
|
+
|
|
790
|
+
if player[:weapon] == drop[:type]
|
|
791
|
+
weapon_klass = Weapon::Base.registry[drop[:type]]
|
|
792
|
+
max = weapon_klass&.new&.max_ammo
|
|
793
|
+
player[:ammo] = max ? [player[:ammo] + drop[:ammo], max].min : player[:ammo]
|
|
794
|
+
else
|
|
795
|
+
spawn_drop(session, player[:x], player[:y], player[:weapon], player[:ammo]) if player[:weapon]
|
|
796
|
+
player[:weapon] = drop[:type]
|
|
797
|
+
player[:ammo] = drop[:ammo]
|
|
798
|
+
player[:obtained_weapons] << drop[:type]
|
|
799
|
+
end
|
|
800
|
+
session[:drops].delete(drop)
|
|
801
|
+
end
|
|
802
|
+
|
|
434
803
|
def update_wavesfight_session(roster, session, dt)
|
|
435
804
|
roster.each do |player|
|
|
436
805
|
player[:fire_flash] -= 1 if player[:fire_flash].to_i > 0
|
|
806
|
+
regen_player(player, dt, session[:clock])
|
|
437
807
|
end
|
|
438
808
|
|
|
439
809
|
session[:enemies].each do |enemy|
|
|
@@ -455,8 +825,8 @@ module Termfront
|
|
|
455
825
|
target = roster.find { |player| player[:alive] && projectile.hit_player?(player[:x], player[:y]) }
|
|
456
826
|
if target
|
|
457
827
|
dmg = enemy_damage(projectile.type)
|
|
458
|
-
|
|
459
|
-
send_json(target[:socket], { t: "hit", d: dmg })
|
|
828
|
+
apply_damage_to_player(target, dmg, session[:clock])
|
|
829
|
+
send_json(target[:socket], { t: "hit", d: dmg, s: target[:shield], h: target[:health] })
|
|
460
830
|
true
|
|
461
831
|
else
|
|
462
832
|
false
|
|
@@ -465,7 +835,7 @@ module Termfront
|
|
|
465
835
|
end
|
|
466
836
|
|
|
467
837
|
if session[:enemies].all? { |enemy| !enemy.alive }
|
|
468
|
-
start_wavesfight_wave(session)
|
|
838
|
+
start_wavesfight_wave(session, roster)
|
|
469
839
|
broadcast(roster, { t: "wave_start", wave: session[:wave], difficulty: session[:difficulty] })
|
|
470
840
|
end
|
|
471
841
|
end
|
|
@@ -496,6 +866,15 @@ module Termfront
|
|
|
496
866
|
return unless best
|
|
497
867
|
|
|
498
868
|
best.take_damage(1)
|
|
869
|
+
return if best.alive
|
|
870
|
+
|
|
871
|
+
spawn_drop(session, best.x, best.y, best.drop_type, best.drop_ammo)
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def spawn_drop(session, x, y, type, ammo)
|
|
875
|
+
id = session[:next_drop_id]
|
|
876
|
+
session[:next_drop_id] += 1
|
|
877
|
+
session[:drops] << { id: id, x: x, y: y, type: type, ammo: ammo }
|
|
499
878
|
end
|
|
500
879
|
|
|
501
880
|
def enemy_damage(type)
|
|
@@ -503,7 +882,7 @@ module Termfront
|
|
|
503
882
|
enemy_klass ? enemy_klass.allocate.send(:damage) : 10
|
|
504
883
|
end
|
|
505
884
|
|
|
506
|
-
def
|
|
885
|
+
def apply_damage_to_player(player, amount, clock)
|
|
507
886
|
if player[:shield] > 0
|
|
508
887
|
overflow = amount - player[:shield]
|
|
509
888
|
player[:shield] = [player[:shield] - amount, 0].max
|
|
@@ -512,9 +891,21 @@ module Termfront
|
|
|
512
891
|
player[:health] = [player[:health] - amount, 0].max
|
|
513
892
|
end
|
|
514
893
|
|
|
894
|
+
player[:last_damage] = clock
|
|
515
895
|
player[:alive] = false if player[:health] <= 0
|
|
516
896
|
end
|
|
517
897
|
|
|
898
|
+
def regen_player(player, dt, now)
|
|
899
|
+
return unless player[:alive]
|
|
900
|
+
|
|
901
|
+
if player[:shield] < Config::SHIELD_MAX && (now - player[:last_damage]) >= Config::SHIELD_DELAY
|
|
902
|
+
player[:shield] = [player[:shield] + Config::SHIELD_REGEN * dt, Config::SHIELD_MAX].min
|
|
903
|
+
end
|
|
904
|
+
return unless player[:shield] >= Config::SHIELD_MAX && player[:health] < Config::HEALTH_MAX
|
|
905
|
+
|
|
906
|
+
player[:health] = [player[:health] + Config::SHIELD_REGEN * dt, Config::HEALTH_MAX].min
|
|
907
|
+
end
|
|
908
|
+
|
|
518
909
|
def all_wavesfight_players_dead?(roster)
|
|
519
910
|
roster.none? { |player| player[:alive] }
|
|
520
911
|
end
|
|
@@ -538,17 +929,27 @@ module Termfront
|
|
|
538
929
|
}
|
|
539
930
|
end,
|
|
540
931
|
projectiles: session[:projectiles].map { |projectile| { x: projectile.x, y: projectile.y, type: projectile.type } },
|
|
541
|
-
drops: []
|
|
932
|
+
drops: session[:drops].map { |drop| { id: drop[:id], x: drop[:x], y: drop[:y], type: drop[:type], am: drop[:ammo] } }
|
|
542
933
|
}
|
|
543
934
|
broadcast(roster, msg)
|
|
544
935
|
end
|
|
545
936
|
|
|
546
|
-
def start_wavesfight_wave(session)
|
|
937
|
+
def start_wavesfight_wave(session, roster = nil)
|
|
547
938
|
session[:wave] += 1
|
|
548
939
|
session[:difficulty] = [session[:difficulty], 1 + ((session[:wave] - 1) / 3)].max
|
|
549
940
|
session[:difficulty] = [session[:difficulty], Enemy::Base::DIFFICULTIES.size - 1].min
|
|
550
941
|
session[:enemies] = build_wavesfight_enemies(session[:mission], session[:wave], session[:difficulty])
|
|
551
942
|
session[:projectiles].clear
|
|
943
|
+
replenish_wavesfight_roster(roster, session) if roster && session[:wave] > 1
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
def replenish_wavesfight_roster(roster, session)
|
|
947
|
+
roster.each do |player|
|
|
948
|
+
player[:shield] = Config::SHIELD_MAX
|
|
949
|
+
player[:health] = [player[:health] + 20.0, Config::HEALTH_MAX].min
|
|
950
|
+
player[:last_damage] = session[:clock] - Config::SHIELD_DELAY
|
|
951
|
+
player[:alive] = true
|
|
952
|
+
end
|
|
552
953
|
end
|
|
553
954
|
|
|
554
955
|
def build_wavesfight_enemies(mission, wave, difficulty_index)
|
|
@@ -575,47 +976,40 @@ module Termfront
|
|
|
575
976
|
spawns
|
|
576
977
|
end
|
|
577
978
|
|
|
578
|
-
def
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
cert.public_key = key.public_key
|
|
586
|
-
cert.not_before = Time.now
|
|
587
|
-
cert.not_after = Time.now + 365 * 24 * 60 * 60
|
|
588
|
-
cert.sign(key, OpenSSL::Digest.new("SHA256"))
|
|
589
|
-
[cert, key]
|
|
590
|
-
end
|
|
591
|
-
|
|
592
|
-
def load_or_create_cert
|
|
593
|
-
cert_file = ENV.fetch("TERMFRONT_TLS_CERT_FILE", "termfront_server.crt")
|
|
594
|
-
key_file = ENV.fetch("TERMFRONT_TLS_KEY_FILE", "termfront_server.key")
|
|
595
|
-
|
|
596
|
-
if File.exist?(cert_file) && File.exist?(key_file)
|
|
597
|
-
certs = OpenSSL::X509::Certificate.load(File.read(cert_file))
|
|
598
|
-
certs = [certs] unless certs.is_a?(Array)
|
|
599
|
-
cert = certs.first
|
|
600
|
-
chain = certs.drop(1)
|
|
601
|
-
key = OpenSSL::PKey::RSA.new(File.read(key_file))
|
|
602
|
-
puts "Loaded existing certificate."
|
|
603
|
-
else
|
|
604
|
-
cert, key = generate_self_signed_cert
|
|
605
|
-
File.write(cert_file, cert.to_pem)
|
|
606
|
-
File.write(key_file, key.to_pem)
|
|
607
|
-
puts "Generated new self-signed certificate."
|
|
608
|
-
chain = []
|
|
979
|
+
def load_cert
|
|
980
|
+
cert_file = ENV["TERMFRONT_TLS_CERT_FILE"]
|
|
981
|
+
key_file = ENV["TERMFRONT_TLS_KEY_FILE"]
|
|
982
|
+
|
|
983
|
+
if cert_file.nil? || cert_file.empty? || key_file.nil? || key_file.empty?
|
|
984
|
+
raise "TLS not configured: set TERMFRONT_TLS_CERT_FILE and TERMFRONT_TLS_KEY_FILE to PEM paths " \
|
|
985
|
+
"(use a fullchain certificate, e.g. issued by Let's Encrypt)."
|
|
609
986
|
end
|
|
987
|
+
unless File.exist?(cert_file) && File.exist?(key_file)
|
|
988
|
+
raise "TLS cert or key file not found at the configured paths."
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
certs = OpenSSL::X509::Certificate.load(File.read(cert_file))
|
|
992
|
+
certs = [certs] unless certs.is_a?(Array)
|
|
993
|
+
cert = certs.first
|
|
994
|
+
chain = certs.drop(1)
|
|
995
|
+
key = OpenSSL::PKey::RSA.new(File.read(key_file))
|
|
996
|
+
puts "Loaded TLS certificate."
|
|
610
997
|
[cert, key, chain]
|
|
611
998
|
end
|
|
612
999
|
|
|
1000
|
+
def wavesfight_mission_ids
|
|
1001
|
+
@wavesfight_mission_ids ||= Mission::Base.wavesfight.map { |klass| klass.new.id }.freeze
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def pvp_map
|
|
1005
|
+
@pvp_map ||= Map.new(PVP_MAP)
|
|
1006
|
+
end
|
|
1007
|
+
|
|
613
1008
|
def pvp_spawns
|
|
614
1009
|
@pvp_spawns ||= begin
|
|
615
|
-
map = Map.new(PVP_MAP)
|
|
616
1010
|
PVP_SPAWN_CANDIDATES.each do |spawn|
|
|
617
1011
|
x, y, = spawn
|
|
618
|
-
raise "Invalid PvP spawn #{spawn.inspect}" if
|
|
1012
|
+
raise "Invalid PvP spawn #{spawn.inspect}" if pvp_map.blocked?(x, y)
|
|
619
1013
|
end
|
|
620
1014
|
PVP_SPAWN_CANDIDATES
|
|
621
1015
|
end
|