pwn 0.5.759 → 0.5.761

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,6 +3,252 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  describe PWN::Banner do
6
+ it 'keeps compact ghost hints in one cell rather than reserving multi-cell actor tiles' do
7
+ %w[R M C F].each do |pixel|
8
+ glyph, = described_class::MINI_PIXELS.fetch(pixel)
9
+ expect(glyph.length).to eq(1)
10
+ expect(glyph).to eq('⣻')
11
+ end
12
+ end
13
+
14
+ it 'advances Galaga waves after actual shot hits and detects both enemy and hostile-shot collisions' do
15
+ opts = { width: 8, height: 8, rng: Random.new(3) }
16
+ state = described_class.send(:mini_galaga_round, opts)
17
+ state[:started] = true
18
+ state[:enemies] = [{ x: 2.0, y: 1.0, home: [2, 1], diving: false, phase: 0 }]
19
+ state[:shots] = [[2.0, 1.7]]
20
+ expect(described_class.send(:mini_galaga_move, opts.merge(state: state))).to eq(:wave_complete)
21
+ expect(state[:score]).to eq(50)
22
+ expect(state[:enemies]).to be_empty
23
+ state[:hostile] = [[state[:ship][0], state[:ship][1] - 0.3, 0]]
24
+ expect(described_class.send(:mini_galaga_move, opts.merge(state: state))).to eq(:game_over)
25
+ state[:hostile].clear
26
+ state[:enemies] = [{ x: state[:ship][0], y: state[:ship][1] - 0.1, home: [2, 1], diving: true, phase: 0 }]
27
+ expect(described_class.send(:mini_galaga_move, opts.merge(state: state))).to eq(:game_over)
28
+ frames = described_class.send(:mini_replay, name: :galaga, width: 8, height: 9, seed: 3)
29
+ expect(frames.map { |frame| frame[:event] }).to include(:wave_complete)
30
+ expect(frames.any? { |frame| frame[:state][:level].to_i > 1 }).to be true
31
+ end
32
+
33
+ it 'progresses Frogger after all goals and rejects occupied goals and log-edge falls' do
34
+ opts = { width: 8, height: 8, rng: Random.new(3) }
35
+ state = described_class.send(:mini_frogger_round, opts)
36
+ state[:started] = true
37
+ state[:filled] = state[:goals].take(2)
38
+ state[:frog] = [state[:goals].last.to_f, 1]
39
+ expect(described_class.send(:mini_frogger_move, opts.merge(state: state, keys: [:up]))).to eq(:level_complete)
40
+ state[:frog] = [state[:goals].last.to_f, 1]
41
+ expect(described_class.send(:mini_frogger_move, opts.merge(state: state, keys: [:up]))).to eq(:game_over)
42
+ water = state[:lanes].find { |lane| lane[:water] }
43
+ water[:offset] = 6
44
+ water[:speed] = 0.2
45
+ state[:frog] = [7.0, water[:y]]
46
+ expect(described_class.send(:mini_frogger_move, opts.merge(state: state, keys: []))).to eq(:game_over)
47
+ frames = described_class.send(:mini_replay, name: :frogger, width: 5, height: 6, seed: 3)
48
+ expect(frames.map { |frame| frame[:event] }).to include(:level_complete)
49
+ expect(frames.any? { |frame| frame[:state][:level].to_i > 1 }).to be true
50
+ end
51
+
52
+ it 'recovers both new games to input-ready play and ignores wreck controls' do
53
+ %i[galaga frogger].each do |name|
54
+ game = described_class::MiniGame.new(name: name, width: 5, height: 5, seed: 3)
55
+ game.step
56
+ game.key(:up)
57
+ frames = []
58
+ 600.times do
59
+ frame = game.step
60
+ frames << frame
61
+ break if frame[:event] == :game_over
62
+ end
63
+ expect(frames.last[:event]).to eq(:game_over)
64
+ 8.times do
65
+ game.key(:right)
66
+ game.key(' ')
67
+ game.step
68
+ end
69
+ ready = game.step
70
+ expect(ready[:event]).to eq(:ready)
71
+ 5.times { expect(game.step).to eq(ready) }
72
+ game.key(:left)
73
+ expect(game.step[:event]).to eq(:play)
74
+ end
75
+ end
76
+
77
+ it 'paints distinct directional mouths and compact ghosts without reducing maze dimensions' do
78
+ opts = { width: 18, height: 12 }
79
+ game = described_class::MiniGame.new(name: :pacman, **opts)
80
+ state = Marshal.load(Marshal.dump(game.step[:state]))
81
+ expect(state[:maze].first.length).to eq(18)
82
+ expect(state[:maze].length).to eq(12)
83
+ expect(state[:ghosts].map { |ghost| ghost[:color] }).to eq(%w[R M C])
84
+ state[:ghosts].clear
85
+ state[:pacman] = [2, 2]
86
+ rows = %i[right down left up].map do |direction|
87
+ state[:direction] = direction
88
+ frames = []
89
+ described_class.send(:mini_pacman_snapshot, frames: frames, state: state)
90
+ expect(game.cells(frames.first).map(&:length)).to all(eq(18))
91
+ frames.first[:rows]
92
+ end
93
+ expect(rows.uniq.length).to eq(4)
94
+ state[:tick] = 2
95
+ closed = []
96
+ described_class.send(:mini_pacman_snapshot, frames: closed, state: state)
97
+ expect(rows).not_to include(closed.first[:rows])
98
+ state[:ghosts] = [{ position: [3, 2], color: 'R' }]
99
+ ghosts = []
100
+ described_class.send(:mini_pacman_snapshot, frames: ghosts, state: state)
101
+ cells = game.cells(ghosts.first)
102
+ expect(cells.flatten.count { |cell| cell[:foreground] == :red }).to eq(1)
103
+ end
104
+
105
+ it 'plays Frogger with immediate hops, traffic, moving logs, water deaths and goals' do
106
+ game = described_class::MiniGame.new(name: :frogger, width: 8, height: 8, seed: 3)
107
+ frame = game.step
108
+ 10.times { expect(game.step).to eq(frame) }
109
+ game.key(:left)
110
+ expect(game.step[:state][:frog][0]).to eq(frame[:state][:frog][0] - 1)
111
+ opts = { width: 8, height: 8, rng: Random.new(3) }
112
+ state = described_class.send(:mini_frogger_round, opts)
113
+ water = state[:lanes].find { |lane| lane[:water] }
114
+ water[:offset] = 0
115
+ state[:frog] = [1.0, water[:y]]
116
+ state[:started] = true
117
+ expect(described_class.send(:mini_frogger_move, opts.merge(state: state, keys: []))).to eq(:play)
118
+ expect(state[:frog][0]).to be_within(0.001).of(1.0 + water[:speed])
119
+ state[:frog] = [water[:length] + water[:offset] + 0.5, water[:y]]
120
+ expect(described_class.send(:mini_frogger_move, opts.merge(state: state, keys: []))).to eq(:game_over)
121
+ road = state[:lanes].find { |lane| !lane[:water] }
122
+ state[:frog] = [road[:offset] + 0.5, road[:y]]
123
+ expect(described_class.send(:mini_frogger_move, opts.merge(state: state, keys: []))).to eq(:game_over)
124
+ state[:frog] = [state[:goals].first.to_f, 1]
125
+ expect(described_class.send(:mini_frogger_move, opts.merge(state: state, keys: [:up]))).to eq(:goal)
126
+ expect(state[:filled]).to eq([state[:goals].first])
127
+ expect(state[:frog][1]).to eq(7)
128
+ end
129
+
130
+ it 'plays Galaga with input-ready launch, shots, formation enemies, dives and hostile fire' do
131
+ game = described_class::MiniGame.new(name: :galaga, width: 12, height: 12, seed: 7)
132
+ ready = game.step
133
+ 5.times { expect(game.step).to eq(ready) }
134
+ expect(ready[:state][:enemies].length).to be > 4
135
+ game.key(:left)
136
+ moved = game.step
137
+ expect(moved[:state][:ship][0]).to be < ready[:state][:ship][0]
138
+ game.key(' ')
139
+ expect(game.step[:state][:shots]).not_to be_empty
140
+ frames = described_class.send(:mini_replay, name: :galaga, width: 12, height: 13, seed: 7)
141
+ expect(frames.any? { |frame| frame[:state][:enemies]&.any? { |enemy| enemy[:diving] } }).to be true
142
+ expect(frames.any? { |frame| frame[:state][:hostile]&.any? }).to be true
143
+ expect(frames.map { |frame| frame[:event] }).to include(:hit, :game_over)
144
+ end
145
+
146
+ it 'randomizes real maze walls while retaining connected pellets and power across seeds and rounds' do
147
+ [5, 6, 8, 11, 16, 24].each do |size|
148
+ mazes = 12.times.map do |seed|
149
+ state = described_class.send(:mini_pacman_round, width: size, height: size, rng: Random.new(seed))
150
+ reachable = [state[:pacman]]
151
+ reachable.each do |point|
152
+ described_class.send(:mini_pacman_neighbors, point: point, maze: state[:maze]).each_value do |neighbor|
153
+ reachable << neighbor unless reachable.include?(neighbor)
154
+ end
155
+ end
156
+ expect((state[:pellets] + state[:power]) - reachable).to be_empty
157
+ state[:maze]
158
+ end
159
+ expect(mazes.uniq.length).to be >= (size == 5 ? 2 : 4)
160
+ rng = Random.new(91)
161
+ rounds = Array.new(8) { described_class.send(:mini_pacman_round, width: size, height: size, rng: rng)[:maze] }
162
+ expect(rounds.uniq.length).to be > 1
163
+ end
164
+ end
165
+
166
+ it 'selects Pac-Man instead of Pong and renders a connected pellet maze at every supported size' do
167
+ expect(described_class.mini_names).to eq(%i[falling_blocks snake pacman asteroids galaga frogger])
168
+ [5, 6, 8, 11, 16].each do |size|
169
+ game = described_class::MiniGame.new(name: :pacman, width: size, height: size)
170
+ frame = game.step
171
+ state = frame[:state]
172
+ maze = state[:maze]
173
+ open = (0...maze.length).flat_map { |y| (0...maze.first.length).filter_map { |x| [x, y] unless maze[y][x] == '#' } }
174
+ reached = [state[:pacman]]
175
+ reached.each do |x, y|
176
+ [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]].each do |point|
177
+ reached << point if open.include?(point) && !reached.include?(point)
178
+ end
179
+ end
180
+ expect(reached.sort).to eq(open.sort)
181
+ expect(state[:pellets]).not_to be_empty
182
+ expect(state[:power].length).to eq(4)
183
+ expect(frame[:event]).to eq(:ready)
184
+ expect(game.cells(frame).length).to eq(size)
185
+ expect(game.cells(frame).map(&:length)).to all(eq(size))
186
+ 10.times { expect(game.step).to eq(frame) }
187
+ end
188
+ end
189
+
190
+ it 'resolves Pac-Man power pellets before ghost contact and respawns eaten ghosts' do
191
+ state = described_class.send(:mini_pacman_round, width: 7, height: 7)
192
+ state[:pacman] = [2, 1]
193
+ state[:ghosts][0][:position] = [1, 1]
194
+ expect(described_class.send(:mini_pacman_move, state: state, keys: [:left], rng: Random.new(3))).to eq(:ghost_eaten)
195
+ expect(state[:score]).to eq(250)
196
+ expect(state[:frightened]).to be_positive
197
+ expect(state[:power]).not_to include([1, 1])
198
+ expect(state[:ghosts][0][:position]).to eq(state[:ghosts][0][:home])
199
+ expect(state[:ghosts][0][:delay]).to be_positive
200
+ end
201
+
202
+ it 'does not eat a returning ghost twice or kill Pac-Man while it is recovering at home' do
203
+ state = described_class.send(:mini_pacman_round, width: 7, height: 7)
204
+ state[:pacman] = state[:ghosts][0][:home].dup
205
+ state[:frightened] = 5
206
+ expect(described_class.send(:mini_pacman_contact, state: state)).to eq(:ghost_eaten)
207
+ expect(described_class.send(:mini_pacman_contact, state: state)).to be_nil
208
+ state[:frightened] = 0
209
+ expect(described_class.send(:mini_pacman_contact, state: state)).to be_nil
210
+ expect(state[:score]).to eq(200)
211
+ end
212
+
213
+ it 'detects Pac-Man collisions, expires fright, completes levels and recovers to arrow-ready play' do
214
+ state = described_class.send(:mini_pacman_round, width: 7, height: 7)
215
+ state[:ghosts][0][:position] = [2, 5]
216
+ state[:frightened] = 1
217
+ expect(described_class.send(:mini_pacman_move, state: state, keys: [:right], rng: Random.new(3))).to eq(:game_over)
218
+ state[:ghosts].clear
219
+ state[:pellets] = [[3, 5]]
220
+ state[:power].clear
221
+ state[:tick] = 0
222
+ expect(described_class.send(:mini_pacman_move, state: state, keys: [:right], rng: Random.new(3))).to eq(:level_complete)
223
+
224
+ game = described_class::MiniGame.new(name: :pacman, width: 5, height: 5)
225
+ game.step
226
+ game.key(:right)
227
+ frames = Array.new(500) { game.step }
228
+ death = frames.index { |frame| frame[:event] == :game_over }
229
+ expect(death).not_to be_nil
230
+ x, y = frames[death][:state][:pacman]
231
+ expect(game.cells(frames[death])[y][x]).to include(glyph: '█', foreground: :white)
232
+ expect(frames.drop(death + 6).map { |frame| frame[:event] }).to all(eq(:ready))
233
+ game.key(:up)
234
+ expect(game.step[:event]).to eq(:play)
235
+ end
236
+
237
+ it 'runs reproducible Pac-Man demos with moving ghosts, eating, recovery and complete bounded frames' do
238
+ [5, 8, 16].each do |size|
239
+ frames = described_class.send(:mini_replay, name: :pacman, width: size, height: size + 1, seed: 42)
240
+ expect(frames.length).to eq(1800)
241
+ expect(frames.map { |frame| frame[:event] }).to include(:play, :power)
242
+ expect(frames.map { |frame| frame[:event] }).to include(:level_complete) if size == 5
243
+ expect(frames.filter_map { |frame| frame[:state][:pacman] }.uniq.length).to be > 5
244
+ expect(frames.filter_map { |frame| frame[:state][:ghosts]&.first&.dig(:position) }.uniq.length).to be > 2
245
+ expect(frames.map { |frame| frame[:rows].length }).to all(eq(size + 1))
246
+ expect(frames.flat_map { |frame| frame[:rows].map(&:length) }).to all(eq(size))
247
+ described_class.instance_variable_set(:@mini_replays, {})
248
+ expect(described_class.send(:mini_replay, name: :pacman, width: size, height: size + 1, seed: 42)).to eq(frames)
249
+ end
250
+ end
251
+
6
252
  it 'reclaims the wordmark row for unbranded games and leaves tiny panes blank' do
