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 +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +14 -1
- data/data/events/final_push.json +0 -11
- data/lib/termfront/audio_manager.rb +6 -2
- data/lib/termfront/config.rb +2 -1
- data/lib/termfront/game.rb +2 -1
- data/lib/termfront/mission/final_push.rb +1 -1
- data/lib/termfront/network/client.rb +28 -55
- data/lib/termfront/network/connection.rb +8 -0
- data/lib/termfront/network/server.rb +489 -95
- data/lib/termfront/network/wavesfight_client.rb +104 -55
- data/lib/termfront/renderer.rb +128 -73
- data/lib/termfront/terminal_output.rb +1 -6
- data/lib/termfront/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68852aaca6be7127d8b644aae4670a56e72d5528b249dc081d72ba0d22c5f74a
|
|
4
|
+
data.tar.gz: 440209c869681acd0f9d77d65fb367e6d055c2ffe3a5a57821dd530912199ecd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
data/data/events/final_push.json
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/termfront/config.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Termfront
|
|
4
4
|
module Config
|
|
5
|
-
FRAME_DT = 1.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"
|
data/lib/termfront/game.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
@@ -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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 *
|
|
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
|