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.
- checksums.yaml +4 -4
- data/documentation/pwn-ai-Agent.md +44 -10
- data/etc/default_skills/pwn/banner/SKILL.md +1 -1
- data/lib/pwn/banner.rb +593 -53
- data/lib/pwn/plugins/repl/ai_console.rb +46 -9
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/banner_spec.rb +428 -20
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +167 -6
- data/third_party/pwn_rdoc.jsonl +23 -2
- metadata +1 -1
data/spec/lib/pwn/banner_spec.rb
CHANGED
|
@@ -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] -
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
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
|
|
267
|
-
expect(points
|
|
268
|
-
expect(points.map(&:
|
|
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
|
|
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 -
|
|
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 -
|
|
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
|
|
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 -
|
|
323
|
-
ship = pixels.select { |pixel| (pixel.ord -
|
|
324
|
-
expect(ship.sum { |pixel| ((pixel.ord -
|
|
325
|
-
expect(area).to be <=
|
|
326
|
-
# A lone
|
|
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 -
|
|
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
|
|
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
|
|
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(
|
|
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
|