7
253
  described_class.mini_names.product([1, 3, 5, 8, 16]).each do |name, size|
8
254
  [0, 6, 100, 1799].each do |frame|
@@ -25,7 +271,250 @@ describe PWN::Banner do
25
271
  end
26
272
  end
27
273
  end
274
+ end
275
+
276
+ describe PWN::Banner::MiniGame do
277
+ it 'paints Galaga as a filled symmetric upward triangle without changing ship physics' do
278
+ [8, 12, 24].each do |size|
279
+ game = described_class.new(name: :galaga, width: size, height: size)
280
+ frame = game.step
281
+ expect(frame[:state][:ship]).to eq([size / 2.0, size - 1.0])
282
+ dots = []
283
+ game.cells(frame).each_with_index do |row, y|
284
+ row.each_with_index do |cell, x|
285
+ next unless cell[:foreground] == :cyan
286
+
287
+ mask = cell[:glyph].ord - 0x2800
288
+ [[0, 0], [0, 1], [0, 2], [1, 0], [1, 1], [1, 2], [0, 3], [1, 3]].each_with_index do |(dx, dy), bit|
289
+ dots << [(x * 2) + dx, (y * 4) + dy] if mask[bit] == 1
290
+ end
291
+ end
292
+ end
293
+ rows = dots.group_by(&:last).sort.map { |_, points| points.map(&:first).sort }
294
+ expect(rows.map(&:length)).to eq([1, 3, 3, 5, 5])
295
+ rows.each { |xs| expect(xs).to eq((xs.first..xs.last).to_a) }
296
+ expect(rows.map { |xs| xs.first + xs.last }.uniq.length).to eq(1)
297
+ game.key(:left)
298
+ expect(game.step[:state][:ship]).to eq([(size / 2.0) - 0.75, size - 1.0])
299
+ end
300
+ end
301
+
302
+ it 'retains inner walls and branching corridors with single-cell actors in delivered console panes' do
303
+ # The console starts at an eight-cell interior at both 100x26 and 120x36.
304
+ # Settings can grow that pane; include odd, even and large interiors.
305
+ [8, 9, 12, 18, 24].each do |size|
306
+ layouts = 12.times.map do |seed|
307
+ game = described_class.new(name: :pacman, width: size, height: size, seed: seed)
308
+ frame = game.step
309
+ maze = frame[:state][:maze]
310
+ expect(maze.length).to eq(size)
311
+ expect(maze.map(&:length)).to all(eq(size))
312
+ inner = maze[2...-2].map { |row| row[2...-2] }.join
313
+ expect(inner.count('#')).to be >= 4
314
+ expect(inner.count('#').fdiv(inner.length)).to be_between(0.2, 0.8)
315
+ expect(inner.count(' ')).to be >= 4
316
+ reached = [frame[:state][:pacman]]
317
+ reached.each do |point|
318
+ PWN::Banner.send(:mini_pacman_neighbors, point: point, maze: maze).each_value do |neighbor|
319
+ reached << neighbor unless reached.include?(neighbor)
320
+ end
321
+ end
322
+ expect(reached.length).to eq(maze.join.count(' '))
323
+ expect(frame[:rows].join.count('><^vO')).to eq(1)
324
+ expect(frame[:rows].join.count('RMCF')).to eq(frame[:state][:ghosts].length)
325
+ expect(frame[:rows][1]).to eq('#' * size)
326
+ expect(frame[:rows].last).to eq('#' * size)
327
+ game.key(:right)
328
+ expect(game.step[:state][:pacman]).to eq([2, size - 2])
329
+ maze
330
+ end
331
+ expect(layouts.uniq.length).to be >= 8
332
+ end
333
+ end
334
+
335
+ it 'uses the entire Pac-Man pane beyond sixteen cells without shrinking or padding the maze' do
336
+ [17, 24, 64].each do |size|
337
+ game = described_class.new(name: :pacman, width: size, height: size)
338
+ frame = game.step
339
+ expect(game.cells(frame).length).to eq(size)
340
+ expect(game.cells(frame).map(&:length)).to all(eq(size))
341
+ expect(frame[:state][:maze].last).to eq('#' * size)
342
+ game.key(:up)
343
+ expect(game.step[:state][:pacman]).to eq([1, size - 3])
344
+ end
345
+ end
346
+
347
+ it 'starts Pac-Man on arrows, eats pellets, buffers a blocked turn and stops at walls' do
348
+ game = described_class.new(name: :pacman, width: 7, height: 7, seed: 9)
349
+ expect(game.step[:state][:pacman]).to eq([1, 5])
350
+ game.key(:right)
351
+ moved = game.step
352
+ expect(moved[:state][:pacman]).to eq([2, 5])
353
+ expect(moved[:state][:score]).to eq(10)
354
+ game.key(:up)
355
+ 2.times { game.step }
356
+ expect(game.step[:state][:pacman]).to eq([3, 5])
357
+ 2.times { game.step }
358
+ expect(game.step[:state][:pacman]).to eq([3, 4])
359
+ game.key(:left)
360
+ 2.times { game.step }
361
+ expect(game.step[:state][:pacman]).to eq([3, 3])
362
+ end
363
+
364
+ it 'renders bounded, unbranded colored frames without accumulating a replay' do
365
+ PWN::Banner.mini_names.product([5, 8, 16]).each do |name, size|
366
+ game = described_class.new(name: name, width: size, height: size)
367
+ 40.times do
368
+ frame = game.step
369
+ cells = game.cells(frame)
370
+ expect(cells.length).to eq(size)
371
+ expect(cells.map(&:length)).to all(eq(size))
372
+ expect(cells.flatten.map { |cell| cell[:glyph] }.join).not_to match(/[PWN]/)
373
+ expect(cells.flatten).to all(include(:foreground, :background))
374
+ end
375
+ expect(game.length).to eq(0)
376
+ end
377
+ end
28
378
 
