pwn 0.5.749 → 0.5.751

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Blockchain.md +107 -9
  4. data/documentation/pwn-ai-Agent.md +310 -4
  5. data/documentation/pwn_silent_help_learn_demo.gif +0 -0
  6. data/etc/default_skills/pwn/banner/SKILL.md +7 -3
  7. data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
  8. data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
  9. data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
  10. data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
  11. data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
  12. data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
  13. data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
  14. data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
  15. data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
  16. data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
  17. data/lib/pwn/ai/agent/loop.rb +287 -18
  18. data/lib/pwn/ai/agent/swarm.rb +28 -4
  19. data/lib/pwn/ai/open_ai.rb +4 -2
  20. data/lib/pwn/banner.rb +743 -2
  21. data/lib/pwn/blockchain/btc.rb +494 -196
  22. data/lib/pwn/blockchain/eth.rb +440 -81
  23. data/lib/pwn/blockchain.rb +6 -0
  24. data/lib/pwn/config.rb +22 -0
  25. data/lib/pwn/migrate.rb +9 -1
  26. data/lib/pwn/plugins/repl/ai.rb +182 -17
  27. data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
  28. data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
  29. data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
  30. data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
  31. data/lib/pwn/plugins/repl.rb +25 -3
  32. data/lib/pwn/version.rb +1 -1
  33. data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
  34. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
  35. data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
  36. data/spec/lib/pwn/banner_spec.rb +643 -0
  37. data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
  38. data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
  39. data/spec/lib/pwn/blockchain_spec.rb +6 -0
  40. data/spec/lib/pwn/config_spec.rb +10 -0
  41. data/spec/lib/pwn/migrate_spec.rb +40 -3
  42. data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
  43. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
  44. data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
  45. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
  46. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
  47. data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
  48. data/third_party/pwn_rdoc.jsonl +229 -10
  49. metadata +18 -6
  50. data/etc/default_skills/pwn/blockchain/btc/references/urls.md +0 -3
