termfront 0.1.2 → 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 +40 -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 +461 -88
- 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,13 @@ module Termfront
|
|
|
43
63
|
end
|
|
44
64
|
|
|
45
65
|
def run
|
|
46
|
-
cert, key =
|
|
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
|
|
72
|
+
ctx.extra_chain_cert = chain unless chain.empty?
|
|
51
73
|
|
|
52
74
|
tcp_server = TCPServer.new("0.0.0.0", @port)
|
|
53
75
|
ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, ctx)
|
|
@@ -59,11 +81,20 @@ module Termfront
|
|
|
59
81
|
begin
|
|
60
82
|
client = ssl_server.accept
|
|
61
83
|
configure_client(client)
|
|
62
|
-
|
|
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
|
|
63
94
|
rescue OpenSSL::SSL::SSLError => e
|
|
64
|
-
puts "SSL handshake failed: #{e.
|
|
95
|
+
puts "SSL handshake failed: #{e.class}"
|
|
65
96
|
rescue StandardError => e
|
|
66
|
-
puts "Accept error: #{e.
|
|
97
|
+
puts "Accept error: #{e.class}"
|
|
67
98
|
end
|
|
68
99
|
end
|
|
69
100
|
end
|
|
@@ -84,61 +115,77 @@ module Termfront
|
|
|
84
115
|
client.close
|
|
85
116
|
return
|
|
86
117
|
end
|
|
87
|
-
peer = begin
|
|
88
|
-
client.peeraddr[2]
|
|
89
|
-
rescue StandardError
|
|
90
|
-
"unknown"
|
|
91
|
-
end
|
|
92
118
|
if request[:mode] == :wavesfight
|
|
93
|
-
enqueue_wavesfight_player(client,
|
|
119
|
+
enqueue_wavesfight_player(client, request)
|
|
94
120
|
else
|
|
95
|
-
enqueue_pvp_player(client,
|
|
121
|
+
enqueue_pvp_player(client, request[:team_size])
|
|
96
122
|
end
|
|
97
123
|
end
|
|
98
124
|
|
|
99
|
-
def enqueue_pvp_player(client,
|
|
100
|
-
puts "Player connected from #{peer}, queued for #{team_size}v#{team_size}"
|
|
101
|
-
|
|
125
|
+
def enqueue_pvp_player(client, team_size)
|
|
102
126
|
match_players = nil
|
|
127
|
+
rejected = false
|
|
103
128
|
@queue_mutex.synchronize do
|
|
104
|
-
@queues[team_size]
|
|
105
|
-
|
|
106
|
-
if @queues[team_size].size >= required
|
|
107
|
-
match_players = @queues[team_size].shift(required)
|
|
129
|
+
if @queues[team_size].size >= MAX_QUEUE_PER_MODE
|
|
130
|
+
rejected = true
|
|
108
131
|
else
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
111
140
|
end
|
|
112
141
|
end
|
|
142
|
+
if rejected
|
|
143
|
+
close_socket(client)
|
|
144
|
+
return
|
|
145
|
+
end
|
|
113
146
|
return unless match_players
|
|
114
147
|
|
|
115
|
-
Thread.new { run_match(team_size, match_players) }
|
|
148
|
+
Thread.new { supervise_match(match_players) { run_match(team_size, match_players) } }
|
|
116
149
|
end
|
|
117
150
|
|
|
118
|
-
def enqueue_wavesfight_player(client,
|
|
151
|
+
def enqueue_wavesfight_player(client, request)
|
|
119
152
|
mission_id = request[:mission_id]
|
|
120
153
|
difficulty = request[:difficulty]
|
|
121
154
|
key = [mission_id, difficulty]
|
|
122
|
-
puts "Player connected from #{peer}, queued for wavesfight #{mission_id} diff=#{difficulty}"
|
|
123
155
|
|
|
124
156
|
match_players = nil
|
|
157
|
+
rejected = false
|
|
125
158
|
@queue_mutex.synchronize do
|
|
126
|
-
@wavesfight_queues[key]
|
|
127
|
-
|
|
128
|
-
match_players = @wavesfight_queues[key].shift(2)
|
|
159
|
+
if @wavesfight_queues[key].size >= MAX_QUEUE_PER_MODE
|
|
160
|
+
rejected = true
|
|
129
161
|
else
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
132
169
|
end
|
|
133
170
|
end
|
|
171
|
+
if rejected
|
|
172
|
+
close_socket(client)
|
|
173
|
+
return
|
|
174
|
+
end
|
|
134
175
|
return unless match_players
|
|
135
176
|
|
|
136
|
-
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
|
|
137
184
|
end
|
|
138
185
|
|
|
139
186
|
def read_queue_request(client)
|
|
140
187
|
buf = +""
|
|
141
|
-
deadline = Time.now +
|
|
188
|
+
deadline = Time.now + QUEUE_HANDSHAKE_TIMEOUT
|
|
142
189
|
|
|
143
190
|
while Time.now < deadline
|
|
144
191
|
readable, = IO.select([client], nil, nil, 0.5)
|
|
@@ -150,6 +197,8 @@ module Termfront
|
|
|
150
197
|
next
|
|
151
198
|
end
|
|
152
199
|
|
|
200
|
+
return nil if buf.bytesize > MAX_MSG_BYTES
|
|
201
|
+
|
|
153
202
|
while (nl = buf.index("\n"))
|
|
154
203
|
line = buf.slice!(0, nl + 1)
|
|
155
204
|
begin
|
|
@@ -158,11 +207,15 @@ module Termfront
|
|
|
158
207
|
next
|
|
159
208
|
end
|
|
160
209
|
next unless msg[:t] == "queue"
|
|
210
|
+
return nil unless queue_token_acceptable?(msg)
|
|
161
211
|
|
|
162
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
|
+
|
|
163
216
|
return {
|
|
164
217
|
mode: :wavesfight,
|
|
165
|
-
mission_id:
|
|
218
|
+
mission_id: mission_id,
|
|
166
219
|
difficulty: [[msg[:difficulty].to_i, 0].max, Enemy::Base::DIFFICULTIES.size - 1].min
|
|
167
220
|
}
|
|
168
221
|
end
|
|
@@ -172,7 +225,7 @@ module Termfront
|
|
|
172
225
|
end
|
|
173
226
|
end
|
|
174
227
|
|
|
175
|
-
|
|
228
|
+
nil
|
|
176
229
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
177
230
|
nil
|
|
178
231
|
end
|
|
@@ -183,12 +236,22 @@ module Termfront
|
|
|
183
236
|
|
|
184
237
|
roster = players.each_with_index.map do |entry, idx|
|
|
185
238
|
team = idx < team_size ? 0 : 1
|
|
239
|
+
spawn = pvp_spawns[idx]
|
|
186
240
|
{
|
|
187
241
|
id: idx,
|
|
188
242
|
team: team,
|
|
189
243
|
socket: entry[:socket],
|
|
190
|
-
|
|
191
|
-
|
|
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),
|
|
192
255
|
buf: +"",
|
|
193
256
|
alive: true
|
|
194
257
|
}
|
|
@@ -205,6 +268,10 @@ module Termfront
|
|
|
205
268
|
})
|
|
206
269
|
end
|
|
207
270
|
|
|
271
|
+
match_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
272
|
+
last_activity = match_start
|
|
273
|
+
last_tick_at = match_start
|
|
274
|
+
|
|
208
275
|
loop do
|
|
209
276
|
sockets = roster.filter_map do |player|
|
|
210
277
|
sock = player[:socket]
|
|
@@ -215,19 +282,44 @@ module Termfront
|
|
|
215
282
|
break if sockets.empty?
|
|
216
283
|
|
|
217
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
|
+
|
|
218
298
|
next unless readable
|
|
219
299
|
|
|
300
|
+
last_activity = now
|
|
301
|
+
|
|
220
302
|
readable.each do |sock|
|
|
221
303
|
player = roster.find { |entry| entry[:socket] == sock }
|
|
222
304
|
next unless player
|
|
223
305
|
|
|
224
306
|
begin
|
|
225
307
|
player[:buf] << sock.read_nonblock(4096)
|
|
226
|
-
|
|
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
|
|
227
320
|
rescue IO::WaitReadable
|
|
228
321
|
next
|
|
229
322
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
230
|
-
puts "Player #{player[:id]} disconnected from #{player[:peer]}"
|
|
231
323
|
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
232
324
|
close_players(roster)
|
|
233
325
|
puts "Match aborted."
|
|
@@ -254,13 +346,51 @@ module Termfront
|
|
|
254
346
|
next
|
|
255
347
|
end
|
|
256
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
|
+
|
|
257
356
|
case msg[:t]
|
|
258
357
|
when "ping"
|
|
259
358
|
send_json(player[:socket], { t: "pong", ts: msg[:ts] })
|
|
260
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))
|
|
261
391
|
broadcast(roster, msg.merge(from: player[:id]), except: player[:id])
|
|
262
392
|
when "hit"
|
|
263
|
-
route_hit(roster, player, msg)
|
|
393
|
+
route_hit(roster, player, msg, Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
264
394
|
when "dead"
|
|
265
395
|
player[:alive] = false
|
|
266
396
|
broadcast(roster, { t: "dead", from: player[:id] }, except: player[:id])
|
|
@@ -268,14 +398,48 @@ module Termfront
|
|
|
268
398
|
end
|
|
269
399
|
end
|
|
270
400
|
|
|
271
|
-
def route_hit(roster, attacker,
|
|
272
|
-
|
|
273
|
-
|
|
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)
|
|
274
408
|
return unless target
|
|
275
|
-
return unless target[:alive] && attacker[:alive]
|
|
276
|
-
return if target[:team] == attacker[:team]
|
|
277
409
|
|
|
278
|
-
|
|
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
|
|
279
443
|
end
|
|
280
444
|
|
|
281
445
|
def winning_team(roster)
|
|
@@ -307,6 +471,117 @@ module Termfront
|
|
|
307
471
|
end
|
|
308
472
|
end
|
|
309
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
|
+
|
|
310
585
|
def run_wavesfight_match(mission_id, difficulty, players)
|
|
311
586
|
mission_klass = Mission::Base.wavesfight.find { |klass| klass.new.id == mission_id }
|
|
312
587
|
unless mission_klass
|
|
@@ -322,15 +597,17 @@ module Termfront
|
|
|
322
597
|
{
|
|
323
598
|
id: idx,
|
|
324
599
|
socket: entry[:socket],
|
|
325
|
-
peer: entry[:peer],
|
|
326
600
|
buf: +"",
|
|
327
601
|
x: spawn[0],
|
|
328
602
|
y: spawn[1],
|
|
329
603
|
angle: spawn[2],
|
|
330
604
|
shield: Config::SHIELD_MAX,
|
|
331
605
|
health: Config::HEALTH_MAX,
|
|
606
|
+
last_damage: -Config::SHIELD_DELAY,
|
|
607
|
+
last_state_at: nil,
|
|
332
608
|
weapon: :ar,
|
|
333
609
|
ammo: 60,
|
|
610
|
+
obtained_weapons: Set.new(INITIAL_OBTAINED_WEAPONS),
|
|
334
611
|
fire_flash: 0,
|
|
335
612
|
alive: true
|
|
336
613
|
}
|
|
@@ -343,9 +620,11 @@ module Termfront
|
|
|
343
620
|
wave: 0,
|
|
344
621
|
enemies: [],
|
|
345
622
|
projectiles: [],
|
|
623
|
+
drops: [],
|
|
624
|
+
next_drop_id: 0,
|
|
346
625
|
clock: Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
347
626
|
}
|
|
348
|
-
start_wavesfight_wave(session)
|
|
627
|
+
start_wavesfight_wave(session, roster)
|
|
349
628
|
|
|
350
629
|
roster.each do |player|
|
|
351
630
|
send_json(player[:socket], {
|
|
@@ -357,12 +636,20 @@ module Termfront
|
|
|
357
636
|
})
|
|
358
637
|
end
|
|
359
638
|
|
|
639
|
+
match_start = session[:clock]
|
|
640
|
+
last_activity = match_start
|
|
360
641
|
last_broadcast = session[:clock]
|
|
361
642
|
loop do
|
|
362
643
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
363
644
|
dt = now - session[:clock]
|
|
364
645
|
session[:clock] = now
|
|
365
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
|
+
|
|
366
653
|
sockets = roster.filter_map do |player|
|
|
367
654
|
sock = player[:socket]
|
|
368
655
|
sock unless sock.closed?
|
|
@@ -373,13 +660,23 @@ module Termfront
|
|
|
373
660
|
|
|
374
661
|
readable, = IO.select(sockets, nil, nil, 0.01)
|
|
375
662
|
if readable
|
|
663
|
+
last_activity = now
|
|
376
664
|
readable.each do |sock|
|
|
377
665
|
player = roster.find { |entry| entry[:socket] == sock }
|
|
378
666
|
next unless player
|
|
379
667
|
|
|
380
668
|
begin
|
|
381
669
|
player[:buf] << sock.read_nonblock(4096)
|
|
382
|
-
|
|
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
|
|
383
680
|
rescue IO::WaitReadable
|
|
384
681
|
next
|
|
385
682
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
@@ -413,26 +710,74 @@ module Termfront
|
|
|
413
710
|
next
|
|
414
711
|
end
|
|
415
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
|
+
|
|
416
720
|
case msg[:t]
|
|
417
721
|
when "ping"
|
|
418
722
|
send_json(player[:socket], { t: "pong", ts: msg[:ts] })
|
|
419
723
|
when "state"
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
player[:
|
|
425
|
-
|
|
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
|
|
426
743
|
when "fire"
|
|
427
744
|
player[:fire_flash] = 4
|
|
428
745
|
process_wavesfight_fire(session, player)
|
|
746
|
+
when "pickup"
|
|
747
|
+
process_pickup(session, player, msg)
|
|
429
748
|
end
|
|
430
749
|
end
|
|
431
750
|
end
|
|
432
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
|
+
|
|
433
777
|
def update_wavesfight_session(roster, session, dt)
|
|
434
778
|
roster.each do |player|
|
|
435
779
|
player[:fire_flash] -= 1 if player[:fire_flash].to_i > 0
|
|
780
|
+
regen_player(player, dt, session[:clock])
|
|
436
781
|
end
|
|
437
782
|
|
|
438
783
|
session[:enemies].each do |enemy|
|
|
@@ -454,8 +799,8 @@ module Termfront
|
|
|
454
799
|
target = roster.find { |player| player[:alive] && projectile.hit_player?(player[:x], player[:y]) }
|
|
455
800
|
if target
|
|
456
801
|
dmg = enemy_damage(projectile.type)
|
|
457
|
-
|
|
458
|
-
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] })
|
|
459
804
|
true
|
|
460
805
|
else
|
|
461
806
|
false
|
|
@@ -464,7 +809,7 @@ module Termfront
|
|
|
464
809
|
end
|
|
465
810
|
|
|
466
811
|
if session[:enemies].all? { |enemy| !enemy.alive }
|
|
467
|
-
start_wavesfight_wave(session)
|
|
812
|
+
start_wavesfight_wave(session, roster)
|
|
468
813
|
broadcast(roster, { t: "wave_start", wave: session[:wave], difficulty: session[:difficulty] })
|
|
469
814
|
end
|
|
470
815
|
end
|
|
@@ -495,6 +840,15 @@ module Termfront
|
|
|
495
840
|
return unless best
|
|
496
841
|
|
|
497
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 }
|
|
498
852
|
end
|
|
499
853
|
|
|
500
854
|
def enemy_damage(type)
|
|
@@ -502,7 +856,7 @@ module Termfront
|
|
|
502
856
|
enemy_klass ? enemy_klass.allocate.send(:damage) : 10
|
|
503
857
|
end
|
|
504
858
|
|
|
505
|
-
def
|
|
859
|
+
def apply_damage_to_player(player, amount, clock)
|
|
506
860
|
if player[:shield] > 0
|
|
507
861
|
overflow = amount - player[:shield]
|
|
508
862
|
player[:shield] = [player[:shield] - amount, 0].max
|
|
@@ -511,9 +865,21 @@ module Termfront
|
|
|
511
865
|
player[:health] = [player[:health] - amount, 0].max
|
|
512
866
|
end
|
|
513
867
|
|
|
868
|
+
player[:last_damage] = clock
|
|
514
869
|
player[:alive] = false if player[:health] <= 0
|
|
515
870
|
end
|
|
516
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
|
+
|
|
517
883
|
def all_wavesfight_players_dead?(roster)
|
|
518
884
|
roster.none? { |player| player[:alive] }
|
|
519
885
|
end
|
|
@@ -537,17 +903,27 @@ module Termfront
|
|
|
537
903
|
}
|
|
538
904
|
end,
|
|
539
905
|
projectiles: session[:projectiles].map { |projectile| { x: projectile.x, y: projectile.y, type: projectile.type } },
|
|
540
|
-
drops: []
|
|
906
|
+
drops: session[:drops].map { |drop| { id: drop[:id], x: drop[:x], y: drop[:y], type: drop[:type], am: drop[:ammo] } }
|
|
541
907
|
}
|
|
542
908
|
broadcast(roster, msg)
|
|
543
909
|
end
|
|
544
910
|
|
|
545
|
-
def start_wavesfight_wave(session)
|
|
911
|
+
def start_wavesfight_wave(session, roster = nil)
|
|
546
912
|
session[:wave] += 1
|
|
547
913
|
session[:difficulty] = [session[:difficulty], 1 + ((session[:wave] - 1) / 3)].max
|
|
548
914
|
session[:difficulty] = [session[:difficulty], Enemy::Base::DIFFICULTIES.size - 1].min
|
|
549
915
|
session[:enemies] = build_wavesfight_enemies(session[:mission], session[:wave], session[:difficulty])
|
|
550
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
|
|
551
927
|
end
|
|
552
928
|
|
|
553
929
|
def build_wavesfight_enemies(mission, wave, difficulty_index)
|
|
@@ -574,43 +950,40 @@ module Termfront
|
|
|
574
950
|
spawns
|
|
575
951
|
end
|
|
576
952
|
|
|
577
|
-
def
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
cert.public_key = key.public_key
|
|
585
|
-
cert.not_before = Time.now
|
|
586
|
-
cert.not_after = Time.now + 365 * 24 * 60 * 60
|
|
587
|
-
cert.sign(key, OpenSSL::Digest.new("SHA256"))
|
|
588
|
-
[cert, key]
|
|
589
|
-
end
|
|
590
|
-
|
|
591
|
-
def load_or_create_cert
|
|
592
|
-
cert_file = ENV.fetch("TERMFRONT_TLS_CERT_FILE", "termfront_server.crt")
|
|
593
|
-
key_file = ENV.fetch("TERMFRONT_TLS_KEY_FILE", "termfront_server.key")
|
|
594
|
-
|
|
595
|
-
if File.exist?(cert_file) && File.exist?(key_file)
|
|
596
|
-
cert = OpenSSL::X509::Certificate.new(File.read(cert_file))
|
|
597
|
-
key = OpenSSL::PKey::RSA.new(File.read(key_file))
|
|
598
|
-
puts "Loaded existing certificate."
|
|
599
|
-
else
|
|
600
|
-
cert, key = generate_self_signed_cert
|
|
601
|
-
File.write(cert_file, cert.to_pem)
|
|
602
|
-
File.write(key_file, key.to_pem)
|
|
603
|
-
puts "Generated new self-signed certificate."
|
|
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)."
|
|
604
960
|
end
|
|
605
|
-
|
|
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."
|
|
971
|
+
[cert, key, chain]
|
|
972
|
+
end
|
|
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)
|
|
606
980
|
end
|
|
607
981
|
|
|
608
982
|
def pvp_spawns
|
|
609
983
|
@pvp_spawns ||= begin
|
|
610
|
-
map = Map.new(PVP_MAP)
|
|
611
984
|
PVP_SPAWN_CANDIDATES.each do |spawn|
|
|
612
985
|
x, y, = spawn
|
|
613
|
-
raise "Invalid PvP spawn #{spawn.inspect}" if
|
|
986
|
+
raise "Invalid PvP spawn #{spawn.inspect}" if pvp_map.blocked?(x, y)
|
|
614
987
|
end
|
|
615
988
|
PVP_SPAWN_CANDIDATES
|
|
616
989
|
end
|