379
+ it 'lets the player move only the left Pong paddle while the ball and opponent keep playing' do
380
+ game = PWN::Banner::MiniGame.new(name: :pong, width: 8, height: 8, seed: 73)
381
+ first = game.step
382
+ game.key(:up)
383
+ moved = game.step
384
+ expect(moved[:state][:paddles][0]).to be < first[:state][:paddles][0]
385
+ expect(moved[:state][:ball]).not_to eq(first[:state][:ball])
386
+ game.key(:down)
387
+ expect(game.step[:state][:paddles][0]).to eq(first[:state][:paddles][0])
388
+ game.key(:left)
389
+ expect(game.step[:state][:paddles][0]).to eq(first[:state][:paddles][0])
390
+ 100.times do
391
+ game.key(:up)
392
+ game.step
393
+ end
394
+ expect(game.step[:state][:paddles][0]).to eq(0)
395
+ end
396
+
397
+ it 'steers Snake with arrows without reversing into its neck or changing direction twice per tick' do
398
+ game = PWN::Banner::MiniGame.new(name: :snake, width: 16, height: 16, seed: 73)
399
+ first = game.step[:state]
400
+ game.key(:left)
401
+ moved = game.step[:state]
402
+ expect(moved[:body].first).to eq([first[:body][0][0] + 1, first[:body][0][1]])
403
+ turn = moved[:body][0][1].positive? ? :up : :down
404
+ game.key(turn)
405
+ game.key(:left)
406
+ turned = game.step[:state]
407
+ expect(turned[:body].first).to eq([moved[:body][0][0], moved[:body][0][1] + (turn == :up ? -1 : 1)])
408
+ game.key(:left)
409
+ expect(game.step[:state][:body].first[0]).to eq(turned[:body][0][0] - 1)
410
+ end
411
+
412
+ it 'keeps Snake stopped after collision until a new arrow starts the next round' do
413
+ game = described_class.new(name: :snake, width: 5, height: 5, seed: 73)
414
+ expect(game.step[:event]).to eq(:ready)
415
+ game.key(:right)
416
+ 7.times { expect(game.step[:event]).to eq(:play) }
417
+ expect(game.step[:event]).to eq(:game_over)
418
+ ready = game.step
419
+ expect(ready[:event]).to eq(:ready)
420
+ 20.times { expect(game.step).to eq(ready) }
421
+ game.key(:up)
422
+ expect(game.step[:state][:body].first).to eq([2, 4])
423
+ end
424
+
425
+ it 'packs playable Snake over the full width and bottom with quarter-cell pixels' do
426
+ [5, 8, 16].each do |size|
427
+ board = Array.new(size * 2) { ' ' * (size * 2) }
428
+ board[0][0] = 'g'
429
+ board[-1][-1] = 'y'
430
+ game = described_class.new(name: :snake, width: size, height: size)
431
+ cells = game.cells(rows: ['label'] + board)
432
+ expect(cells.first.first).to include(glyph: '▘', foreground: :green)
433
+ expect(cells.last.last).to include(glyph: '▗', foreground: :yellow)
434
+ end
435
+ end
436
+
437
+ it 'moves Tetris sideways, rotates with space and soft drops one row with down' do
438
+ game = PWN::Banner::MiniGame.new(name: :falling_blocks, width: 8, height: 8, seed: 73)
439
+ first = game.step[:state]
440
+ game.key(:left)
441
+ left = game.step[:state]
442
+ expect(left[:active]).to eq(first[:active].map { |x, y| [x - 1, y] })
443
+ game.key(:right)
444
+ expect(game.step[:state][:active]).to eq(first[:active])
445
+ game.key(' ')
446
+ rotated = game.step[:state]
447
+ expect(rotated[:active]).not_to eq(first[:active])
448
+ expect(rotated[:active].map(&:last).min).to eq(0)
449
+ game.key(:down)
450
+ dropped = game.step[:state]
451
+ expect(dropped[:active]).to eq(rotated[:active].map { |x, y| [x, y + 1] })
452
+ expect(dropped[:stack]).to eq(first[:stack])
453
+ events = 150.times.map do
454
+ game.key(:down)
455
+ game.step[:event]
456
+ end
457
+ expect(events).to include(:lock)
458
+ end
459
+
460
+ it 'explodes on a real ship impact, ignores wreck controls, then recovers with brief protection' do
461
+ rng = instance_double(Random)
462
+ allow(Random).to receive(:new).with(73).and_return(rng)
463
+ allow(rng).to receive(:rand).and_return(0.0, 0.82, 0.5, 0.0, 0.5)
464
+ game = described_class.new(name: :asteroids, width: 5, height: 5, seed: 73)
465
+ frames = Array.new(20) { game.step }
466
+ crash = frames.find { |frame| frame[:event] == :crash }
467
+ expect(crash).not_to be_nil
468
+ ship = crash[:state][:ship]
469
+ expect(crash[:state][:rocks].any? do |rock|
470
+ dx = PWN::Banner.send(:mini_delta, from: ship[0], to: rock[0], span: 5)
471
+ dy = PWN::Banner.send(:mini_delta, from: ship[1], to: rock[1], span: 5)
472
+ (dx * dx) + (dy * dy) <= (rock[4] + 0.55)**2
473
+ end).to be(true)
474
+ wrecks = frames.select { |frame| frame[:state][:explosion].to_i.positive? }
475
+ expect(wrecks.length).to be >= 8
476
+ expect(wrecks.map { |frame| frame[:rows] }.uniq.length).to be >= 4
477
+ wrecks.each do |frame|
478
+ expect(game.cells(frame).flatten.any? { |cell| cell[:foreground] == :cyan && cell[:glyph] != ' ' }).to be(false)
479
+ expect(game.cells(frame).flatten.any? { |cell| %i[red yellow white].include?(cell[:foreground]) && cell[:glyph] != ' ' }).to be(true)
480
+ end
481
+ game.key(:up)
482
+ game.key(' ')
483
+ frozen = game.step
484
+ expect(frozen[:state][:ship]).to eq(ship)
485
+ expect(frozen[:state][:shots]).to be_empty
486
+ recovery = Array.new(15) { game.step }.find { |frame| frame[:event] == :recover }
487
+ expect(recovery).not_to be_nil
488
+ expect(recovery[:state][:invulnerable]).to be_positive
489
+ game.key(:up)
490
+ expect(game.step[:state][:ship]).not_to eq(ship)
491
+ end
492
+
493
+ it 'turns and thrusts Asteroids only on arrows and fires only on space' do
494
+ game = PWN::Banner::MiniGame.new(name: :asteroids, width: 16, height: 16, seed: 73)
495
+ first = game.step[:state]
496
+ idle = game.step[:state]
497
+ expect(idle[:ship]).to eq(first[:ship])
498
+ expect(idle[:shots]).to be_empty
499
+ game.key(:left)
500
+ left = game.step[:state]
501
+ expect(left[:ship][2]).to be_within(0.001).of((first[:ship][2] - (Math::PI / 4)) % (Math::PI * 2))
502
+ game.key(:right)
503
+ expect(game.step[:state][:ship][2]).to be_within(0.001).of(first[:ship][2])
504
+ game.key(:up)
505
+ moved = game.step[:state]
506
+ expect(moved[:ship].take(2)).not_to eq(first[:ship].take(2))
507
+ distance = moved[:ship].take(2).zip(first[:ship]).sum { |a, b| (a - b)**2 }
508
+ expect(Math.sqrt(distance)).to be >= 0.079
509
+ expect(moved[:thrust]).to be(true)
510
+ game.key(:down)
511
+ expect(game.step[:state][:thrust]).to be(true)
512
+ game.key(' ')
513
+ expect(game.step[:state][:shots].length).to eq(1)
514
+ end
515
+ end
516
+
517
+ describe PWN::Banner do
29
518
  def block_colors(rows)
