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.
@@ -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 = 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
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
- 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
63
94
  rescue OpenSSL::SSL::SSLError => e
64
- puts "SSL handshake failed: #{e.message}"
95
+ puts "SSL handshake failed: #{e.class}"
65
96
  rescue StandardError => e
66
- puts "Accept error: #{e.message}"
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, peer, request)
119
+ enqueue_wavesfight_player(client, request)
94
120
  else
95
- enqueue_pvp_player(client, peer, request[:team_size])
121
+ enqueue_pvp_player(client, request[:team_size])
96
122
  end
97
123
  end
98
124
 
99
- def enqueue_pvp_player(client, peer, team_size)
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] << { socket: client, peer: peer }
105
- required = team_size * 2
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
- waiting = @queues[team_size].size
110
- 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
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, peer, request)
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] << { socket: client, peer: peer }
127
- if @wavesfight_queues[key].size >= 2
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
- waiting = @wavesfight_queues[key].size
131
- 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
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 + 15
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: msg[:mission_id].to_s,
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
- { mode: :pvp, team_size: 1 }
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
- peer: entry[:peer],
191
- 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),
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
- 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
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, msg)
272
- target_id = msg[:target].to_i
273
- 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)
274
408
  return unless target
275
- return unless target[:alive] && attacker[:alive]
276
- return if target[:team] == attacker[:team]
277
409
 
278
- 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
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
- 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
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
- player[:x] = msg[:x]
421
- player[:y] = msg[:y]
422
- player[:angle] = msg[:a]
423
- player[:weapon] = msg[:w]&.to_sym || player[:weapon]
424
- player[:ammo] = msg[:am] if msg.key?(:am)
425
- 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
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
- apply_wavesfight_damage(target, dmg)
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 apply_wavesfight_damage(player, amount)
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 generate_self_signed_cert
578
- key = OpenSSL::PKey::RSA.new(2048)
579
- cert = OpenSSL::X509::Certificate.new
580
- cert.version = 2
581
- cert.serial = rand(1 << 64)
582
- cert.subject = OpenSSL::X509::Name.parse("/CN=termfront-pvp")
583
- cert.issuer = cert.subject
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
- [cert, key]
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 map.blocked?(x, y)
986
+ raise "Invalid PvP spawn #{spawn.inspect}" if pvp_map.blocked?(x, y)
614
987
  end
615
988
  PVP_SPAWN_CANDIDATES
616
989
  end