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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64c01550bc002aa40824296474323c7dc6126447fe52bd8acb8cf150514a68af
4
- data.tar.gz: 07c871ac0a859394d94d98189700195a8a98469c0f319d8748482ff79f617883
3
+ metadata.gz: 68852aaca6be7127d8b644aae4670a56e72d5528b249dc081d72ba0d22c5f74a
4
+ data.tar.gz: 440209c869681acd0f9d77d65fb367e6d055c2ffe3a5a57821dd530912199ecd
5
5
  SHA512:
6
- metadata.gz: 665f32c4d60b34330ba53c5beafed57826c5b1402a9855c613cd40941dbb4ea8bd29dd93eeb564b90513809d7f26e7f649624d1fe749054ead899884865f74c3
7
- data.tar.gz: 5d5f99b125210dbf8980cea284f112279178d228499f1caad9880deb99869cced727bfd180d5b1b8db645c10aa6627c7ba79e218b6093fd8e74b37f2bf5bf102
6
+ metadata.gz: 6efb979956fa649ee558ad0190547fd70c62fda5ef8b8ca4dc06f513c15c87c02c74151756486615a0cc21301e6b45d6f62e01e58230ae28b574ceb3570053ae
7
+ data.tar.gz: 12dfe9fde65aa1517facf4e7e21a37b6db9550e5cc99663801efb33db74f1a1b88af2503c6c6c54be70d60374328009f85334422413737514845602091f70893
data/CHANGELOG.md CHANGED
@@ -6,6 +6,65 @@ The format is based on Keep a Changelog, and this project follows Semantic Versi
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.5] - 2026-05-25
10
+
11
+ ### Changed
12
+
13
+ - Radar distance culling now compares squared distances against `Config::RADAR_RANGE_SQ` instead of taking a square root per entity per frame
14
+ - Renderer reuses the per-column raycast buffers and the virtual pixel grid across frames instead of allocating fresh arrays each tick, only re-allocating when the terminal is resized
15
+ - Build the radar background grid, horizontal rule, and ANSI-styled radar glyphs once and reuse them across frames
16
+ - Look up 256-color SGR escape sequences from a precomputed table and memoize truecolor SGR sequences to avoid rebuilding the same ANSI strings every cell
17
+ - Reuse renderer sprite collection arrays and the radar line buffer across frames, and sort sprites with a comparator block instead of `sort_by`
18
+ - Cache the terminal `winsize` inside the renderer and invalidate the cache from a `SIGWINCH` handler instead of issuing an ioctl every frame
19
+ - PvP hit raycast now culls candidates beyond `MAX_PVP_RANGE` and skips the line-of-sight check when the candidate is already farther than the current best
20
+ - Multiplayer server `broadcast` now serializes each outgoing message once and writes the same JSON line to every recipient
21
+ - PvP server now aggregates outgoing player state on a 30 Hz server tick instead of relaying each incoming state message immediately, reducing TLS write bursts on small VMs
22
+ - PvP match loop `IO.select` timeout shortened from 500 ms to ~16 ms so the new 30 Hz broadcast tick is not delayed by idle reads
23
+ - PvP client opponent interpolation now converges within ~40 ms (lerp factor raised from 15 to 25) so remote players track the new 30 Hz broadcast tick within a single frame
24
+ - Run the title demo, campaign missions, Wavesfight, and PvP at 60 FPS
25
+
26
+ ### Removed
27
+
28
+ - Stopped emitting DEC 2026 synchronized update escape sequences around each frame and removed the `TERMFRONT_SYNC_UPDATES` environment switch; non-supporting terminals (some SSH paths, older xterm) were paying parsing cost for shapes they ignore, while supporting terminals show no visible regression
29
+
30
+ ### Fixed
31
+
32
+ - Wavesfight wave advance now fully restores each surviving and revived player's shield to `Config::SHIELD_MAX`; previously only a +35 partial recovery was applied, which left revived players at 35%
33
+
34
+ ## [0.1.34] - 2026-05-25
35
+
36
+ ### Fixed
37
+
38
+ - Fixed PvP `route_hit` so the server always sends the fixed `Config::PVP_HIT_DMG` damage value, ignoring the attacker-supplied `d` field
39
+ - 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
40
+ - Enforce TLS 1.2 as the minimum protocol version on both the multiplayer server and client
41
+ - Stop logging client peer IP addresses on the multiplayer server
42
+ - Connect multiplayer clients to the official server address only; remove the free-form server address input
43
+ - Restrict audio manifest entries to paths under `data/audio/`
44
+ - Reject Wavesfight co-op queue requests with unknown mission ids
45
+ - Guard match worker threads against uncaught exceptions and ensure player sockets are closed
46
+ - Cap each matchmaking queue at 64 waiting players and reject excess connections
47
+ - Move per-connection handshake off the accept loop and drop silent clients after a short timeout
48
+ - Cap server and client receive buffers and disconnect peers that flood bytes without a newline
49
+ - End multiplayer matches after a maximum duration or when all players have been idle
50
+ - Restrict the weapon field on multiplayer state messages to the legal loadout (`pistol`, `ar`)
51
+ - Validate enemy / weapon / projectile type symbols received from the server against a fixed whitelist on the client side before converting to symbols
52
+ - 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
53
+ - 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
54
+ - Reject multiplayer state messages whose position delta exceeds the maximum physical step from the previous server-known position
55
+ - Rate-limit incoming multiplayer messages per type per player; sustained overflow ends the match for the offending client
56
+ - 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
57
+ - 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
58
+ - 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
59
+ - Wavesfight co-op now restores shield, health, and revives downed players between waves to match singleplayer behavior
60
+ - Final Push: relocated the rightmost crawler off the dividing wall so it can be killed and the mission can complete
61
+
62
+ ### Added
63
+
64
+ - Honor `TERMFRONT_TLS_CA_FILE` on multiplayer clients to trust an additional CA certificate
65
+ - 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)
66
+ - 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
67
+
9
68
  ## [0.1.3] - 2026-05-24