30
519
  rows.flat_map do |row|
31
520
  [0, 2].map do |bit|
@@ -154,7 +643,17 @@ describe PWN::Banner do
154
643
  expect(described_class::MINI_PIXELS.fetch(rows[1][2])).to eq(['▘', :red])
155
644
  end
156
645
 
157
- it 'draws a tiny connected five-pixel arrowhead rather than a chunky rotating blob' do
646
+ it 'draws Asteroids on a finer two-by-four dot raster, including bottom and wrapped edges' do
647
+ board = Array.new(5) { ' ' * 5 }
648
+ 4.times { |y| described_class.send(:mini_dot, board: board, x: 0, y: y, color: 0) }
649
+ described_class.send(:mini_dot, board: board, x: 9, y: 19, color: 2)
650
+ described_class.send(:mini_dot, board: board, x: 10, y: 20, color: 0)
651
+ expect(described_class::MINI_PIXELS.fetch(board[0][0])).to eq(["\u2847", :cyan])
652
+ expect(described_class::MINI_PIXELS.fetch(board[4][4])).to eq(["\u2880", :yellow])
653
+ expect(board.join.chars.reject { |pixel| pixel == ' ' }.length).to eq(2)
654
+ end
655
+
656
+ it 'draws a filled connected fine-dot ship with a solid center in every heading' do
158
657
  8.times do |direction|
