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
data/lib/pwn/banner.rb CHANGED
@@ -1,8 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- # This file, using the autoload directive loads Banner modules
5
- # into memory only when they're needed. For more information, see:
4
+ # Static banners and pure, PWN-branded retro ASCII/color loops: falling_blocks,
5
+ # snake, pong, and asteroids. mini_frame returns fresh rows on a borderless canvas;
6
+ # use equal width and height (5..16) for complete square artwork. Dimensions
7
+ # clamp to 0..16 (colored blocks: 0..64), with tiny panes showing a wordmark. The caller
8
+ # samples mini_names once per session and advances the explicit frame index
9
+ # every mini_frame_seconds(name:) (0.05 for Pong/Asteroids, 0.1 otherwise).
10
+ # mini_cells replays 1800 seeded gameplay
11
+ # frames (MINI_FRAME_COUNT); mini_frame retains its legacy 60-frame ASCII loop.
12
+ # These are decorative game simulations, not interactive games or telemetry.
13
+ # mini_cells uses local Random.new with seed 73 by default; callers may keep
14
+ # a different integer seed per session. Six immutable replays are cached.
15
+ # Tetrominoes stack and clear; snake pursues food and grows; Pong rallies
16
+ # include imperfect paddle tracking, misses and score pips between serves.
17
+ # Blocks use horizontal half-cells with independent foreground/background
18
+ # colors, filling the pane below PWN (up to 64 cells). Other games retain
19
+ # quadrant pixels; snake stays centered with food/head color priority.
20
+ # White line-clear flashes and game-over/reset wipes are intentional.
21
+ # Pong uses half-cell motion and one-cell-high paddles without speeding up play.
22
+ # Asteroids uses five-pixel cyan arrowheads on a half-cell grid, sparse small
23
+ # white rocks, yellow shots and red thrust; wrapped collisions split real rocks.
24
+ # Static Banner modules autoload only when needed. For more information, see:
6
25
  # http://www.rubyinside.com/ruby-techniques-revealed-autoload-1652.html
7
26
  module Banner
8
27
  autoload :Anon, 'pwn/banner/anon'
@@ -21,6 +40,693 @@ module PWN
21
40
  autoload :Radare2AI, 'pwn/banner/radare2_ai'
22
41
  autoload :WhiteRabbit, 'pwn/banner/white_rabbit'
23
42
 