10
69
 
11
70
  ### 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)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Termfront
4
4
  module Config
5
- FRAME_DT = 1.0 / 30.0
5
+ FRAME_DT = 1.0 / 60.0
6
6
  FOV = 66.0 * Math::PI / 180.0
7
7
  PLAYER_RADIUS = 0.2
8
8
  KEY_TIMEOUT = 5
@@ -29,6 +29,7 @@ module Termfront
29
29
 
30
30
  RADAR_RADIUS = 3
31
31
  RADAR_RANGE = 12.0
32
+ RADAR_RANGE_SQ = RADAR_RANGE * RADAR_RANGE
32
33
 
33
34
  PVP_PORT = 7777
34
35
  PVP_DEFAULT_ADDRESS = "termfront.gamelinks007.net:443"
@@ -10,6 +10,7 @@ module Termfront
10
10
  @scene_player = ScenePlayer.new(@stdout, audio: @audio)
11
11
  @demo_player = DemoPlayer.new(@stdout, @renderer)
12
12
  @difficulty = nil
13
+ Signal.trap("WINCH") { @renderer.invalidate_size_cache! }
13
14
  end
14
15
 
15
16
  def start
@@ -413,7 +414,7 @@ module Termfront
413
414
  end
414
415
 
415
416
  def replenish_wavesfight_loadout
416
- @player.shield = [@player.shield + 35.0, Config::SHIELD_MAX].min
417
+ @player.shield = Config::SHIELD_MAX
417
418
  @player.health = [@player.health + 20.0, Config::HEALTH_MAX].min
418
419
  @player.last_damage = -Config::SHIELD_DELAY
419
420
  @player.dead = 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
@@ -428,7 +392,7 @@ module Termfront
428
392
 
429
393
  def interpolate_opponents(dt)
430
394
  @remotes.each_value do |remote|
431
- remote[:lerp_t] = [remote[:lerp_t] + dt * 15.0, 1.0].min
395
+ remote[:lerp_t] = [remote[:lerp_t] + dt * 25.0, 1.0].min
432
396
  t = remote[:lerp_t]
433
397
  remote[:render].x = remote[:prev].x + (remote[:current].x - remote[:prev].x) * t
434
398
  remote[:render].y = remote[:prev].y + (remote[:current].y - remote[:prev].y) * t
@@ -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