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.
@@ -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 = load_or_create_cert
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
- enqueue_player(client)
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.message}"
99
+ puts "SSL handshake failed: #{e.class}"
66
100
  rescue StandardError => e
67
- puts "Accept error: #{e.message}"
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, peer, request)
123
+ enqueue_wavesfight_player(client, request)
95
124
  else
96
- enqueue_pvp_player(client, peer, request[:team_size])
125
+ enqueue_pvp_player(client, request[:team_size])
97
126
  end
98
127
  end
99
128
 
100
- def enqueue_pvp_player(client, peer, team_size)
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] << { socket: client, peer: peer }
106
- required = team_size * 2
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
- waiting = @queues[team_size].size
111
- puts "Queue #{team_size}v#{team_size}: #{waiting}/#{required}"
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, peer, request)
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] << { socket: client, peer: peer }
128
- if @wavesfight_queues[key].size >= 2
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
- waiting = @wavesfight_queues[key].size
132
- puts "Queue wavesfight #{mission_id}: #{waiting}/2"
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 + 15
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: msg[:mission_id].to_s,
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
- { mode: :pvp, team_size: 1 }
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
- peer: entry[:peer],
192
- spawn: pvp_spawns[idx],
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, 0.5)
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
- consume_messages(roster, player)
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
- broadcast(roster, msg.merge(from: player[:id]), except: player[:id])
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, msg)
273
- target_id = msg[:target].to_i
274
- target = roster.find { |player| player[:id] == target_id }
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
- send_json(target[:socket], { t: "hit", from: attacker[:id], d: msg[:d] || Config::PVP_HIT_DMG })
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
- send_json(player[:socket], msg)
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.write(JSON.generate(msg) + "\n")
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
- consume_wavesfight_messages(roster, session, player)
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
- player[:x] = msg[:x]
422
- player[:y] = msg[:y]
423
- player[:angle] = msg[:a]
424
- player[:weapon] = msg[:w]&.to_sym || player[:weapon]
425
- player[:ammo] = msg[:am] if msg.key?(:am)
426
- player[:fire_flash] = msg[:ff] || 0
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
- apply_wavesfight_damage(target, dmg)
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 apply_wavesfight_damage(player, amount)
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 generate_self_signed_cert
579
- key = OpenSSL::PKey::RSA.new(2048)
580
- cert = OpenSSL::X509::Certificate.new
581
- cert.version = 2
582
- cert.serial = rand(1 << 64)
583
- cert.subject = OpenSSL::X509::Name.parse("/CN=termfront-pvp")
584
- cert.issuer = cert.subject
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 map.blocked?(x, y)
1012
+ raise "Invalid PvP spawn #{spawn.inspect}" if pvp_map.blocked?(x, y)
619
1013
  end
620
1014
  PVP_SPAWN_CANDIDATES
621
1015
  end