doom 0.8.0 → 0.11.0

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +17 -0
  3. data/README.md +65 -4
  4. data/bin/doom +134 -24
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +239 -153
  8. data/lib/doom/game/framebuffer_blitter.rb +38 -0
  9. data/lib/doom/game/geometry.rb +68 -0
  10. data/lib/doom/game/intermission.rb +22 -39
  11. data/lib/doom/game/item_pickup.rb +87 -75
  12. data/lib/doom/game/menu.rb +90 -85
  13. data/lib/doom/game/monster_ai.rb +150 -136
  14. data/lib/doom/game/player.rb +105 -0
  15. data/lib/doom/game/player_physics.rb +384 -0
  16. data/lib/doom/game/player_state.rb +38 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +169 -93
  19. data/lib/doom/game/sector_effects.rb +25 -18
  20. data/lib/doom/game/snapshot.rb +584 -0
  21. data/lib/doom/game/sound_engine.rb +33 -58
  22. data/lib/doom/game/state_hash.rb +125 -0
  23. data/lib/doom/game/ticcmd.rb +61 -0
  24. data/lib/doom/game/world.rb +420 -0
  25. data/lib/doom/map/data.rb +95 -0
  26. data/lib/doom/net/client.rb +204 -0
  27. data/lib/doom/net/desync_monitor.rb +101 -0
  28. data/lib/doom/net/game_server.rb +232 -0
  29. data/lib/doom/net/lockstep.rb +170 -0
  30. data/lib/doom/net/protocol.rb +350 -0
  31. data/lib/doom/net/session.rb +282 -0
  32. data/lib/doom/net/transport.rb +110 -0
  33. data/lib/doom/platform/gosu_window.rb +604 -832
  34. data/lib/doom/platform/sdl.rb +74 -0
  35. data/lib/doom/platform/window_logic.rb +61 -0
  36. data/lib/doom/render/font.rb +5 -3
  37. data/lib/doom/render/hardware_renderer.rb +547 -0
  38. data/lib/doom/render/ray_tracing/bvh.rb +103 -0
  39. data/lib/doom/render/ray_tracing/material_state.rb +66 -0
  40. data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
  41. data/lib/doom/render/ray_tracing_renderer.rb +940 -0
  42. data/lib/doom/render/renderer.rb +466 -440
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +7 -7
  45. data/lib/doom/render/spinel_native/kernel.rb +780 -0
  46. data/lib/doom/render/spinel_native_renderer.rb +162 -0
  47. data/lib/doom/render/status_bar.rb +14 -10
  48. data/lib/doom/render/weapon_renderer.rb +9 -10
  49. data/lib/doom/render/world_mesh.rb +270 -0
  50. data/lib/doom/render/zbuffer_renderer.rb +151 -0
  51. data/lib/doom/version.rb +1 -1
  52. data/lib/doom/wad/flat.rb +1 -1
  53. data/lib/doom/wad/hud_graphics.rb +7 -3
  54. data/lib/doom/wad/palette.rb +3 -3
  55. data/lib/doom/wad/patch.rb +1 -1
  56. data/lib/doom/wad/reader.rb +12 -15
  57. data/lib/doom/wad/sound.rb +14 -6
  58. data/lib/doom/wad/sprite.rb +36 -35
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +197 -16
  62. metadata +94 -7
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Net
5
+ # The client half of the authoritative-server game. Talks only to the
6
+ # server (outbound UDP, so NAT is a non-issue), joins by restoring a
7
+ # snapshot of the live world, then follows the server's finalized frame
8
+ # stream -- applying joins, leaves and commands in tic order -- so its world
9
+ # stays byte-identical to the server's. It runs no simulation of its own and
10
+ # never predicts: the local player sees their own input after the round trip
11
+ # to the server and back, which is the price of a model where a dead phone
12
+ # can't stall anyone.
13
+ #
14
+ # Everything is polled, never blocking, so the game keeps rendering while a
15
+ # join is still in flight or a resync is underway.
16
+ #
17
+ # A join is a small state machine: HELLO -> WELCOME (who you are, which map)
18
+ # -> SNAPSHOT chunks (the world) -> playing. HELLO is resent until WELCOME
19
+ # arrives; if the snapshot never completes, or the frame stream leaves a gap
20
+ # too old for the server's redundancy to fill, the client asks again and the
21
+ # server sends a fresh snapshot at the current tic.
22
+ class Client
23
+ HELLO_RESEND_SECONDS = 0.25
24
+ SYNC_TIMEOUT_SECONDS = 1.5 # No completed snapshot this long -> ask again
25
+ # If the newest frame we've heard of is this far past the one we still
26
+ # need, the missing one has aged out of the server's resend window and is
27
+ # gone for good -- only a fresh snapshot recovers us.
28
+ RESYNC_GAP_TICS = 12
29
+
30
+ attr_reader :world, :local_id, :synced_tic
31
+
32
+ # `load_map` is a callable name -> Map::MapData; the client learns which
33
+ # map from WELCOME and has no other way to reach the WAD.
34
+ def initialize(host:, port:, sprites:, load_map:, sound: nil,
35
+ clock: -> { Time.now }, transport: nil)
36
+ @host = host
37
+ @port = port
38
+ @sprites = sprites
39
+ @load_map = load_map
40
+ @sound = sound
41
+ @clock = clock
42
+ @transport = transport || Transport.new
43
+ reset_join
44
+ @last_hello_at = nil
45
+ end
46
+
47
+ def local_port = @transport.local_port
48
+ def connected? = !@local_id.nil? # WELCOME received
49
+ def playing? = !@world.nil? # snapshot restored, following frames
50
+
51
+ # Pump the network: chase the join if not yet playing, then drain and
52
+ # apply whatever has arrived. Safe every frame.
53
+ def poll
54
+ send_hello_if_due
55
+ @transport.poll.each { |msg, _host, _port| handle(msg) }
56
+ resync_if_stuck
57
+ end
58
+
59
+ # Send this frame's input for future tics. `pairs` is [[tic, Ticcmd], ...];
60
+ # recent inputs are repeated by the caller so a lost packet is covered.
61
+ def send_input(pairs)
62
+ return unless @local_id
63
+
64
+ send_packet(Protocol.encode_input(@local_id, pairs))
65
+ end
66
+
67
+ def quit
68
+ send_packet(Protocol.encode_quit(@local_id)) if @local_id && !@transport.closed?
69
+ @transport.close
70
+ end
71
+
72
+ private
73
+
74
+ def reset_join
75
+ @local_id = nil
76
+ @map_name = nil
77
+ @snapshot_tic = nil
78
+ @chunks = {}
79
+ @chunk_total = nil
80
+ @world = nil
81
+ @synced_tic = nil
82
+ @pending_frames = {}
83
+ @synced_at = nil
84
+ end
85
+
86
+ def send_packet(bytes) = @transport.send_to_addr(@host, @port, bytes)
87
+
88
+ def send_hello_if_due
89
+ return if playing?
90
+
91
+ now = @clock.call
92
+ return if @last_hello_at && (now - @last_hello_at) < HELLO_RESEND_SECONDS
93
+
94
+ @last_hello_at = now
95
+ send_packet(Protocol.encode_hello)
96
+ end
97
+
98
+ def handle(msg)
99
+ case msg[:type]
100
+ when Protocol::WELCOME then on_welcome(msg)
101
+ when Protocol::SNAPSHOT then on_chunk(msg)
102
+ when Protocol::FRAME then on_frame(msg)
103
+ end
104
+ end
105
+
106
+ # WELCOME may be the first one (join) or a fresh one answering a resync
107
+ # request; either way we start reassembling the snapshot it names and drop
108
+ # any stale in-progress reassembly for an older snapshot tic.
109
+ def on_welcome(msg)
110
+ return if @snapshot_tic == msg[:snapshot_tic] && @map_name
111
+
112
+ @local_id = msg[:player_id]
113
+ @map_name = msg[:map]
114
+ @snapshot_tic = msg[:snapshot_tic]
115
+ @map = @load_map.call(@map_name)
116
+ @chunks = {}
117
+ @chunk_total = nil
118
+ @last_hello_at = @clock.call # a WELCOME resets the hello clock
119
+ end
120
+
121
+ def on_chunk(msg)
122
+ return unless @snapshot_tic == msg[:snapshot_tic] && @map
123
+
124
+ total = msg[:total]
125
+ index = msg[:index]
126
+ # A stray or hostile chunk (bad total, or an index outside the range)
127
+ # must not corrupt reassembly: without this an out-of-range index could
128
+ # push @chunks.size up to @chunk_total while a real index stays missing,
129
+ # so the join below would splice a gap and Snapshot.load would raise on
130
+ # the network path. Drop it instead.
131
+ return if total.nil? || total.zero? || index.nil? || index >= total
132
+
133
+ @chunk_total = total
134
+ @chunks[index] = msg[:chunk]
135
+ return unless @chunks.size == @chunk_total && (0...@chunk_total).all? { |i| @chunks.key?(i) }
136
+
137
+ bytes = (0...@chunk_total).map { |i| @chunks[i] }.join
138
+ begin
139
+ world = Game::Snapshot.load(bytes, map: @map, sprites: @sprites, sound: @sound)
140
+ rescue StandardError => e
141
+ # A truncated or corrupt snapshot is recoverable: ask for a fresh one
142
+ # rather than letting the exception kill the client mid-game.
143
+ warn "snapshot load failed (#{e.class}: #{e.message}); requesting resync"
144
+ request_resync(@clock.call)
145
+ return
146
+ end
147
+
148
+ @world = world
149
+ @synced_tic = @snapshot_tic
150
+ @synced_at = @clock.call
151
+ # Frames at or before the snapshot are already baked into it.
152
+ @pending_frames.reject! { |tic, _| tic <= @synced_tic }
153
+ drain_pending
154
+ end
155
+
156
+ def on_frame(msg)
157
+ msg[:frames].each { |f| @pending_frames[f[:tic]] ||= f }
158
+ drain_pending
159
+ end
160
+
161
+ # Apply every consecutive frame we have, in tic order. A missing tic stops
162
+ # the drain -- lockstep can't skip -- and we wait for it to arrive (or for
163
+ # resync_if_stuck to give up on it).
164
+ def drain_pending
165
+ return unless @synced_tic
166
+
167
+ while (f = @pending_frames.delete(@synced_tic + 1))
168
+ f[:joins].each { |id| @world.add_player(id: id) }
169
+ f[:leaves].each { |id| @world.remove_player(id) }
170
+ @world.run_tic(f[:cmds].to_h)
171
+ @synced_tic += 1
172
+ @synced_at = @clock.call
173
+ end
174
+ prune_pending
175
+ end
176
+
177
+ def prune_pending
178
+ @pending_frames.reject! { |tic, _| tic <= @synced_tic }
179
+ end
180
+
181
+ # Ask for a fresh snapshot when the join stalls or an unfillable frame gap
182
+ # opens. Re-sending HELLO is the request; the server answers an existing
183
+ # client with a new WELCOME plus a snapshot at the current tic.
184
+ def resync_if_stuck
185
+ now = @clock.call
186
+
187
+ if playing?
188
+ newest = @pending_frames.keys.max
189
+ gap = newest && (newest - @synced_tic) > RESYNC_GAP_TICS
190
+ request_resync(now) if gap
191
+ elsif connected? && @synced_at.nil?
192
+ # WELCOME arrived but the snapshot never finished reassembling.
193
+ request_resync(now) if @last_hello_at && (now - @last_hello_at) >= SYNC_TIMEOUT_SECONDS
194
+ end
195
+ end
196
+
197
+ def request_resync(now)
198
+ reset_join
199
+ @last_hello_at = now
200
+ send_packet(Protocol.encode_hello)
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Net
5
+ # Watches for lockstep divergence.
6
+ #
7
+ # Each peer fingerprints its world every CHECK_INTERVAL tics and broadcasts
8
+ # the result. When two peers report different fingerprints for the same
9
+ # tic, their simulations have already diverged and every tic after it is
10
+ # meaningless. The job here is to say so at the tic it happened, with
11
+ # enough detail to diagnose it, instead of letting the games drift apart
12
+ # while both players wonder why the other is shooting at nothing.
13
+ #
14
+ # Peers exchange per-section hashes, not just the folded one: five extra
15
+ # numbers a second is nothing on the wire, and it turns "something
16
+ # diverged" into "the monsters diverged", which is the difference between
17
+ # an alarming report and a useful one.
18
+ #
19
+ # Reports can arrive before or after the local check for the same tic, so
20
+ # both directions are buffered and compared whenever a pair completes.
21
+ class DesyncMonitor
22
+ CHECK_INTERVAL = 35 # One second at DOOM's tic rate
23
+ HISTORY_TICS = 350 # Keep ~10s; enough to pair up late reports
24
+
25
+ Desync = Struct.new(:tic, :peer_id, :local, :remote, :sections, keyword_init: true) do
26
+ def message
27
+ where = sections.nil? || sections.empty? ? 'unknown section' : sections.join(', ')
28
+ "desync at tic #{tic} with peer #{peer_id}: " \
29
+ "local #{format('%08x', local)} != remote #{format('%08x', remote)} (#{where})"
30
+ end
31
+ end
32
+
33
+ attr_reader :desyncs
34
+
35
+ # `on_desync` is called with a Desync the moment one is detected.
36
+ def initialize(interval: CHECK_INTERVAL, &on_desync)
37
+ @interval = interval
38
+ @on_desync = on_desync
39
+ @local = {} # tic => section hashes
40
+ @remote = {} # tic => { peer_id => section hashes }
41
+ @desyncs = []
42
+ end
43
+
44
+ def check_due?(tic)
45
+ (tic % @interval).zero?
46
+ end
47
+
48
+ # Fingerprint the world if this tic is a checkpoint. Returns the section
49
+ # hashes to broadcast, or nil on a tic that is not checked.
50
+ def record(tic, world)
51
+ return nil unless check_due?(tic)
52
+
53
+ sections = world.state_hash_sections
54
+ @local[tic] = sections
55
+ compare(tic)
56
+ prune(tic)
57
+ sections
58
+ end
59
+
60
+ # Section hashes received from another peer.
61
+ def remote_report(tic, peer_id, sections)
62
+ (@remote[tic] ||= {})[peer_id] = sections
63
+ compare(tic)
64
+ end
65
+
66
+ def desynced?
67
+ !@desyncs.empty?
68
+ end
69
+
70
+ private
71
+
72
+ def compare(tic)
73
+ local = @local[tic]
74
+ peers = @remote[tic]
75
+ return unless local && peers
76
+
77
+ peers.each do |peer_id, remote|
78
+ local_hash = Game::StateHash.fold(local)
79
+ remote_hash = Game::StateHash.fold(remote)
80
+ next if local_hash == remote_hash
81
+ next if @desyncs.any? { |d| d.tic == tic && d.peer_id == peer_id }
82
+
83
+ differing = Game::StateHash::SECTIONS.reject { |s| local[s] == remote[s] }
84
+ desync = Desync.new(tic: tic, peer_id: peer_id,
85
+ local: local_hash, remote: remote_hash, sections: differing)
86
+ @desyncs << desync
87
+ @on_desync&.call(desync)
88
+ end
89
+ end
90
+
91
+ # Bound memory: a long session would otherwise keep every checkpoint.
92
+ def prune(tic)
93
+ cutoff = tic - HISTORY_TICS
94
+ return if cutoff <= 0
95
+
96
+ @local.delete_if { |t, _| t < cutoff }
97
+ @remote.delete_if { |t, _| t < cutoff }
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Net
5
+ # The authoritative game server for large, chaotic sessions.
6
+ #
7
+ # Peer-to-peer lockstep does not survive a conference: with mobile clients
8
+ # behind CGNAT they cannot reach each other, and lockstep makes everyone
9
+ # wait for the slowest, so the first person to close their laptop freezes
10
+ # the match. This server fixes both. Clients speak only to it (outbound
11
+ # UDP, so NAT is a non-issue), and it never waits: on every tic it takes
12
+ # whatever input has arrived, fills the gaps with neutral commands, runs
13
+ # the tic, and broadcasts the finalized result. A player whose phone dies
14
+ # simply stops acting -- nobody else is held up.
15
+ #
16
+ # It is still deterministic underneath: it holds the one canonical world,
17
+ # every client runs the identical command stream, and joins and leaves are
18
+ # tic events (a deathmatch spawn draws the shared RNG, so it must happen on
19
+ # the same tic everywhere). Because it holds the canonical world, it can
20
+ # hand a late joiner a snapshot of the exact current state instead of
21
+ # making them replay the match from the start.
22
+ #
23
+ # Nothing here blocks. `poll` drains the socket, `advance` runs whatever
24
+ # tics are due against an injected clock; a host loops them.
25
+ class GameServer
26
+ TICRATE = 35
27
+ TIC_SECONDS = 1.0 / TICRATE
28
+ FRAME_REDUNDANCY = 6 # Recent frames repeated per packet against loss
29
+ CLIENT_TIMEOUT = 5.0 # Drop a client silent this long
30
+
31
+ # A connected client's bookkeeping (named Member so it is not confused
32
+ # with Net::Client, the actual client program in the same module).
33
+ Member = Struct.new(:player_id, :host, :port, :last_seen, :joined) do
34
+ def key = "#{host}:#{port}"
35
+ end
36
+
37
+ attr_reader :world, :transport, :tic, :clients
38
+
39
+ def initialize(world:, transport:, max_players: 16, clock: -> { Time.now },
40
+ client_timeout: CLIENT_TIMEOUT)
41
+ @world = world
42
+ @transport = transport
43
+ @max_players = max_players
44
+ @clock = clock
45
+ @client_timeout = client_timeout
46
+ @tic = world.leveltime
47
+ @start_tic = world.leveltime
48
+ @start_time = @clock.call
49
+
50
+ @clients = {} # addr key => Client
51
+ @by_id = {} # player_id => Client
52
+ @inputs = Hash.new { |h, k| h[k] = {} } # tic => { player_id => Ticcmd }
53
+ @pending_joins = [] # player ids to add on the next tic
54
+ @pending_leaves = [] # player ids to remove on the next tic
55
+ @frame_log = [] # recent finalized frames, for redundancy
56
+ end
57
+
58
+ # Drain and dispatch whatever has arrived. Safe every loop iteration.
59
+ def poll
60
+ @transport.poll.each { |msg, host, port| handle(msg, host, port) }
61
+ end
62
+
63
+ # Drop clients that have gone silent, so their stale bodies stop being
64
+ # simulated and stop holding a player slot. Called from the host loop.
65
+ # A leave is a tic event like a join, applied on the next tic.
66
+ def reap(now = @clock.call)
67
+ @clients.values.each do |c|
68
+ next unless c.joined
69
+ next if now - c.last_seen < @client_timeout
70
+
71
+ @clients.delete(c.key)
72
+ @by_id.delete(c.player_id)
73
+ @pending_leaves << c.player_id
74
+ end
75
+ end
76
+
77
+ # Run every tic whose deadline has passed, capping the catch-up so one
78
+ # long stall cannot make a single call run thousands of tics. Returns how
79
+ # many ran.
80
+ def advance(now = @clock.call, limit: 8)
81
+ target = @start_tic + ((now - @start_time) * TICRATE).floor
82
+ ran = 0
83
+ while @tic < target && ran < limit
84
+ step_one_tic
85
+ ran += 1
86
+ end
87
+ ran
88
+ end
89
+
90
+ def player_count = @world.players.size
91
+
92
+ private
93
+
94
+ # Advance exactly one tic: apply membership, gather input, simulate,
95
+ # then tell everyone what happened.
96
+ def step_one_tic
97
+ this_tic = @tic + 1
98
+
99
+ joins = @pending_joins.dup
100
+ leaves = @pending_leaves.dup
101
+ @pending_joins.clear
102
+ @pending_leaves.clear
103
+
104
+ apply_joins(joins)
105
+ apply_leaves(leaves)
106
+
107
+ cmds = finalize_cmds(this_tic)
108
+ @world.run_tic(cmds)
109
+ @tic = this_tic
110
+ @inputs.delete(this_tic)
111
+
112
+ frame = { tic: this_tic, joins: joins, leaves: leaves,
113
+ cmds: cmds.map { |id, c| [id, c] } }
114
+ record_and_broadcast(frame)
115
+ send_welcomes(joins, this_tic)
116
+ end
117
+
118
+ # Missing players coast on a neutral command rather than stalling the tic.
119
+ # That is the whole point of the deadline: nobody waits.
120
+ def finalize_cmds(tic)
121
+ have = @inputs[tic]
122
+ @world.players.each_with_object({}) do |p, h|
123
+ h[p.id] = have[p.id] || Game::Ticcmd.none
124
+ end
125
+ end
126
+
127
+ def apply_joins(ids)
128
+ ids.each do |id|
129
+ @world.add_player(id: id)
130
+ client = @by_id[id]
131
+ client.joined = true if client
132
+ end
133
+ end
134
+
135
+ def apply_leaves(ids)
136
+ ids.each { |id| @world.remove_player(id) }
137
+ end
138
+
139
+ def record_and_broadcast(frame)
140
+ @frame_log << frame
141
+ @frame_log.shift while @frame_log.size > FRAME_REDUNDANCY
142
+ # Cap the packet under a safe MTU: at high player counts the full
143
+ # redundancy would fragment or be truncated. Redundancy degrades
144
+ # gracefully, the newest frame always ships.
145
+ packet = Protocol.encode_frame_capped(@frame_log)
146
+ @clients.each_value { |c| @transport.send_to_addr(c.host, c.port, packet) }
147
+ end
148
+
149
+ def handle(msg, host, port)
150
+ case msg[:type]
151
+ when Protocol::HELLO then on_hello(host, port)
152
+ when Protocol::INPUT then on_input(msg, host, port)
153
+ when Protocol::QUIT then on_quit(host, port)
154
+ end
155
+ end
156
+
157
+ def on_hello(host, port)
158
+ existing = @clients["#{host}:#{port}"]
159
+ if existing
160
+ # A repeated hello is a resync request: the welcome was lost, or the
161
+ # client fell too far behind the frame stream to recover. Answer with
162
+ # a fresh snapshot at the current tic, not just the welcome, so it can
163
+ # restart from live state.
164
+ send_snapshot_to(existing) if existing.joined
165
+ return
166
+ end
167
+ return if @world.players.size + @pending_joins.size >= @max_players
168
+
169
+ id = next_free_id
170
+ return unless id
171
+
172
+ client = Member.new(id, host, port, @clock.call, false)
173
+ @clients[client.key] = client
174
+ @by_id[id] = client
175
+ @pending_joins << id
176
+ end
177
+
178
+ def on_input(msg, host, port)
179
+ client = @clients["#{host}:#{port}"]
180
+ return unless client
181
+
182
+ client.last_seen = @clock.call
183
+ # A client may only speak for the id it was assigned. The check does not
184
+ # vary per command, so reject the whole packet up front rather than
185
+ # re-testing it inside the loop.
186
+ return unless msg[:player_id] == client.player_id
187
+
188
+ msg[:cmds].each do |tic, cmd|
189
+ next if tic <= @tic # already simulated; too late to matter
190
+
191
+ @inputs[tic][client.player_id] = cmd
192
+ end
193
+ end
194
+
195
+ def on_quit(host, port)
196
+ client = @clients.delete("#{host}:#{port}")
197
+ return unless client
198
+
199
+ @by_id.delete(client.player_id)
200
+ @pending_leaves << client.player_id if client.joined
201
+ end
202
+
203
+ def next_free_id
204
+ taken = @by_id.keys + @pending_joins
205
+ (0...@max_players).find { |id| !taken.include?(id) }
206
+ end
207
+
208
+ # Hand each new joiner a snapshot of the world at the tic they joined,
209
+ # split into datagram-sized chunks. They restore it and follow the frame
210
+ # stream from the next tic.
211
+ def send_welcomes(join_ids, _tic)
212
+ join_ids.each do |id|
213
+ client = @by_id[id]
214
+ send_snapshot_to(client) if client
215
+ end
216
+ end
217
+
218
+ # WELCOME plus the world snapshot at the current tic, chunked. One dump
219
+ # per recipient is fine -- joins and resyncs are rare next to the tic loop.
220
+ def send_snapshot_to(client)
221
+ @transport.send_to_addr(client.host, client.port, Protocol.encode_welcome(
222
+ player_id: client.player_id, num_players: @world.players.size,
223
+ mode: @world.mode, skill: Session::DEFAULT_SKILL,
224
+ snapshot_tic: @tic, map: @world.map.name
225
+ ))
226
+ Protocol.snapshot_chunks(@tic, Game::Snapshot.dump(@world)).each do |chunk|
227
+ @transport.send_to_addr(client.host, client.port, chunk)
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end