159
658
  frames = []
160
659
  described_class.send(:mini_asteroids_snapshot, frames: frames, width: 8, height: 7, ship: [4, 3, direction * Math::PI / 4], thrust: false, rocks: [], shots: [], sparks: [])
@@ -163,13 +662,18 @@ describe PWN::Banner do
163
662
  row.chars.each_with_index do |pixel, x|
164
663
  next if pixel == ' '
165
664
 
166
- mask = (pixel.ord - 256) % 16
167
- 4.times { |bit| points << [(x * 2) + (bit % 2), (y * 2) + (bit / 2)] if mask[bit] == 1 }
665
+ glyph = described_class::MINI_PIXELS.fetch(pixel).first
666
+ expect(glyph.ord).to be_between(0x2801, 0x28ff)
667
+ mask = glyph.ord - 0x2800
668
+ [[0, 0], [0, 1], [0, 2], [1, 0], [1, 1], [1, 2], [0, 3], [1, 3]].each_with_index do |(dx, dy), bit|
669
+ points << [(x * 2) + dx, (y * 4) + dy] if mask[bit] == 1
670
+ end
168
671
  end
169
672
  end
170
- expect(points.length).to eq(5)
171
- expect(points.map(&:first).minmax.then { |a, b| b - a }).to eq(2)
172
- expect(points.map(&:last).minmax.then { |a, b| b - a }).to eq(2)
673
+ expect(points.length).to be_between(10, 13)
674
+ expect(points).to include([8, 12])
675
+ expect(points.map(&:first).minmax.then { |a, b| b - a }).to be_between(3, 4)
676
+ expect(points.map(&:last).minmax.then { |a, b| b - a }).to be_between(3, 4)
173
677
  connected = [points.shift]
