termfront 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64c01550bc002aa40824296474323c7dc6126447fe52bd8acb8cf150514a68af
4
- data.tar.gz: 07c871ac0a859394d94d98189700195a8a98469c0f319d8748482ff79f617883
3
+ metadata.gz: 2169347d66dd50b7734b9de8afc2398a0b979649cd98cb36aa6cc6dcdc9373f5
4
+ data.tar.gz: afd578bf2aabf93476c81d96fdcceeb5d55289d6137dade78429b75bdd84753a
5
5
  SHA512:
6
- metadata.gz: 665f32c4d60b34330ba53c5beafed57826c5b1402a9855c613cd40941dbb4ea8bd29dd93eeb564b90513809d7f26e7f649624d1fe749054ead899884865f74c3
7
- data.tar.gz: 5d5f99b125210dbf8980cea284f112279178d228499f1caad9880deb99869cced727bfd180d5b1b8db645c10aa6627c7ba79e218b6093fd8e74b37f2bf5bf102
6
+ metadata.gz: 4bf5e4ee9f0c736626f7c73f05ce04a5a2c128f1c94193d9f74ae6c7788297561f24244d7ae65d03f5b6955ba10d772bfe9ba3a017de4d2bac18b8a527020a8b
7
+ data.tar.gz: d317a9c0ac3ba86c633c3fb1f8b5af951bda951bcc2418213457535ec32d3e0d2471fca9c3e34588f5f134800c46a495a68cbfa58f86f9349739e1dc206ffe7d
data/CHANGELOG.md CHANGED
@@ -6,6 +6,40 @@ The format is based on Keep a Changelog, and this project follows Semantic Versi
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.34] - 2026-05-25
10
+
11
+ ### Fixed
12
+
13
+ - Fixed PvP `route_hit` so the server always sends the fixed `Config::PVP_HIT_DMG` damage value, ignoring the attacker-supplied `d` field
14
+ - Require `TERMFRONT_TLS_CERT_FILE` and `TERMFRONT_TLS_KEY_FILE` to be set and point to existing PEM files; removed the self-signed certificate generation fallback
15
+ - Enforce TLS 1.2 as the minimum protocol version on both the multiplayer server and client
16
+ - Stop logging client peer IP addresses on the multiplayer server
17
+ - Connect multiplayer clients to the official server address only; remove the free-form server address input
18
+ - Restrict audio manifest entries to paths under `data/audio/`
19
+ - Reject Wavesfight co-op queue requests with unknown mission ids
20
+ - Guard match worker threads against uncaught exceptions and ensure player sockets are closed
21
+ - Cap each matchmaking queue at 64 waiting players and reject excess connections
22
+ - Move per-connection handshake off the accept loop and drop silent clients after a short timeout
23
+ - Cap server and client receive buffers and disconnect peers that flood bytes without a newline
24
+ - End multiplayer matches after a maximum duration or when all players have been idle
25
+ - Restrict the weapon field on multiplayer state messages to the legal loadout (`pistol`, `ar`)
26
+ - Validate enemy / weapon / projectile type symbols received from the server against a fixed whitelist on the client side before converting to symbols
27
+ - Validate position, ammo, and fire-flash fields on Wavesfight co-op state messages; reject the update when position is non-finite or outside the map
28
+ - Validate PvP state fields (position, shield, health, ammo, fire-flash) before relaying to opponents; drop the relay when position is non-finite or outside the map
29
+ - Reject multiplayer state messages whose position delta exceeds the maximum physical step from the previous server-known position
30
+ - Rate-limit incoming multiplayer messages per type per player; sustained overflow ends the match for the offending client
31
+ - Track PvP shield and health on the server; the server applies hit damage, regenerates shields between hits, and uses authoritative values when relaying state to opponents
32
+ - Determine PvP hit targets server-side via raycast from the attacker's known position and facing; enforce weapon cooldown, firing cone, line-of-sight, and team checks instead of trusting the attacker-supplied target id
33
+ - Wavesfight co-op shield no longer stays depleted: the server now regenerates shield and health after `Config::SHIELD_DELAY`, and the client plays the shield regeneration loop SE while regen is active
34
+ - Wavesfight co-op now restores shield, health, and revives downed players between waves to match singleplayer behavior
35
+ - Final Push: relocated the rightmost crawler off the dividing wall so it can be killed and the mission can complete
36
+
37
+ ### Added
38
+
39
+ - Honor `TERMFRONT_TLS_CA_FILE` on multiplayer clients to trust an additional CA certificate
40
+ - Optional shared-token authentication via `TERMFRONT_PVP_TOKEN`; when set on the server, queue requests must carry a matching token (sent automatically when the client has the same env var)
41
+ - Wavesfight co-op now generates weapon drops when enemies are killed; the `E` key picks up the nearest drop within `Config::PICKUP_RADIUS`, swapping the current weapon (which is dropped at the player's position) and tracking obtained weapons per-player so cheaters cannot claim weapons they have not picked up
42
+
9
43
  ## [0.1.3] - 2026-05-24
10
44
 
11
45
  ### Fixed
data/README.md CHANGED
@@ -54,7 +54,7 @@ Start a PvP server:
54
54
  termfront-server
55
55
  ```
56
56
 
57
- Use custom TLS certificate paths:
57
+ TLS certificate / key paths are **required** for the server to start. Use a fullchain certificate (e.g. issued by Let's Encrypt):
58
58
 
59
59
  ```bash
60
60
  TERMFRONT_TLS_CERT_FILE=/path/to/fullchain.pem \
@@ -72,6 +72,19 @@ Default PvP port is `7777`.
72
72
 
73
73
  The default multiplayer client address is `termfront.gamelinks007.net:443`.
74
74
 
75
+ Set `TERMFRONT_TLS_CA_FILE` to trust an additional CA certificate when running the client against a server whose certificate chain is not in the system trust store:
76
+
77
+ ```bash
78
+ TERMFRONT_TLS_CA_FILE=/path/to/ca.pem termfront
79
+ ```
80
+
81
+ Set `TERMFRONT_PVP_TOKEN` on the server to require clients to present the same token before they can queue. Useful for limiting participation to a known group:
82
+
83
+ ```bash
84
+ TERMFRONT_PVP_TOKEN=event_token termfront-server # server side
85
+ TERMFRONT_PVP_TOKEN=event_token termfront # each authorized client
86
+ ```
87
+
75
88
  ## Controls
76
89
 
77
90
  - `W` `A` `S` `D`: move
@@ -19,17 +19,6 @@
19
19
  "id": "final_push_outro",
20
20
  "trigger": { "type": "mission_complete" },
21
21
  "actions": [
22
- {
23
- "type": "demo",
24
- "duration": 3.0,
25
- "caption": "ARCHIVE VAULT / SIGNAL STILL PRESENT",
26
- "path": [
27
- { "x": 20.5, "y": 9.5, "angle": 3.14, "t": 0.0 },
28
- { "x": 16.5, "y": 9.5, "angle": 3.05, "t": 1.0 },
29
- { "x": 11.5, "y": 9.5, "angle": 2.95, "t": 2.0 },
30
- { "x": 7.5, "y": 8.5, "angle": 2.75, "t": 3.0 }
31
- ]
32
- },
33
22
  { "type": "title_card", "text": "FORTRESS CLEARED\nSIGNAL SOURCE UNRESOLVED" },
34
23
  { "type": "dialogue", "speaker": "OPS", "text": "Fortress cleared. The signal is still active somewhere below you." },
35
24
  { "type": "dialogue", "speaker": "OPS", "text": "This facility did not fail containment. Someone revoked it." },
@@ -170,10 +170,14 @@ module Termfront
170
170
 
171
171
  def asset_path(kind, name)
172
172
  relative = @manifest.fetch(kind.to_s, {})[name.to_s]
173
- return unless relative
173
+ return unless relative.is_a?(String) && !relative.empty?
174
174
 
175
+ root = File.expand_path("../../data/audio", __dir__)
175
176
  path = File.expand_path("../../#{relative}", __dir__)
176
- File.file?(path) ? path : nil
177
+ return unless path.start_with?(root + File::SEPARATOR)
178
+ return unless File.file?(path)
179
+
180
+ path
177
181
  end
178
182
 
179
183
  def spawn_player(player, path, loop_playback:, detach: false)
@@ -33,7 +33,7 @@ module Termfront
33
33
  [10.5, 9.5, 10.5, 8.5, :crawler],
34
34
  [18.5, 2.5, 18.5, 5.5, :executor],
35
35
  [22.5, 8.5, 22.5, 10.5, :executor],
36
- [16.5, 9.5, 16.5, 8.5, :crawler]
36
+ [15.5, 9.5, 15.5, 8.5, :crawler]
37
37
  ]
38
38
  end
39
39
  end
@@ -4,6 +4,7 @@ module Termfront
4
4
  module Network
5
5
  class Client
6
6
  TEAM_SIZES = [1, 2, 4].freeze
7
+ ALLOWED_WEAPONS = %w[pistol ar].freeze
7
8
 
8
9
  def initialize(stdout)
9
10
  @stdout = stdout
@@ -14,16 +15,16 @@ module Termfront
14
15
  end
15
16
 
16
17
  def run
17
- addr = prompt_address
18
- return unless addr
19
-
20
18
  team_size = prompt_team_size
21
19
  return unless team_size
22
20
 
23
- host, port = addr.include?(":") ? addr.split(":", 2).then { |h, p| [h, p.to_i] } : [addr, Config::PVP_PORT]
21
+ host, port = Config::PVP_DEFAULT_ADDRESS.split(":", 2).then { |h, p| [h, p.to_i] }
22
+ queue_msg = { t: "queue", team_size: team_size }
23
+ token = ENV["TERMFRONT_PVP_TOKEN"]
24
+ queue_msg[:token] = token if token && !token.empty?
24
25
  begin
25
- @conn.connect(host, port)
26
- @conn.send_msg({ t: "queue", team_size: team_size })
26
+ @conn.connect(host, port, ca_file: ENV["TERMFRONT_TLS_CA_FILE"])
27
+ @conn.send_msg(queue_msg)
27
28
  rescue StandardError => e
28
29
  show_error("Connection failed: #{e.message}")
29
30
  return
@@ -45,52 +46,6 @@ module Termfront
45
46
 
46
47
  private
47
48
 
48
- def prompt_address
49
- input = Config::PVP_DEFAULT_ADDRESS
50
-
51
- STDIN.raw do |stdin|
52
- loop do
53
- rows, cols = @stdout.winsize
54
- buf = TerminalOutput.begin_frame(home: true, clear: true)
55
- lines = Array.new(rows) { " " * cols }
56
-
57
- title = "PvP - Enter Server Address"
58
- tc = [(cols - title.size) / 2 + 1, 1].max
59
- lines[rows / 2 - 3] = TerminalOutput.fit_ansi("#{" " * (tc - 1)}\e[1;96m#{title}\e[0m", cols)
60
-
61
- prompt = "> #{input}_"
62
- pc = [(cols - prompt.size) / 2 + 1, 1].max
63
- lines[rows / 2 - 1] = TerminalOutput.fit_ansi("#{" " * (pc - 1)}\e[97m> #{input}\e[5m_\e[0m", cols)
64
-
65
- hint = "(Enter to continue, ESC to cancel)"
66
- hc = [(cols - hint.size) / 2 + 1, 1].max
67
- lines[rows / 2 + 1] = TerminalOutput.fit_ansi("#{" " * (hc - 1)}\e[90m#{hint}\e[0m", cols)
68
-
69
- lines.each_with_index do |line, index|
70
- buf << line
71
- buf << "\r\n" if index < rows - 1
72
- end
73
- buf << TerminalOutput.end_frame
74
- TerminalOutput.write_all(@stdout, buf)
75
-
76
- next unless IO.select([stdin], nil, nil, Config::FRAME_DT)
77
-
78
- begin
79
- data = stdin.read_nonblock(64)
80
- data.each_byte do |b|
81
- case b
82
- when 27 then return nil
83
- when 13, 10 then return input.empty? ? Config::PVP_DEFAULT_ADDRESS : input
84
- when 127, 8 then input = input[0...-1] unless input.empty?
85
- when 32..126 then input << b.chr
86
- end
87
- end
88
- rescue IO::WaitReadable
89
- end
90
- end
91
- end
92
- end
93
-
94
49
  def prompt_team_size
95
50
  selected = 0
96
51
 
@@ -310,7 +265,15 @@ module Termfront
310
265
  when "state"
311
266
  update_remote_state(msg)
312
267
  when "hit"
313
- @player.apply_damage(msg[:d] || Config::PVP_HIT_DMG)
268
+ if msg.key?(:s) && msg.key?(:h)
269
+ @player.shield = msg[:s]
270
+ @player.health = msg[:h]
271
+ @player.last_damage = @player.game_time
272
+ @player.damage_flash = 3
273
+ @player.dead = msg[:h] <= 0
274
+ else
275
+ @player.apply_damage(msg[:d] || Config::PVP_HIT_DMG)
276
+ end
314
277
  @audio.play_se(:damage)
315
278
  notify_death_if_needed
316
279
  when "dead"
@@ -334,7 +297,8 @@ module Termfront
334
297
  remote[:current].angle = msg[:a]
335
298
  remote[:current].shield = msg[:s]
336
299
  remote[:current].health = msg[:h]
337
- remote[:current].weapon = msg[:w]&.to_sym
300
+ weapon = safe_weapon(msg[:w])
301
+ remote[:current].weapon = weapon if weapon
338
302
  remote[:current].ammo = msg[:am]
339
303
  spawn_remote_projectile_effect(msg) if (remote[:current].fire_flash || 0) <= 0 && (msg[:ff] || 0) > 0
340
304
  remote[:current].fire_flash = msg[:ff] || 0
@@ -945,6 +909,15 @@ module Termfront
945
909
  remain = Config::FRAME_DT - spent
946
910
  sleep(remain) if remain > 0
947
911
  end
912
+
913
+ def safe_weapon(value)
914
+ return nil unless value.is_a?(String) || value.is_a?(Symbol)
915
+
916
+ name = value.to_s
917
+ return nil unless ALLOWED_WEAPONS.include?(name)
918
+
919
+ name.to_sym
920
+ end
948
921
  end
949
922
  end
950
923
  end
@@ -8,6 +8,8 @@ require "time"
8
8
  module Termfront
9
9
  module Network
10
10
  class Connection
11
+ MAX_MSG_BYTES = 64 * 1024
12
+
11
13
  PeerInfo = Struct.new(
12
14
  :certificate_sha256,
13
15
  :public_key_sha256,
@@ -64,6 +66,11 @@ module Termfront
64
66
  data = @sock.read_nonblock(4096)
65
67
  @buf << data
66
68
 
69
+ if @buf.bytesize > MAX_MSG_BYTES
70
+ close
71
+ break
72
+ end
73
+
67
74
  while (nl = @buf.index("\n"))
68
75
  line = @buf.slice!(0, nl + 1)
69
76
  begin
@@ -120,6 +127,7 @@ module Termfront
120
127
  ctx = OpenSSL::SSL::SSLContext.new
121
128
  ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
122
129
  ctx.verify_hostname = true if ctx.respond_to?(:verify_hostname=)
130
+ ctx.min_version = OpenSSL::SSL::TLS1_2_VERSION
123
131
  ctx.cert_store = build_cert_store(ca_file: ca_file)
124
132
  ctx
125
133
  end