43
+ # Bounded, square character canvases. The caller owns the surrounding border.
44
+ MINI_WIDTH = 16
45
+ MINI_HEIGHT = 16
46
+ MINI_FRAME_SECONDS = 0.1
47
+ MINI_FRAME_COUNT = 1800
48
+ MINI_SEED = 73
49
+ MINI_CACHE_LIMIT = 6
50
+ MINI_ASCII_FRAME_COUNT = 60
51
+ # Five quadrant pixels, not a scaled-down vector sprite. Eight stable headings
52
+ # keep the nose legible even in the 5-column pane; physics still turns smoothly.
53
+ MINI_SHIP = Array.new(8) do |heading|
54
+ points = heading.even? ? [[-1, -1], [0, -1], [1, 0], [0, 1], [-1, 1]] : [[0, -1], [1, 0], [1, 1], [0, 1], [-1, 0]]
55
+ (heading / 2).times { points = points.map { |x, y| [-y, x] } }
56
+ points.map(&:freeze).freeze
57
+ end.freeze
58
+
59
+ # Compact replay pixels expand to fresh cells only at the API boundary.
60
+ MINI_PIXELS = {
61
+ ' ' => [' ', :blue], 'P' => ['P', :blue], 'W' => ['W', :blue], 'N' => ['N', :blue],
62
+ 'c' => ['█', :cyan], 'y' => ['█', :yellow], 'm' => ['█', :magenta],
63
+ 'b' => ['█', :blue], 'r' => ['█', :red], 'g' => ['█', :green],
64
+ 'w' => ['█', :white], 'f' => ['▄', :red], 'o' => ['▀', :yellow],
65
+ 'l' => ['▌', :cyan], 'q' => ['▐', :magenta], ':' => ['░', :blue]
66
+ }.merge(%i[cyan magenta yellow white green red blue].each_with_index.flat_map do |color, index|
67
+ ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'.chars.each_with_index.map do |glyph, mask|
68
+ [(256 + (index * 16) + mask).chr(Encoding::UTF_8), [glyph, color]]
69
+ end
70
+ end.to_h).merge(%i[black cyan magenta yellow white green red blue].each_with_index.flat_map do |top, top_index|
71
+ %i[black cyan magenta yellow white green red blue].each_with_index.map do |bottom, bottom_index|
72
+ mask = (top == :black ? 0 : 3) | (bottom == :black ? 0 : 12)
73
+ cell = if top == bottom
74
+ [top == :black ? ' ' : '█', top, :black]
75
+ elsif top == :black
76
+ ['▄', bottom, :black]
77
+ else
78
+ ['▀', top, bottom]
79
+ end
80
+ [(1024 + (((top_index * 8) + bottom_index) * 16) + mask).chr(Encoding::UTF_8), cell]
81
+ end
82
+ end.to_h).transform_values(&:freeze).freeze
83
+ MINI_SHAPES = [
84
+ [[0, 0], [1, 0], [2, 0], [3, 0]],
85
+ [[0, 0], [1, 0], [0, 1], [1, 1]],
86
+ [[0, 0], [1, 0], [2, 0], [1, 1]],
87
+ [[0, 0], [0, 1], [1, 1], [2, 1]],
88
+ [[2, 0], [0, 1], [1, 1], [2, 1]],
89
+ [[1, 0], [2, 0], [0, 1], [1, 1]],
90
+ [[0, 0], [1, 0], [1, 1], [2, 1]]
91
+ ].map { |shape| shape.map(&:freeze).freeze }.freeze
92
+
93
+ # Supported Method Parameters::
94
+ # Return fresh ASCII rows for decorative retro loops, not interactive games.
95
+ # Use equal width and height for a square; 5..16 retains the complete artwork.
96
+ # Smaller panes degrade to a wordmark. Caller owns randomness, clock and IO.
97
+ # PWN::Banner.mini_frame(
98
+ # name: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',
99
+ # frame: 'optional - integer frame index, defaults to zero and wraps at 60',
100
+ # width: 'optional - output columns clamped to zero through 16, defaults to 16',
101
+ # height: 'optional - output rows clamped to zero through 16, defaults to 16'
102
+ # )
103
+
104
+ public_class_method def self.mini_frame(opts = {})
105
+ name = (opts[:name] || :falling_blocks).to_s
106
+ raise ArgumentError, "Unknown mini-banner: #{name}" unless mini_names.map(&:to_s).include?(name)
107
+
108
+ phase = (opts[:frame] || 0).to_i % MINI_ASCII_FRAME_COUNT
109
+ width = (opts[:width] || MINI_WIDTH).to_i.clamp(0, MINI_WIDTH)
110
+ height = (opts[:height] || MINI_HEIGHT).to_i.clamp(0, MINI_HEIGHT)
111
+ return [] if width.zero? || height.zero?
112
+
113
+ rows = Array.new(height) { ' ' * width }
114
+ rows[0] = 'PWN'[0, width].center(width)
115
+ return rows if width < 5 || height < 5
116
+
117
+ send("mini_#{name}", rows: rows, phase: phase, width: width, height: height)
118
+ rows
119
+ end
120
+
121
+ # Supported Method Parameters::
122
+ # List animation names; callers may sample once and retain the session choice.
123
+ # PWN::Banner.mini_names
124
+
125
+ public_class_method def self.mini_names
126
+ %i[falling_blocks snake pong asteroids]
127
+ end
128
+
129
+ # Supported Method Parameters::
130
+ # Return the presentation interval; simulations preserve their gameplay speed.
131
+ # PWN::Banner.mini_frame_seconds(
132
+ # name: 'optional - animation name, defaults to falling_blocks'
133
+ # )
134
+
135
+ public_class_method def self.mini_frame_seconds(opts = {})
136
+ name = (opts[:name] || :falling_blocks).to_s
137
+ raise ArgumentError, "Unknown mini-banner: #{name}" unless mini_names.map(&:to_s).include?(name)
138
+
139
+ %w[pong asteroids].include?(name) ? 0.05 : MINI_FRAME_SECONDS
140
+ end
141
+
142
+ # Supported Method Parameters::
143
+ # Return rows of single-column Unicode block cells, with named terminal colors.
144
+ # Each fresh cell has glyph, foreground and background keys; no ANSI or IO.
145
+ # Square means terminal-cell dimensions, not physical pixels. Caller owns borders.
146
+ # Use mini_frame_seconds for cadence; 1800 frames span 90 or 180 seconds.
147
+ # Colored blocks fill the canvas with half-cell pieces; other games cap at 16.
148
+ # PWN::Banner.mini_cells(
149
+ # name: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',
150
+ # frame: 'optional - integer index, defaults to zero and wraps at 1800',
151
+ # width: 'optional - columns clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
152
+ # height: 'optional - rows clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
153
+ # seed: 'optional - integer local PRNG seed, defaults to 73; retain per session'
154
+ # )
155
+
156
+ public_class_method def self.mini_cells(opts = {})
157
+ name = (opts[:name] || :falling_blocks).to_s
158
+ phase = (opts[:frame] || 0).to_i % MINI_FRAME_COUNT
159
+ raise ArgumentError, "Unknown mini-banner: #{name}" unless mini_names.map(&:to_s).include?(name)
160
+
161
+ limit = name == 'falling_blocks' ? 64 : MINI_WIDTH
162
+ width = (opts[:width] || MINI_WIDTH).to_i.clamp(0, limit)
163
+ height = (opts[:height] || MINI_HEIGHT).to_i.clamp(0, limit)
164
+ return [] if width.zero? || height.zero?
165
+
166
+ rows = if width < 5 || height < 5
167
+ ['PWN'[0, width].center(width)] + Array.new(height - 1) { ' ' * width }
168
+ else
169
+ mini_replay(name: name, width: width, height: height, seed: (opts[:seed] || MINI_SEED).to_i)[phase][:rows]
170
+ end
171
+ rows.map do |row|
172
+ row.chars.map do |pixel|
173
+ glyph, foreground, background = MINI_PIXELS.fetch(pixel)
174
+ { glyph: glyph.dup, foreground: foreground, background: background || :black }
175
+ end
176
+ end
177
+ end
178
+
179
+ # Six immutable, compact replays bound memory even when callers resize/reseed.
180
+ private_class_method def self.mini_replay(opts = {})
181
+ key = [opts[:name].to_s.dup.freeze, opts[:width], opts[:height], opts[:seed]].freeze
182
+ @mini_replays ||= {}
183
+ return @mini_replays[key] if @mini_replays.key?(key)
184
+
185
+ name, width, height, seed = key
186
+ # Two independently colored horizontal halves per terminal cell. Four
187
+ # independent quadrants can require more than the terminal's two colors.
188
+ # Keep full pane width and the bottom half-row, below the wordmark.
189
+ height = ((height - 1) * 2) + 1 if name == 'falling_blocks'
190
+ frames = []
191
+ blank = Array.new(height - 1) { ' ' * width }
192
+ mini_emit(frames: frames, board: blank, event: :ready, ticks: 6)
193
+ send("mini_simulate_#{name}", frames: frames, width: width, height: height - 1, rng: Random.new(seed))
194
+ # A white hold and top-to-bottom wipe explicitly mark the replay boundary.
195
+ frames = frames.take(MINI_FRAME_COUNT - height - 6)
196
+ board = frames.last[:rows].drop(1).map { |row| row.gsub(/[^ ]/, 'w') }
197
+ mini_emit(frames: frames, board: board, event: :reset, ticks: 6)
198
+ board.length.times do |y|
199
+ board[y] = ' ' * width
200
+ mini_emit(frames: frames, board: board, event: :reset)
201
+ end
202
+ mini_emit(frames: frames, board: blank, event: :ready)
203
+ if %w[falling_blocks snake].include?(name)
204
+ packed = {}.compare_by_identity
205
+ frames = frames.map do |frame|
206
+ packed[frame] ||= begin
207
+ board = if name == 'falling_blocks'
208
+ mini_block_board(board: frame[:rows].drop(1))
209
+ else
210
+ mini_quarter_board(board: frame[:rows].drop(1), name: name)
211
+ end
212
+ mini_freeze(value: frame.merge(rows: ['PWN'.center(board.first.length)] + board))
213
+ end
214
+ end
215
+ end
216
+ @mini_replays.shift while @mini_replays.length >= MINI_CACHE_LIMIT
217
+ @mini_replays[key] = frames.freeze
218
+ end
219
+
220
+ private_class_method def self.mini_freeze(opts = {})
221
+ value = opts[:value]
222
+ case value
223
+ when Hash
224
+ value.each_value { |child| mini_freeze(value: child) }
225
+ when Array
226
+ value.each { |child| mini_freeze(value: child) }
227
+ end
228
+ value.freeze
229
+ end
230
+
231
+ # Each block occupies a horizontal half-cell for its entire lifetime. Unlike
232
+ # four independent quadrants, two halves always fit foreground/background,
233
+ # including differently colored neighbors, holes and white clear flashes.
234
+ private_class_method def self.mini_block_board(opts = {})
235
+ opts[:board].each_slice(2).map do |top, bottom|
236
+ top.chars.each_with_index.map do |pixel, x|
237
+ a = ' cmywgrb'.index(pixel)
238
+ b = ' cmywgrb'.index(bottom[x])
239
+ mask = (a.zero? ? 0 : 3) | (b.zero? ? 0 : 12)
240
+ mask.zero? ? ' ' : (1024 + (((a * 8) + b) * 16) + mask).chr(Encoding::UTF_8)
241
+ end.join
242
+ end
243
+ end
244
+
245
+ # Retain snake's centered quadrant presentation.
246
+ # A foreground-on-black terminal cell cannot show several colors plus black:
247
+ # merge occupancy rather than erasing neighboring pieces/body at color seams.
248
+ private_class_method def self.mini_quarter_board(opts = {})
249
+ source = opts[:board]
250
+ width = source.first.length
251
+ height = source.length
252
+
253
+ board = Array.new(height) { ' ' * width }
254
+ colors = { 'c' => 0, 'm' => 1, 'y' => 2, 'w' => 3, 'g' => 4, 'r' => 5, 'f' => 5, 'b' => 6 }
255
+ points = source.each_with_index.flat_map do |row, y|
256
+ row.chars.each_with_index.filter_map { |pixel, x| [x, y, pixel] unless pixel == ' ' }
257
+ end
258
+ points.sort_by! { |_, _, pixel| { 'f' => 2, 'y' => 1 }.fetch(pixel, 0) } if opts[:name] == 'snake'
259
+ points.each do |x, y, pixel|
260
+ mini_pixel(board: board, x: x + (width / 2), y: y + (height / 2), color: colors.fetch(pixel), merge: true)
261
+ end
262
+ board
263
+ end
264
+
265
+ private_class_method def self.mini_emit(opts = {})
266
+ board = opts[:board]
267
+ rows = ['PWN'.center(board.first.length)] + board.map(&:dup)
268
+ record = { rows: rows, event: opts[:event] || :play, state: opts[:state] || {} }
269
+ mini_freeze(value: record)
270
+ (opts[:ticks] || 1).times { opts[:frames] << record }
271
+ end
272
+
273
+ private_class_method def self.mini_rotations(opts = {})
274
+ shape = opts[:shape]
275
+ Array.new(4) do
276
+ current = shape
277
+ rotated = shape.map { |x, y| [-y, x] }
278
+ min_x = rotated.map(&:first).min
279
+ min_y = rotated.map(&:last).min
280
+ shape = rotated.map { |x, y| [x - min_x, y - min_y] }.sort
281
+ current
282
+ end.uniq
283
+ end
284
+
285
+ private_class_method def self.mini_fits?(opts = {})
286
+ board = opts[:board]
287
+ opts[:shape].all? do |dx, dy|
288
+ x = opts[:x] + dx
289
+ y = opts[:y] + dy
290
+ x >= 0 && x < board.first.length && y >= 0 && y < board.length && board[y][x] == ' '
291
+ end
292
+ end
293
+
294
+ private_class_method def self.mini_landing(opts = {})
295
+ board = opts[:board]
296
+ rng = opts[:rng]
297
+ candidates = []
298
+ rotations = mini_rotations(shape: opts[:shape])
299
+ spawn_x = (board.first.length - opts[:shape].map(&:first).max - 1) / 2
300
+ rotations.each_with_index do |shape, rotation|
301
+ # Only plan maneuvers the displayed spawn/rotate/steer sequence can make.
302
+ # An obstructed rotation is rejected, never mistaken for a top-out.
303
+ break unless mini_fits?(board: board, shape: shape, x: spawn_x, y: 0)
304
+
305
+ (board.first.length - shape.map(&:first).max).times do |x|
306
+ corridor = [spawn_x, x].min..[spawn_x, x].max
307
+ next unless corridor.all? { |px| mini_fits?(board: board, shape: shape, x: px, y: 0) }
308
+
309
+ y = 0
310
+ y += 1 while mini_fits?(board: board, shape: shape, x: x, y: y + 1)
311
+ test = board.map(&:dup)
312
+ shape.each { |dx, dy| test[y + dy][x + dx] = 'c' }
313
+ lines = test.count { |row| !row.include?(' ') }
314
+ heights = []
315
+ holes = 0
316
+ board.first.length.times do |column|
317
+ top = test.index { |row| row[column] != ' ' } || test.length
318
+ heights << (test.length - top)
319
+ holes += test.drop(top).count { |row| row[column] == ' ' }
320
+ end
321
+ score = (lines * 12) - (holes * 9) - (heights.sum * 0.65) - (heights.each_cons(2).sum { |a, b| (a - b).abs } * 0.5) + (rng.rand * 1.4)
322
+ candidates << { shape: shape, rotation: rotation, x: x, y: y, score: score }
323
+ end
324
+ end
325
+ candidates.max_by { |candidate| candidate[:score] }
326
+ end
327
+
328
+ private_class_method def self.mini_simulate_falling_blocks(opts = {})
329
+ frames = opts[:frames]
330
+ width = opts[:width]
331
+ height = opts[:height]
332
+ rng = opts[:rng]
333
+ board = Array.new(height) { ' ' * width }
334
+ bag = []
335
+ while frames.length < MINI_FRAME_COUNT
336
+ bag = (0...MINI_SHAPES.length).to_a.shuffle(random: rng) if bag.empty?
337
+ type = bag.shift
338
+ shape = MINI_SHAPES[type]
339
+ landing = mini_landing(board: board, shape: shape, rng: rng)
340
+ x = (width - shape.map(&:first).max - 1) / 2
341
+ unless landing && mini_fits?(board: board, shape: shape, x: x, y: 0)
342
+ mini_reset_board(frames: frames, board: board)
343
+ board = Array.new(height) { ' ' * width }
344
+ next
345
+ end
346
+ rotations = mini_rotations(shape: shape)
347
+ plan = [[shape, x, 0]]
348
+ blocked = false
349
+ (1..landing[:rotation]).each do |rotation|
350
+ shape = rotations[rotation]
351
+ if mini_fits?(board: board, shape: shape, x: x, y: 0)
352
+ plan << [shape, x, 0]
353
+ else
354
+ blocked = true
355
+ end
356
+ end
357
+ until x == landing[:x] || blocked
358
+ x += landing[:x] <=> x
359
+ blocked = !mini_fits?(board: board, shape: shape, x: x, y: 0)
360
+ plan << [shape, x, 0] unless blocked
361
+ end
362
+ if blocked
363
+ mini_reset_board(frames: frames, board: board)
364
+ board = Array.new(height) { ' ' * width }
365
+ next
366
+ end
367
+ (1..landing[:y]).each { |y| plan << [shape, x, y] }
368
+ color = %w[c y m b r g g][type]
369
+ plan.each do |piece, px, py|
370
+ picture = board.map(&:dup)
371
+ active = piece.map { |dx, dy| [px + dx, py + dy] }
372
+ active.each { |ax, ay| picture[ay][ax] = color }
373
+ mini_emit(frames: frames, board: picture,
374
+ state: { active: active, stack: board.map(&:dup), type: type })
375
+ end
376
+ shape.each { |dx, dy| board[landing[:y] + dy][x + dx] = color }
377
+ mini_emit(frames: frames, board: board, event: :lock, ticks: 2)
378
+ full = board.each_index.reject { |y| board[y].include?(' ') }
379
+ next if full.empty?
380
+
381
+ flash = board.map(&:dup)
382
+ full.each { |y| flash[y] = 'w' * width }
383
+ mini_emit(frames: frames, board: flash, event: :clear, state: { lines: full }, ticks: 3)
384
+ board = Array.new(full.length) { ' ' * width } + board.each_with_index.filter_map { |row, y| row unless full.include?(y) }
385
+ mini_emit(frames: frames, board: board, event: :collapse, ticks: 2)
386
+ end
387
+ end
388
+
389
+ private_class_method def self.mini_reset_board(opts = {})
390
+ board = opts[:board].map { |row| row.gsub(/[^ ]/, 'w') }
391
+ mini_emit(frames: opts[:frames], board: board, event: :game_over, ticks: 8)
392
+ board.length.times do |y|
393
+ board[y] = ' ' * board.first.length
394
+ mini_emit(frames: opts[:frames], board: board, event: :reset)
395
+ end
396
+ mini_emit(frames: opts[:frames], board: board, event: :ready, ticks: 4)
397
+ end
398
+
399
+ private_class_method def self.mini_snake_route(opts = {})
400
+ body = opts[:body]
401
+ food = opts[:food]
402
+ width = opts[:width]
403
+ height = opts[:height]
404
+ rng = opts[:rng]
405
+ queue = [[body.first, nil]]
406
+ seen = body.take(body.length - 1).to_h { |point| [point, true] }
407
+ directions = [[1, 0], [-1, 0], [0, 1], [0, -1]].shuffle(random: rng)
408
+ cursor = 0
409
+ while cursor < queue.length
410
+ point, first = queue[cursor]
411
+ cursor += 1
412
+ return first if point == food
413
+
414
+ directions.each do |dx, dy|
415
+ target = [point[0] + dx, point[1] + dy]
416
+ next unless target[0].between?(0, width - 1) && target[1].between?(0, height - 1)
417
+ next if seen[target]
418
+
419
+ seen[target] = true
420
+ queue << [target, first || target]
421
+ end
422
+ end
423
+ directions.map { |dx, dy| [body.first[0] + dx, body.first[1] + dy] }.find do |point|
424
+ point[0].between?(0, width - 1) && point[1].between?(0, height - 1) && !body.take(body.length - 1).include?(point)
425
+ end
426
+ end
427
+
428
+ private_class_method def self.mini_simulate_snake(opts = {})
429
+ frames = opts[:frames]
430
+ width = opts[:width]
431
+ height = opts[:height]
432
+ rng = opts[:rng]
433
+ spaces = (0...height).flat_map { |y| (0...width).map { |x| [x, y] } }
434
+ while frames.length < MINI_FRAME_COUNT
435
+ y = rng.rand(height)
436
+ body = [[2, y], [1, y], [0, y]]
437
+ food = (spaces - body).sample(random: rng)
438
+ steps = 0
439
+ loop do
440
+ board = Array.new(height) { ' ' * width }
441
+ body.each_with_index { |(x, row), index| board[row][x] = index.zero? ? 'y' : 'g' }
442
+ board[food[1]][food[0]] = 'f' if food
443
+ mini_emit(frames: frames, board: board, ticks: 2, state: { body: body.map(&:dup), food: food&.dup })
444
+ break if frames.length >= MINI_FRAME_COUNT
445
+
446
+ target = mini_snake_route(body: body, food: food, width: width, height: height, rng: rng) if food
447
+ if !food || !target || steps >= width * height * 4
448
+ mini_reset_board(frames: frames, board: board)
449
+ break
450
+ end
451
+ body.unshift(target)
452
+ if target == food
453
+ food = (spaces - body).sample(random: rng)
454
+ steps = 0
455
+ else
456
+ body.pop
457
+ steps += 1
458
+ end
459
+ end
460
+ end
461
+ end
462
+
463
+ private_class_method def self.mini_simulate_pong(opts = {})
464
+ frames = opts[:frames]
465
+ width = opts[:width]
466
+ height = opts[:height]
467
+ rng = opts[:rng]
468
+ paddles = [(height - 1) / 2.0, (height - 1) / 2.0]
469
+ scores = [0, 0]
470
+ while frames.length < MINI_FRAME_COUNT
471
+ x = (width - 1) / 2.0
472
+ y = rng.rand(1...(height - 1)).to_f
473
+ vx = (rng.rand(2).zero? ? -1 : 1) * 0.325
474
+ vy = ((rng.rand * 0.4) + 0.1) * (rng.rand(2).zero? ? -1 : 1)
475
+ bias = Array.new(2) { rng.rand(5).zero? ? rng.rand(-2..2) : rng.rand - 0.5 }
476
+ rally = 0
477
+ mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: [x, y], scores: scores, event: :serve, ticks: 16)
478
+ loop do
479
+ paddles.each_index do |side|
480
+ incoming = side.zero? ? vx.negative? : vx.positive?
481
+ target = incoming ? y - 0.25 + bias[side] : (height - 1) / 2.0
482
+ speed = incoming ? 0.275 : 0.11
483
+ paddles[side] = (paddles[side] + (target - paddles[side]).clamp(-speed, speed)).clamp(0, height - 1)
484
+ end
485
+ nx = x + vx
486
+ ny = y + vy
487
+ if ny.negative? || ny > height - 1
488
+ ny = ny.negative? ? -ny : (2 * (height - 1)) - ny
489
+ vy = -vy
490
+ end
491
+ side = if x >= 1 && nx < 1
492
+ 0
493
+ elsif x <= width - 2 && nx > width - 2
494
+ 1
495
+ end
496
+ event = :play
497
+ if side && (ny - paddles[side]).between?(-0.25, 0.75)
498
+ nx = side.zero? ? 2 - nx : (2 * (width - 2)) - nx
499
+ vx = -vx
500
+ offset = ny - (paddles[side] + 0.25)
501
+ vy = ((offset * 0.65) + (rng.rand * 0.15) - 0.075).clamp(-0.4, 0.4)
502
+ vy = (rng.rand(2).zero? ? -0.11 : 0.11) if vy.abs < 0.075
503
+ bias[1 - side] = rng.rand(5).zero? ? rng.rand(-2..2) : rng.rand - 0.5
504
+ rally += 1
505
+ event = :bounce
506
+ end
507
+ x = nx
508
+ y = ny
509
+ if x.negative? || x > width - 1
510
+ scores[x.negative? ? 1 : 0] += 1
511
+ mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: nil, scores: scores, event: :score, ticks: 20)
512
+ break
513
+ end
514
+ mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: [x, y], scores: scores, event: event, rally: rally)
515
+ break if frames.length >= MINI_FRAME_COUNT
516
+ end
517
+ end
518
+ end
519
+
520
+ private_class_method def self.mini_pong_snapshot(opts = {})
521
+ width, height, paddles, ball, scores = opts.values_at(:width, :height, :paddles, :ball, :scores)
522
+ board = Array.new(height) { ' ' * width }
523
+ height.times { |y| board[y][width / 2] = ':' if y.even? }
524
+ paddles.each_with_index do |top, side|
525
+ 2.times do |dy|
526
+ mini_pixel(board: board, x: side.zero? ? 0 : (width * 2) - 1, y: (top * 2).floor + dy, color: side)
527
+ end
528
+ end
529
+ mini_pixel(board: board, x: (ball[0] * 2).round, y: (ball[1] * 2).round, color: 2) if ball
530
+ if opts[:event] == :score
531
+ [scores[0], (width - 1) / 2].min.times { |x| board[0][x] = 'c' }
532
+ [scores[1], (width - 1) / 2].min.times { |x| board[0][width - 1 - x] = 'm' }
533
+ end
534
+ mini_emit(frames: opts[:frames], board: board, event: opts[:event], ticks: opts[:ticks],
535
+ state: { ball: ball&.dup, paddles: paddles.dup, paddle_height: 1.0, scores: scores.dup, rally: opts[:rally] || 0 })
536
+ end
537
+
538
+ # Two-by-two quadrant pixels: same-color neighbors coalesce, no ANSI/IO.
539
+ private_class_method def self.mini_pixel(opts = {})
540
+ board = opts[:board]
541
+ x = opts[:x] % (board.first.length * 2)
542
+ y = opts[:y] % (board.length * 2)
543
+ color = opts[:color]
544
+ prior = board[y / 2][x / 2].ord - 256
545
+ mask = prior >= 0 && (opts[:merge] || prior / 16 == color) ? prior % 16 : 0
546
+ mask |= 1 << ((y % 2 * 2) + (x % 2))
547
+ board[y / 2][x / 2] = (256 + (color * 16) + mask).chr(Encoding::UTF_8)
548
+ end
549
+
550
+ private_class_method def self.mini_simulate_asteroids(opts = {})
551
+ frames, width, height, rng = opts.values_at(:frames, :width, :height, :rng)
552
+ ship = [width / 2.0, height / 2.0, rng.rand * Math::PI * 2]
553
+ velocity = [0.0, 0.0]
554
+ rocks = []
555
+ shots = []
556
+ sparks = []
557
+ serial = 0
558
+ tick = 0
559
+ while frames.length < MINI_FRAME_COUNT
560
+ event = :play
561
+ # Reserve room for both fragments before spawning a large rock.
562
+ rock_budget = [3, width / 4].min + 1
563
+ occupied = rocks.sum { |rock| rock[4] > 0.25 ? 2 : 1 }
564
+ if rocks.empty? || ((tick % 120).zero? && occupied + 2 <= rock_budget)
565
+ serial += 1
566
+ x = rng.rand * width
567
+ y = rng.rand * height
568
+ dx = mini_delta(from: ship[0], to: x, span: width)
569
+ dy = mini_delta(from: ship[1], to: y, span: height)
570
+ if (dx * dx) + (dy * dy) < 1.5**2
571
+ x = (x + (width / 2.0)) % width
572
+ y = (y + (height / 2.0)) % height
573
+ end
574
+ rocks << [x, y, (rng.rand - 0.5) * 0.12, (rng.rand - 0.5) * 0.12, 0.5, serial]
575
+ event = :respawn
576
+ end
577
+ rocks.each do |rock|
578
+ rock[0] = (rock[0] + rock[2]) % width
579
+ rock[1] = (rock[1] + rock[3]) % height
580
+ end
581
+ target = rocks.min_by do |rock|
582
+ (mini_delta(from: ship[0], to: rock[0], span: width)**2) + (mini_delta(from: ship[1], to: rock[1], span: height)**2)
583
+ end
584
+ angle = Math.atan2(mini_delta(from: ship[1], to: target[1], span: height), mini_delta(from: ship[0], to: target[0], span: width))
585
+ turn = mini_delta(from: ship[2], to: angle, span: Math::PI * 2).clamp(-0.1, 0.1)
586
+ ship[2] = (ship[2] + turn) % (Math::PI * 2)
587
+ thrust = tick % 80 < 24
588
+ [Math.cos(ship[2]), Math.sin(ship[2])].each_with_index do |direction, axis|
589
+ velocity[axis] = ((velocity[axis] * 0.99) + (thrust ? direction * 0.006 : 0)).clamp(-0.15, 0.15)
590
+ ship[axis] = (ship[axis] + velocity[axis]) % (axis.zero? ? width : height)
591
+ end
592
+ shots << [ship[0], ship[1], Math.cos(ship[2]) * 0.36, Math.sin(ship[2]) * 0.36, 24] if (tick % 12).zero?
593
+ shots.each do |shot|
594
+ shot[0] = (shot[0] + shot[2]) % width
595
+ shot[1] = (shot[1] + shot[3]) % height
596
+ shot[4] -= 1
597
+ hit = rocks.find do |rock|
598
+ dx = mini_delta(from: shot[0], to: rock[0], span: width)
599
+ dy = mini_delta(from: shot[1], to: rock[1], span: height)
600
+ (dx * dx) + (dy * dy) <= rock[4]**2
601
+ end
602
+ next unless hit
603
+
604
+ rocks.delete(hit)
605
+ shot[4] = 0
606
+ sparks << [hit[0], hit[1], 8]
607
+ if hit[4] > 0.25
608
+ [-1, 1].each do |sign|
609
+ serial += 1
610
+ rocks << [hit[0], hit[1], hit[2] + (sign * 0.07), hit[3] - (sign * 0.05), 0.2, serial]
611
+ end
612
+ end
613
+ event = :hit
614
+ end
615
+ shots.reject! { |shot| shot[4] <= 0 }
616
+ sparks.each { |spark| spark[2] -= 1 }
617
+ sparks.reject! { |spark| spark[2] <= 0 }
618
+ mini_asteroids_snapshot(frames: frames, width: width, height: height, ship: ship, thrust: thrust,
619
+ rocks: rocks, shots: shots, sparks: sparks, event: event)
620
+ tick += 1
621
+ end
622
+ end
623
+
624
+ private_class_method def self.mini_delta(opts = {})
625
+ span = opts[:span]
626
+ ((opts[:to] - opts[:from] + (span / 2.0)) % span) - (span / 2.0)
627
+ end
628
+
629
+ private_class_method def self.mini_asteroids_snapshot(opts = {})
630
+ width, height, ship, rocks, shots, sparks = opts.values_at(:width, :height, :ship, :rocks, :shots, :sparks)
631
+ board = Array.new(height) { ' ' * width }
632
+ rocks.each do |rock|
633
+ x, y, radius = rock.values_at(0, 1, 4)
634
+ count = radius > 0.25 ? 4 : 1
635
+ count.times do |index|
636
+ angle = index * Math::PI * 2 / count
637
+ dx = count == 1 ? 0 : Math.cos(angle) * radius
638
+ dy = count == 1 ? 0 : Math.sin(angle) * radius
639
+ mini_pixel(board: board, x: ((x + dx) * 2).round, y: ((y + dy) * 2).round, color: 3)
640
+ end
641
+ end
642
+ sparks.each do |x, y, life|
643
+ 2.times do |index|
644
+ angle = index * Math::PI
645
+ radius = (8 - life) * 0.12
646
+ mini_pixel(board: board, x: ((x + (Math.cos(angle) * radius)) * 2).round, y: ((y + (Math.sin(angle) * radius)) * 2).round, color: 3)
647
+ end
648
+ end
649
+ shots.each { |x, y,| mini_pixel(board: board, x: (x * 2).round, y: (y * 2).round, color: 2) }
650
+ if opts[:thrust]
651
+ # A black subpixel gap separates exhaust from hull: a terminal cell has
652
+ # only two colors, so cyan/red/black in one quadrant glyph loses the red.
653
+ mini_pixel(board: board, x: ((ship[0] * 2) - (Math.cos(ship[2]) * 3)).round,
654
+ y: ((ship[1] * 2) - (Math.sin(ship[2]) * 3)).round, color: 5)
655
+ end
656
+ MINI_SHIP[(ship[2] * 4 / Math::PI).round % 8].each do |dx, dy|
657
+ mini_pixel(board: board, x: (ship[0] * 2).round + dx, y: (ship[1] * 2).round + dy, color: 0)
658
+ end
659
+ mini_emit(frames: opts[:frames], board: board, event: opts[:event],
660
+ state: { ship: ship.dup, thrust: opts[:thrust], rocks: rocks.map(&:dup), shots: shots.map(&:dup) })
661
+ end
662
+
663
+ private_class_method def self.mini_asteroids(opts = {})
664
+ rows, phase, width, height = opts.values_at(:rows, :phase, :width, :height)
665
+ 3.times { |index| rows[1 + (((phase / 3) + index) % (height - 1))][((phase / 2) + (index * 3)) % width] = 'O' }
666
+ rows[height / 2][width / 2] = %w[> v < ^][phase / 15]
667
+ rows[1 + ((phase / 2) % (height - 1))][((width / 2) + phase) % width] = '.'
668
+ end
669
+
670
+ private_class_method def self.mini_falling_blocks(opts = {})
671
+ rows = opts[:rows]
672
+ phase = opts[:phase]
673
+ width = opts[:width]
674
+ height = opts[:height]
675
+ # Alternating T and O tetrominoes complete two prepared rows, then clear.
676
+ shape = phase < 30 ? ['###', ' # '] : ['##', '##']
677
+ tick = phase % 30
678
+ left = (width - shape.first.length) / 2
679
+ shape.each_with_index do |line, dy|
680
+ rows[height - 2 + dy] = '#' * width
681
+ line.chars.each_with_index do |cell, dx|
682
+ rows[height - 2 + dy][left + dx] = ' ' if cell == '#'
683
+ end
684
+ end
685
+ top = 1 + ([tick, 24].min * (height - 3) / 24)
686
+ shape.each_with_index do |line, dy|
687
+ line.chars.each_with_index { |cell, dx| rows[top + dy][left + dx] = cell if cell == '#' }
688
+ end
689
+ return unless tick >= 26
690
+
691
+ rows[-2] = rows[-1] = (tick.even? ? '=' : ' ') * width
692
+ # Do not alias caller-mutable strings across rows, including clear frames.
693
+ rows[-1] = rows[-1].dup
694
+ end
695
+
696
+ private_class_method def self.mini_snake(opts = {})
697
+ rows = opts[:rows]
698
+ width = opts[:width]
699
+ height = opts[:height]
700
+ # A closed perimeter path keeps both the body and the cycle seam contiguous.
701
+ path = (0...width).map { |x| [x, 1] }
702
+ path += (2...height).map { |y| [width - 1, y] }
703
+ path += (0...(width - 1)).to_a.reverse.map { |x| [x, height - 1] }
704
+ path += (2...(height - 1)).to_a.reverse.map { |y| [0, y] }
705
+ head = opts[:phase] * path.length / MINI_ASCII_FRAME_COUNT
706
+ rows[height / 2][width / 2] = '*'
707
+ [width, 7].min.times do |offset|
708
+ x, y = path[(head - offset) % path.length]
709
+ rows[y][x] = offset.zero? ? '@' : 'o'
710
+ end
711
+ end
712
+
713
+ private_class_method def self.mini_pong(opts = {})
714
+ rows = opts[:rows]
715
+ width = opts[:width]
716
+ height = opts[:height]
717
+ phase = opts[:phase]
718
+ # Triangle waves reflect the ball, with no teleport at the cycle boundary.
719
+ x = 1 + ((30 - (phase - 30).abs) * (width - 3) / 30)
720
+ y = 1 + ((15 - ((phase % 30) - 15).abs) * (height - 2) / 15)
721
+ (1...height).each { |row| rows[row][width / 2] = ':' if row.odd? }
722
+ paddle_top = y.clamp(1, height - 2)
723
+ (paddle_top..(paddle_top + 1)).each do |row|
724
+ rows[row][0] = '|'
725
+ rows[row][width - 1] = '|'
726
+ end
727
+ rows[y][x] = 'o'
728
+ end
729
+
24
730
  # Supported Method Parameters::