174
678
  loop do
175
679
  adjacent = points.select { |point| connected.any? { |other| point.zip(other).all? { |a, b| (a - b).abs <= 1 } } }
@@ -207,27 +711,27 @@ describe PWN::Banner do
207
711
  end
208
712
  end
209
713
 
210
- it 'keeps rocks small and neutral with sparse quarter-cell shots and thrust' do
714
+ it 'keeps rocks small and neutral with sparse fine-dot shots and thrust' do
211
715
  frames = []
212
716
  described_class.send(:mini_asteroids_snapshot, frames: frames, width: 8, height: 7, ship: [4, 3, 0], thrust: false,
213
717
  rocks: [[1, 1, 0, 0, 0.5, 1]], shots: [], sparks: [])
214
- rock_pixels = frames.first[:rows].drop(1).flat_map(&:chars).reject { |pixel| pixel == ' ' || (pixel.ord - 256) / 16 == 0 }
718
+ rock_pixels = frames.first[:rows].drop(1).flat_map(&:chars).reject { |pixel| pixel == ' ' || (pixel.ord - 4096) / 256 == 0 }
215
719
  expect(rock_pixels.map { |pixel| described_class::MINI_PIXELS.fetch(pixel)[1] }.uniq).to eq([:white])
216
- expect(rock_pixels.sum { |pixel| ((pixel.ord - 256) % 16).digits(2).sum }).to eq(4)
720
+ expect(rock_pixels.sum { |pixel| ((pixel.ord - 4096) % 256).digits(2).sum }).to eq(4)
217
721
  end
