termfront 0.1.3 → 0.1.4
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 +34 -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/mission/final_push.rb +1 -1
- data/lib/termfront/network/client.rb +27 -54
- data/lib/termfront/network/connection.rb +8 -0
- data/lib/termfront/network/server.rb +459 -91
- data/lib/termfront/network/wavesfight_client.rb +104 -55
- data/lib/termfront/version.rb +1 -1
- metadata +1 -1
|
@@ -3,11 +3,31 @@
|
|
|
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
|
|
11
31
|
PVP_MAP = [
|
|
12
32
|
"####################",
|
|
13
33
|
"#........##........#",
|
|
@@ -43,11 +63,12 @@ module Termfront
|
|
|
43
63
|
end
|
|
44
64
|
|
|
45
65
|
def run
|
|
46
|
-
cert, key, chain =
|
|
66
|
+
cert, key, chain = load_cert
|
|
47
67
|
|
|
48
68
|
ctx = OpenSSL::SSL::SSLContext.new
|
|
49
69
|
ctx.cert = cert
|
|
50
70
|
ctx.key = key
|
|
71
|
+
ctx.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
51
72
|
ctx.extra_chain_cert = chain unless chain.empty?
|
|
52
73
|
|
|
53
74
|
tcp_server = TCPServer.new("0.0.0.0", @port)
|
|
@@ -60,11 +81,20 @@ module Termfront
|
|
|
60
81
|
begin
|
|
61
82
|
client = ssl_server.accept
|
|
62
83
|
configure_client(client)
|
|
63
|
-
|
|
84
|
+
Thread.new(client) do |c|
|
|
85
|
+
enqueue_player(c)
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
puts "Connection handler error: #{e.class}"
|
|
88
|
+
begin
|
|
89
|
+
c.close
|
|
90
|
+
rescue StandardError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
64
94
|
rescue OpenSSL::SSL::SSLError => e
|
|
65
|
-
puts "SSL handshake failed: #{e.
|
|
95
|
+
puts "SSL handshake failed: #{e.class}"
|
|
66
96
|
rescue StandardError => e
|
|
67
|
-
puts "Accept error: #{e.
|
|
97
|
+
puts "Accept error: #{e.class}"
|
|
68
98
|
end
|
|
69
99
|
end
|
|
70
100
|
end
|
|
@@ -85,61 +115,77 @@ module Termfront
|
|
|
85
115
|
client.close
|
|
86
116
|
return
|
|
87
117
|
end
|
|
88
|
-
peer = begin
|
|
89
|
-
client.peeraddr[2]
|
|
90
|
-
rescue StandardError
|
|
91
|
-
"unknown"
|
|
92
|
-
end
|
|
93
118
|
if request[:mode] == :wavesfight
|
|
94
|
-
enqueue_wavesfight_player(client,
|
|
119
|
+
enqueue_wavesfight_player(client, request)
|
|
95
120
|
else
|
|
96
|
-
enqueue_pvp_player(client,
|
|
121
|
+
enqueue_pvp_player(client, request[:team_size])
|
|
97
122
|
end
|
|
98
123
|
end
|
|
99
124
|
|
|
100
|
-
def enqueue_pvp_player(client,
|
|
101
|
-
puts "Player connected from #{peer}, queued for #{team_size}v#{team_size}"
|
|
102
|
-
|
|
125
|
+
def enqueue_pvp_player(client, team_size)
|
|
103
126
|
match_players = nil
|
|
127
|
+
rejected = false
|
|
104
128
|
@queue_mutex.synchronize do
|
|
105
|
-
@queues[team_size]
|
|
106
|
-
|
|
107
|
-
if @queues[team_size].size >= required
|
|
108
|
-
match_players = @queues[team_size].shift(required)
|
|
129
|
+
if @queues[team_size].size >= MAX_QUEUE_PER_MODE
|
|
130
|
+
rejected = true
|
|
109
131
|
else
|
|
110
|
-
|
|
111
|
-
|
|
132
|
+
@queues[team_size] << { socket: client }
|
|
133
|
+
required = team_size * 2
|
|
134
|
+
if @queues[team_size].size >= required
|
|
135
|
+
match_players = @queues[team_size].shift(required)
|
|
136
|
+
else
|
|
137
|
+
waiting = @queues[team_size].size
|
|
138
|
+
puts "Queue #{team_size}v#{team_size}: #{waiting}/#{required}"
|
|
139
|
+
end
|
|
112
140
|
end
|
|
113
141
|
end
|
|
142
|
+
if rejected
|
|
143
|
+
close_socket(client)
|
|
144
|
+
return
|
|
145
|
+
end
|
|
114
146
|
return unless match_players
|
|
115
147
|
|
|
116
|
-
Thread.new { run_match(team_size, match_players) }
|
|
148
|
+
Thread.new { supervise_match(match_players) { run_match(team_size, match_players) } }
|
|
117
149
|
end
|
|
118
150
|
|
|
119
|
-
def enqueue_wavesfight_player(client,
|
|
151
|
+
def enqueue_wavesfight_player(client, request)
|
|
120
152
|
mission_id = request[:mission_id]
|
|
121
153
|
difficulty = request[:difficulty]
|
|
122
154
|
key = [mission_id, difficulty]
|
|
123
|
-
puts "Player connected from #{peer}, queued for wavesfight #{mission_id} diff=#{difficulty}"
|
|
124
155
|
|
|
125
156
|
match_players = nil
|
|
157
|
+
rejected = false
|
|
126
158
|
@queue_mutex.synchronize do
|
|
127
|
-
@wavesfight_queues[key]
|
|
128
|
-
|
|
129
|
-
match_players = @wavesfight_queues[key].shift(2)
|
|
159
|
+
if @wavesfight_queues[key].size >= MAX_QUEUE_PER_MODE
|
|
160
|
+
rejected = true
|
|
130
161
|
else
|
|
131
|
-
|
|
132
|
-
|
|
162
|
+
@wavesfight_queues[key] << { socket: client }
|
|
163
|
+
if @wavesfight_queues[key].size >= 2
|
|
164
|
+
match_players = @wavesfight_queues[key].shift(2)
|
|
165
|
+
else
|
|
166
|
+
waiting = @wavesfight_queues[key].size
|
|
167
|
+
puts "Queue wavesfight #{mission_id}: #{waiting}/2"
|
|
168
|
+
end
|
|
133
169
|
end
|
|
134
170
|
end
|
|
171
|
+
if rejected
|
|
172
|
+
close_socket(client)
|
|
173
|
+
return
|
|
174
|
+
end
|
|
135
175
|
return unless match_players
|
|
136
176
|
|
|
137
|
-
Thread.new { run_wavesfight_match(mission_id, difficulty, match_players) }
|
|
177
|
+
Thread.new { supervise_match(match_players) { run_wavesfight_match(mission_id, difficulty, match_players) } }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def close_socket(client)
|
|
181
|
+
client.close
|
|
182
|
+
rescue StandardError
|
|
183
|
+
nil
|
|
138
184
|
end
|
|
139
185
|
|
|
140
186
|
def read_queue_request(client)
|
|
141
187
|
buf = +""
|
|
142
|
-
deadline = Time.now +
|
|
188
|
+
deadline = Time.now + QUEUE_HANDSHAKE_TIMEOUT
|
|
143
189
|
|
|
144
190
|
while Time.now < deadline
|
|
145
191
|
readable, = IO.select([client], nil, nil, 0.5)
|
|
@@ -151,6 +197,8 @@ module Termfront
|
|
|
151
197
|
next
|
|
152
198
|
end
|
|
153
199
|
|
|
200
|
+
return nil if buf.bytesize > MAX_MSG_BYTES
|
|
201
|
+
|
|
154
202
|
while (nl = buf.index("\n"))
|
|
155
203
|
line = buf.slice!(0, nl + 1)
|
|
156
204
|
begin
|
|
@@ -159,11 +207,15 @@ module Termfront
|
|
|
159
207
|
next
|
|
160
208
|
end
|
|
161
209
|
next unless msg[:t] == "queue"
|
|
210
|
+
return nil unless queue_token_acceptable?(msg)
|
|
162
211
|
|
|
163
212
|
if msg[:mode].to_s == "wavesfight"
|
|
213
|
+
mission_id = msg[:mission_id].to_s
|
|
214
|
+
return nil unless wavesfight_mission_ids.include?(mission_id)
|
|
215
|
+
|
|
164
216
|
return {
|
|
165
217
|
mode: :wavesfight,
|
|
166
|
-
mission_id:
|
|
218
|
+
mission_id: mission_id,
|
|
167
219
|
difficulty: [[msg[:difficulty].to_i, 0].max, Enemy::Base::DIFFICULTIES.size - 1].min
|
|
168
220
|
}
|
|
169
221
|
end
|
|
@@ -173,7 +225,7 @@ module Termfront
|
|
|
173
225
|
end
|
|
174
226
|
end
|
|
175
227
|
|
|
176
|
-
|
|
228
|
+
nil
|
|
177
229
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
178
230
|
nil
|
|
179
231
|
end
|
|
@@ -184,12 +236,22 @@ module Termfront
|
|
|
184
236
|
|
|
185
237
|
roster = players.each_with_index.map do |entry, idx|
|
|
186
238
|
team = idx < team_size ? 0 : 1
|
|
239
|
+
spawn = pvp_spawns[idx]
|
|
187
240
|
{
|
|
188
241
|
id: idx,
|
|
189
242
|
team: team,
|
|
190
243
|
socket: entry[:socket],
|
|
191
|
-
|
|
192
|
-
|
|
244
|
+
spawn: spawn,
|
|
245
|
+
x: spawn[0],
|
|
246
|
+
y: spawn[1],
|
|
247
|
+
angle: spawn[2],
|
|
248
|
+
last_state_at: nil,
|
|
249
|
+
shield: Config::SHIELD_MAX.to_f,
|
|
250
|
+
health: Config::HEALTH_MAX.to_f,
|
|
251
|
+
last_damage: -Config::SHIELD_DELAY,
|
|
252
|
+
weapon: :ar,
|
|
253
|
+
last_hit_at: nil,
|
|
254
|
+
obtained_weapons: Set.new(INITIAL_OBTAINED_WEAPONS),
|
|
193
255
|
buf: +"",
|
|
194
256
|
alive: true
|
|
195
257
|
}
|
|
@@ -206,6 +268,10 @@ module Termfront
|
|
|
206
268
|
})
|
|
207
269
|
end
|
|
208
270
|
|
|
271
|
+
match_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
272
|
+
last_activity = match_start
|
|
273
|
+
last_tick_at = match_start
|
|
274
|
+
|
|
209
275
|
loop do
|
|
210
276
|
sockets = roster.filter_map do |player|
|
|
211
277
|
sock = player[:socket]
|
|
@@ -216,19 +282,44 @@ module Termfront
|
|
|
216
282
|
break if sockets.empty?
|
|
217
283
|
|
|
218
284
|
readable, = IO.select(sockets, nil, nil, 0.5)
|
|
285
|
+
|
|
286
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
287
|
+
if (reason = match_timeout_reason(now, match_start, last_activity))
|
|
288
|
+
broadcast(roster, { t: "match_end", reason: reason })
|
|
289
|
+
close_players(roster)
|
|
290
|
+
puts "Match ended (#{reason})."
|
|
291
|
+
return
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
dt = now - last_tick_at
|
|
295
|
+
roster.each { |player| regen_player(player, dt, now) }
|
|
296
|
+
last_tick_at = now
|
|
297
|
+
|
|
219
298
|
next unless readable
|
|
220
299
|
|
|
300
|
+
last_activity = now
|
|
301
|
+
|
|
221
302
|
readable.each do |sock|
|
|
222
303
|
player = roster.find { |entry| entry[:socket] == sock }
|
|
223
304
|
next unless player
|
|
224
305
|
|
|
225
306
|
begin
|
|
226
307
|
player[:buf] << sock.read_nonblock(4096)
|
|
227
|
-
|
|
308
|
+
if player[:buf].bytesize > MAX_MSG_BYTES
|
|
309
|
+
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
310
|
+
close_players(roster)
|
|
311
|
+
puts "Match aborted."
|
|
312
|
+
return
|
|
313
|
+
end
|
|
314
|
+
if consume_messages(roster, player) == :rate_limit_exceeded
|
|
315
|
+
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
316
|
+
close_players(roster)
|
|
317
|
+
puts "Match aborted (rate limit)."
|
|
318
|
+
return
|
|
319
|
+
end
|
|
228
320
|
rescue IO::WaitReadable
|
|
229
321
|
next
|
|
230
322
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
231
|
-
puts "Player #{player[:id]} disconnected from #{player[:peer]}"
|
|
232
323
|
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
233
324
|
close_players(roster)
|
|
234
325
|
puts "Match aborted."
|
|
@@ -255,13 +346,51 @@ module Termfront
|
|
|
255
346
|
next
|
|
256
347
|
end
|
|
257
348
|
|
|
349
|
+
unless allow_message(player, msg[:t].to_s, Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
350
|
+
player[:dropped_msgs] = (player[:dropped_msgs] || 0) + 1
|
|
351
|
+
return :rate_limit_exceeded if player[:dropped_msgs] > MAX_DROPPED_MSGS
|
|
352
|
+
|
|
353
|
+
next
|
|
354
|
+
end
|
|
355
|
+
|
|
258
356
|
case msg[:t]
|
|
259
357
|
when "ping"
|
|
260
358
|
send_json(player[:socket], { t: "pong", ts: msg[:ts] })
|
|
261
359
|
when "state"
|
|
360
|
+
next unless valid_position?(msg, pvp_map)
|
|
361
|
+
|
|
362
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
363
|
+
new_x = msg[:x].to_f
|
|
364
|
+
new_y = msg[:y].to_f
|
|
365
|
+
next unless position_delta_acceptable?(player[:x], player[:y], player[:last_state_at],
|
|
366
|
+
new_x, new_y, now)
|
|
367
|
+
|
|
368
|
+
player[:x] = new_x
|
|
369
|
+
player[:y] = new_y
|
|
370
|
+
player[:angle] = msg[:a].to_f
|
|
371
|
+
player[:last_state_at] = now
|
|
372
|
+
|
|
373
|
+
if msg.key?(:w)
|
|
374
|
+
weapon = normalize_weapon(msg[:w], player)
|
|
375
|
+
if weapon
|
|
376
|
+
player[:weapon] = weapon
|
|
377
|
+
msg = msg.merge(w: weapon.to_s)
|
|
378
|
+
else
|
|
379
|
+
msg = msg.except(:w)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
if msg.key?(:am)
|
|
383
|
+
ammo = validate_int(msg[:am], min: -1, max: 999)
|
|
384
|
+
msg = ammo ? msg.merge(am: ammo) : msg.except(:am)
|
|
385
|
+
end
|
|
386
|
+
if msg.key?(:ff)
|
|
387
|
+
ff = validate_int(msg[:ff], min: 0, max: 10)
|
|
388
|
+
msg = msg.merge(ff: ff || 0)
|
|
389
|
+
end
|
|
390
|
+
msg = msg.merge(s: player[:shield].round(1), h: player[:health].round(1))
|
|
262
391
|
broadcast(roster, msg.merge(from: player[:id]), except: player[:id])
|
|
263
392
|
when "hit"
|
|
264
|
-
route_hit(roster, player, msg)
|
|
393
|
+
route_hit(roster, player, msg, Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
265
394
|
when "dead"
|
|
266
395
|
player[:alive] = false
|
|
267
396
|
broadcast(roster, { t: "dead", from: player[:id] }, except: player[:id])
|
|
@@ -269,14 +398,48 @@ module Termfront
|
|
|
269
398
|
end
|
|
270
399
|
end
|
|
271
400
|
|
|
272
|
-
def route_hit(roster, attacker,
|
|
273
|
-
|
|
274
|
-
|
|
401
|
+
def route_hit(roster, attacker, _msg, clock)
|
|
402
|
+
return unless attacker[:alive]
|
|
403
|
+
|
|
404
|
+
weapon = Weapon::Base.build(attacker[:weapon] || :ar)
|
|
405
|
+
return if attacker[:last_hit_at] && (clock - attacker[:last_hit_at]) < weapon.cooldown
|
|
406
|
+
|
|
407
|
+
target = pvp_target_from_raycast(roster, attacker, weapon)
|
|
275
408
|
return unless target
|
|
276
|
-
return unless target[:alive] && attacker[:alive]
|
|
277
|
-
return if target[:team] == attacker[:team]
|
|
278
409
|
|
|
279
|
-
|
|
410
|
+
attacker[:last_hit_at] = clock
|
|
411
|
+
apply_damage_to_player(target, Config::PVP_HIT_DMG, clock)
|
|
412
|
+
send_json(target[:socket],
|
|
413
|
+
{ t: "hit", from: attacker[:id], d: Config::PVP_HIT_DMG,
|
|
414
|
+
s: target[:shield].round(1), h: target[:health].round(1) })
|
|
415
|
+
broadcast(roster, { t: "dead", from: target[:id] }, except: target[:id]) unless target[:alive]
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def pvp_target_from_raycast(roster, attacker, weapon)
|
|
419
|
+
dx = Math.cos(attacker[:angle])
|
|
420
|
+
dy = Math.sin(attacker[:angle])
|
|
421
|
+
best = nil
|
|
422
|
+
best_dot = Float::INFINITY
|
|
423
|
+
|
|
424
|
+
roster.each do |other|
|
|
425
|
+
next if other[:id] == attacker[:id]
|
|
426
|
+
next unless other[:alive]
|
|
427
|
+
next if other[:team] == attacker[:team]
|
|
428
|
+
|
|
429
|
+
ox = other[:x] - attacker[:x]
|
|
430
|
+
oy = other[:y] - attacker[:y]
|
|
431
|
+
dot = ox * dx + oy * dy
|
|
432
|
+
next if dot < 0.1
|
|
433
|
+
|
|
434
|
+
perp = (ox * (-dy) + oy * dx).abs
|
|
435
|
+
next if perp > weapon.hit_width
|
|
436
|
+
next unless pvp_map.line_of_sight?(attacker[:x], attacker[:y], other[:x], other[:y])
|
|
437
|
+
next unless dot < best_dot
|
|
438
|
+
|
|
439
|
+
best = other
|
|
440
|
+
best_dot = dot
|
|
441
|
+
end
|
|
442
|
+
best
|
|
280
443
|
end
|
|
281
444
|
|
|
282
445
|
def winning_team(roster)
|
|
@@ -308,6 +471,117 @@ module Termfront
|
|
|
308
471
|
end
|
|
309
472
|
end
|
|
310
473
|
|
|
474
|
+
def valid_position?(msg, map)
|
|
475
|
+
x = msg[:x]
|
|
476
|
+
y = msg[:y]
|
|
477
|
+
a = msg[:a]
|
|
478
|
+
return false unless x.is_a?(Numeric) && y.is_a?(Numeric) && a.is_a?(Numeric)
|
|
479
|
+
|
|
480
|
+
fx = x.to_f
|
|
481
|
+
fy = y.to_f
|
|
482
|
+
fa = a.to_f
|
|
483
|
+
return false unless fx.finite? && fy.finite? && fa.finite?
|
|
484
|
+
return false if fx < 0 || fx >= map.width
|
|
485
|
+
return false if fy < 0 || fy >= map.height
|
|
486
|
+
|
|
487
|
+
true
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def validate_int(value, min:, max:)
|
|
491
|
+
return nil unless value.is_a?(Numeric) && value.to_f.finite?
|
|
492
|
+
|
|
493
|
+
int = value.to_i
|
|
494
|
+
return nil if int < min || int > max
|
|
495
|
+
|
|
496
|
+
int
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def allow_message(player, msg_type, now)
|
|
500
|
+
limit = RATE_LIMITS[msg_type] || DEFAULT_RATE_LIMIT
|
|
501
|
+
buckets = (player[:rate_buckets] ||= {})
|
|
502
|
+
bucket = (buckets[msg_type] ||= { tokens: limit.to_f, last_refill: now })
|
|
503
|
+
|
|
504
|
+
elapsed = now - bucket[:last_refill]
|
|
505
|
+
bucket[:tokens] = [bucket[:tokens] + elapsed * limit, limit.to_f].min
|
|
506
|
+
bucket[:last_refill] = now
|
|
507
|
+
|
|
508
|
+
return false if bucket[:tokens] < 1.0
|
|
509
|
+
|
|
510
|
+
bucket[:tokens] -= 1.0
|
|
511
|
+
true
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def position_delta_acceptable?(prev_x, prev_y, prev_at, new_x, new_y, now)
|
|
515
|
+
dt = if prev_at.nil?
|
|
516
|
+
0.1
|
|
517
|
+
else
|
|
518
|
+
[now - prev_at, MAX_STATE_DT].min
|
|
519
|
+
end
|
|
520
|
+
return true if dt <= 0
|
|
521
|
+
|
|
522
|
+
max_step = Config::MOVE_SPEED * dt * POSITION_DELTA_MARGIN
|
|
523
|
+
delta_sq = (new_x - prev_x)**2 + (new_y - prev_y)**2
|
|
524
|
+
delta_sq <= max_step * max_step
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def validate_float(value, min:, max:)
|
|
528
|
+
return nil unless value.is_a?(Numeric) && value.to_f.finite?
|
|
529
|
+
|
|
530
|
+
f = value.to_f
|
|
531
|
+
return nil if f < min || f > max
|
|
532
|
+
|
|
533
|
+
f
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def expected_pvp_token
|
|
537
|
+
token = ENV["TERMFRONT_PVP_TOKEN"]
|
|
538
|
+
token.nil? || token.empty? ? nil : token
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def queue_token_acceptable?(msg)
|
|
542
|
+
expected = expected_pvp_token
|
|
543
|
+
return true if expected.nil?
|
|
544
|
+
|
|
545
|
+
provided = msg[:token]
|
|
546
|
+
return false unless provided.is_a?(String)
|
|
547
|
+
return false unless provided.bytesize == expected.bytesize
|
|
548
|
+
|
|
549
|
+
OpenSSL.fixed_length_secure_compare(provided, expected)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def normalize_weapon(value, player = nil)
|
|
553
|
+
return nil unless value.is_a?(String) || value.is_a?(Symbol)
|
|
554
|
+
|
|
555
|
+
name = value.to_s
|
|
556
|
+
return nil unless ALLOWED_MP_WEAPONS.include?(name)
|
|
557
|
+
|
|
558
|
+
sym = name.to_sym
|
|
559
|
+
if player && player[:obtained_weapons] && !player[:obtained_weapons].include?(sym)
|
|
560
|
+
return nil
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
sym
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def match_timeout_reason(now, match_start, last_activity)
|
|
567
|
+
return "match_ttl" if now - match_start > MATCH_MAX_DURATION
|
|
568
|
+
return "idle" if now - last_activity > MATCH_IDLE_TIMEOUT
|
|
569
|
+
|
|
570
|
+
nil
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def supervise_match(match_players)
|
|
574
|
+
yield
|
|
575
|
+
rescue StandardError => e
|
|
576
|
+
puts "Match thread crashed: #{e.class}"
|
|
577
|
+
ensure
|
|
578
|
+
match_players.each do |entry|
|
|
579
|
+
entry[:socket].close
|
|
580
|
+
rescue StandardError
|
|
581
|
+
nil
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
311
585
|
def run_wavesfight_match(mission_id, difficulty, players)
|
|
312
586
|
mission_klass = Mission::Base.wavesfight.find { |klass| klass.new.id == mission_id }
|
|
313
587
|
unless mission_klass
|
|
@@ -323,15 +597,17 @@ module Termfront
|
|
|
323
597
|
{
|
|
324
598
|
id: idx,
|
|
325
599
|
socket: entry[:socket],
|
|
326
|
-
peer: entry[:peer],
|
|
327
600
|
buf: +"",
|
|
328
601
|
x: spawn[0],
|
|
329
602
|
y: spawn[1],
|
|
330
603
|
angle: spawn[2],
|
|
331
604
|
shield: Config::SHIELD_MAX,
|
|
332
605
|
health: Config::HEALTH_MAX,
|
|
606
|
+
last_damage: -Config::SHIELD_DELAY,
|
|
607
|
+
last_state_at: nil,
|
|
333
608
|
weapon: :ar,
|
|
334
609
|
ammo: 60,
|
|
610
|
+
obtained_weapons: Set.new(INITIAL_OBTAINED_WEAPONS),
|
|
335
611
|
fire_flash: 0,
|
|
336
612
|
alive: true
|
|
337
613
|
}
|
|
@@ -344,9 +620,11 @@ module Termfront
|
|
|
344
620
|
wave: 0,
|
|
345
621
|
enemies: [],
|
|
346
622
|
projectiles: [],
|
|
623
|
+
drops: [],
|
|
624
|
+
next_drop_id: 0,
|
|
347
625
|
clock: Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
348
626
|
}
|
|
349
|
-
start_wavesfight_wave(session)
|
|
627
|
+
start_wavesfight_wave(session, roster)
|
|
350
628
|
|
|
351
629
|
roster.each do |player|
|
|
352
630
|
send_json(player[:socket], {
|
|
@@ -358,12 +636,20 @@ module Termfront
|
|
|
358
636
|
})
|
|
359
637
|
end
|
|
360
638
|
|
|
639
|
+
match_start = session[:clock]
|
|
640
|
+
last_activity = match_start
|
|
361
641
|
last_broadcast = session[:clock]
|
|
362
642
|
loop do
|
|
363
643
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
364
644
|
dt = now - session[:clock]
|
|
365
645
|
session[:clock] = now
|
|
366
646
|
|
|
647
|
+
if (reason = match_timeout_reason(now, match_start, last_activity))
|
|
648
|
+
broadcast(roster, { t: "match_end", reason: reason })
|
|
649
|
+
close_players(roster)
|
|
650
|
+
return
|
|
651
|
+
end
|
|
652
|
+
|
|
367
653
|
sockets = roster.filter_map do |player|
|
|
368
654
|
sock = player[:socket]
|
|
369
655
|
sock unless sock.closed?
|
|
@@ -374,13 +660,23 @@ module Termfront
|
|
|
374
660
|
|
|
375
661
|
readable, = IO.select(sockets, nil, nil, 0.01)
|
|
376
662
|
if readable
|
|
663
|
+
last_activity = now
|
|
377
664
|
readable.each do |sock|
|
|
378
665
|
player = roster.find { |entry| entry[:socket] == sock }
|
|
379
666
|
next unless player
|
|
380
667
|
|
|
381
668
|
begin
|
|
382
669
|
player[:buf] << sock.read_nonblock(4096)
|
|
383
|
-
|
|
670
|
+
if player[:buf].bytesize > MAX_MSG_BYTES
|
|
671
|
+
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
672
|
+
close_players(roster)
|
|
673
|
+
return
|
|
674
|
+
end
|
|
675
|
+
if consume_wavesfight_messages(roster, session, player) == :rate_limit_exceeded
|
|
676
|
+
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
677
|
+
close_players(roster)
|
|
678
|
+
return
|
|
679
|
+
end
|
|
384
680
|
rescue IO::WaitReadable
|
|
385
681
|
next
|
|
386
682
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
@@ -414,26 +710,74 @@ module Termfront
|
|
|
414
710
|
next
|
|
415
711
|
end
|
|
416
712
|
|
|
713
|
+
unless allow_message(player, msg[:t].to_s, session[:clock])
|
|
714
|
+
player[:dropped_msgs] = (player[:dropped_msgs] || 0) + 1
|
|
715
|
+
return :rate_limit_exceeded if player[:dropped_msgs] > MAX_DROPPED_MSGS
|
|
716
|
+
|
|
717
|
+
next
|
|
718
|
+
end
|
|
719
|
+
|
|
417
720
|
case msg[:t]
|
|
418
721
|
when "ping"
|
|
419
722
|
send_json(player[:socket], { t: "pong", ts: msg[:ts] })
|
|
420
723
|
when "state"
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
player[:
|
|
426
|
-
|
|
724
|
+
next unless valid_position?(msg, session[:map])
|
|
725
|
+
|
|
726
|
+
new_x = msg[:x].to_f
|
|
727
|
+
new_y = msg[:y].to_f
|
|
728
|
+
next unless position_delta_acceptable?(player[:x], player[:y], player[:last_state_at],
|
|
729
|
+
new_x, new_y, session[:clock])
|
|
730
|
+
|
|
731
|
+
player[:x] = new_x
|
|
732
|
+
player[:y] = new_y
|
|
733
|
+
player[:angle] = msg[:a].to_f
|
|
734
|
+
player[:last_state_at] = session[:clock]
|
|
735
|
+
weapon = normalize_weapon(msg[:w], player)
|
|
736
|
+
player[:weapon] = weapon if weapon
|
|
737
|
+
if msg.key?(:am)
|
|
738
|
+
ammo = validate_int(msg[:am], min: 0, max: 999)
|
|
739
|
+
player[:ammo] = ammo if ammo
|
|
740
|
+
end
|
|
741
|
+
ff = validate_int(msg[:ff], min: 0, max: 10)
|
|
742
|
+
player[:fire_flash] = ff || 0
|
|
427
743
|
when "fire"
|
|
428
744
|
player[:fire_flash] = 4
|
|
429
745
|
process_wavesfight_fire(session, player)
|
|
746
|
+
when "pickup"
|
|
747
|
+
process_pickup(session, player, msg)
|
|
430
748
|
end
|
|
431
749
|
end
|
|
432
750
|
end
|
|
433
751
|
|
|
752
|
+
def process_pickup(session, player, msg)
|
|
753
|
+
drop_id = msg[:id]
|
|
754
|
+
return unless drop_id.is_a?(Numeric)
|
|
755
|
+
|
|
756
|
+
drop = session[:drops].find { |d| d[:id] == drop_id }
|
|
757
|
+
return unless drop
|
|
758
|
+
|
|
759
|
+
dx = drop[:x] - player[:x]
|
|
760
|
+
dy = drop[:y] - player[:y]
|
|
761
|
+
return if (dx * dx + dy * dy) > Config::PICKUP_RADIUS**2
|
|
762
|
+
return unless ALLOWED_MP_WEAPONS.include?(drop[:type].to_s)
|
|
763
|
+
|
|
764
|
+
if player[:weapon] == drop[:type]
|
|
765
|
+
weapon_klass = Weapon::Base.registry[drop[:type]]
|
|
766
|
+
max = weapon_klass&.new&.max_ammo
|
|
767
|
+
player[:ammo] = max ? [player[:ammo] + drop[:ammo], max].min : player[:ammo]
|
|
768
|
+
else
|
|
769
|
+
spawn_drop(session, player[:x], player[:y], player[:weapon], player[:ammo]) if player[:weapon]
|
|
770
|
+
player[:weapon] = drop[:type]
|
|
771
|
+
player[:ammo] = drop[:ammo]
|
|
772
|
+
player[:obtained_weapons] << drop[:type]
|
|
773
|
+
end
|
|
774
|
+
session[:drops].delete(drop)
|
|
775
|
+
end
|
|
776
|
+
|
|
434
777
|
def update_wavesfight_session(roster, session, dt)
|
|
435
778
|
roster.each do |player|
|
|
436
779
|
player[:fire_flash] -= 1 if player[:fire_flash].to_i > 0
|
|
780
|
+
regen_player(player, dt, session[:clock])
|
|
437
781
|
end
|
|
438
782
|
|
|
439
783
|
session[:enemies].each do |enemy|
|
|
@@ -455,8 +799,8 @@ module Termfront
|
|
|
455
799
|
target = roster.find { |player| player[:alive] && projectile.hit_player?(player[:x], player[:y]) }
|
|
456
800
|
if target
|
|
457
801
|
dmg = enemy_damage(projectile.type)
|
|
458
|
-
|
|
459
|
-
send_json(target[:socket], { t: "hit", d: dmg })
|
|
802
|
+
apply_damage_to_player(target, dmg, session[:clock])
|
|
803
|
+
send_json(target[:socket], { t: "hit", d: dmg, s: target[:shield], h: target[:health] })
|
|
460
804
|
true
|
|
461
805
|
else
|
|
462
806
|
false
|
|
@@ -465,7 +809,7 @@ module Termfront
|
|
|
465
809
|
end
|
|
466
810
|
|
|
467
811
|
if session[:enemies].all? { |enemy| !enemy.alive }
|
|
468
|
-
start_wavesfight_wave(session)
|
|
812
|
+
start_wavesfight_wave(session, roster)
|
|
469
813
|
broadcast(roster, { t: "wave_start", wave: session[:wave], difficulty: session[:difficulty] })
|
|
470
814
|
end
|
|
471
815
|
end
|
|
@@ -496,6 +840,15 @@ module Termfront
|
|
|
496
840
|
return unless best
|
|
497
841
|
|
|
498
842
|
best.take_damage(1)
|
|
843
|
+
return if best.alive
|
|
844
|
+
|
|
845
|
+
spawn_drop(session, best.x, best.y, best.drop_type, best.drop_ammo)
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
def spawn_drop(session, x, y, type, ammo)
|
|
849
|
+
id = session[:next_drop_id]
|
|
850
|
+
session[:next_drop_id] += 1
|
|
851
|
+
session[:drops] << { id: id, x: x, y: y, type: type, ammo: ammo }
|
|
499
852
|
end
|
|
500
853
|
|
|
501
854
|
def enemy_damage(type)
|
|
@@ -503,7 +856,7 @@ module Termfront
|
|
|
503
856
|
enemy_klass ? enemy_klass.allocate.send(:damage) : 10
|
|
504
857
|
end
|
|
505
858
|
|
|
506
|
-
def
|
|
859
|
+
def apply_damage_to_player(player, amount, clock)
|
|
507
860
|
if player[:shield] > 0
|
|
508
861
|
overflow = amount - player[:shield]
|
|
509
862
|
player[:shield] = [player[:shield] - amount, 0].max
|
|
@@ -512,9 +865,21 @@ module Termfront
|
|
|
512
865
|
player[:health] = [player[:health] - amount, 0].max
|
|
513
866
|
end
|
|
514
867
|
|
|
868
|
+
player[:last_damage] = clock
|
|
515
869
|
player[:alive] = false if player[:health] <= 0
|
|
516
870
|
end
|
|
517
871
|
|
|
872
|
+
def regen_player(player, dt, now)
|
|
873
|
+
return unless player[:alive]
|
|
874
|
+
|
|
875
|
+
if player[:shield] < Config::SHIELD_MAX && (now - player[:last_damage]) >= Config::SHIELD_DELAY
|
|
876
|
+
player[:shield] = [player[:shield] + Config::SHIELD_REGEN * dt, Config::SHIELD_MAX].min
|
|
877
|
+
end
|
|
878
|
+
return unless player[:shield] >= Config::SHIELD_MAX && player[:health] < Config::HEALTH_MAX
|
|
879
|
+
|
|
880
|
+
player[:health] = [player[:health] + Config::SHIELD_REGEN * dt, Config::HEALTH_MAX].min
|
|
881
|
+
end
|
|
882
|
+
|
|
518
883
|
def all_wavesfight_players_dead?(roster)
|
|
519
884
|
roster.none? { |player| player[:alive] }
|
|
520
885
|
end
|
|
@@ -538,17 +903,27 @@ module Termfront
|
|
|
538
903
|
}
|
|
539
904
|
end,
|
|
540
905
|
projectiles: session[:projectiles].map { |projectile| { x: projectile.x, y: projectile.y, type: projectile.type } },
|
|
541
|
-
drops: []
|
|
906
|
+
drops: session[:drops].map { |drop| { id: drop[:id], x: drop[:x], y: drop[:y], type: drop[:type], am: drop[:ammo] } }
|
|
542
907
|
}
|
|
543
908
|
broadcast(roster, msg)
|
|
544
909
|
end
|
|
545
910
|
|
|
546
|
-
def start_wavesfight_wave(session)
|
|
911
|
+
def start_wavesfight_wave(session, roster = nil)
|
|
547
912
|
session[:wave] += 1
|
|
548
913
|
session[:difficulty] = [session[:difficulty], 1 + ((session[:wave] - 1) / 3)].max
|
|
549
914
|
session[:difficulty] = [session[:difficulty], Enemy::Base::DIFFICULTIES.size - 1].min
|
|
550
915
|
session[:enemies] = build_wavesfight_enemies(session[:mission], session[:wave], session[:difficulty])
|
|
551
916
|
session[:projectiles].clear
|
|
917
|
+
replenish_wavesfight_roster(roster, session) if roster && session[:wave] > 1
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def replenish_wavesfight_roster(roster, session)
|
|
921
|
+
roster.each do |player|
|
|
922
|
+
player[:shield] = [player[:shield] + 35.0, Config::SHIELD_MAX].min
|
|
923
|
+
player[:health] = [player[:health] + 20.0, Config::HEALTH_MAX].min
|
|
924
|
+
player[:last_damage] = session[:clock] - Config::SHIELD_DELAY
|
|
925
|
+
player[:alive] = true
|
|
926
|
+
end
|
|
552
927
|
end
|
|
553
928
|
|
|
554
929
|
def build_wavesfight_enemies(mission, wave, difficulty_index)
|
|
@@ -575,47 +950,40 @@ module Termfront
|
|
|
575
950
|
spawns
|
|
576
951
|
end
|
|
577
952
|
|
|
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 = []
|
|
953
|
+
def load_cert
|
|
954
|
+
cert_file = ENV["TERMFRONT_TLS_CERT_FILE"]
|
|
955
|
+
key_file = ENV["TERMFRONT_TLS_KEY_FILE"]
|
|
956
|
+
|
|
957
|
+
if cert_file.nil? || cert_file.empty? || key_file.nil? || key_file.empty?
|
|
958
|
+
raise "TLS not configured: set TERMFRONT_TLS_CERT_FILE and TERMFRONT_TLS_KEY_FILE to PEM paths " \
|
|
959
|
+
"(use a fullchain certificate, e.g. issued by Let's Encrypt)."
|
|
609
960
|
end
|
|
961
|
+
unless File.exist?(cert_file) && File.exist?(key_file)
|
|
962
|
+
raise "TLS cert or key file not found at the configured paths."
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
certs = OpenSSL::X509::Certificate.load(File.read(cert_file))
|
|
966
|
+
certs = [certs] unless certs.is_a?(Array)
|
|
967
|
+
cert = certs.first
|
|
968
|
+
chain = certs.drop(1)
|
|
969
|
+
key = OpenSSL::PKey::RSA.new(File.read(key_file))
|
|
970
|
+
puts "Loaded TLS certificate."
|
|
610
971
|
[cert, key, chain]
|
|
611
972
|
end
|
|
612
973
|
|
|
974
|
+
def wavesfight_mission_ids
|
|
975
|
+
@wavesfight_mission_ids ||= Mission::Base.wavesfight.map { |klass| klass.new.id }.freeze
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
def pvp_map
|
|
979
|
+
@pvp_map ||= Map.new(PVP_MAP)
|
|
980
|
+
end
|
|
981
|
+
|
|
613
982
|
def pvp_spawns
|
|
614
983
|
@pvp_spawns ||= begin
|
|
615
|
-
map = Map.new(PVP_MAP)
|
|
616
984
|
PVP_SPAWN_CANDIDATES.each do |spawn|
|
|
617
985
|
x, y, = spawn
|
|
618
|
-
raise "Invalid PvP spawn #{spawn.inspect}" if
|
|
986
|
+
raise "Invalid PvP spawn #{spawn.inspect}" if pvp_map.blocked?(x, y)
|
|
619
987
|
end
|
|
620
988
|
PVP_SPAWN_CANDIDATES
|
|
621
989
|
end
|