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.
@@ -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 = load_or_create_cert
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
- enqueue_player(client)
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.message}"
95
+ puts "SSL handshake failed: #{e.class}"
66
96
  rescue StandardError => e
67
- puts "Accept error: #{e.message}"
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, peer, request)
119
+ enqueue_wavesfight_player(client, request)
95
120
  else
96
- enqueue_pvp_player(client, peer, request[:team_size])
121
+ enqueue_pvp_player(client, request[:team_size])
97
122
  end
98
123
  end
99
124
 
100
- def enqueue_pvp_player(client, peer, team_size)
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] << { socket: client, peer: peer }
106
- required = team_size * 2
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
- waiting = @queues[team_size].size
111
- puts "Queue #{team_size}v#{team_size}: #{waiting}/#{required}"
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, peer, request)
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] << { socket: client, peer: peer }
128
- if @wavesfight_queues[key].size >= 2
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
- waiting = @wavesfight_queues[key].size
132
- puts "Queue wavesfight #{mission_id}: #{waiting}/2"
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 + 15
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: msg[:mission_id].to_s,
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
- { mode: :pvp, team_size: 1 }
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
- peer: entry[:peer],
192
- spawn: pvp_spawns[idx],
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
- consume_messages(roster, player)
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, msg)
273
- target_id = msg[:target].to_i
274
- target = roster.find { |player| player[:id] == target_id }
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
- send_json(target[:socket], { t: "hit", from: attacker[:id], d: msg[:d] || Config::PVP_HIT_DMG })
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
- consume_wavesfight_messages(roster, session, player)
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
- 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
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
- apply_wavesfight_damage(target, dmg)
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 apply_wavesfight_damage(player, amount)
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 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 = []
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 map.blocked?(x, y)
986
+ raise "Invalid PvP spawn #{spawn.inspect}" if pvp_map.blocked?(x, y)
619
987
  end
620
988
  PVP_SPAWN_CANDIDATES
621
989
  end