218
722
 
219
- it 'keeps a readable five-pixel ship and sparse subcell occupancy throughout tiny seeded replays' do
723
+ it 'keeps a solid fine-dot ship and sparse subcell occupancy throughout tiny seeded replays' do
220
724
  [5, 8, 16].product([1, 73, 74]).each do |size, seed|
221
725
  frames = described_class.send(:mini_replay, name: :asteroids, width: size, height: size, seed: seed)
222
726
  frames.each do |frame|
223
727
  next unless frame[:state][:ship]
224
728
 
225
729
  pixels = frame[:rows].drop(1).flat_map(&:chars).reject { |pixel| pixel == ' ' }
226
- area = pixels.sum { |pixel| ((pixel.ord - 256) % 16).digits(2).sum }
227
- ship = pixels.select { |pixel| (pixel.ord - 256) / 16 == 0 }
228
- expect(ship.sum { |pixel| ((pixel.ord - 256) % 16).digits(2).sum }).to eq(5)
229
- expect(area).to be <= [20, size * (size - 1)].min
230
- # A lone quadrant can occupy a cell: measure ink area above, not full
730
+ area = pixels.sum { |pixel| ((pixel.ord - 4096) % 256).digits(2).sum }
731
+ ship = pixels.select { |pixel| (pixel.ord - 4096) / 256 == 0 }
732
+ expect(ship.sum { |pixel| ((pixel.ord - 4096) % 256).digits(2).sum }).to be_between(10, 13)
733
+ expect(area).to be <= 28
734
+ # A lone dot can occupy a cell: measure ink area above, not full
231
735
  # character rectangles, while bounding the number of colored cells too.