@@ -3,6 +3,649 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  describe PWN::Banner do
6
+ def block_colors(rows)
7
+ rows.flat_map do |row|
8
+ [0, 2].map do |bit|
9
+ row.chars.map do |pixel|
10
+ glyph, foreground, background = described_class::MINI_PIXELS.fetch(pixel)
11
+ mask = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'.index(glyph)
12
+ mask[bit] == 1 ? foreground : background || :black
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ it 'packs every rotation losslessly beside different colors, holes and white flashes' do
19
+ described_class::MINI_SHAPES.each_with_index do |shape, type|
20
+ described_class.send(:mini_rotations, shape: shape).each do |rotation|
21
+ [0, 1].each do |offset|
22
+ source = Array.new(8) { 'cmywgrb '.dup }
23
+ rotation.each { |x, y| source[y + offset][x + 2] = 'cymbrgg'[type] }
24
+ packed = described_class.send(:mini_block_board, board: source)
25
+ expected = source.map { |row| row.chars.map { |pixel| pixel == ' ' ? :black : described_class::MINI_PIXELS.fetch(pixel)[1] } }
26
+ expect(block_colors(packed)).to eq(expected)
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ it 'keeps every active tetromino color beside the stack throughout its fall and lock' do
33
+ rotations = Hash.new { |hash, key| hash[key] = [] }
34
+ [5, 8, 16, 20].product([1, 73, 74]).each do |size, seed|
35
+ args = { name: :falling_blocks, width: size, height: size, seed: seed }
36
+ frames = described_class.send(:mini_replay, **args)
37
+ frames.each_with_index do |frame, index|
38
+ next unless frame[:state][:active]
39
+
40
+ points = frame[:state][:active]
41
+ type = frame[:state][:type]
42
+ cells = described_class.mini_cells(**args, frame: index)
43
+ color = %i[cyan yellow magenta blue red green green][type]
44
+ expected = frame[:state][:stack].map { |row| row.chars.map { |pixel| pixel == ' ' ? :black : described_class::MINI_PIXELS.fetch(pixel)[1] } }
45
+ points.each { |x, y| expected[y][x] = color }
46
+ expect(block_colors(frame[:rows].drop(1))).to eq(expected)
47
+ points.each do |x, y|
48
+ cell = cells[1 + (y / 2)][x]
49
+ mask = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'.index(cell[:glyph])
50
+ actual = mask[(y % 2) * 2] == 1 ? cell[:foreground] : cell[:background]
51
+ expect(actual).to eq(color)
52
+ end
53
+ next unless frames[index + 1][:event] == :lock
54
+
55
+ expect(frames[index + 1][:rows]).to eq(frame[:rows])
56
+ expect(described_class.mini_cells(**args, frame: index + 1)).to eq(cells)
57
+ rotations[type] << points.map { |x, y| [x - points.map(&:first).min, y - points.map(&:last).min] }.sort
58
+ end
59
+ end
60
+ described_class::MINI_SHAPES.each_with_index do |shape, type|
61
+ expected = described_class.send(:mini_rotations, shape: shape).map(&:sort).uniq
62
+ expect(rotations[type].uniq).to match_array(expected)
63
+ end
64
+ end
65
+
66
+ it 'changes only completed rows to white on clear and preserves survivor colors on collapse' do
67
+ frames = described_class.send(:mini_replay, name: :falling_blocks, width: 8, height: 8, seed: 73)
68
+ clears = 0
69
+ frames.each_cons(2) do |before, after|
70
+ next unless after[:event] == :clear && before[:event] == :lock
71
+
72
+ expected = block_colors(before[:rows].drop(1))
73
+ after[:state][:lines].each { |y| expected[y] = Array.new(8, :white) }
74
+ expect(block_colors(after[:rows].drop(1))).to eq(expected)
75
+ clears += 1
76
+ end
77
+ expect(clears).to be_positive
78
+ frames.each_cons(2) do |before, after|
79
+ next unless before[:event] == :clear && after[:event] == :collapse
80
+
81
+ lines = before[:state][:lines]
82
+ survivors = block_colors(before[:rows].drop(1)).each_with_index.filter_map { |row, y| row unless lines.include?(y) }
83
+ expect(block_colors(after[:rows].drop(1))).to eq(Array.new(lines.length) { Array.new(8, :black) } + survivors)
84
+ end
85
+ end
86
+
87
+ it 'lands blocks on both bottom quadrants and spans the full board width' do
88
+ [5, 8, 16, 20].each do |size|
89
+ frames = described_class.send(:mini_replay, name: :falling_blocks, width: size, height: size, seed: 73)
90
+ locks = frames.select { |frame| frame[:event] == :lock }
91
+ expect(locks.first[:rows].last.chars.any? { |pixel| pixel != ' ' && ((pixel.ord - 256) % 16) & 12 != 0 }).to be(true)
92
+ [0, size - 1].each do |column|
93
+ expect(locks.any? { |frame| frame[:rows].drop(1).any? { |row| row[column] != ' ' } }).to be(true)
94
+ end
95
+ cells = described_class.mini_cells(name: :falling_blocks, width: size, height: size, frame: 100)
96
+ expect(cells.length).to eq(size)
97
+ expect(cells.map(&:length)).to all(eq(size))
98
+ end
99
+ end
100
+
101
+ it 'packs blocks into two quadrants and snake segments into one without losing ink' do
102
+ %i[falling_blocks snake].product((5..16).to_a).each do |name, size|
103
+ frames = described_class.send(:mini_replay, name: name, width: size, height: size, seed: 73)
104
+ frames.each do |frame|
105
+ pixels = frame[:rows].drop(1).join.chars.reject { |pixel| pixel == ' ' }
106
+ expect(pixels).to all(satisfy { |pixel| pixel.ord >= 256 })
107
+ area = pixels.sum { |pixel| ((pixel.ord - 256) % 16).digits(2).sum }
108
+ state = frame[:state]
109
+ expected = if state[:body]
110
+ state[:body].length + (state[:food] ? 1 : 0)
111
+ elsif state[:active]
112
+ (state[:stack].sum { |row| row.count('^ ') } + 4) * 2
113
+ end
114
+ expect(area).to eq(expected) if expected
115
+ expect(frame[:rows].length).to eq(size)
116
+ expect(frame[:rows].map(&:length)).to all(eq(size))
117
+ expect(frame[:rows].first).to eq('PWN'.center(size))
118
+ end
119
+ end
120
+ end
121
+
122
+ it 'keeps snake food legible without erasing shared-cell quadrants' do
123
+ # Head, body and food may share a character cell, but never lose occupied
124
+ # quadrants. Food wins its cell's color; yellow head wins over green body.
125
+ rows = described_class.send(:mini_quarter_board, board: ['gyf ', ' ', ' ', ' '], name: 'snake')
126
+ expect(described_class::MINI_PIXELS.fetch(rows[1][1])).to eq(['▀', :yellow])
127
+ expect(described_class::MINI_PIXELS.fetch(rows[1][2])).to eq(['▘', :red])
128
+ end
129
+
130
+ it 'draws a tiny connected five-pixel arrowhead rather than a chunky rotating blob' do
131
+ 8.times do |direction|
132
+ frames = []
133
+ described_class.send(:mini_asteroids_snapshot, frames: frames, width: 8, height: 7, ship: [4, 3, direction * Math::PI / 4], thrust: false, rocks: [], shots: [], sparks: [])
134
+ points = []
135
+ frames.first[:rows].drop(1).each_with_index do |row, y|
136
+ row.chars.each_with_index do |pixel, x|
137
+ next if pixel == ' '
138
+
139
+ mask = (pixel.ord - 256) % 16
140
+ 4.times { |bit| points << [(x * 2) + (bit % 2), (y * 2) + (bit / 2)] if mask[bit] == 1 }
141
+ end
142
+ end
143
+ expect(points.length).to eq(5)
144
+ expect(points.map(&:first).minmax.then { |a, b| b - a }).to eq(2)
145
+ expect(points.map(&:last).minmax.then { |a, b| b - a }).to eq(2)
146
+ connected = [points.shift]
147
+ loop do
148
+ adjacent = points.select { |point| connected.any? { |other| point.zip(other).all? { |a, b| (a - b).abs <= 1 } } }
149
+ break if adjacent.empty?
150
+
151
+ connected.concat(adjacent)
152
+ points -= adjacent
153
+ end
154
+ expect(points).to be_empty
155
+ end
156
+ end
157
+
158
+ it 'offers seeded Asteroids with rotating thrust, wrapped drift, shots and real breakups' do
159
+ expect(described_class.mini_names).to include(:asteroids)
160
+ expect(described_class.mini_frame_seconds(name: :asteroids)).to eq(0.05)
161
+ (5..16).each do |size|
162
+ frames = described_class.send(:mini_replay, name: :asteroids, width: size, height: size, seed: 73)
163
+ states = frames.filter_map { |frame| frame[:state] if frame[:state][:ship] }
164
+ expect(frames.map { |frame| frame[:event] }).to include(:hit, :respawn)
165
+ expect(states.map { |state| state[:thrust] }.uniq).to contain_exactly(true, false)
166
+ expect(states.map { |state| state[:ship][2].round(1) }.uniq.length).to be > 12
167
+ expect(states.map { |state| state[:shots].length }.max).to be_between(1, 2)
168
+ expect(states.map { |state| state[:rocks].length }.max).to be <= [3, size / 4].min + 1
169
+ expect(states.flat_map { |state| state[:rocks].map { |rock| rock[4] } }).to include(0.2, 0.5)
170
+ states.each_cons(2) do |a, b|
171
+ expect((((b[:ship][0] - a[:ship][0] + (size / 2.0)) % size) - (size / 2.0)).abs).to be <= 0.16
172
+ a[:rocks].each do |rock|
173
+ next_rock = b[:rocks].find { |candidate| candidate[5] == rock[5] }
174
+ next unless next_rock
175
+
176
+ expect(next_rock[0]).to be_within(0.000001).of((rock[0] + rock[2]) % size)
177
+ expect(next_rock[1]).to be_within(0.000001).of((rock[1] + rock[3]) % (size - 1))
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ it 'keeps rocks small and neutral with sparse quarter-cell shots and thrust' do
184
+ frames = []
185
+ described_class.send(:mini_asteroids_snapshot, frames: frames, width: 8, height: 7, ship: [4, 3, 0], thrust: false,
186
+ rocks: [[1, 1, 0, 0, 0.5, 1]], shots: [], sparks: [])
187
+ rock_pixels = frames.first[:rows].drop(1).flat_map(&:chars).reject { |pixel| pixel == ' ' || (pixel.ord - 256) / 16 == 0 }
188
+ expect(rock_pixels.map { |pixel| described_class::MINI_PIXELS.fetch(pixel)[1] }.uniq).to eq([:white])
189
+ expect(rock_pixels.sum { |pixel| ((pixel.ord - 256) % 16).digits(2).sum }).to eq(4)
190
+ end
191
+
192
+ it 'keeps a readable five-pixel ship and sparse subcell occupancy throughout tiny seeded replays' do
193
+ [5, 8, 16].product([1, 73, 74]).each do |size, seed|
194
+ frames = described_class.send(:mini_replay, name: :asteroids, width: size, height: size, seed: seed)
195
+ frames.each do |frame|
196
+ next unless frame[:state][:ship]
197
+
198
+ pixels = frame[:rows].drop(1).flat_map(&:chars).reject { |pixel| pixel == ' ' }
199
+ area = pixels.sum { |pixel| ((pixel.ord - 256) % 16).digits(2).sum }
200
+ ship = pixels.select { |pixel| (pixel.ord - 256) / 16 == 0 }
201
+ expect(ship.sum { |pixel| ((pixel.ord - 256) % 16).digits(2).sum }).to eq(5)
202
+ expect(area).to be <= [20, size * (size - 1)].min
203
+ # A lone quadrant can occupy a cell: measure ink area above, not full
204
+ # character rectangles, while bounding the number of colored cells too.
205
+ expect(pixels.length).to be <= 14
206
+ end
207
+ end
208
+ end
209
+
210
+ it 'spawns rocks clear of the ship instead of starting with overlapping sprites' do
211
+ [5, 8, 16].product([1, 73, 74]).each do |size, seed|
212
+ frames = described_class.send(:mini_replay, name: :asteroids, width: size, height: size, seed: seed)
213
+ state = frames.find { |frame| frame[:state][:ship] }[:state]
214
+ state[:rocks].each do |rock|
215
+ dx = described_class.send(:mini_delta, from: state[:ship][0], to: rock[0], span: size)
216
+ dy = described_class.send(:mini_delta, from: state[:ship][1], to: rock[1], span: size - 1)
217
+ expect((dx * dx) + (dy * dy)).to be > 1.3**2
218
+ end
219
+ end
220
+ end
221
+
222
+ it 'keeps red exhaust visible behind the cyan hull rather than erasing it in shared cells' do
223
+ [5, 8, 16].each do |size|
224
+ frames = described_class.send(:mini_replay, name: :asteroids, width: size, height: size, seed: 73)
225
+ thrust = frames.select { |frame| frame[:state][:thrust] }
226
+ visible = thrust.count { |frame| frame[:rows].drop(1).join.chars.any? { |pixel| (pixel.ord - 256) / 16 == 5 } }
227
+ expect(visible.to_f / thrust.length).to be >= 0.95
228
+ end
229
+ end
230
+
231
+ it 'removes rocks only when an advancing shot actually intersects their wrapped collision radius' do
232
+ [5, 8, 16].each do |size|
233
+ frames = described_class.send(:mini_replay, name: :asteroids, width: size, height: size, seed: 73)
234
+ collisions = 0
235
+ frames.each_cons(2) do |before, after|
236
+ a = before[:state]
237
+ b = after[:state]
238
+ next unless a[:ship] && b[:ship]
239
+
240
+ removed = a[:rocks].reject { |rock| b[:rocks].any? { |other| other[5] == rock[5] } }
241
+ removed.each do |rock|
242
+ expect(after[:event]).to eq(:hit)
243
+ x, y, angle = b[:ship]
244
+ candidates = a[:shots] + [[x, y, Math.cos(angle) * 0.36, Math.sin(angle) * 0.36]]
245
+ expect(candidates.any? do |shot|
246
+ dx = described_class.send(:mini_delta, from: shot[0] + shot[2], to: rock[0] + rock[2], span: size)
247
+ dy = described_class.send(:mini_delta, from: shot[1] + shot[3], to: rock[1] + rock[3], span: size - 1)
248
+ (dx * dx) + (dy * dy) <= rock[4]**2
249
+ end).to be(true)
250
+ collisions += 1
251
+ end
252
+ end
253
+ expect(collisions).to be > 10
254
+ end
255
+ end
256
+
257
+ it 'uses a faster presentation cadence and subcell, one-cell-high Pong paddles' do
258
+ expect(described_class.mini_frame_seconds(name: :pong)).to eq(0.05)
259
+ expect(described_class.mini_frame_seconds(name: :snake)).to eq(0.1)
260
+ (5..16).each do |size|
261
+ frames = described_class.send(:mini_replay, name: :pong, width: size, height: size, seed: 73)
262
+ states = frames.filter_map { |frame| frame[:state] if frame[:state][:ball] }
263
+ expect(states.map { |state| state[:paddle_height] }.uniq).to eq([1.0])
264
+ frames.each_cons(2) do |a, b|
265
+ next unless a[:state][:ball] && b[:state][:ball]
266
+
267
+ expect(a[:state][:ball].zip(b[:state][:ball]).map { |x, y| (x - y).abs }).to all(be <= 0.51)
268
+ end
269
+ glyphs = (6...180).flat_map { |frame| described_class.mini_cells(name: :pong, width: size, height: size, frame: frame).flatten.map { |cell| cell[:glyph] } }
270
+ expect(glyphs & %w[▘ ▝ ▖ ▗]).not_to be_empty
271
+ end
272
+ end
273
+
274
+ it 'replays extended seeded gameplay instead of repeating every six seconds' do
275
+ expect(described_class::MINI_FRAME_COUNT).to be >= 1800
276
+ described_class.mini_names.each do |name|
277
+ frames = Array.new(240) { |frame| described_class.mini_cells(name: name, frame: frame, seed: 73) }
278
+ expect(frames.uniq.length).to be > 80
279
+ expect(frames[0, 60]).not_to eq(frames[60, 60])
280
+ expect(frames).to eq(Array.new(240) { |frame| described_class.mini_cells(name: name, frame: frame, seed: 73) })
281
+ expect(frames).not_to eq(Array.new(240) { |frame| described_class.mini_cells(name: name, frame: frame, seed: 74) })
282
+ end
283
+ end
284
+ end
285
+
286
+ describe PWN::Banner do
287
+ describe '.mini_cells' do
288
+ it 'returns independent mutable cells and glyph strings even on clear frames' do
289
+ described_class.mini_names.each do |name|
290
+ [0, 26, 58].each do |frame|
291
+ args = { name: name, frame: frame }
292
+ original = described_class.mini_cells(**args)
293
+ changed = described_class.mini_cells(**args)
294
+ expect(changed.flatten.map(&:object_id).uniq.length).to eq(256)
295
+ expect(changed.flatten.map { |cell| cell[:glyph].object_id }.uniq.length).to eq(256)
296
+ changed.flatten.each do |cell|
297
+ cell[:glyph].replace('!')
298
+ cell[:foreground] = :red
299
+ end
300
+ expect(described_class.mini_cells(**args)).to eq(original)
301
+ end
302
+ end
303
+ end
304
+
305
+ it 'keeps every frame bounded, single-column, named-color, deterministic and side-effect free' do
306
+ expect(Random).not_to receive(:rand)
307
+ expect(Time).not_to receive(:now)
308
+ expect(Process).not_to receive(:clock_gettime)
309
+ expect(Thread).not_to receive(:new)
310
+ expect(described_class).not_to receive(:sleep)
311
+ expect do
312
+ described_class.mini_names.each do |name|
313
+ (5..16).each do |size|
314
+ args = { name: name, width: size, height: size }
315
+ frames = Array.new(60) { |frame| described_class.mini_cells(**args, frame: frame) }
316
+ expect(frames.uniq.length).to be >= 4
317
+ expect(frames.map(&:length)).to all(eq(size))
318
+ expect(frames.flatten(1).map(&:length)).to all(eq(size))
319
+ frames.flatten(2).each do |cell|
320
+ expect(cell.keys).to contain_exactly(:glyph, :foreground, :background)
321
+ expect(cell[:glyph]).to match(/\A[ PWN█▀▄▌▐░▘▝▖▗▞▛▚▜▙▟]\z/)
322
+ expect(%i[black red green yellow blue magenta cyan white]).to include(cell[:foreground], cell[:background])
323
+ end
324
+ expect(described_class.mini_cells(**args, frame: described_class::MINI_FRAME_COUNT)).to eq(frames.first)
325
+ expect(described_class.mini_cells(**args, frame: -1)).to eq(described_class.mini_cells(**args, frame: described_class::MINI_FRAME_COUNT - 1))
326
+ expect(described_class.mini_cells(**args, name: name.to_s, frame: 23)).to eq(frames[23])
327
+ end
328
+ end
329
+ end.to output('').to_stdout.and output('').to_stderr
330
+ expect(described_class.mini_cells(width: 0)).to eq([])
331
+ expect(described_class.mini_cells(height: -1)).to eq([])
332
+ expect(described_class.mini_cells(name: :snake, width: 100, height: 100)).to eq(described_class.mini_cells(name: :snake))
333
+ expect(described_class.mini_cells(width: 100, height: 100)).to eq(described_class.mini_cells(width: 64, height: 64))
334
+ expect(described_class.mini_cells(width: '3', height: '2').map { |row| row.map { |cell| cell[:glyph] }.join }).to eq(['PWN', ' '])
335
+ expect { described_class.mini_cells(name: :unknown) }.to raise_error(ArgumentError, /Unknown mini-banner/)
336
+ end
337
+
338
+ def replay(name, size, seed = 73)
339
+ described_class.send(:mini_replay, name: name, width: size, height: size, seed: seed)
340
+ end
341
+
342
+ def logical_ink(frame, full: false)
343
+ width = frame[:rows].first.length
344
+ height = frame[:rows].length - 1
345
+ height *= 2 if full
346
+ Array.new(height) do |y|
347
+ Array.new(width) do |x|
348
+ px = full ? x * 2 : x + (width / 2)
349
+ py = y + (full ? 0 : height / 2)
350
+ pixel = frame[:rows][1 + (py / 2)][px / 2]
351
+ mask = pixel == ' ' ? 0 : (pixel.ord - 256) % 16
352
+ mask[(py % 2 * 2) + (px % 2)] == 1 ? '*' : ' '
353
+ end.join
354
+ end
355
+ end
356
+
357
+ it 'rejects unreachable placements rather than ending a game on a blocked rotation' do
358
+ board = [' ', 'r ', 'r ', 'rr ']
359
+ shape = described_class::MINI_SHAPES.first
360
+ landing = described_class.send(:mini_landing, board: board, shape: shape, rng: Random.new(73))
361
+ rotations = described_class.send(:mini_rotations, shape: shape)
362
+ expect(landing).not_to be_nil
363
+ rotations.take(landing[:rotation] + 1).each do |piece|
364
+ expect(described_class.send(:mini_fits?, board: board, shape: piece, x: 0, y: 0)).to be(true)
365
+ end
366
+ (0..landing[:x]).each do |x|
367
+ expect(described_class.send(:mini_fits?, board: board, shape: landing[:shape], x: x, y: 0)).to be(true)
368
+ end
369
+ end
370
+
371
+ it 'stacks all seven four-cell tetrominoes with legal gravity, rotations and line collapse' do
372
+ (5..16).each do |size|
373
+ frames = replay(:falling_blocks, size)
374
+ active = frames.select { |frame| frame[:state][:active] }
375
+ expect(active.map { |frame| frame[:state][:type] }.uniq.length).to eq(7)
376
+ expect(frames.map { |frame| frame[:event] }).to include(:lock, :clear, :collapse)
377
+ active.each do |frame|
378
+ points, stack = frame[:state].values_at(:active, :stack)
379
+ expect(points.uniq.length).to eq(4)
380
+ points.each do |x, y|
381
+ expect(x).to be_between(0, size - 1)
382
+ expect(y).to be_between(0, ((size - 1) * 2) - 1)
383
+ expect(stack[y][x]).to eq(' ')
384
+ end
385
+ expected = stack.map { |row| row.gsub(/[^ ]/, '*') }
386
+ points.each { |x, y| expected[y][x] = '*' }
387
+ expect(logical_ink(frame, full: true)).to eq(expected)
388
+ end
389
+ frames.each_cons(2) do |before, after|
390
+ a = before[:state][:active]
391
+ b = after[:state][:active]
392
+ if a && b
393
+ expect(before[:state][:stack]).to eq(after[:state][:stack])
394
+ expect(b.map(&:last).min - a.map(&:last).min).to be_between(0, 1)
395
+ expect((b.map(&:first).min - a.map(&:first).min).abs).to be <= 1
396
+ elsif a && after[:event] == :lock
397
+ stack = before[:state][:stack]
398
+ expect(a.any? { |x, y| y == ((size - 1) * 2) - 1 || stack[y + 1][x] != ' ' }).to be(true)
399
+ expect(after[:rows]).to eq(before[:rows])
400
+ elsif before[:event] == :clear && after[:event] == :collapse
401
+ lines = before[:state][:lines]
402
+ expect(lines.map { |y| logical_ink(before, full: true)[y] }).to all(eq('*' * size))
403
+ survivors = logical_ink(before, full: true).each_with_index.filter_map { |row, y| row unless lines.include?(y) }
404
+ expect(logical_ink(after, full: true)).to eq(Array.new(lines.length, ' ' * size) + survivors)
405
+ end
406
+ end
407
+ end
408
+ end
409
+
410
+ it 'moves a contiguous non-overlapping snake toward real food and grows only on pickup' do
411
+ (5..16).each do |size|
412
+ frames = replay(:snake, size)
413
+ states = frames.filter_map { |frame| frame[:state] if frame[:state][:body] }
414
+ expect(states.map { |state| state[:body].length }.max).to be > 6
415
+ states.each do |state|
416
+ body, food = state.values_at(:body, :food)
417
+ expect(body.uniq.length).to eq(body.length)
418
+ body.each do |x, y|
419
+ expect(x).to be_between(0, size - 1)
420
+ expect(y).to be_between(0, size - 2)
421
+ end
422
+ body.each_cons(2) { |a, b| expect(a.zip(b).sum { |x, y| (x - y).abs }).to eq(1) }
423
+ expect(body).not_to include(food) if food
424
+ end
425
+ frames.each do |frame|
426
+ body, food = frame[:state].values_at(:body, :food)
427
+ next unless body
428
+
429
+ expected = Array.new(size - 1) { ' ' * size }
430
+ (body + [food].compact).each { |x, y| expected[y][x] = '*' }
431
+ expect(logical_ink(frame)).to eq(expected)
432
+ end
433
+ frames.each_cons(2) do |before, after|
434
+ a = before[:state]
435
+ b = after[:state]
436
+ next unless a[:body] && b[:body] && a != b
437
+
438
+ expect(a[:body].first.zip(b[:body].first).sum { |x, y| (x - y).abs }).to eq(1)
439
+ if b[:body].first == a[:food]
440
+ expect(b[:body].drop(1)).to eq(a[:body])
441
+ else
442
+ expect(b[:body].drop(1)).to eq(a[:body].take(a[:body].length - 1))
443
+ expect(b[:food]).to eq(a[:food])
444
+ end
445
+ end
446
+ end
447
+ end
448
+
449
+ it 'plays varied Pong rallies with legal reflections, paddle tracking, misses and scores' do
450
+ (5..16).each do |size|
451
+ frames = replay(:pong, size)
452
+ expect(frames.map { |frame| frame[:event] }).to include(:bounce, :score, :serve)
453
+ expect(frames.filter_map { |frame| frame[:state][:rally] }.max).to be >= 2
454
+ expect(frames.filter_map { |frame| frame[:state][:rally] }.max).to be >= 5 if size == 16
455
+ frames.each_cons(2) do |before, after|
456
+ a = before[:state]
457
+ b = after[:state]
458
+ next unless a[:paddles] && b[:paddles]
459
+
460
+ expect(a[:paddles].zip(b[:paddles]).map { |x, y| (x.round - y.round).abs }).to all(be <= 1)
461
+ if a[:ball] && b[:ball]
462
+ expect(a[:ball].zip(b[:ball]).map { |x, y| (x.round - y.round).abs }).to all(be <= 1)
463
+ expect(b[:ball][0]).to be_between(0, size - 1)
464
+ expect(b[:ball][1]).to be_between(0, size - 2)
465
+ if after[:event] == :bounce
466
+ side = b[:ball][0] < (size - 1) / 2.0 ? 0 : 1
467
+ expect(b[:ball][1] - b[:paddles][side]).to be_between(-0.25, 0.75)
468
+ expect(a[:ball][0]).to be_between(1, size - 2)
469
+ end
470
+ elsif a[:ball] && after[:event] == :score
471
+ expect(a[:ball][0] < 1 || a[:ball][0] > size - 2).to be(true)
472
+ expect(b[:scores].sum - a[:scores].sum).to eq(1)
473
+ elsif b[:ball]
474
+ expect(after[:event]).to eq(:serve)
475
+ end
476
+ end
477
+ end
478
+ end
479
+
480
+ it 'returns deterministic named-color block cells without changing the ASCII API' do
481
+ rows = described_class.mini_cells(name: :falling_blocks, width: 5, height: 5)
482
+ expect(rows.length).to eq(5)
483
+ expect(rows.map(&:length)).to eq([5] * 5)
484
+ expect(rows.first.map { |cell| cell[:glyph] }.join).to eq(' PWN ')
485
+ expect(rows[1][1]).to eq(glyph: ' ', foreground: :blue, background: :black)
486
+ expect(described_class.mini_cells(name: :falling_blocks, width: 5, height: 5, frame: described_class::MINI_FRAME_COUNT)).to eq(rows)
487
+ expect(described_class.mini_frame(width: 5, height: 5)[1]).to eq(' ### ')
488
+ end
489
+
490
+ it 'bounds immutable replay caching and survives eviction, arbitrary seek and caller mutation' do
491
+ first = replay(:snake, 8)
492
+ expect(first.length).to eq(described_class::MINI_FRAME_COUNT)
493
+ expect(first).to be_frozen
494
+ expect { first[20][:rows][1].replace('bad') }.to raise_error(FrozenError)
495
+ expect { first[20][:state][:body][0][0] = 99 }.to raise_error(FrozenError)
496
+ expect(replay(:snake, 8)).to equal(first)
497
+ 9.times { |seed| replay(:pong, 5, seed) }
498
+ expect(described_class.instance_variable_get(:@mini_replays).length).to eq(described_class::MINI_CACHE_LIMIT)
499
+ expect(replay(:snake, 8)).to eq(first)
500
+ [1799, -1, 921, 600, 6, 1800, -1800].each do |frame|
501
+ cells = described_class.mini_cells(name: :snake, width: 8, height: 8, frame: frame)
502
+ expect(cells.map { |row| row.map { |cell| cell[:glyph] }.join }).to eq(
503
+ first[frame % described_class::MINI_FRAME_COUNT][:rows].map { |row| row.chars.map { |pixel| described_class::MINI_PIXELS.fetch(pixel).first }.join }
504
+ )
505
+ end
506
+ end
507
+
508
+ it 'has a visible reset and blank-to-blank seam rather than teleporting at replay wrap' do
509
+ described_class.mini_names.each do |name|
510
+ [5, 8, 16].each do |size|
511
+ frames = replay(name, size)
512
+ expect(frames.length).to eq(described_class::MINI_FRAME_COUNT)
513
+ expect(frames.last[:rows]).to eq(frames.first[:rows])
514
+ expect(frames[-size - 6][:event]).to eq(:reset)
515
+ frames.last(size).each_cons(2) do |before, after|
516
+ expect(before[:rows].zip(after[:rows]).count { |a, b| a != b }).to be <= 1
517
+ end
518
+ end
519
+ end
520
+ end
521
+ end
522
+ end
523
+
524
+ describe PWN::Banner do
525
+ describe '.mini_frame' do
526
+ it 'offers retro games with deterministic cycles and branding at every square size' do
527
+ expect(described_class.mini_names).to eq(%i[falling_blocks snake pong asteroids])
528
+ described_class.mini_names.each do |name|
529
+ (5..16).each do |size|
530
+ args = { name: name, width: size, height: size }
531
+ frames = Array.new(described_class::MINI_FRAME_COUNT) { |frame| described_class.mini_frame(**args, frame: frame) }
532
+ expect(frames.uniq.length).to be >= 4
533
+ expect(frames.flatten).to all(match(/\A[ -~]{#{size}}\z/))
534
+ expect(frames.map(&:length)).to all(eq(size))
535
+ expect(frames.map(&:first)).to all(include('PWN'))
536
+ expect(described_class.mini_frame(**args, frame: described_class::MINI_FRAME_COUNT)).to eq(frames.first)
537
+ expect(described_class.mini_frame(**args, name: name.to_s, frame: -1)).to eq(frames.last)
538
+ expect(described_class.mini_frame(**args, frame: 3)).to eq(frames[3])
539
+ end
540
+ end
541
+ end
542
+
543
+ it 'returns a compact printable ASCII wordmark without terminal side effects' do
544
+ lines = nil
545
+ expect { lines = described_class.mini_frame(frame: 0) }.not_to output.to_stdout
546
+ expect(lines.length).to eq(16)
547
+ expect(lines).to all(match(/\A[ -~]{16}\z/))
548
+ expect(lines.first).to include('PWN')
549
+ end
550
+
551
+ it 'clips safely for tiny panes and caps oversized requests at native dimensions' do
552
+ described_class.mini_names.each do |name|
553
+ [1, 3, 5, 8, 16].product([1, 3, 5, 8, 16]).each do |width, height|
554
+ rows = described_class.mini_frame(name: name, width: width, height: height)
555
+ expect(rows.length).to eq(height)
556
+ expect(rows.map(&:length)).to all(eq(width))
557
+ end
558
+ end
559
+ expect(described_class.mini_frame(width: 0)).to eq([])
560
+ expect(described_class.mini_frame(height: -1)).to eq([])
561
+ expect(described_class.mini_frame(width: -1)).to eq([])
562
+ expect(described_class.mini_frame(width: 10**20, height: 10**20)).to eq(described_class.mini_frame)
563
+ expect(described_class.mini_frame(name: nil, frame: nil, width: nil, height: nil)).to eq(described_class.mini_frame)
564
+ expect(described_class.mini_frame(width: '3', height: '2').map(&:length)).to eq([3, 3])
565
+ end
566
+
567
+ it 'returns independent rows and name lists without retaining caller mutations' do
568
+ original = described_class.mini_frame
569
+ changed = described_class.mini_frame
570
+ changed.first.replace('changed')
571
+ changed.clear
572
+ described_class.mini_names.clear
573
+ expect(described_class.mini_frame).to eq(original)
574
+ expect(described_class.mini_names.length).to eq(4)
575
+ end
576
+
577
+ it 'keeps recognizable pieces, bodies and paddles in the smallest complete canvas' do
578
+ expect(described_class.mini_frame(name: :falling_blocks, width: 5, height: 5)).to eq(
579
+ [' PWN ', ' ### ', ' # ', '# #', '## ##']
580
+ )
581
+ expect(described_class.mini_frame(name: :snake, width: 5, height: 5)).to eq(
582
+ [' PWN ', '@ ', 'o * ', 'o ', 'oo ']
583
+ )
584
+ expect(described_class.mini_frame(name: :pong, width: 5, height: 5)).to eq(
585
+ [' PWN ', '|o: |', '| |', ' : ', ' ']
586
+ )
587
+ end
588
+
589
+ it 'lands tetrominoes before flashing and clearing the completed rows' do
590
+ [5, 8, 16].each do |size|
591
+ [0, 30].each do |start|
592
+ args = { name: :falling_blocks, width: size, height: size }
593
+ expect(described_class.mini_frame(**args, frame: start + 24).last(2)).to eq(['#' * size] * 2)
594
+ expect(described_class.mini_frame(**args, frame: start + 26).last(2)).to eq(['=' * size] * 2)
595
+ expect(described_class.mini_frame(**args, frame: start + 29).last(2)).to eq([' ' * size] * 2)
596
+ end
597
+ end
598
+ end
599
+
600
+ it 'moves snake and ball at most one cell per axis including the loop seam' do
601
+ { snake: '@', pong: 'o' }.each do |name, glyph|
602
+ (5..16).each do |size|
603
+ positions = Array.new(described_class::MINI_FRAME_COUNT + 1) do |frame|
604
+ rows = described_class.mini_frame(name: name, frame: frame, width: size, height: size)
605
+ y = rows.index { |row| row.include?(glyph) }
606
+ [rows[y].index(glyph), y]
607
+ end
608
+ positions.each_cons(2) do |before, after|
609
+ expect((before[0] - after[0]).abs).to be <= 1
610
+ expect((before[1] - after[1]).abs).to be <= 1
611
+ end
612
+ end
613
+ end
614
+ end
615
+
616
+ it 'never aliases mutable rows even during the line-clear animation' do
617
+ described_class.mini_names.each do |name|
618
+ described_class::MINI_FRAME_COUNT.times do |frame|
619
+ rows = described_class.mini_frame(name: name, frame: frame)
620
+ expect(rows.map(&:object_id).uniq.length).to eq(rows.length)
621
+ end
622
+ end
623
+ end
624
+
625
+ it 'never samples randomness, reads the clock, starts threads, sleeps, or prints' do
626
+ expect(Random).not_to receive(:rand)
627
+ expect(Time).not_to receive(:now)
628
+ expect(Process).not_to receive(:clock_gettime)
629
+ expect(Thread).not_to receive(:new)
630
+ expect(described_class).not_to receive(:sleep)
631
+ expect do
632
+ described_class.mini_names.each { |name| described_class.mini_frame(name: name, frame: 5) }
633
+ end.to output('').to_stdout.and output('').to_stderr
634
+ end
635
+
636
+ it 'rejects unknown artwork rather than silently randomizing it' do
637
+ expect { described_class.mini_frame(name: :missing) }.to raise_error(ArgumentError, /Unknown mini-banner/)
638
+ end
639
+
640
+ it 'preserves the original static banner dispatch' do
641
+ modules = %i[Anon Bubble Cheshire CodeCave DontPanic ForkBomb FSociety JmpEsp Matrix Ninja OffTheAir Pirate Radare2 Radare2AI WhiteRabbit]
642
+ modules.each_with_index do |name, index|
643
+ expect(described_class.const_get(name)).to receive(:get).and_return(name.to_s)
644
+ expect(described_class.get(index: index + 1)).to eq(name.to_s)
645
+ end
646
+ end
647
+ end
648
+
6
649
  it 'should display information for authors' do
7
650
  authors_response = PWN::Banner
8
651
  expect(authors_response).to respond_to :authors