pwn 0.5.760 → 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|
@@ -28,6 +274,93 @@ describe PWN::Banner do
28
274
  end
29
275
 
30
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
+
31
364
  it 'renders bounded, unbranded colored frames without accumulating a replay' do
32
365
  PWN::Banner.mini_names.product([5, 8, 16]).each do |name, size|
33
366
  game = described_class.new(name: name, width: size, height: size)
@@ -76,6 +409,31 @@ describe PWN::Banner::MiniGame do
76
409
  expect(game.step[:state][:body].first[0]).to eq(turned[:body][0][0] - 1)
77
410
  end
78
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
+
79
437
  it 'moves Tetris sideways, rotates with space and soft drops one row with down' do
80
438
  game = PWN::Banner::MiniGame.new(name: :falling_blocks, width: 8, height: 8, seed: 73)
81
439
  first = game.step[:state]
@@ -99,6 +457,39 @@ describe PWN::Banner::MiniGame do
99
457
  expect(events).to include(:lock)
100
458
  end
101
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
+
102
493
  it 'turns and thrusts Asteroids only on arrows and fires only on space' do
103
494
  game = PWN::Banner::MiniGame.new(name: :asteroids, width: 16, height: 16, seed: 73)
104
495
  first = game.step[:state]
@@ -107,12 +498,14 @@ describe PWN::Banner::MiniGame do
107
498
  expect(idle[:shots]).to be_empty
108
499
  game.key(:left)
109
500
  left = game.step[:state]
110
- expect(left[:ship][2]).to be_within(0.001).of((first[:ship][2] - 0.2) % (Math::PI * 2))
501
+ expect(left[:ship][2]).to be_within(0.001).of((first[:ship][2] - (Math::PI / 4)) % (Math::PI * 2))
111
502
  game.key(:right)
112
503
  expect(game.step[:state][:ship][2]).to be_within(0.001).of(first[:ship][2])
113
504
  game.key(:up)
114
505
  moved = game.step[:state]
115
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
116
509
  expect(moved[:thrust]).to be(true)
117
510
  game.key(:down)
118
511
  expect(game.step[:state][:thrust]).to be(true)
@@ -250,7 +643,17 @@ describe PWN::Banner do
250
643
  expect(described_class::MINI_PIXELS.fetch(rows[1][2])).to eq(['▘', :red])
251
644
  end
252
645
 
253
- 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
254
657
  8.times do |direction|
255
658
  frames = []
256
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: [])
@@ -259,13 +662,18 @@ describe PWN::Banner do
259
662
  row.chars.each_with_index do |pixel, x|
260
663
  next if pixel == ' '
261
664
 
262
- mask = (pixel.ord - 256) % 16
263
- 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
264
671
  end
265
672
  end
266
- expect(points.length).to eq(5)
267
- expect(points.map(&:first).minmax.then { |a, b| b - a }).to eq(2)
268
- 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)
269
677
  connected = [points.shift]
270
678
  loop do
271
679
  adjacent = points.select { |point| connected.any? { |other| point.zip(other).all? { |a, b| (a - b).abs <= 1 } } }
@@ -303,27 +711,27 @@ describe PWN::Banner do
303
711
  end
304
712
  end
305
713
 
306
- 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
307
715
  frames = []
308
716
  described_class.send(:mini_asteroids_snapshot, frames: frames, width: 8, height: 7, ship: [4, 3, 0], thrust: false,
309
717
  rocks: [[1, 1, 0, 0, 0.5, 1]], shots: [], sparks: [])
310
- 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 }
311
719
  expect(rock_pixels.map { |pixel| described_class::MINI_PIXELS.fetch(pixel)[1] }.uniq).to eq([:white])
312
- 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)
313
721
  end
314
722
 
315
- 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
316
724
  [5, 8, 16].product([1, 73, 74]).each do |size, seed|
317
725
  frames = described_class.send(:mini_replay, name: :asteroids, width: size, height: size, seed: seed)
318
726
  frames.each do |frame|
319
727
  next unless frame[:state][:ship]
320
728
 
321
729
  pixels = frame[:rows].drop(1).flat_map(&:chars).reject { |pixel| pixel == ' ' }
322
- area = pixels.sum { |pixel| ((pixel.ord - 256) % 16).digits(2).sum }
323
- ship = pixels.select { |pixel| (pixel.ord - 256) / 16 == 0 }
324
- expect(ship.sum { |pixel| ((pixel.ord - 256) % 16).digits(2).sum }).to eq(5)
325
- expect(area).to be <= [20, size * (size - 1)].min
326
- # 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
327
735
  # character rectangles, while bounding the number of colored cells too.
328
736
  expect(pixels.length).to be <= 14
329
737
  end
@@ -346,7 +754,7 @@ describe PWN::Banner do
346
754
  [5, 8, 16].each do |size|
347
755
  frames = described_class.send(:mini_replay, name: :asteroids, width: size, height: size, seed: 73)
348
756
  thrust = frames.select { |frame| frame[:state][:thrust] }
349
- 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 } }
350
758
  expect(visible.to_f / thrust.length).to be >= 0.95
351
759
  end
352
760
  end
@@ -441,7 +849,7 @@ describe PWN::Banner do
441
849
  expect(frames.flatten(1).map(&:length)).to all(eq(size))
442
850
  frames.flatten(2).each do |cell|
443
851
  expect(cell.keys).to contain_exactly(:glyph, :foreground, :background)
444
- expect(cell[:glyph]).to match(/\A[ PWN█▀▄▌▐░▘▝▖▗▞▛▚▜▙▟]\z/)
852
+ expect(cell[:glyph]).to match(/\A[ PWN█▀▄▌▐░▒·●▘▝▖▗▞▛▚▜▙▟ᗧᗤᗢᗣ│•✹═≈▰▱\u2800-\u28ff]\z/)
445
853
  expect(%i[black red green yellow blue magenta cyan white]).to include(cell[:foreground], cell[:background])
446
854
  end
447
855
  expect(described_class.mini_cells(**args, frame: described_class::MINI_FRAME_COUNT)).to eq(frames.first)
@@ -647,7 +1055,7 @@ end
647
1055
  describe PWN::Banner do
648
1056
  describe '.mini_frame' do
649
1057
  it 'offers retro games with deterministic cycles and branding at every square size' do
650
- 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])
651
1059
  described_class.mini_names.each do |name|
652
1060
  (5..16).each do |size|
653
1061
  args = { name: name, width: size, height: size }
@@ -694,7 +1102,7 @@ describe PWN::Banner do
694
1102
  changed.clear
695
1103
  described_class.mini_names.clear
696
1104
  expect(described_class.mini_frame).to eq(original)
697
- expect(described_class.mini_names.length).to eq(4)
1105
+ expect(described_class.mini_names.length).to eq(6)
698
1106
  end
699
1107
 
700
1108
  it 'keeps recognizable pieces, bodies and paddles in the smallest complete canvas' do