232
736
  expect(pixels.length).to be <= 14
233
737
  end
@@ -250,7 +754,7 @@ describe PWN::Banner do
250
754
  [5, 8, 16].each do |size|
251
755
  frames = described_class.send(:mini_replay, name: :asteroids, width: size, height: size, seed: 73)
252
756
  thrust = frames.select { |frame| frame[:state][:thrust] }
253
- visible = thrust.count { |frame| frame[:rows].drop(1).join.chars.any? { |pixel| (pixel.ord - 256) / 16 == 5 } }
757
+ visible = thrust.count { |frame| frame[:rows].drop(1).join.chars.any? { |pixel| (pixel.ord - 4096) / 256 == 5 } }
254
758
  expect(visible.to_f / thrust.length).to be >= 0.95
255
759
  end
256
760
  end
@@ -345,7 +849,7 @@ describe PWN::Banner do
345
849
  expect(frames.flatten(1).map(&:length)).to all(eq(size))
346
850
  frames.flatten(2).each do |cell|
347
851
  expect(cell.keys).to contain_exactly(:glyph, :foreground, :background)
348
- expect(cell[:glyph]).to match(/\A[ PWN█▀▄▌▐░▘▝▖▗▞▛▚▜▙▟]\z/)
852
+ expect(cell[:glyph]).to match(/\A[ PWN█▀▄▌▐░▒·●▘▝▖▗▞▛▚▜▙▟ᗧᗤᗢᗣ│•✹═≈▰▱\u2800-\u28ff]\z/)
349
853
  expect(%i[black red green yellow blue magenta cyan white]).to include(cell[:foreground], cell[:background])
350
854
  end
351
855
  expect(described_class.mini_cells(**args, frame: described_class::MINI_FRAME_COUNT)).to eq(frames.first)
@@ -551,7 +1055,7 @@ end
551
1055
  describe PWN::Banner do
552
1056
  describe '.mini_frame' do
553
1057
  it 'offers retro games with deterministic cycles and branding at every square size' do
554
- expect(described_class.mini_names).to eq(%i[falling_blocks snake pong asteroids])
1058
+ expect(described_class.mini_names).to eq(%i[falling_blocks snake pacman asteroids galaga frogger])
555
1059
  described_class.mini_names.each do |name|
556
1060
  (5..16).each do |size|
557
1061
  args = { name: name, width: size, height: size }
@@ -598,7 +1102,7 @@ describe PWN::Banner do
598
1102
  changed.clear
599
1103
  described_class.mini_names.clear
600
1104
  expect(described_class.mini_frame).to eq(original)
601
- expect(described_class.mini_names.length).to eq(4)
1105
+ expect(described_class.mini_names.length).to eq(6)
602
1106
  end
603
1107
 
604
1108
  it 'keeps recognizable pieces, bodies and paddles in the smallest complete canvas' do