25
731
  # PWN::Banner.get(
26
732
  # index: 'optional - defaults to random banner index'
@@ -100,6 +806,41 @@ module PWN
100
806
  index: 'optional - defaults to random banner index'
101
807
  )
102
808
 
809
+ # List compact animation names; sample once per session, not per redraw.
810
+ #{self}.mini_names
811
+
812
+ # Presentation cadence for colored games; ASCII keeps MINI_FRAME_SECONDS.
813
+ #{self}.mini_frame_seconds(
814
+ name: 'optional - animation name, defaults to falling_blocks'
815
+ )
816
+
817
+ # Return fresh ASCII rows; caller owns timing, theme, and terminal drawing.
818
+ # Decorative retro loops, not interactive games or progress/telemetry.
819
+ # Advance every MINI_FRAME_SECONDS (0.1 seconds); ASCII wraps at 60.
820
+ # Pass equal width and height for a square; complete artwork fits 5..16.
821
+ # No border is included. Smaller panes show only a clipped PWN wordmark.
822
+ #{self}.mini_frame(
823
+ name: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',
824
+ frame: 'optional - integer frame index, defaults to zero and wraps at 60',
825
+ width: 'optional - columns clamped to zero through 16; defaults to 16',
826
+ height: 'optional - rows clamped to zero through 16; defaults to 16'
827
+ )
828
+
829
+ # Return fresh Unicode block cells: { glyph:, foreground:, background: }.
830
+ # Colors are standard terminal names; blocks may use colored backgrounds.
831
+ # No ANSI or IO. Other games retain black backgrounds.
832
+ # 1800 frames; mini_frame_seconds is 0.05 for Pong/Asteroids, 0.1 otherwise.
833
+ # Caller samples mini_names once per session. No timers or animation threads.
834
+ # Seeded gameplay, bounded six-replay cache; white hold/wipe marks resets.
835
+ # Square refers to terminal cells, not physical pixels. Caller owns borders.
836
+ #{self}.mini_cells(
837
+ name: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',
838
+ frame: 'optional - integer frame index, defaults to zero and wraps at 1800',
839
+ width: 'optional - columns clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
840
+ height: 'optional - rows clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
841
+ seed: 'optional - integer local PRNG seed, defaults to 73; retain per session'
842
+ )
843
+
103
844
  # Run welcome and return its result
104
845
  #{self}.welcome
105
846